diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 70256b2a3276..21b00a0f5bb7 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -1028,8 +1028,8 @@ jobs: node-version-file: 'dev-packages/e2e-tests/test-applications/${{ matrix.test-application }}/package.json' - name: Set up Bun if: - contains(fromJSON('["node-exports-test-app","nextjs-16-bun", "elysia-bun", "elysia-bun-static", "hono-4", - "bun-bytecode", "bun-mysql"]'), matrix.test-application) + matrix.test-application == 'node-exports-test-app' || contains(matrix.test-application, 'bun') || + contains(matrix.label, 'bun') uses: oven-sh/setup-bun@v2 with: bun-version: '1.3.14' @@ -1040,10 +1040,7 @@ jobs: use-installer: true token: ${{ secrets.GITHUB_TOKEN }} - name: Set up Deno - if: - matrix.test-application == 'deno' || matrix.test-application == 'deno-static' || matrix.test-application == - 'deno-redis' || matrix.test-application == 'hono-4' || matrix.test-application == 'deno-mysql' || - matrix.test-application == 'deno-pg' + if: contains(matrix.test-application, 'deno') || contains(matrix.label, 'deno') uses: denoland/setup-deno@v2.0.5 with: deno-version: ${{ matrix.deno-version || 'v2.8.3' }} diff --git a/dev-packages/e2e-tests/lib/getTestMatrix.mjs b/dev-packages/e2e-tests/lib/getTestMatrix.mjs index b8be7af5f528..7433dc989ead 100644 --- a/dev-packages/e2e-tests/lib/getTestMatrix.mjs +++ b/dev-packages/e2e-tests/lib/getTestMatrix.mjs @@ -85,9 +85,17 @@ function addIncludesForTestApp(testApp, includes, { optionalMode }) { } variants.forEach(variant => { + // Allow skipping an individual variant (e.g. one blocked by an upstream bug) while keeping the + // others. `sentryTest.skip` above skips the whole app; this is the per-variant equivalent. + if (variant.skip) { + return; + } + + // Don't leak the `skip` flag into the matrix include. + const { skip: _skip, ...variantConfig } = variant; includes.push({ 'test-application': testApp, - ...variant, + ...variantConfig, }); }); } diff --git a/dev-packages/e2e-tests/run.ts b/dev-packages/e2e-tests/run.ts index e2c56813a3bf..317aa42a3a64 100644 --- a/dev-packages/e2e-tests/run.ts +++ b/dev-packages/e2e-tests/run.ts @@ -13,6 +13,7 @@ interface SentryTestVariant { 'build-command': string; 'assert-command'?: string; label?: string; + skip?: boolean; } interface PackageJson { @@ -82,7 +83,13 @@ async function getVariantBuildCommand( packageJsonPath: string, variantLabel: string, testAppPath: string, -): Promise<{ buildCommand: string; assertCommand: string; testLabel: string; matchedVariantLabel?: string }> { +): Promise<{ + buildCommand: string; + assertCommand: string; + testLabel: string; + matchedVariantLabel?: string; + skip?: boolean; +}> { try { const packageJsonContent = await readFile(packageJsonPath, 'utf-8'); const packageJson: PackageJson = JSON.parse(packageJsonContent); @@ -100,6 +107,7 @@ async function getVariantBuildCommand( assertCommand: matchingVariant['assert-command'] || 'pnpm test:assert', testLabel: matchingVariant.label || testAppPath, matchedVariantLabel: matchingVariant.label, + skip: matchingVariant.skip, }; } diff --git a/dev-packages/e2e-tests/test-applications/hono-4-legacy/.gitignore b/dev-packages/e2e-tests/test-applications/hono-4-legacy/.gitignore new file mode 100644 index 000000000000..534f51704346 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/hono-4-legacy/.gitignore @@ -0,0 +1,36 @@ +# prod +dist/ + +# dev +.yarn/ +!.yarn/releases +.vscode/* +!.vscode/launch.json +!.vscode/*.code-snippets +.idea/workspace.xml +.idea/usage.statistics.xml +.idea/shelf + +# deps +node_modules/ +.wrangler + +# env +.env +.env.production +.dev.vars + +# logs +logs/ +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +# test +test-results + +# misc +.DS_Store diff --git a/dev-packages/e2e-tests/test-applications/hono-4-legacy/deno.json b/dev-packages/e2e-tests/test-applications/hono-4-legacy/deno.json new file mode 100644 index 000000000000..d7748bc95ecb --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/hono-4-legacy/deno.json @@ -0,0 +1,11 @@ +{ + "imports": { + "@sentry/hono": "npm:@sentry/hono", + "@sentry/hono/deno": "npm:@sentry/hono/deno", + "@sentry/deno": "npm:@sentry/deno", + "@sentry/core": "npm:@sentry/core", + "@opentelemetry/api": "npm:@opentelemetry/api@^1.9.0", + "hono": "npm:hono@^4.13.1" + }, + "nodeModulesDir": "manual" +} diff --git a/dev-packages/e2e-tests/test-applications/hono-4-legacy/package.json b/dev-packages/e2e-tests/test-applications/hono-4-legacy/package.json new file mode 100644 index 000000000000..ab3b68ce1f5d --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/hono-4-legacy/package.json @@ -0,0 +1,52 @@ +{ + "name": "hono-4-legacy", + "type": "module", + "version": "0.0.0", + "private": true, + "scripts": { + "dev:cf": "wrangler dev --var \"E2E_TEST_DSN:$E2E_TEST_DSN\" --log-level=$(test $CI && echo 'none' || echo 'log')", + "dev:node": "node --import tsx/esm --import ./src/instrument.node.ts src/entry.node.ts", + "dev:bun": "bun src/entry.bun.ts", + "dev:deno": "deno run --allow-net --allow-env --allow-read src/entry.deno.ts", + "build": "wrangler deploy --dry-run", + "test:build": "pnpm install && pnpm build", + "test:assert": "TEST_ENV=production playwright test" + }, + "dependencies": { + "@sentry/bun": "latest || *", + "@sentry/cloudflare": "latest || *", + "@sentry/deno": "latest || *", + "@sentry/hono": "latest || *", + "@sentry/node": "latest || *", + "@hono/node-server": "^2.0.5", + "hono": "^4.13.1" + }, + "devDependencies": { + "@playwright/test": "~1.63.0", + "@cloudflare/workers-types": "^4.20240725.0", + "@sentry-internal/test-utils": "link:../../../test-utils", + "tsx": "4.21.0", + "typescript": "^5.5.2", + "wrangler": "^4.61.0" + }, + "volta": { + "node": "24.15.0", + "extends": "../../package.json" + }, + "sentryTest": { + "variants": [ + { + "assert-command": "RUNTIME=node pnpm test:assert", + "label": "hono-4-legacy (node)" + }, + { + "assert-command": "RUNTIME=bun pnpm test:assert", + "label": "hono-4-legacy (bun)" + }, + { + "assert-command": "RUNTIME=deno pnpm test:assert", + "label": "hono-4-legacy (deno)" + } + ] + } +} diff --git a/dev-packages/e2e-tests/test-applications/hono-4-legacy/playwright.config.ts b/dev-packages/e2e-tests/test-applications/hono-4-legacy/playwright.config.ts new file mode 100644 index 000000000000..5087b887e1f2 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/hono-4-legacy/playwright.config.ts @@ -0,0 +1,30 @@ +import { getPlaywrightConfig } from '@sentry-internal/test-utils'; +import { RUNTIME, type Runtime } from './tests/constants'; + +const testEnv = process.env.TEST_ENV; + +if (!testEnv) { + throw new Error('No test env defined'); +} + +const APP_PORT = 38787; + +const startCommands: Record = { + cloudflare: `pnpm dev:cf --port ${APP_PORT}`, + node: `pnpm dev:node`, + bun: `pnpm dev:bun`, + deno: `pnpm dev:deno`, +}; + +const config = getPlaywrightConfig( + { + startCommand: startCommands[RUNTIME], + port: APP_PORT, + }, + { + workers: '100%', + retries: 0, + }, +); + +export default config; diff --git a/dev-packages/e2e-tests/test-applications/hono-4-legacy/src/entry.bun.ts b/dev-packages/e2e-tests/test-applications/hono-4-legacy/src/entry.bun.ts new file mode 100644 index 000000000000..e057eb78d4c5 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/hono-4-legacy/src/entry.bun.ts @@ -0,0 +1,23 @@ +import { Hono } from 'hono'; +import { sentry } from '@sentry/hono/bun'; +import { addRoutes } from './routes'; + +const app = new Hono(); + +app.use( + sentry(app, { + dsn: process.env.E2E_TEST_DSN, + environment: 'qa', + tracesSampleRate: 1.0, + tunnel: 'http://localhost:3031/', + }), +); + +addRoutes(app); + +const port = Number(process.env.PORT || 38787); + +export default { + port, + fetch: app.fetch, +}; diff --git a/dev-packages/e2e-tests/test-applications/hono-4-legacy/src/entry.cloudflare.ts b/dev-packages/e2e-tests/test-applications/hono-4-legacy/src/entry.cloudflare.ts new file mode 100644 index 000000000000..e348dde56226 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/hono-4-legacy/src/entry.cloudflare.ts @@ -0,0 +1,18 @@ +import { Hono } from 'hono'; +import { sentry } from '@sentry/hono/cloudflare'; +import { addRoutes } from './routes'; + +const app = new Hono<{ Bindings: { E2E_TEST_DSN: string } }>(); + +app.use( + sentry(app, env => ({ + dsn: env.E2E_TEST_DSN, + environment: 'qa', + tracesSampleRate: 1.0, + tunnel: 'http://localhost:3031/', + })), +); + +addRoutes(app); + +export default app; diff --git a/dev-packages/e2e-tests/test-applications/hono-4-legacy/src/entry.deno.ts b/dev-packages/e2e-tests/test-applications/hono-4-legacy/src/entry.deno.ts new file mode 100644 index 000000000000..15bd12a74111 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/hono-4-legacy/src/entry.deno.ts @@ -0,0 +1,21 @@ +import { Hono } from 'hono'; +import { sentry } from '@sentry/hono/deno'; +import { addRoutes } from './routes'; + +const app = new Hono(); + +app.use( + sentry(app, { + dsn: Deno.env.get('E2E_TEST_DSN'), + environment: 'qa', + dataCollection: {}, + tracesSampleRate: 1.0, + tunnel: 'http://localhost:3031/', + }), +); + +addRoutes(app); + +const port = Number(Deno.env.get('PORT') || 38787); + +Deno.serve({ port }, app.fetch); diff --git a/dev-packages/e2e-tests/test-applications/hono-4-legacy/src/entry.node.ts b/dev-packages/e2e-tests/test-applications/hono-4-legacy/src/entry.node.ts new file mode 100644 index 000000000000..898a92e08be4 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/hono-4-legacy/src/entry.node.ts @@ -0,0 +1,16 @@ +import { Hono } from 'hono'; +import { sentry } from '@sentry/hono/node'; +import { serve } from '@hono/node-server'; +import { addRoutes } from './routes'; + +const app = new Hono(); + +app.use(sentry(app)); + +addRoutes(app); + +const port = Number(process.env.PORT || 38787); + +serve({ fetch: app.fetch, port }, () => { + console.log(`Hono (Node) listening on port ${port}`); +}); diff --git a/dev-packages/e2e-tests/test-applications/hono-4-legacy/src/instrument.node.ts b/dev-packages/e2e-tests/test-applications/hono-4-legacy/src/instrument.node.ts new file mode 100644 index 000000000000..82f2a3864125 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/hono-4-legacy/src/instrument.node.ts @@ -0,0 +1,8 @@ +import * as Sentry from '@sentry/hono/node'; + +Sentry.init({ + dsn: process.env.E2E_TEST_DSN, + environment: 'qa', + tracesSampleRate: 1.0, + tunnel: 'http://localhost:3031/', +}); diff --git a/dev-packages/e2e-tests/test-applications/hono-4-legacy/src/middleware.ts b/dev-packages/e2e-tests/test-applications/hono-4-legacy/src/middleware.ts new file mode 100644 index 000000000000..cc7bfae9896d --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/hono-4-legacy/src/middleware.ts @@ -0,0 +1,17 @@ +import type { MiddlewareHandler } from 'hono'; + +export const middlewareA: MiddlewareHandler = async function middlewareA(c, next) { + // Add some delay + await new Promise(resolve => setTimeout(resolve, 50)); + await next(); +}; + +export const middlewareB: MiddlewareHandler = async function middlewareB(_c, next) { + // Add some delay + await new Promise(resolve => setTimeout(resolve, 60)); + await next(); +}; + +export const failingMiddleware: MiddlewareHandler = async function failingMiddleware(_c, _next) { + throw new Error('Middleware error'); +}; diff --git a/dev-packages/e2e-tests/test-applications/hono-4-legacy/src/route-groups/test-errors.ts b/dev-packages/e2e-tests/test-applications/hono-4-legacy/src/route-groups/test-errors.ts new file mode 100644 index 000000000000..b8f2fd96fe93 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/hono-4-legacy/src/route-groups/test-errors.ts @@ -0,0 +1,45 @@ +import { Hono } from 'hono'; +import { HTTPException } from 'hono/http-exception'; + +const errorRoutes = new Hono(); + +// Middleware that throws a 5xx HTTPException (should be captured) +errorRoutes.use('/middleware-http-exception/*', async (_c, _next) => { + throw new HTTPException(503, { message: 'Service Unavailable from middleware' }); +}); + +errorRoutes.get('/middleware-http-exception', c => c.text('should not reach')); + +// Middleware that throws a 4xx HTTPException (should NOT be captured) +errorRoutes.use('/middleware-http-exception-4xx/*', async (_c, _next) => { + throw new HTTPException(401, { message: 'Unauthorized from middleware' }); +}); + +errorRoutes.get('/middleware-http-exception-4xx', c => c.text('should not reach')); + +// Sub-app with a custom onError handler that swallows errors +const subAppWithOnError = new Hono(); + +subAppWithOnError.onError((err, c) => { + return c.text(`Handled by onError: ${err.message}`, 500); +}); + +subAppWithOnError.get('/fail', () => { + throw new Error('Error caught by custom onError'); +}); + +errorRoutes.route('/custom-on-error', subAppWithOnError); + +// Nested sub-apps: parent mounts child, child route throws +const childApp = new Hono(); + +childApp.get('/error', () => { + throw new Error('Nested child app error'); +}); + +const parentApp = new Hono(); +parentApp.route('/child', childApp); + +errorRoutes.route('/nested', parentApp); + +export { errorRoutes }; diff --git a/dev-packages/e2e-tests/test-applications/hono-4-legacy/src/route-groups/test-middleware.ts b/dev-packages/e2e-tests/test-applications/hono-4-legacy/src/route-groups/test-middleware.ts new file mode 100644 index 000000000000..d82201b7cdb3 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/hono-4-legacy/src/route-groups/test-middleware.ts @@ -0,0 +1,83 @@ +import { Hono } from 'hono'; +import { failingMiddleware, middlewareA, middlewareB } from '../middleware'; + +const middlewareRoutes = new Hono(); + +middlewareRoutes.get('/named', c => c.json({ middleware: 'named' })); +middlewareRoutes.get('/anonymous', c => c.json({ middleware: 'anonymous' })); +middlewareRoutes.get('/multi', c => c.json({ middleware: 'multi' })); +middlewareRoutes.get('/error', c => c.text('should not reach')); +middlewareRoutes.get('/param/:id', c => c.json({ paramId: c.req.param('id') })); + +// Self-contained sub-app registering its own middleware via .use() +const subAppWithMiddleware = new Hono(); + +subAppWithMiddleware.use('/named/*', middlewareA); +subAppWithMiddleware.use('/anonymous/*', async (c, next) => { + c.header('X-Custom', 'anonymous'); + await next(); +}); +subAppWithMiddleware.use('/multi/*', middlewareA, middlewareB); +subAppWithMiddleware.use('/error/*', failingMiddleware); +subAppWithMiddleware.use('/param/*', middlewareA); + +// .all() handler (1 parameter) — should NOT be wrapped as middleware by patchRoute. +subAppWithMiddleware.all('/all-handler', async function allCatchAll(c) { + return c.json({ handler: 'all' }); +}); + +subAppWithMiddleware.route('/', middlewareRoutes); + +// Sub-app with inline middleware for different registration styles. +// patchRoute wraps non-last handlers per method+path group as middleware. +const subAppWithInlineMiddleware = new Hono(); + +const METHODS = ['get', 'post', 'put', 'delete', 'patch'] as const; + +// Direct method registration for each HTTP method +METHODS.forEach(method => { + subAppWithInlineMiddleware[method]( + '/direct', + async function inlineMiddleware(_c, next) { + await next(); + }, + c => c.text(`${method} direct response`), + ); + + subAppWithInlineMiddleware[method]('/direct/separately', async function inlineSeparateMiddleware(_c, next) { + await next(); + }); + subAppWithInlineMiddleware[method]('/direct/separately', c => c.text(`${method} direct separate response`)); +}); + +// .all(): .all('/path', mw, handler) +subAppWithInlineMiddleware.all( + '/all', + async function inlineMiddlewareAll(_c, next) { + await next(); + }, + c => c.text('all response'), +); +subAppWithInlineMiddleware.all('/all/separately', async function inlineSeparateMiddlewareAll(_c, next) { + await next(); +}); +subAppWithInlineMiddleware.all('/all/separately', c => c.text('all separate response')); + +// .on() registration for each HTTP method +METHODS.forEach(method => { + subAppWithInlineMiddleware.on( + method, + '/on', + async function inlineMiddlewareOn(_c, next) { + await next(); + }, + c => c.text(`${method} on response`), + ); + + subAppWithInlineMiddleware.on(method, '/on/separately', async function inlineSeparateMiddlewareOn(_c, next) { + await next(); + }); + subAppWithInlineMiddleware.on(method, '/on/separately', c => c.text(`${method} on separate response`)); +}); + +export { middlewareRoutes, subAppWithMiddleware, subAppWithInlineMiddleware }; diff --git a/dev-packages/e2e-tests/test-applications/hono-4-legacy/src/route-groups/test-multi-fetch.ts b/dev-packages/e2e-tests/test-applications/hono-4-legacy/src/route-groups/test-multi-fetch.ts new file mode 100644 index 000000000000..58eb5b504640 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/hono-4-legacy/src/route-groups/test-multi-fetch.ts @@ -0,0 +1,99 @@ +import { Hono } from 'hono'; +import { HTTPException } from 'hono/http-exception'; + +const ITEMS: Record = { + 'self-watering-plant': { name: 'Self-Watering Plant', stock: 5, price: 2500 }, + 'solar-powered-cyberdeck': { name: 'Solar-Powered Cyberdeck', stock: 0, price: 5000 }, +}; + +// Inventory service — standalone sub-app used as a data source. +// Mounted on the main app AND called internally via .request() from the storefront. +const inventoryApp = new Hono(); + +inventoryApp.get('/item/:productId', c => { + const productId = c.req.param('productId'); + const item = ITEMS[productId]; + if (!item) { + throw new HTTPException(404, { message: `Item ${productId} not found` }); + } + return c.json({ productId, ...item }); +}); + +inventoryApp.get('/item/:productId/stock', c => { + const productId = c.req.param('productId'); + const item = ITEMS[productId]; + if (!item) { + throw new HTTPException(404, { message: `Stock check failed: ${productId}` }); + } + return c.json({ productId, inStock: item.stock > 0, quantity: item.stock }); +}); + +// Storefront service — orchestrates internal .request() calls to inventoryApp. +const storefrontApp = new Hono(); + +storefrontApp.use('/*', async function storefrontAuth(_c, next) { + await new Promise(resolve => setTimeout(resolve, 10)); + await next(); +}); + +// Single internal fetch: look up one product +storefrontApp.get('/product/:productId', async c => { + const res = await inventoryApp.request(`/item/${c.req.param('productId')}`); + if (!res.ok) { + throw new HTTPException(404, { message: 'Product not found' }); + } + const item = await res.json(); + return c.json({ product: item, source: 'storefront' }); +}); + +// Parallel internal fetches: compare two products via Promise.all +storefrontApp.get('/compare/:productId1/:productId2', async c => { + const [res1, res2] = await Promise.all([ + inventoryApp.request(`/item/${c.req.param('productId1')}`), + inventoryApp.request(`/item/${c.req.param('productId2')}`), + ]); + if (!res1.ok || !res2.ok) { + throw new HTTPException(404, { message: 'One or more products not found' }); + } + const [item1, item2] = (await Promise.all([res1.json(), res2.json()])) as [ + Record, + Record, + ]; + return c.json({ + items: [item1, item2], + priceDifference: Math.abs(item1.price - item2.price), + }); +}); + +// Sequential chained fetches: look up item, then check its stock +storefrontApp.get('/product/:productId/availability', async c => { + const itemRes = await inventoryApp.request(`/item/${c.req.param('productId')}`); + if (!itemRes.ok) { + throw new HTTPException(404, { message: 'Product not found' }); + } + const item: Record = await itemRes.json(); + + const stockRes = await inventoryApp.request(`/item/${item.productId}/stock`); + const stock: Record = await stockRes.json(); + + return c.json({ + product: item.name, + available: stock.inStock, + quantity: stock.quantity, + }); +}); + +// Error propagation: internal 404 causes the handler to throw a plain Error +storefrontApp.get('/product-or-throw/:productId', async c => { + const res = await inventoryApp.request(`/item/${c.req.param('productId')}`); + if (!res.ok) { + throw new Error(`Failed to fetch product: ${c.req.param('productId')}`); + } + return c.json({ product: await res.json() }); +}); + +const multiFetchRoutes = new Hono(); +multiFetchRoutes.route('/inventory', inventoryApp); +multiFetchRoutes.route('/storefront', storefrontApp); + +export { multiFetchRoutes }; diff --git a/dev-packages/e2e-tests/test-applications/hono-4-legacy/src/route-groups/test-route-patterns.ts b/dev-packages/e2e-tests/test-applications/hono-4-legacy/src/route-groups/test-route-patterns.ts new file mode 100644 index 000000000000..ac6fea1320cf --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/hono-4-legacy/src/route-groups/test-route-patterns.ts @@ -0,0 +1,33 @@ +import { Hono } from 'hono'; + +const routePatterns = new Hono(); + +const METHODS = ['get', 'post', 'put', 'delete', 'patch'] as const; + +// Direct method registration for each HTTP method (sync handlers) +METHODS.forEach(method => { + routePatterns[method]('/', c => c.text(`${method} response`)); +}); + +// Async handler +routePatterns.get('/async', async c => { + await new Promise(resolve => setTimeout(resolve, 10)); + return c.text('async response'); +}); + +// Dedicated route for query_string test to avoid transaction name collisions +routePatterns.get('/query-test', c => c.text('query test response')); + +// Dedicated route for request data extraction tests to avoid transaction name collisions +routePatterns.get('/request-data', c => c.text('request data response')); +routePatterns.post('/request-data', c => c.text('request data response')); + +// .all() registration +routePatterns.all('/all', c => c.text('all handler response')); + +// .on() registration +METHODS.forEach(method => { + routePatterns.on(method, '/on', c => c.text(`${method} on response`)); +}); + +export { routePatterns }; diff --git a/dev-packages/e2e-tests/test-applications/hono-4-legacy/src/routes.ts b/dev-packages/e2e-tests/test-applications/hono-4-legacy/src/routes.ts new file mode 100644 index 000000000000..b095618fb52b --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/hono-4-legacy/src/routes.ts @@ -0,0 +1,133 @@ +import { type Hono as HonoType, Hono } from 'hono'; +import { HTTPException } from 'hono/http-exception'; +import { failingMiddleware, middlewareA, middlewareB } from './middleware'; +import { errorRoutes } from './route-groups/test-errors'; +import { middlewareRoutes, subAppWithInlineMiddleware, subAppWithMiddleware } from './route-groups/test-middleware'; +import { multiFetchRoutes } from './route-groups/test-multi-fetch'; +import { routePatterns } from './route-groups/test-route-patterns'; + +export function addRoutes(app: HonoType<{ Bindings?: { E2E_TEST_DSN: string } }>): void { + app.get('/', c => { + return c.text('Hello Hono!'); + }); + + app.get('/test-param/:paramId', c => { + return c.json({ paramId: c.req.param('paramId') }); + }); + + app.get('/error/:cause', c => { + throw new Error('This is a test error for Sentry!', { + cause: c.req.param('cause'), + }); + }); + + app.get('/linked-error', () => { + const cause = new Error('Failure 1'); + const errorCause = new Error('Failure 2', { cause }); + throw new Error('Failure 3', { cause: errorCause }); + }); + + app.get('/http-exception/:code', c => { + // oxlint-disable-next-line typescript/no-explicit-any + const code = Number(c.req.param('code')) as any; + throw new HTTPException(code, { message: `HTTPException ${code}` }); + }); + + // Root-app middleware: registered on the patched main app instance + app.use('/test-middleware/named/*', middlewareA); + app.use('/test-middleware/anonymous/*', async (c, next) => { + c.header('X-Custom', 'anonymous'); + await next(); + }); + app.use('/test-middleware/multi/*', middlewareA, middlewareB); + app.use('/test-middleware/error/*', failingMiddleware); + app.use('/test-middleware/param/*', middlewareA); + app.route('/test-middleware', middlewareRoutes); + + // Sub-app middleware: registered on the sub-app, wrapped at mount time by route() patching + app.route('/test-subapp-middleware', subAppWithMiddleware); + + // Inline middleware patterns: direct method, .all(), .on() with inline/separate middleware + app.route('/test-inline-middleware', subAppWithInlineMiddleware); + + // Inline middleware on the main app via HTTP method registration (not .use()). + app.get( + '/test-main-inline/get', + async function mainInlineGet(_c, next) { + await next(); + }, + c => c.text('main inline get'), + ); + app.post( + '/test-main-inline/post', + async function mainInlinePost(_c, next) { + await next(); + }, + c => c.text('main inline post'), + ); + app.all( + '/test-main-inline/all', + async function mainInlineAll(_c, next) { + await next(); + }, + c => c.text('main inline all'), + ); + app.query( + '/test-main-inline/query', + async function mainInlineQuery(_c, next) { + await next(); + }, + async c => { + const body = await c.req.json<{ value: string }>(); + return c.json({ method: c.req.method, value: body.value }); + }, + ); + + // Combined: .use() middleware + inline middleware via .get() on the same path. + app.use('/test-main-inline/combined/*', middlewareA); + app.get( + '/test-main-inline/combined/resource', + async function combinedInlineMw(_c, next) { + await next(); + }, + c => c.text('combined response'), + ); + + // Route patterns: HTTP methods, .all(), .on(), sync/async, errors + app.route('/test-routes', routePatterns); + + // Error-specific routes: onError handler, nested sub-apps, middleware HTTPException + app.route('/test-errors', errorRoutes); + + // Multi-fetch routes: storefront sub-app calls inventoryApp via .request() + app.route('/test-multi-fetch', multiFetchRoutes); + + // .basePath() with sub-app mounting via .route() + const apiSubApp = new Hono(); + apiSubApp.use(async function apiAuth(_c, next) { + await next(); + }); + apiSubApp.get('/users', c => c.json({ users: [{ id: 1, name: 'Alice' }] })); + apiSubApp.get('/users/:userId', c => c.json({ userId: c.req.param('userId') })); + + app.basePath('/test-basepath').route('/v1', apiSubApp); + + app.use(async function trailingMiddleware(_c, next) { + // Trailing middleware to make sure the route names are resolved correctly (not `/*`). + await new Promise(resolve => setTimeout(resolve, 50)); + await next(); + }); + + // .use() on the cloned instance returned by .basePath() — the clone has its own + // .use class field, so this tests whether middleware instrumentation propagates. + app + .basePath('/test-basepath-mw') + .use(async function basepathMiddleware(_c, next) { + await new Promise(resolve => setTimeout(resolve, 50)); + await next(); + }) + .get('/hello', c => c.json({ greeting: 'world' })); + + // .get() registered on the root app after .basePath()/.route() chains + app.get('/test-late-get', c => c.json({ registered: 'after-chains' })); +} diff --git a/dev-packages/e2e-tests/test-applications/hono-4-legacy/start-event-proxy.mjs b/dev-packages/e2e-tests/test-applications/hono-4-legacy/start-event-proxy.mjs new file mode 100644 index 000000000000..8f6b794b1827 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/hono-4-legacy/start-event-proxy.mjs @@ -0,0 +1,6 @@ +import { startEventProxyServer } from '@sentry-internal/test-utils'; + +startEventProxyServer({ + port: 3031, + proxyServerName: 'hono-4-legacy', +}); diff --git a/dev-packages/e2e-tests/test-applications/hono-4-legacy/tests/basepath-and-late-routes.test.ts b/dev-packages/e2e-tests/test-applications/hono-4-legacy/tests/basepath-and-late-routes.test.ts new file mode 100644 index 000000000000..c7ad43b6b945 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/hono-4-legacy/tests/basepath-and-late-routes.test.ts @@ -0,0 +1,91 @@ +import { expect, test } from '@playwright/test'; +import { waitForStreamedSpan, getSpanOp, collectStreamedSpansUntilSegment } from '@sentry-internal/test-utils'; +import { APP_NAME } from './constants'; + +test.describe('basePath with sub-app routes', () => { + test('traces GET on a sub-app mounted via .basePath().route()', async ({ baseURL }) => { + const segmentPromise = waitForStreamedSpan( + APP_NAME, + segment => + segment.is_segment && getSpanOp(segment) === 'http.server' && segment.name === 'GET /test-basepath/v1/users', + ); + + const response = await fetch(`${baseURL}/test-basepath/v1/users`); + expect(response.status).toBe(200); + + const body = await response.json(); + expect(body).toEqual({ users: [{ id: 1, name: 'Alice' }] }); + + const segment = await segmentPromise; + expect(segment.name).toBe('GET /test-basepath/v1/users'); + expect(getSpanOp(segment)).toBe('http.server'); + }); + + test('traces parameterized route under .basePath().route()', async ({ baseURL }) => { + const segmentPromise = waitForStreamedSpan( + APP_NAME, + segment => + segment.is_segment && + getSpanOp(segment) === 'http.server' && + segment.name === 'GET /test-basepath/v1/users/:userId', + ); + + const response = await fetch(`${baseURL}/test-basepath/v1/users/42`); + expect(response.status).toBe(200); + + const body = await response.json(); + expect(body).toEqual({ userId: '42' }); + + const segment = await segmentPromise; + expect(segment.name).toBe('GET /test-basepath/v1/users/:userId'); + expect(getSpanOp(segment)).toBe('http.server'); + }); +}); + +// TODO: this test is currently skipped because we do not yet support middleware registered on new instances (e.g. here via .basePath(..).use(...)). +test.skip('.basePath() middleware instrumentation', () => { + test('creates middleware span for .use() on .basePath() clone', async ({ baseURL }) => { + const segmentPromise = collectStreamedSpansUntilSegment( + APP_NAME, + segment => getSpanOp(segment) === 'http.server' && segment.name === 'GET /test-basepath-mw/hello', + ); + + const response = await fetch(`${baseURL}/test-basepath-mw/hello`); + expect(response.status).toBe(200); + + const body = await response.json(); + expect(body).toEqual({ greeting: 'world' }); + + const segmentSpans = await segmentPromise; + const segment = segmentSpans.find( + segment => + segment.is_segment && getSpanOp(segment) === 'http.server' && segment.name === 'GET /test-basepath-mw/hello', + )!; + expect(segment.name).toBe('GET /test-basepath-mw/hello'); + + const spans = segmentSpans.filter( + span => !span.is_segment && span.attributes['sentry.segment.id']?.value === segment.span_id, + ); + const middlewareSpan = spans.find(span => getSpanOp(span) === 'middleware' && span.name === 'basepathMiddleware'); + + expect(middlewareSpan).toBeDefined(); + expect(middlewareSpan?.attributes['sentry.origin']?.value).toBe('auto.middleware.hono'); + }); +}); + +test('traces .get() route registered after .basePath()/.route() chains', async ({ baseURL }) => { + const segmentPromise = waitForStreamedSpan( + APP_NAME, + segment => segment.is_segment && getSpanOp(segment) === 'http.server' && segment.name === 'GET /test-late-get', + ); + + const response = await fetch(`${baseURL}/test-late-get`); + expect(response.status).toBe(200); + + const body = await response.json(); + expect(body).toEqual({ registered: 'after-chains' }); + + const segment = await segmentPromise; + expect(segment.name).toBe('GET /test-late-get'); + expect(getSpanOp(segment)).toBe('http.server'); +}); diff --git a/dev-packages/e2e-tests/test-applications/hono-4-legacy/tests/constants.ts b/dev-packages/e2e-tests/test-applications/hono-4-legacy/tests/constants.ts new file mode 100644 index 000000000000..2aee60fc6be8 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/hono-4-legacy/tests/constants.ts @@ -0,0 +1,5 @@ +export type Runtime = 'cloudflare' | 'node' | 'bun' | 'deno'; + +export const RUNTIME = (process.env.RUNTIME || 'node') as Runtime; + +export const APP_NAME = 'hono-4-legacy'; diff --git a/dev-packages/e2e-tests/test-applications/hono-4-legacy/tests/errors.test.ts b/dev-packages/e2e-tests/test-applications/hono-4-legacy/tests/errors.test.ts new file mode 100644 index 000000000000..e3c44c67d4d6 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/hono-4-legacy/tests/errors.test.ts @@ -0,0 +1,329 @@ +import { expect, test } from '@playwright/test'; +import { + waitForError, + waitForStreamedSpan, + getSpanOp, + collectStreamedSpansUntilSegment, +} from '@sentry-internal/test-utils'; +import { APP_NAME, RUNTIME } from './constants'; + +test.describe('route handler errors', () => { + test('captures error with mechanism and trace correlation', async ({ baseURL }) => { + const errorPromise = waitForError(APP_NAME, event => { + return event.exception?.values?.[0]?.value === 'This is a test error for Sentry!'; + }); + + const segmentPromise = waitForStreamedSpan( + APP_NAME, + segment => segment.is_segment && getSpanOp(segment) === 'http.server' && !!segment.name?.includes('/error/'), + ); + + const response = await fetch(`${baseURL}/error/test-cause`); + expect(response.status).toBe(500); + + const errorEvent = await errorPromise; + const segmentEvent = await segmentPromise; + + expect(segmentEvent.name).toBe('GET /error/:cause'); + + expect(errorEvent.exception?.values).toHaveLength(1); + + const exception = errorEvent.exception?.values?.[0]; + expect(exception?.value).toBe('This is a test error for Sentry!'); + expect(exception?.mechanism).toEqual({ + handled: false, + type: 'auto.http.hono.context_error', + }); + + expect(errorEvent.transaction).toBe('GET /error/:cause'); + expect(errorEvent.request?.method).toBe('GET'); + expect(errorEvent.request?.url).toContain('/error/test-cause'); + expect(errorEvent.request?.headers).toBeDefined(); + + expect(errorEvent.contexts?.trace?.trace_id).toBe(segmentEvent?.trace_id); + }); + + test('captures three linked errors', async ({ baseURL }) => { + const errorPromise = waitForError(APP_NAME, event => { + return event.exception?.values?.some(exception => exception.value === 'Failure 3'); + }); + + const response = await fetch(`${baseURL}/linked-error`); + expect(response.status).toBe(500); + + const errorEvent = await errorPromise; + expect(errorEvent.exception?.values).toHaveLength(3); + + const firstCause = errorEvent.exception?.values?.[0]; + expect(firstCause?.value).toBe('Failure 1'); + expect(firstCause?.mechanism).toEqual({ + exception_id: 2, + handled: true, + parent_id: 1, + source: 'cause', + type: 'chained', + }); + + const secondCause = errorEvent.exception?.values?.[1]; + expect(secondCause?.value).toBe('Failure 2'); + expect(secondCause?.mechanism).toEqual({ + exception_id: 1, + handled: true, + parent_id: 0, + source: 'cause', + type: 'chained', + }); + + const capturedError = errorEvent.exception?.values?.[2]; + expect(capturedError?.value).toBe('Failure 3'); + expect(capturedError?.mechanism).toEqual({ + exception_id: 0, + handled: false, + type: 'auto.http.hono.context_error', + }); + + expect(errorEvent.transaction).toBe('GET /linked-error'); + }); +}); + +test.describe('HTTPException errors', () => { + test('captures 5xx HTTPException', async ({ baseURL }) => { + const errorPromise = waitForError(APP_NAME, event => { + return event.exception?.values?.[0]?.value === 'HTTPException 500'; + }); + + const response = await fetch(`${baseURL}/http-exception/500`); + expect(response.status).toBe(500); + + const errorEvent = await errorPromise; + expect(errorEvent.exception?.values?.[0]?.value).toBe('HTTPException 500'); + expect(errorEvent.exception?.values?.[0]?.mechanism).toEqual({ + handled: false, + type: 'auto.http.hono.context_error', + }); + }); + + // On Node/Bun, httpServerSpansIntegration drops transactions for 3xx/4xx responses (ignoreStatusCodes), so we just use a request guard. + // On Cloudflare the transaction is available, and we additionally verify its name. + [301, 302].forEach(code => { + test(`does not capture ${code} HTTPException`, async ({ baseURL }) => { + let errorEventOccurred = false; + + waitForError(APP_NAME, event => { + if (event.exception?.values?.[0]?.value === `HTTPException ${code}`) { + errorEventOccurred = true; + } + return false; + }); + + const segmentPromise = waitForStreamedSpan( + APP_NAME, + segment => + segment.is_segment && + (RUNTIME === 'cloudflare' + ? getSpanOp(segment) === 'http.server' && !!segment.name?.includes('/http-exception/') + : getSpanOp(segment) === 'http.server' && segment.name === 'GET /'), + ); + + const response = await fetch(`${baseURL}/http-exception/${code}`, { redirect: 'manual' }); + expect(response.status).toBe(code); + + if (RUNTIME !== 'cloudflare') { + // Simple request guard for non-Cloudflare runtimes since the other transaction is dropped for 4xx responses + await fetch(`${baseURL}/`); + } + + const segment = await segmentPromise; + + if (RUNTIME === 'cloudflare') { + expect(segment.name).toBe('GET /http-exception/:code'); + } + + expect(errorEventOccurred).toBe(false); + }); + }); + + [401, 403, 404].forEach(code => { + test(`does not capture ${code} HTTPException`, async ({ baseURL }) => { + let errorEventOccurred = false; + + waitForError(APP_NAME, event => { + if (event.exception?.values?.[0]?.value === `HTTPException ${code}`) { + errorEventOccurred = true; + } + return false; + }); + + const segmentPromise = waitForStreamedSpan( + APP_NAME, + segment => + segment.is_segment && + (RUNTIME === 'cloudflare' + ? getSpanOp(segment) === 'http.server' && !!segment.name?.includes('/http-exception/') + : getSpanOp(segment) === 'http.server' && segment.name === 'GET /'), + ); + + const response = await fetch(`${baseURL}/http-exception/${code}`); + expect(response.status).toBe(code); + + if (RUNTIME !== 'cloudflare') { + // Simple request guard for non-Cloudflare runtimes since the other transaction is dropped for 4xx responses + await fetch(`${baseURL}/`); + } + + const segment = await segmentPromise; + + if (RUNTIME === 'cloudflare') { + expect(segment.name).toBe('GET /http-exception/:code'); + } + + expect(errorEventOccurred).toBe(false); + }); + }); +}); + +test.describe('middleware errors', () => { + test('captures 5xx HTTPException thrown in middleware with error span status', async ({ baseURL }) => { + const errorPromise = waitForError(APP_NAME, event => { + return event.exception?.values?.[0]?.value === 'Service Unavailable from middleware'; + }); + + const segmentPromise = collectStreamedSpansUntilSegment( + APP_NAME, + segment => + getSpanOp(segment) === 'http.server' && !!segment.name?.includes('/test-errors/middleware-http-exception'), + ); + + const response = await fetch(`${baseURL}/test-errors/middleware-http-exception`); + expect(response.status).toBe(503); + + const errorEvent = await errorPromise; + expect(errorEvent.exception?.values?.[0]?.value).toBe('Service Unavailable from middleware'); + expect(errorEvent.exception?.values?.[0]?.mechanism?.type).toBe('auto.http.hono.context_error'); + expect(errorEvent.exception?.values?.[0]?.mechanism?.handled).toBe(false); + expect(errorEvent.transaction).toBe('GET /test-errors/middleware-http-exception'); + + const segmentSpans = await segmentPromise; + const segment = segmentSpans.find( + segment => + segment.is_segment && + getSpanOp(segment) === 'http.server' && + !!segment.name?.includes('/test-errors/middleware-http-exception'), + )!; + const middlewareSpan = segmentSpans + .filter(span => !span.is_segment && span.attributes['sentry.segment.id']?.value === segment.span_id) + .find(s => getSpanOp(s) === 'middleware'); + expect(middlewareSpan?.status).toBe('error'); + }); + + test('does not capture 4xx HTTPException thrown in middleware', async ({ baseURL }) => { + let errorEventOccurred = false; + + waitForError(APP_NAME, event => { + if (event.exception?.values?.[0]?.value === 'Unauthorized from middleware') { + errorEventOccurred = true; + } + return false; + }); + + const segmentPromise = collectStreamedSpansUntilSegment(APP_NAME, segment => { + if (RUNTIME === 'cloudflare') { + return ( + getSpanOp(segment) === 'http.server' && !!segment.name?.includes('/test-errors/middleware-http-exception-4xx') + ); + } + return getSpanOp(segment) === 'http.server' && segment.name === 'GET /'; + }); + + const response = await fetch(`${baseURL}/test-errors/middleware-http-exception-4xx`); + expect(response.status).toBe(401); + + if (RUNTIME !== 'cloudflare') { + await fetch(`${baseURL}/`); + } + + const segmentSpans = await segmentPromise; + const segment = segmentSpans.find(segment => { + if (!segment.is_segment) return false; + if (RUNTIME === 'cloudflare') { + return ( + getSpanOp(segment) === 'http.server' && !!segment.name?.includes('/test-errors/middleware-http-exception-4xx') + ); + } + return getSpanOp(segment) === 'http.server' && segment.name === 'GET /'; + })!; + + if (RUNTIME === 'cloudflare') { + expect(segment.name).toBe('GET /test-errors/middleware-http-exception-4xx'); + + const middlewareSpan = segmentSpans + .filter(span => !span.is_segment && span.attributes['sentry.segment.id']?.value === segment.span_id) + .find(s => getSpanOp(s) === 'middleware'); + expect(middlewareSpan?.status).not.toBe('error'); + } + + expect(errorEventOccurred).toBe(false); + }); +}); + +test.describe('nested sub-app errors', () => { + test('captures error from nested child sub-app', async ({ baseURL }) => { + const errorPromise = waitForError(APP_NAME, event => { + return event.exception?.values?.[0]?.value === 'Nested child app error'; + }); + + const segmentPromise = waitForStreamedSpan( + APP_NAME, + segment => + segment.is_segment && getSpanOp(segment) === 'http.server' && !!segment.name?.includes('/nested/child/error'), + ); + + const response = await fetch(`${baseURL}/test-errors/nested/child/error`); + expect(response.status).toBe(500); + + const errorEvent = await errorPromise; + const segment = await segmentPromise; + + expect(segment.name).toBe('GET /test-errors/nested/child/error'); + + expect(errorEvent.exception?.values?.[0]?.value).toBe('Nested child app error'); + expect(errorEvent.exception?.values?.[0]?.mechanism).toEqual({ + handled: false, + type: 'auto.http.hono.context_error', + }); + expect(errorEvent.request?.method).toBe('GET'); + expect(errorEvent.request?.url).toContain('/test-errors/nested/child/error'); + expect(errorEvent.request?.headers).toBeDefined(); + }); +}); + +test.describe('custom onError handler', () => { + test('captures error even when onError handles the response', async ({ baseURL }) => { + const errorPromise = waitForError(APP_NAME, event => { + return event.exception?.values?.[0]?.value === 'Error caught by custom onError'; + }); + + const segmentPromise = waitForStreamedSpan( + APP_NAME, + segment => + segment.is_segment && getSpanOp(segment) === 'http.server' && !!segment.name?.includes('/custom-on-error/fail'), + ); + + const response = await fetch(`${baseURL}/test-errors/custom-on-error/fail`); + expect(response.status).toBe(500); + + const body = await response.text(); + expect(body).toContain('Handled by onError'); + + const errorEvent = await errorPromise; + const segment = await segmentPromise; + + expect(segment.name).toBe('GET /test-errors/custom-on-error/fail'); + + expect(errorEvent.exception?.values?.[0]?.value).toBe('Error caught by custom onError'); + expect(errorEvent.exception?.values?.[0]?.mechanism).toEqual({ + handled: false, + type: 'auto.http.hono.context_error', + }); + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/hono-4-legacy/tests/middleware.test.ts b/dev-packages/e2e-tests/test-applications/hono-4-legacy/tests/middleware.test.ts new file mode 100644 index 000000000000..111ff3bfe34a --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/hono-4-legacy/tests/middleware.test.ts @@ -0,0 +1,404 @@ +import { expect, test } from '@playwright/test'; +import { waitForError, getSpanOp, collectStreamedSpansUntilSegment } from '@sentry-internal/test-utils'; +import { APP_NAME } from './constants'; + +const SCENARIOS = [ + { + name: 'root app middleware', + prefix: '/test-middleware', + }, + { + name: 'sub-app middleware (route group)', + prefix: '/test-subapp-middleware', + }, +] as const; + +for (const { name, prefix } of SCENARIOS) { + test.describe(name, () => { + test('creates a span for named middleware', async ({ baseURL }) => { + const segmentPromise = collectStreamedSpansUntilSegment( + APP_NAME, + segment => getSpanOp(segment) === 'http.server' && !!segment.name?.includes(`${prefix}/named`), + ); + + const response = await fetch(`${baseURL}${prefix}/named`); + expect(response.status).toBe(200); + + const segmentSpans = await segmentPromise; + const segment = segmentSpans.find( + segment => + segment.is_segment && getSpanOp(segment) === 'http.server' && !!segment.name?.includes(`${prefix}/named`), + )!; + expect(segment.name).toBe(`GET ${prefix}/named`); + + const spans = segmentSpans.filter( + span => !span.is_segment && span.attributes['sentry.segment.id']?.value === segment.span_id, + ); + + const middlewareSpan = spans.find(span => getSpanOp(span) === 'middleware' && span.name === 'middlewareA'); + + expect(middlewareSpan).toEqual( + expect.objectContaining({ + name: 'middlewareA', + attributes: expect.objectContaining({ + 'sentry.op': { value: 'middleware', type: 'string' }, + 'sentry.origin': { value: 'auto.middleware.hono', type: 'string' }, + }), + }), + ); + expect(middlewareSpan?.status).not.toBe('error'); + + const durationMs = (middlewareSpan!.end_timestamp - middlewareSpan!.start_timestamp) * 1000; + expect(durationMs).toBeGreaterThanOrEqual(49); + }); + + test('creates a span for anonymous middleware', async ({ baseURL }) => { + const segmentPromise = collectStreamedSpansUntilSegment( + APP_NAME, + segment => getSpanOp(segment) === 'http.server' && !!segment.name?.includes(`${prefix}/anonymous`), + ); + + const response = await fetch(`${baseURL}${prefix}/anonymous`); + expect(response.status).toBe(200); + + const segmentSpans = await segmentPromise; + const segment = segmentSpans.find( + segment => + segment.is_segment && getSpanOp(segment) === 'http.server' && !!segment.name?.includes(`${prefix}/anonymous`), + )!; + expect(segment.name).toBe(`GET ${prefix}/anonymous`); + + const spans = segmentSpans.filter( + span => !span.is_segment && span.attributes['sentry.segment.id']?.value === segment.span_id, + ); + + const anonymousSpan = spans.find(span => getSpanOp(span) === 'middleware' && span.name === ''); + expect(anonymousSpan).toBeDefined(); + expect(anonymousSpan?.attributes['sentry.origin']?.value).toBe('auto.middleware.hono'); + expect(anonymousSpan?.status).not.toBe('error'); + }); + + test('multiple middleware are sibling spans under the same parent', async ({ baseURL }) => { + const segmentPromise = collectStreamedSpansUntilSegment( + APP_NAME, + segment => getSpanOp(segment) === 'http.server' && !!segment.name?.includes(`${prefix}/multi`), + ); + + const response = await fetch(`${baseURL}${prefix}/multi`); + expect(response.status).toBe(200); + + const segmentSpans = await segmentPromise; + const segment = segmentSpans.find( + segment => + segment.is_segment && getSpanOp(segment) === 'http.server' && !!segment.name?.includes(`${prefix}/multi`), + )!; + expect(segment.name).toBe(`GET ${prefix}/multi`); + + const spans = segmentSpans.filter( + span => !span.is_segment && span.attributes['sentry.segment.id']?.value === segment.span_id, + ); + + const middlewareSpans = spans.sort((a, b) => (a.start_timestamp ?? 0) - (b.start_timestamp ?? 0)); + + expect(middlewareSpans).toHaveLength(2); + expect(middlewareSpans[0].name).toBe('middlewareA'); + expect(middlewareSpans[1].name).toBe('middlewareB'); + + expect(middlewareSpans[0]?.parent_span_id).toBe(middlewareSpans[1]?.parent_span_id); + + // middlewareA has a 50ms delay, middlewareB has a 60ms delay + const aDurationMs = (middlewareSpans[0].end_timestamp - middlewareSpans[0]?.start_timestamp) * 1000; + const bDurationMs = (middlewareSpans[1].end_timestamp - middlewareSpans[1]?.start_timestamp) * 1000; + expect(aDurationMs).toBeGreaterThanOrEqual(49); + expect(bDurationMs).toBeGreaterThanOrEqual(59); + }); + + test('captures error thrown in middleware', async ({ baseURL }) => { + const errorPromise = waitForError(APP_NAME, event => { + return event.exception?.values?.[0]?.value === 'Middleware error'; + }); + + const response = await fetch(`${baseURL}${prefix}/error`); + expect(response.status).toBe(500); + + const errorEvent = await errorPromise; + expect(errorEvent.exception?.values?.[0]?.value).toBe('Middleware error'); + expect(errorEvent.exception?.values?.[0]?.mechanism).toEqual( + expect.objectContaining({ + handled: false, + type: 'auto.http.hono.context_error', + }), + ); + + // The transaction name on the error event determines the culprit shown in Sentry. + expect(errorEvent.transaction).toBe(`GET ${prefix}/error`); + }); + + test('sets error status on middleware span when middleware throws', async ({ baseURL }) => { + const segmentPromise = collectStreamedSpansUntilSegment( + APP_NAME, + segment => getSpanOp(segment) === 'http.server' && !!segment.name?.includes(`${prefix}/error`), + ); + + await fetch(`${baseURL}${prefix}/error`); + + const segmentSpans = await segmentPromise; + const segment = segmentSpans.find( + segment => + segment.is_segment && getSpanOp(segment) === 'http.server' && !!segment.name?.includes(`${prefix}/error`), + )!; + expect(segment.name).toBe(`GET ${prefix}/error`); + + const spans = segmentSpans.filter( + span => !span.is_segment && span.attributes['sentry.segment.id']?.value === segment.span_id, + ); + + const failingSpan = spans.find(span => getSpanOp(span) === 'middleware' && span.status === 'error'); + + expect(failingSpan).toBeDefined(); + expect(failingSpan?.status).toBe('error'); + }); + + test('uses parameterized route in span name', async ({ baseURL }) => { + const segmentPromise = collectStreamedSpansUntilSegment( + APP_NAME, + segment => getSpanOp(segment) === 'http.server' && !!segment.name?.includes(`${prefix}/param/`), + ); + + const response = await fetch(`${baseURL}${prefix}/param/42`); + expect(response.status).toBe(200); + + const segmentSpans = await segmentPromise; + const segment = segmentSpans.find( + segment => + segment.is_segment && getSpanOp(segment) === 'http.server' && !!segment.name?.includes(`${prefix}/param/`), + )!; + expect(segment.name).toBe(`GET ${prefix}/param/:id`); + + const spans = segmentSpans.filter( + span => !span.is_segment && span.attributes['sentry.segment.id']?.value === segment.span_id, + ); + const middlewareSpan = spans.find(span => getSpanOp(span) === 'middleware' && span.name === 'middlewareA'); + expect(middlewareSpan).toBeDefined(); + }); + + test('includes request data on error events from middleware', async ({ baseURL }) => { + const errorPromise = waitForError(APP_NAME, event => { + return event.exception?.values?.[0]?.value === 'Middleware error' && !!event.request?.url?.includes(prefix); + }); + + await fetch(`${baseURL}${prefix}/error`); + + const errorEvent = await errorPromise; + expect(errorEvent.request).toEqual( + expect.objectContaining({ + method: 'GET', + url: expect.stringContaining(`${prefix}/error`), + }), + ); + }); + }); +} + +test.describe('.all() handler in sub-app', () => { + test('does not create middleware span for .all() route handler', async ({ baseURL }) => { + const segmentPromise = collectStreamedSpansUntilSegment( + APP_NAME, + segment => + getSpanOp(segment) === 'http.server' && !!segment.name?.includes('/test-subapp-middleware/all-handler'), + ); + + const response = await fetch(`${baseURL}/test-subapp-middleware/all-handler`); + expect(response.status).toBe(200); + + const body = await response.json(); + expect(body).toEqual({ handler: 'all' }); + + const segmentSpans = await segmentPromise; + const segment = segmentSpans.find( + segment => + segment.is_segment && + getSpanOp(segment) === 'http.server' && + !!segment.name?.includes('/test-subapp-middleware/all-handler'), + )!; + expect(segment.name).toBe('GET /test-subapp-middleware/all-handler'); + + const spans = segmentSpans.filter( + span => !span.is_segment && span.attributes['sentry.segment.id']?.value === segment.span_id, + ); + + // No middleware is called for this route, so there should be no spans. + expect(spans).toEqual([]); + }); +}); + +const INLINE_PREFIX = '/test-inline-middleware'; + +const REGISTRATION_STYLES = [ + { name: 'direct method (.get())', path: '/direct' }, + { name: '.all()', path: '/all' }, + { name: '.on()', path: '/on' }, +] as const; + +const MIDDLEWARE_STYLES = [ + { name: 'inline', path: '' }, + { name: 'separately registered', path: '/separately' }, +] as const; + +test.describe('inline middleware spans (sub-app)', () => { + for (const { name: regName, path: regPath } of REGISTRATION_STYLES) { + for (const { name: mwName, path: mwPath } of MIDDLEWARE_STYLES) { + test(`creates middleware span for ${mwName} middleware via ${regName}`, async ({ baseURL }) => { + const fullPath = `${INLINE_PREFIX}${regPath}${mwPath}`; + + const segmentPromise = collectStreamedSpansUntilSegment( + APP_NAME, + segment => getSpanOp(segment) === 'http.server' && !!segment.name?.includes(fullPath), + ); + + const response = await fetch(`${baseURL}${fullPath}`); + expect(response.status).toBe(200); + + const segmentSpans = await segmentPromise; + const segment = segmentSpans.find( + segment => segment.is_segment && getSpanOp(segment) === 'http.server' && !!segment.name?.includes(fullPath), + )!; + expect(segment.name).toBe(`GET ${fullPath}`); + + const EXPECTED_DESCRIPTIONS: Record> = { + '/direct': { '': 'inlineMiddleware', '/separately': 'inlineSeparateMiddleware' }, + '/all': { '': 'inlineMiddlewareAll', '/separately': 'inlineSeparateMiddlewareAll' }, + '/on': { '': 'inlineMiddlewareOn', '/separately': 'inlineSeparateMiddlewareOn' }, + }; + const expectedDescription = EXPECTED_DESCRIPTIONS[regPath]![mwPath]!; + + const inlineSpan = segmentSpans + .filter(span => !span.is_segment && span.attributes['sentry.segment.id']?.value === segment.span_id) + .find(s => s.name === expectedDescription); + expect(inlineSpan).toBeDefined(); + expect(getSpanOp(inlineSpan!)).toBe('middleware'); + expect(inlineSpan?.attributes['sentry.origin']?.value).toBe('auto.middleware.hono'); + expect(inlineSpan?.status).not.toBe('error'); + }); + } + } +}); + +const MAIN_INLINE_PREFIX = '/test-main-inline'; + +const MAIN_INLINE_CASES = [ + { name: '.get()', path: '/get', method: 'GET', expectedMiddlewareName: 'mainInlineGet' }, + { name: '.post()', path: '/post', method: 'POST', expectedMiddlewareName: 'mainInlinePost' }, + { name: '.all()', path: '/all', method: 'GET', expectedMiddlewareName: 'mainInlineAll' }, +] as const; + +test.describe('inline middleware spans (main app)', () => { + test('creates middleware span for inline middleware via .query()', async ({ baseURL }) => { + const fullPath = `${MAIN_INLINE_PREFIX}/query`; + const segmentPromise = collectStreamedSpansUntilSegment( + APP_NAME, + segment => getSpanOp(segment) === 'http.server' && segment.name === `QUERY ${fullPath}`, + ); + + const response = await fetch(`${baseURL}${fullPath}`, { + method: 'QUERY', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ value: 'query-body' }), + }); + expect(response.status).toBe(200); + await expect(response.json()).resolves.toEqual({ method: 'QUERY', value: 'query-body' }); + + const segmentSpans = await segmentPromise; + const segment = segmentSpans.find( + segment => segment.is_segment && getSpanOp(segment) === 'http.server' && segment.name === `QUERY ${fullPath}`, + )!; + expect(segment.name).toBe(`QUERY ${fullPath}`); + expect(getSpanOp(segment)).toBe('http.server'); + expect(segment.attributes['sentry.segment.name.source']?.value).toBe('route'); + expect(segment.attributes['http.request.method']?.value).toBe('QUERY'); + + const middlewareSpans = segmentSpans + .filter(span => !span.is_segment && span.attributes['sentry.segment.id']?.value === segment.span_id) + .filter(span => getSpanOp(span) === 'middleware'); + expect(middlewareSpans).toHaveLength(1); + expect(middlewareSpans[0]).toEqual( + expect.objectContaining({ + name: 'mainInlineQuery', + attributes: expect.objectContaining({ + 'sentry.op': { value: 'middleware', type: 'string' }, + 'sentry.origin': { value: 'auto.middleware.hono', type: 'string' }, + }), + }), + ); + }); + + MAIN_INLINE_CASES.forEach(({ name, path, method, expectedMiddlewareName }) => { + test(`creates middleware span for inline middleware via ${name}`, async ({ baseURL }) => { + const fullPath = `${MAIN_INLINE_PREFIX}${path}`; + + const segmentPromise = collectStreamedSpansUntilSegment( + APP_NAME, + segment => getSpanOp(segment) === 'http.server' && segment.name === `${method} ${fullPath}`, + ); + + const response = await fetch(`${baseURL}${fullPath}`, { method }); + expect(response.status).toBe(200); + + const segmentSpans = await segmentPromise; + const segment = segmentSpans.find( + segment => + segment.is_segment && getSpanOp(segment) === 'http.server' && segment.name === `${method} ${fullPath}`, + )!; + expect(segment.name).toBe(`${method} ${fullPath}`); + + const spans = segmentSpans.filter( + span => !span.is_segment && span.attributes['sentry.segment.id']?.value === segment.span_id, + ); + const inlineSpan = spans.find(s => s.name === expectedMiddlewareName); + + expect(inlineSpan).toBeDefined(); + expect(getSpanOp(inlineSpan!)).toBe('middleware'); + expect(inlineSpan?.attributes['sentry.origin']?.value).toBe('auto.middleware.hono'); + expect(inlineSpan?.status).not.toBe('error'); + + const middlewareSpans = spans.filter(s => getSpanOp(s) === 'middleware'); + expect(middlewareSpans).toHaveLength(1); + }); + }); + + test('creates spans for both .use() middleware and inline middleware via .get()', async ({ baseURL }) => { + const fullPath = `${MAIN_INLINE_PREFIX}/combined/resource`; + + const segmentPromise = collectStreamedSpansUntilSegment( + APP_NAME, + segment => getSpanOp(segment) === 'http.server' && segment.name === `GET ${fullPath}`, + ); + + const response = await fetch(`${baseURL}${fullPath}`); + expect(response.status).toBe(200); + + const segmentSpans = await segmentPromise; + const segment = segmentSpans.find( + segment => segment.is_segment && getSpanOp(segment) === 'http.server' && segment.name === `GET ${fullPath}`, + )!; + expect(segment.name).toBe(`GET ${fullPath}`); + + const spans = segmentSpans.filter( + span => !span.is_segment && span.attributes['sentry.segment.id']?.value === segment.span_id, + ); + const middlewareSpans = spans.filter(s => getSpanOp(s) === 'middleware'); + + expect(middlewareSpans).toHaveLength(2); + + const [spanA, spanB] = middlewareSpans.sort((a, b) => (a.name ?? '').localeCompare(b.name ?? '')); + expect(spanA.name).toBe('combinedInlineMw'); + expect(getSpanOp(spanA!)).toBe('middleware'); + expect(spanA.attributes['sentry.origin']?.value).toBe('auto.middleware.hono'); + expect(spanA?.status).not.toBe('error'); + + expect(spanB.name).toBe('middlewareA'); + expect(getSpanOp(spanB!)).toBe('middleware'); + expect(spanB.attributes['sentry.origin']?.value).toBe('auto.middleware.hono'); + expect(spanB?.status).not.toBe('error'); + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/hono-4-legacy/tests/multi-fetch.test.ts b/dev-packages/e2e-tests/test-applications/hono-4-legacy/tests/multi-fetch.test.ts new file mode 100644 index 000000000000..29298c7b8f89 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/hono-4-legacy/tests/multi-fetch.test.ts @@ -0,0 +1,370 @@ +import { expect, test } from '@playwright/test'; +import { + waitForError, + waitForStreamedSpan, + getSpanOp, + collectStreamedSpansUntilSegment, +} from '@sentry-internal/test-utils'; +import { APP_NAME } from './constants'; + +const STOREFRONT = '/test-multi-fetch/storefront'; +const INVENTORY = '/test-multi-fetch/inventory'; + +test.describe('multi-fetch: internal .request() calls between sub-apps', () => { + test.describe('single internal fetch', () => { + test('returns enriched product data and creates span with parameterized route', async ({ baseURL }) => { + const segmentPromise = waitForStreamedSpan( + APP_NAME, + segment => + segment.is_segment && + getSpanOp(segment) === 'http.server' && + segment.name === `GET ${STOREFRONT}/product/:productId`, + ); + + const response = await fetch(`${baseURL}${STOREFRONT}/product/self-watering-plant`); + expect(response.status).toBe(200); + + const body = await response.json(); + expect(body.product).toEqual( + expect.objectContaining({ + productId: 'self-watering-plant', + name: 'Self-Watering Plant', + stock: 5, + price: 2500, + }), + ); + expect(body.source).toBe('storefront'); + + const segment = await segmentPromise; + expect(segment.name).toBe(`GET ${STOREFRONT}/product/:productId`); + expect(getSpanOp(segment)).toBe('http.server'); + }); + + test('creates storefrontAuth middleware span', async ({ baseURL }) => { + const segmentPromise = collectStreamedSpansUntilSegment( + APP_NAME, + segment => getSpanOp(segment) === 'http.server' && segment.name === `GET ${STOREFRONT}/product/:productId`, + ); + + const response = await fetch(`${baseURL}${STOREFRONT}/product/solar-powered-cyberdeck`); + expect(response.status).toBe(200); + + const segmentSpans = await segmentPromise; + const segment = segmentSpans.find( + segment => + segment.is_segment && + getSpanOp(segment) === 'http.server' && + segment.name === `GET ${STOREFRONT}/product/:productId`, + )!; + const spans = segmentSpans.filter( + span => !span.is_segment && span.attributes['sentry.segment.id']?.value === segment.span_id, + ); + + const middlewareSpan = spans.find(span => getSpanOp(span) === 'middleware' && span.name === 'storefrontAuth'); + + expect(middlewareSpan).toEqual( + expect.objectContaining({ + name: 'storefrontAuth', + attributes: expect.objectContaining({ + 'sentry.op': { value: 'middleware', type: 'string' }, + 'sentry.origin': { value: 'auto.middleware.hono', type: 'string' }, + }), + }), + ); + expect(middlewareSpan?.status).not.toBe('error'); + }); + }); + + test.describe('parallel internal fetches', () => { + test('aggregates data from two concurrent .request() calls', async ({ baseURL }) => { + const segmentPromise = waitForStreamedSpan( + APP_NAME, + segment => + segment.is_segment && + getSpanOp(segment) === 'http.server' && + segment.name === `GET ${STOREFRONT}/compare/:productId1/:productId2`, + ); + + const response = await fetch(`${baseURL}${STOREFRONT}/compare/self-watering-plant/solar-powered-cyberdeck`); + expect(response.status).toBe(200); + + const body = await response.json(); + expect(body.items).toHaveLength(2); + expect(body.items[0].productId).toBe('self-watering-plant'); + expect(body.items[1].productId).toBe('solar-powered-cyberdeck'); + expect(body.priceDifference).toBe(2500); + + const segment = await segmentPromise; + expect(segment.name).toBe(`GET ${STOREFRONT}/compare/:productId1/:productId2`); + }); + }); + + test.describe('sequential chained fetches', () => { + test('composes data from item lookup followed by stock check', async ({ baseURL }) => { + const segmentPromise = waitForStreamedSpan( + APP_NAME, + segment => + segment.is_segment && + getSpanOp(segment) === 'http.server' && + segment.name === `GET ${STOREFRONT}/product/:productId/availability`, + ); + + const response = await fetch(`${baseURL}${STOREFRONT}/product/self-watering-plant/availability`); + expect(response.status).toBe(200); + + const body = await response.json(); + expect(body).toEqual({ product: 'Self-Watering Plant', available: true, quantity: 5 }); + + const segment = await segmentPromise; + expect(segment.name).toBe(`GET ${STOREFRONT}/product/:productId/availability`); + expect(getSpanOp(segment)).toBe('http.server'); + }); + + test('reports out-of-stock item as unavailable', async ({ baseURL }) => { + const segmentPromise = waitForStreamedSpan( + APP_NAME, + segment => + segment.is_segment && + getSpanOp(segment) === 'http.server' && + segment.name === `GET ${STOREFRONT}/product/:productId/availability`, + ); + + const response = await fetch(`${baseURL}${STOREFRONT}/product/solar-powered-cyberdeck/availability`); + expect(response.status).toBe(200); + + const body = await response.json(); + expect(body).toEqual({ product: 'Solar-Powered Cyberdeck', available: false, quantity: 0 }); + + await segmentPromise; + }); + }); + + test.describe('error propagation from internal fetch', () => { + test('captures error when handler throws after failed internal .request()', async ({ baseURL }) => { + const errorPromise = waitForError(APP_NAME, event => { + return event.exception?.values?.[0]?.value === 'Failed to fetch product: nonexistent'; + }); + + const segmentPromise = waitForStreamedSpan( + APP_NAME, + segment => + segment.is_segment && + getSpanOp(segment) === 'http.server' && + segment.name === `GET ${STOREFRONT}/product-or-throw/:productId`, + ); + + const response = await fetch(`${baseURL}${STOREFRONT}/product-or-throw/nonexistent`); + expect(response.status).toBe(500); + + const errorEvent = await errorPromise; + expect(errorEvent.exception?.values?.[0]?.value).toBe('Failed to fetch product: nonexistent'); + expect(errorEvent.exception?.values?.[0]?.mechanism).toEqual(expect.objectContaining({ handled: false })); + expect(errorEvent.transaction).toBe(`GET ${STOREFRONT}/product-or-throw/:productId`); + + const segment = await segmentPromise; + expect(segment.name).toBe(`GET ${STOREFRONT}/product-or-throw/:productId`); + expect(segment?.status).toBe('error'); + }); + + test('error event includes request data', async ({ baseURL }) => { + const errorPromise = waitForError(APP_NAME, event => { + return event.exception?.values?.[0]?.value === 'Failed to fetch product: missing'; + }); + + await fetch(`${baseURL}${STOREFRONT}/product-or-throw/missing`); + + const errorEvent = await errorPromise; + expect(errorEvent.request).toEqual( + expect.objectContaining({ + method: 'GET', + url: expect.stringContaining(`${STOREFRONT}/product-or-throw/missing`), + }), + ); + }); + }); + + test.describe('inventory sub-app direct access', () => { + test('creates its own span when accessed directly via HTTP', async ({ baseURL }) => { + const segmentPromise = waitForStreamedSpan( + APP_NAME, + segment => + segment.is_segment && + getSpanOp(segment) === 'http.server' && + segment.name === `GET ${INVENTORY}/item/:productId`, + ); + + const response = await fetch(`${baseURL}${INVENTORY}/item/self-watering-plant`); + expect(response.status).toBe(200); + + const body = await response.json(); + expect(body).toEqual(expect.objectContaining({ productId: 'self-watering-plant', name: 'Self-Watering Plant' })); + + const segment = await segmentPromise; + expect(segment.name).toBe(`GET ${INVENTORY}/item/:productId`); + expect(getSpanOp(segment)).toBe('http.server'); + }); + }); + + test.describe('trace propagation through internal .request() calls', () => { + test('single internal fetch produces an internal-request child span', async ({ baseURL }) => { + const segmentPromise = collectStreamedSpansUntilSegment( + APP_NAME, + segment => getSpanOp(segment) === 'http.server' && segment.name === `GET ${STOREFRONT}/product/:productId`, + ); + + await fetch(`${baseURL}${STOREFRONT}/product/self-watering-plant`); + + const segmentSpans = await segmentPromise; + const segment = segmentSpans.find( + segment => + segment.is_segment && + getSpanOp(segment) === 'http.server' && + segment.name === `GET ${STOREFRONT}/product/:productId`, + )!; + const traceId = segment?.trace_id; + const spans = segmentSpans.filter( + span => !span.is_segment && span.attributes['sentry.segment.id']?.value === segment.span_id, + ); + + const internalRequestSpans = spans.filter( + s => s.attributes['sentry.origin']?.value === 'auto.http.hono.internal_request', + ); + + expect(internalRequestSpans).toHaveLength(1); + expect(internalRequestSpans[0]).toEqual( + expect.objectContaining({ + trace_id: traceId, + attributes: expect.objectContaining({ + 'sentry.op': { value: 'http.server', type: 'string' }, + 'sentry.origin': { value: 'auto.http.hono.internal_request', type: 'string' }, + }), + }), + ); + expect(internalRequestSpans[0].name).toContain('GET /item/self-watering-plant'); + }); + + test('parallel internal fetches produce two sibling internal-request spans', async ({ baseURL }) => { + const segmentPromise = collectStreamedSpansUntilSegment( + APP_NAME, + segment => + getSpanOp(segment) === 'http.server' && segment.name === `GET ${STOREFRONT}/compare/:productId1/:productId2`, + ); + + await fetch(`${baseURL}${STOREFRONT}/compare/self-watering-plant/solar-powered-cyberdeck`); + + const segmentSpans = await segmentPromise; + const segment = segmentSpans.find( + segment => + segment.is_segment && + getSpanOp(segment) === 'http.server' && + segment.name === `GET ${STOREFRONT}/compare/:productId1/:productId2`, + )!; + const traceId = segment?.trace_id; + const spans = segmentSpans.filter( + span => !span.is_segment && span.attributes['sentry.segment.id']?.value === segment.span_id, + ); + + const internalRequestSpans = spans.filter( + s => s.attributes['sentry.origin']?.value === 'auto.http.hono.internal_request', + ); + + expect(internalRequestSpans).toHaveLength(2); + + expect(internalRequestSpans[0]?.parent_span_id).toBe(internalRequestSpans[1]?.parent_span_id); + + expect(internalRequestSpans[0]?.trace_id).toBe(traceId); + expect(internalRequestSpans[1]?.trace_id).toBe(traceId); + + expect(internalRequestSpans[0].attributes['sentry.origin']?.value).toBe('auto.http.hono.internal_request'); + expect(internalRequestSpans[1].attributes['sentry.origin']?.value).toBe('auto.http.hono.internal_request'); + }); + + test('sequential chained fetches produce two ordered internal-request spans', async ({ baseURL }) => { + const segmentPromise = collectStreamedSpansUntilSegment( + APP_NAME, + segment => + getSpanOp(segment) === 'http.server' && segment.name === `GET ${STOREFRONT}/product/:productId/availability`, + ); + + await fetch(`${baseURL}${STOREFRONT}/product/self-watering-plant/availability`); + + const segmentSpans = await segmentPromise; + const segment = segmentSpans.find( + segment => + segment.is_segment && + getSpanOp(segment) === 'http.server' && + segment.name === `GET ${STOREFRONT}/product/:productId/availability`, + )!; + const traceId = segment?.trace_id; + const spans = segmentSpans.filter( + span => !span.is_segment && span.attributes['sentry.segment.id']?.value === segment.span_id, + ); + + const internalRequestSpans = spans + .filter(s => s.attributes['sentry.origin']?.value === 'auto.http.hono.internal_request') + .sort( + (a: { start_timestamp?: number }, b: { start_timestamp?: number }) => + (a.start_timestamp ?? 0) - (b.start_timestamp ?? 0), + ); + + expect(internalRequestSpans).toHaveLength(2); + + // Sequential: second span starts at or after first span ends (with tolerance for clock precision) + expect(internalRequestSpans[1].start_timestamp).toBeGreaterThanOrEqual( + internalRequestSpans[0].end_timestamp! - 0.001, + ); + + expect(internalRequestSpans[0]?.trace_id).toBe(traceId); + expect(internalRequestSpans[1]?.trace_id).toBe(traceId); + }); + + test('internal-request span has no error status for internal 4xx HTTPException', async ({ baseURL }) => { + const segmentPromise = collectStreamedSpansUntilSegment( + APP_NAME, + segment => + getSpanOp(segment) === 'http.server' && segment.name === `GET ${STOREFRONT}/product-or-throw/:productId`, + ); + + await fetch(`${baseURL}${STOREFRONT}/product-or-throw/ghost`); + + const segmentSpans = await segmentPromise; + const segment = segmentSpans.find( + segment => + segment.is_segment && + getSpanOp(segment) === 'http.server' && + segment.name === `GET ${STOREFRONT}/product-or-throw/:productId`, + )!; + const spans = segmentSpans.filter( + span => !span.is_segment && span.attributes['sentry.segment.id']?.value === segment.span_id, + ); + + const internalRequestSpans = spans.filter( + s => s.attributes['sentry.origin']?.value === 'auto.http.hono.internal_request', + ); + + expect(internalRequestSpans).toHaveLength(1); + expect(internalRequestSpans[0]?.status).not.toBe('error'); + }); + + test('error from failed internal fetch is correlated with the storefront trace', async ({ baseURL }) => { + const errorPromise = waitForError(APP_NAME, event => { + return event.exception?.values?.[0]?.value === 'Failed to fetch product: ghost'; + }); + + const segmentPromise = waitForStreamedSpan( + APP_NAME, + segment => + segment.is_segment && + getSpanOp(segment) === 'http.server' && + segment.name === `GET ${STOREFRONT}/product-or-throw/:productId`, + ); + + await fetch(`${baseURL}${STOREFRONT}/product-or-throw/ghost`); + + const [errorEvent, segment] = await Promise.all([errorPromise, segmentPromise]); + + expect(errorEvent.contexts?.trace?.trace_id).toBe(segment?.trace_id); + expect(errorEvent.contexts?.trace?.span_id).toBeDefined(); + }); + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/hono-4-legacy/tests/route-patterns.test.ts b/dev-packages/e2e-tests/test-applications/hono-4-legacy/tests/route-patterns.test.ts new file mode 100644 index 000000000000..bfebb9f5b6ab --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/hono-4-legacy/tests/route-patterns.test.ts @@ -0,0 +1,178 @@ +import { expect, test } from '@playwright/test'; +import { waitForStreamedSpan, getSpanOp, collectStreamedSpansUntilSegment } from '@sentry-internal/test-utils'; +import { APP_NAME } from './constants'; + +const PREFIX = '/test-routes'; + +const REGISTRATION_STYLES = [ + { name: 'direct method', path: '' }, + { name: '.all()', path: '/all' }, + { name: '.on()', path: '/on' }, +] as const; + +test.describe('HTTP methods', () => { + ['GET', 'POST', 'PUT', 'DELETE', 'PATCH'].forEach(method => { + test(`sends transaction for ${method}`, async ({ baseURL }) => { + const segmentPromise = collectStreamedSpansUntilSegment( + APP_NAME, + segment => getSpanOp(segment) === 'http.server' && segment.name === `${method} ${PREFIX}`, + ); + + const response = await fetch(`${baseURL}${PREFIX}`, { method }); + expect(response.status).toBe(200); + + const segmentSpans = await segmentPromise; + const segment = segmentSpans.find( + segment => segment.is_segment && getSpanOp(segment) === 'http.server' && segment.name === `${method} ${PREFIX}`, + )!; + expect(segment.name).toBe(`${method} ${PREFIX}`); + expect(getSpanOp(segment)).toBe('http.server'); + expect(segment.attributes?.['sentry.segment.name.source']?.value).toBe('route'); + + const spans = segmentSpans.filter( + span => !span.is_segment && span.attributes['sentry.segment.id']?.value === segment.span_id, + ); + const middlewareSpans = spans.filter(s => getSpanOp(s) === 'middleware'); + expect(middlewareSpans).toEqual([]); + }); + }); +}); + +test.describe('route registration styles', () => { + REGISTRATION_STYLES.forEach(({ name, path }) => { + test(`${name} sends transaction with route source`, async ({ baseURL }) => { + const segmentPromise = collectStreamedSpansUntilSegment( + APP_NAME, + segment => getSpanOp(segment) === 'http.server' && segment.name === `GET ${PREFIX}${path}`, + ); + + const response = await fetch(`${baseURL}${PREFIX}${path}`); + expect(response.status).toBe(200); + + const segmentSpans = await segmentPromise; + const segment = segmentSpans.find( + segment => + segment.is_segment && getSpanOp(segment) === 'http.server' && segment.name === `GET ${PREFIX}${path}`, + )!; + expect(segment.name).toBe(`GET ${PREFIX}${path}`); + expect(getSpanOp(segment)).toBe('http.server'); + expect(segment.attributes?.['sentry.segment.name.source']?.value).toBe('route'); + + const spans = segmentSpans.filter( + span => !span.is_segment && span.attributes['sentry.segment.id']?.value === segment.span_id, + ); + const middlewareSpans = spans.filter(s => getSpanOp(s) === 'middleware'); + expect(middlewareSpans).toEqual([]); + }); + }); + + [ + { name: '.all()', path: '/all' }, + { name: '.on()', path: '/on' }, + ].forEach(({ name, path }) => { + test(`${name} responds to POST`, async ({ baseURL }) => { + const segmentPromise = collectStreamedSpansUntilSegment( + APP_NAME, + segment => getSpanOp(segment) === 'http.server' && segment.name === `POST ${PREFIX}${path}`, + ); + + const response = await fetch(`${baseURL}${PREFIX}${path}`, { method: 'POST' }); + expect(response.status).toBe(200); + + const segmentSpans = await segmentPromise; + const segment = segmentSpans.find( + segment => + segment.is_segment && getSpanOp(segment) === 'http.server' && segment.name === `POST ${PREFIX}${path}`, + )!; + expect(segment.name).toBe(`POST ${PREFIX}${path}`); + expect(segment.attributes?.['sentry.segment.name.source']?.value).toBe('route'); + + const spans = segmentSpans.filter( + span => !span.is_segment && span.attributes['sentry.segment.id']?.value === segment.span_id, + ); + const middlewareSpans = spans.filter(s => getSpanOp(s) === 'middleware'); + expect(middlewareSpans).toEqual([]); + }); + }); +}); + +test.describe('request data extraction', () => { + test('includes method, url, and headers on span', async ({ baseURL }) => { + const segmentPromise = waitForStreamedSpan( + APP_NAME, + segment => + segment.is_segment && getSpanOp(segment) === 'http.server' && segment.name === `GET ${PREFIX}/request-data`, + ); + + const response = await fetch(`${baseURL}${PREFIX}/request-data`); + expect(response.status).toBe(200); + + const segment = await segmentPromise; + expect(segment.attributes['http.request.method']?.value).toBe('GET'); + expect(segment.attributes['url.full']?.value).toContain(PREFIX); + expect(segment.attributes['http.request.header.host']).toBeDefined(); + }); + + test('includes query_string when present', async ({ baseURL }) => { + const segmentPromise = waitForStreamedSpan( + APP_NAME, + segment => + segment.is_segment && + getSpanOp(segment) === 'http.server' && + segment.name === `GET ${PREFIX}/query-test` && + segment.attributes['url.query']?.value === 'foo=bar&baz=42', + ); + + const response = await fetch(`${baseURL}${PREFIX}/query-test?foo=bar&baz=42`); + expect(response.status).toBe(200); + + const segment = await segmentPromise; + + expect(segment.attributes['http.request.method']?.value).toBe('GET'); + expect(segment.attributes['url.full']?.value).toContain(`${PREFIX}/query-test`); + expect(segment.attributes['url.query']?.value).toBe('foo=bar&baz=42'); + }); + + test('includes request data for POST with headers', async ({ baseURL }) => { + const segmentPromise = waitForStreamedSpan( + APP_NAME, + segment => + segment.is_segment && getSpanOp(segment) === 'http.server' && segment.name === `POST ${PREFIX}/request-data`, + ); + + const response = await fetch(`${baseURL}${PREFIX}/request-data`, { + method: 'POST', + headers: { 'X-Custom-Header': 'test-value' }, + }); + expect(response.status).toBe(200); + + const segment = await segmentPromise; + expect(segment.attributes['http.request.method']?.value).toBe('POST'); + expect(segment.attributes['url.full']?.value).toContain(PREFIX); + expect(segment.attributes['http.request.header.x-custom-header']?.value).toEqual(['test-value']); + }); +}); + +test('async handler sends span', async ({ baseURL }) => { + const segmentPromise = collectStreamedSpansUntilSegment( + APP_NAME, + segment => getSpanOp(segment) === 'http.server' && segment.name === `GET ${PREFIX}/async`, + ); + + const response = await fetch(`${baseURL}${PREFIX}/async`); + expect(response.status).toBe(200); + + const segmentSpans = await segmentPromise; + const segment = segmentSpans.find( + segment => segment.is_segment && getSpanOp(segment) === 'http.server' && segment.name === `GET ${PREFIX}/async`, + )!; + expect(segment.name).toBe(`GET ${PREFIX}/async`); + expect(getSpanOp(segment)).toBe('http.server'); + expect(segment.attributes?.['sentry.segment.name.source']?.value).toBe('route'); + + const spans = segmentSpans.filter( + span => !span.is_segment && span.attributes['sentry.segment.id']?.value === segment.span_id, + ); + const middlewareSpans = spans.filter(s => getSpanOp(s) === 'middleware'); + expect(middlewareSpans).toEqual([]); +}); diff --git a/dev-packages/e2e-tests/test-applications/hono-4-legacy/tests/tracing.test.ts b/dev-packages/e2e-tests/test-applications/hono-4-legacy/tests/tracing.test.ts new file mode 100644 index 000000000000..23ed5c7837fc --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/hono-4-legacy/tests/tracing.test.ts @@ -0,0 +1,144 @@ +import { expect, test } from '@playwright/test'; +import { waitForStreamedSpan, getSpanOp } from '@sentry-internal/test-utils'; +import { APP_NAME, RUNTIME } from './constants'; + +test('sends a span for the index route', async ({ baseURL }) => { + const segmentPromise = waitForStreamedSpan( + APP_NAME, + segment => segment.is_segment && getSpanOp(segment) === 'http.server' && segment.name === 'GET /', + ); + + const response = await fetch(`${baseURL}/`); + expect(response.status).toBe(200); + + const segment = await segmentPromise; + expect(segment.name).toBe('GET /'); + expect(getSpanOp(segment)).toBe('http.server'); +}); + +test('sends a span for a parameterized route', async ({ baseURL }) => { + const segmentPromise = waitForStreamedSpan( + APP_NAME, + segment => segment.is_segment && getSpanOp(segment) === 'http.server' && !!segment.name?.includes('/test-param/'), + ); + + const response = await fetch(`${baseURL}/test-param/123`); + expect(response.status).toBe(200); + + const segment = await segmentPromise; + expect(segment.name).toBe('GET /test-param/:paramId'); + expect(getSpanOp(segment)).toBe('http.server'); +}); + +test('attaches HTTP connection info to the server span', async ({ baseURL, page }) => { + page.on('console', msg => { + console.log(`PAGE LOG: ${msg.text()}`); + }); + const segmentPromise = waitForStreamedSpan( + APP_NAME, + segment => segment.is_segment && getSpanOp(segment) === 'http.server' && segment.name === 'GET /', + ); + + const response = await fetch(`${baseURL}/`); + expect(response.status).toBe(200); + + const segment = await segmentPromise; + const data = segment.attributes ?? {}; + + expect(data['client.address']?.value).toEqual(expect.any(String)); + expect(data['network.peer.address']?.value).toBe(data['client.address']?.value); + + if (RUNTIME !== 'deno') { + // Only exposed in `hono/deno` + expect(data['network.transport']?.value).toBeUndefined(); + } else { + expect(data['network.transport']?.value).toMatch(/tcp/); + } + + if (RUNTIME === 'node' || RUNTIME === 'bun') { + // Node (@hono/node-server) and Bun expose socket-level port and address family. + expect(data['client.port']?.value).toEqual(expect.any(Number)); + expect(data['network.peer.port']?.value).toBe(data['client.port']?.value); + expect(data['network.type']?.value).toMatch(/^ipv[46]$/); + } else if (RUNTIME === 'deno') { + expect(data['client.port']?.value).toEqual(expect.any(Number)); + expect(data['network.peer.port']?.value).toBe(data['client.port']?.value); + } else if (RUNTIME === 'cloudflare') { + // Cloudflare Workers expose no port, address family, or transport. + // This could change in the future and checking for the absence of these fields allows us to notice if/when that happens. + expect(data['client.port']?.value).toBeUndefined(); + expect(data['network.peer.port']?.value).toBeUndefined(); + expect(data['network.type']?.value).toBeUndefined(); + } else { + throw new Error(`No tests for runtime: ${RUNTIME}`); + } +}); + +// Regression guard against connection info attributes. +// The conninfo middleware must only *add* attributes, never replace or clear existing ones. +// These are the baseline attributes the server transaction carries *without* the conninfo feature +test("preserves the baseline server.*, client.* and network.* server span attributes that the SDK sends without Hono's conninfo", async ({ + baseURL, +}) => { + const segmentPromise = waitForStreamedSpan( + APP_NAME, + segment => segment.is_segment && getSpanOp(segment) === 'http.server' && segment.name === 'GET /', + ); + + const response = await fetch(`${baseURL}/`); + expect(response.status).toBe(200); + + const segment = await segmentPromise; + const data = segment.attributes ?? {}; + + if (RUNTIME === 'node') { + expect(data['server.address']?.value).toBe('localhost'); + expect(data['server.port']?.value).toBe(Number(new URL(baseURL!).port)); + expect(data['client.address']?.value).toEqual(expect.any(String)); + expect(data['client.port']?.value).toEqual(expect.any(Number)); + expect(data['network.type']?.value).toMatch(/^ipv[46]$/); + expect(data['network.protocol.name']?.value).toBe('http'); + expect(data['network.protocol.version']?.value).toBe('1.1'); + expect(data['network.transport']?.value).toBeUndefined(); + expect(data['network.local.port']?.value).toBe(data['server.port']?.value); + expect(data['network.local.address']?.value).toEqual(expect.any(String)); + expect(data['network.peer.address']?.value).toBe(data['client.address']?.value); + expect(data['network.peer.port']?.value).toBe(data['client.port']?.value); + } else if (RUNTIME === 'bun') { + expect(data['client.address']?.value).toEqual(expect.any(String)); + expect(data['client.port']?.value).toEqual(expect.any(Number)); + expect(data['network.peer.address']?.value).toBe(data['client.address']?.value); + expect(data['network.peer.port']?.value).toBe(data['client.port']?.value); + expect(data['network.type']?.value).toMatch(/^ipv[46]$/); + } else if (RUNTIME === 'cloudflare') { + expect(data['server.address']?.value).toBe('localhost'); + expect(data['client.address']?.value).toBe('::1'); + expect(data['network.peer.address']?.value).toBe(data['client.address']?.value); + expect(data['network.protocol.name']?.value).toBe('http'); + expect(data['network.protocol.version']?.value).toBe('1.1'); + } else if (RUNTIME === 'deno') { + expect(data['server.address']?.value).toBe('localhost'); + expect(data['client.address']?.value).toEqual(expect.any(String)); + expect(data['client.port']?.value).toEqual(expect.any(Number)); + expect(data['network.peer.address']?.value).toBe(data['client.address']?.value); + expect(data['network.peer.port']?.value).toBe(data['client.port']?.value); + expect(data['network.transport']?.value).toBe('tcp'); + expect(data['network.protocol.name']?.value).toBe('http'); + } else { + throw new Error(`No tests for runtime: ${RUNTIME}`); + } +}); + +test('sends a span for a route that throws', async ({ baseURL }) => { + const segmentPromise = waitForStreamedSpan( + APP_NAME, + segment => segment.is_segment && getSpanOp(segment) === 'http.server' && !!segment.name?.includes('/error/'), + ); + + await fetch(`${baseURL}/error/test-cause`); + + const segment = await segmentPromise; + expect(segment.name).toBe('GET /error/:cause'); + expect(getSpanOp(segment)).toBe('http.server'); + expect(segment?.status).toBe('error'); +}); diff --git a/dev-packages/e2e-tests/test-applications/hono-4-legacy/tsconfig.json b/dev-packages/e2e-tests/test-applications/hono-4-legacy/tsconfig.json new file mode 100644 index 000000000000..3c4abeff44d6 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/hono-4-legacy/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "Bundler", + "strict": true, + "skipLibCheck": true, + "lib": ["ESNext"], + "jsx": "react-jsx", + "jsxImportSource": "hono/jsx", + "types": ["@cloudflare/workers-types"] + } +} diff --git a/dev-packages/e2e-tests/test-applications/hono-4-legacy/wrangler.jsonc b/dev-packages/e2e-tests/test-applications/hono-4-legacy/wrangler.jsonc new file mode 100644 index 000000000000..a3c646c03aa9 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/hono-4-legacy/wrangler.jsonc @@ -0,0 +1,7 @@ +{ + "$schema": "node_modules/wrangler/config-schema.json", + "name": "hono-4-legacy", + "main": "src/entry.cloudflare.ts", + "compatibility_date": "2026-04-20", + "compatibility_flags": ["nodejs_compat"], +} diff --git a/dev-packages/e2e-tests/test-applications/hono-4/build-bun.ts b/dev-packages/e2e-tests/test-applications/hono-4/build-bun.ts new file mode 100644 index 000000000000..ff5129bee50a --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/hono-4/build-bun.ts @@ -0,0 +1,28 @@ +// Builds `src/entry.bun.ts` with the orchestrion `bun build` plugin, emitting `dist/entry.bun.js` +// for the server to run. The plugin injects the `orchestrion:hono:honoConstructor` diagnostics +// channel into the bundled `hono`, which the `honoIntegration` default subscribes to. + +// @ts-ignore -- subpath export resolved by Bun at runtime; the package tsconfig's node module +// resolution can't see `exports` subpaths. +import { sentryBunPlugin } from '@sentry/bun/plugin'; +import { join } from 'path'; + +void (async () => { + const result = await Bun.build({ + entrypoints: [join(__dirname, 'src/entry.bun.ts')], + target: 'bun', + outdir: join(__dirname, 'dist'), + // `@sentry/bun` (and its deps) stay external, so we don't bundle the whole SDK stack. + external: ['@sentry/bun'], + plugins: [sentryBunPlugin()], + }); + + if (!result.success) { + // eslint-disable-next-line no-console + console.error('BUILD_FAILED', result.logs); + process.exit(1); + } + + // eslint-disable-next-line no-console + console.log('BUILD_OK'); +})(); diff --git a/dev-packages/e2e-tests/test-applications/hono-4/deno.json b/dev-packages/e2e-tests/test-applications/hono-4/deno.json index d7748bc95ecb..7a7e681c4a82 100644 --- a/dev-packages/e2e-tests/test-applications/hono-4/deno.json +++ b/dev-packages/e2e-tests/test-applications/hono-4/deno.json @@ -1,7 +1,5 @@ { "imports": { - "@sentry/hono": "npm:@sentry/hono", - "@sentry/hono/deno": "npm:@sentry/hono/deno", "@sentry/deno": "npm:@sentry/deno", "@sentry/core": "npm:@sentry/core", "@opentelemetry/api": "npm:@opentelemetry/api@^1.9.0", diff --git a/dev-packages/e2e-tests/test-applications/hono-4/package.json b/dev-packages/e2e-tests/test-applications/hono-4/package.json index db3b18166812..a3fd78b9ec67 100644 --- a/dev-packages/e2e-tests/test-applications/hono-4/package.json +++ b/dev-packages/e2e-tests/test-applications/hono-4/package.json @@ -4,30 +4,35 @@ "version": "0.0.0", "private": true, "scripts": { - "dev:cf": "wrangler dev --var \"E2E_TEST_DSN:$E2E_TEST_DSN\" --log-level=$(test $CI && echo 'none' || echo 'log')", "dev:node": "node --import tsx/esm --import ./src/instrument.node.ts src/entry.node.ts", - "dev:bun": "bun src/entry.bun.ts", - "dev:deno": "deno run --allow-net --allow-env --allow-read src/entry.deno.ts", - "build": "wrangler deploy --dry-run", - "test:build": "pnpm install && pnpm build", - "test:assert": "TEST_ENV=production playwright test" + "dev:bun": "bun run dist/entry.bun.js", + "dev:deno": "deno run --allow-net --allow-env --allow-read --preload=npm:@sentry/deno/import src/entry.deno.ts", + "dev:cloudflare": "wrangler dev --config ./dist/hono_4/wrangler.json --var \"E2E_TEST_DSN:$E2E_TEST_DSN\" --log-level=$(test $CI && echo 'none' || echo 'log')", + "test:build": "pnpm install", + "test": "TEST_ENV=production playwright test", + "test:assert": "pnpm test:assert:node", + "test:assert:node": "RUNTIME=node pnpm test", + "test:assert:bun": "bun run build-bun.ts && RUNTIME=bun pnpm test", + "test:assert:deno": "RUNTIME=deno pnpm test", + "test:assert:cloudflare": "vite build && RUNTIME=cloudflare pnpm test" }, "dependencies": { "@sentry/bun": "latest || *", "@sentry/cloudflare": "latest || *", "@sentry/deno": "latest || *", - "@sentry/hono": "latest || *", "@sentry/node": "latest || *", "@hono/node-server": "^2.0.5", "hono": "^4.13.1" }, "devDependencies": { "@playwright/test": "~1.63.0", + "@cloudflare/vite-plugin": "^1.47.0", "@cloudflare/workers-types": "^4.20240725.0", "@sentry-internal/test-utils": "link:../../../test-utils", "tsx": "4.21.0", "typescript": "^5.5.2", - "wrangler": "^4.61.0" + "vite": "^8.1.5", + "wrangler": "^4.114.0" }, "volta": { "node": "24.15.0", @@ -36,16 +41,16 @@ "sentryTest": { "variants": [ { - "assert-command": "RUNTIME=node pnpm test:assert", - "label": "hono-4 (node)" - }, - { - "assert-command": "RUNTIME=bun pnpm test:assert", + "assert-command": "pnpm test:assert:bun", "label": "hono-4 (bun)" }, { - "assert-command": "RUNTIME=deno pnpm test:assert", + "assert-command": "pnpm test:assert:deno", "label": "hono-4 (deno)" + }, + { + "assert-command": "pnpm test:assert:cloudflare", + "label": "hono-4 (cloudflare)" } ] } diff --git a/dev-packages/e2e-tests/test-applications/hono-4/playwright.config.ts b/dev-packages/e2e-tests/test-applications/hono-4/playwright.config.ts index 5087b887e1f2..b8c04df05aee 100644 --- a/dev-packages/e2e-tests/test-applications/hono-4/playwright.config.ts +++ b/dev-packages/e2e-tests/test-applications/hono-4/playwright.config.ts @@ -10,7 +10,7 @@ if (!testEnv) { const APP_PORT = 38787; const startCommands: Record = { - cloudflare: `pnpm dev:cf --port ${APP_PORT}`, + cloudflare: `pnpm dev:cloudflare --port ${APP_PORT}`, node: `pnpm dev:node`, bun: `pnpm dev:bun`, deno: `pnpm dev:deno`, diff --git a/dev-packages/e2e-tests/test-applications/hono-4/src/entry.bun.ts b/dev-packages/e2e-tests/test-applications/hono-4/src/entry.bun.ts index e057eb78d4c5..42f8db18e397 100644 --- a/dev-packages/e2e-tests/test-applications/hono-4/src/entry.bun.ts +++ b/dev-packages/e2e-tests/test-applications/hono-4/src/entry.bun.ts @@ -1,18 +1,11 @@ +// Import the Sentry init first so `honoIntegration` subscribes to the Hono constructor channel +// before any `new Hono()` runs — including the sub-apps that route modules build at module scope. +import './instrument.bun'; import { Hono } from 'hono'; -import { sentry } from '@sentry/hono/bun'; import { addRoutes } from './routes'; const app = new Hono(); -app.use( - sentry(app, { - dsn: process.env.E2E_TEST_DSN, - environment: 'qa', - tracesSampleRate: 1.0, - tunnel: 'http://localhost:3031/', - }), -); - addRoutes(app); const port = Number(process.env.PORT || 38787); diff --git a/dev-packages/e2e-tests/test-applications/hono-4/src/entry.cloudflare.ts b/dev-packages/e2e-tests/test-applications/hono-4/src/entry.cloudflare.ts index e348dde56226..7fb84f59947a 100644 --- a/dev-packages/e2e-tests/test-applications/hono-4/src/entry.cloudflare.ts +++ b/dev-packages/e2e-tests/test-applications/hono-4/src/entry.cloudflare.ts @@ -1,18 +1,8 @@ import { Hono } from 'hono'; -import { sentry } from '@sentry/hono/cloudflare'; import { addRoutes } from './routes'; const app = new Hono<{ Bindings: { E2E_TEST_DSN: string } }>(); -app.use( - sentry(app, env => ({ - dsn: env.E2E_TEST_DSN, - environment: 'qa', - tracesSampleRate: 1.0, - tunnel: 'http://localhost:3031/', - })), -); - addRoutes(app); export default app; diff --git a/dev-packages/e2e-tests/test-applications/hono-4/src/entry.deno.ts b/dev-packages/e2e-tests/test-applications/hono-4/src/entry.deno.ts index 15bd12a74111..eb80da55fc82 100644 --- a/dev-packages/e2e-tests/test-applications/hono-4/src/entry.deno.ts +++ b/dev-packages/e2e-tests/test-applications/hono-4/src/entry.deno.ts @@ -1,19 +1,11 @@ +// Import the Sentry init first so `honoIntegration` subscribes to the Hono constructor channel +// before any `new Hono()` runs — including the sub-apps that route modules build at module scope. +import './instrument.deno'; import { Hono } from 'hono'; -import { sentry } from '@sentry/hono/deno'; import { addRoutes } from './routes'; const app = new Hono(); -app.use( - sentry(app, { - dsn: Deno.env.get('E2E_TEST_DSN'), - environment: 'qa', - dataCollection: {}, - tracesSampleRate: 1.0, - tunnel: 'http://localhost:3031/', - }), -); - addRoutes(app); const port = Number(Deno.env.get('PORT') || 38787); diff --git a/dev-packages/e2e-tests/test-applications/hono-4/src/entry.node.ts b/dev-packages/e2e-tests/test-applications/hono-4/src/entry.node.ts index 898a92e08be4..c67b3a206ef2 100644 --- a/dev-packages/e2e-tests/test-applications/hono-4/src/entry.node.ts +++ b/dev-packages/e2e-tests/test-applications/hono-4/src/entry.node.ts @@ -1,16 +1,14 @@ -import { Hono } from 'hono'; -import { sentry } from '@sentry/hono/node'; import { serve } from '@hono/node-server'; +import { Hono } from 'hono'; import { addRoutes } from './routes'; const app = new Hono(); -app.use(sentry(app)); - addRoutes(app); const port = Number(process.env.PORT || 38787); serve({ fetch: app.fetch, port }, () => { + // eslint-disable-next-line no-console console.log(`Hono (Node) listening on port ${port}`); }); diff --git a/dev-packages/e2e-tests/test-applications/hono-4/src/instrument.bun.ts b/dev-packages/e2e-tests/test-applications/hono-4/src/instrument.bun.ts new file mode 100644 index 000000000000..9cb58d8c2aa2 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/hono-4/src/instrument.bun.ts @@ -0,0 +1,8 @@ +import * as Sentry from '@sentry/bun'; + +Sentry.init({ + dsn: process.env.E2E_TEST_DSN, + environment: 'qa', + tracesSampleRate: 1.0, + tunnel: 'http://localhost:3031/', +}); diff --git a/dev-packages/e2e-tests/test-applications/hono-4/src/instrument.deno.ts b/dev-packages/e2e-tests/test-applications/hono-4/src/instrument.deno.ts new file mode 100644 index 000000000000..49c9d1d452f1 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/hono-4/src/instrument.deno.ts @@ -0,0 +1,9 @@ +import * as Sentry from '@sentry/deno'; + +Sentry.init({ + dsn: Deno.env.get('E2E_TEST_DSN'), + environment: 'qa', + dataCollection: {}, + tracesSampleRate: 1.0, + tunnel: 'http://localhost:3031/', +}); diff --git a/dev-packages/e2e-tests/test-applications/hono-4/src/instrument.node.ts b/dev-packages/e2e-tests/test-applications/hono-4/src/instrument.node.ts index 82f2a3864125..5c3e586fd48b 100644 --- a/dev-packages/e2e-tests/test-applications/hono-4/src/instrument.node.ts +++ b/dev-packages/e2e-tests/test-applications/hono-4/src/instrument.node.ts @@ -1,4 +1,4 @@ -import * as Sentry from '@sentry/hono/node'; +import * as Sentry from '@sentry/node'; Sentry.init({ dsn: process.env.E2E_TEST_DSN, diff --git a/dev-packages/e2e-tests/test-applications/hono-4/src/instrument.server.ts b/dev-packages/e2e-tests/test-applications/hono-4/src/instrument.server.ts new file mode 100644 index 000000000000..0c4afab91058 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/hono-4/src/instrument.server.ts @@ -0,0 +1,7 @@ +// Cloudflare-SDK init +export default (env: { E2E_TEST_DSN: string }) => ({ + dsn: env.E2E_TEST_DSN, + environment: 'qa', + tracesSampleRate: 1.0, + tunnel: 'http://localhost:3031/', +}); diff --git a/dev-packages/e2e-tests/test-applications/hono-4/src/middleware.ts b/dev-packages/e2e-tests/test-applications/hono-4/src/middleware.ts index cc7bfae9896d..6f3a8a98a727 100644 --- a/dev-packages/e2e-tests/test-applications/hono-4/src/middleware.ts +++ b/dev-packages/e2e-tests/test-applications/hono-4/src/middleware.ts @@ -1,17 +1,26 @@ import type { MiddlewareHandler } from 'hono'; -export const middlewareA: MiddlewareHandler = async function middlewareA(c, next) { +// Defined as anonymous function expressions so the function name is inferred from the `const` +// binding. A named expression (`const middlewareA = async function middlewareA() {}`) collides with +// the binding when bundled, and Bun renames the inner function (→ `middlewareA2`), which would then +// surface as the middleware span name. The inferred name stays stable across all runtimes. +export const middlewareA: MiddlewareHandler = async function (c, next) { // Add some delay await new Promise(resolve => setTimeout(resolve, 50)); await next(); }; -export const middlewareB: MiddlewareHandler = async function middlewareB(_c, next) { +export const middlewareB: MiddlewareHandler = async function (_c, next) { // Add some delay await new Promise(resolve => setTimeout(resolve, 60)); await next(); }; -export const failingMiddleware: MiddlewareHandler = async function failingMiddleware(_c, _next) { - throw new Error('Middleware error'); +let failingMiddlewareCount = 0; +export const failingMiddleware: MiddlewareHandler = async function (_c, _next) { + // Each throw gets a unique suffix so the Dedupe integration doesn't collapse the identical errors + // that several tests (and their retries) trigger through this shared middleware — otherwise only the + // first would be reported and the other tests' `waitForError` would time out. Tests match on the + // stable `Middleware error` prefix. + throw new Error(`Middleware error #${(failingMiddlewareCount += 1)}`); }; diff --git a/dev-packages/e2e-tests/test-applications/hono-4/tests/errors.test.ts b/dev-packages/e2e-tests/test-applications/hono-4/tests/errors.test.ts index e3c44c67d4d6..b315233ec15b 100644 --- a/dev-packages/e2e-tests/test-applications/hono-4/tests/errors.test.ts +++ b/dev-packages/e2e-tests/test-applications/hono-4/tests/errors.test.ts @@ -5,7 +5,7 @@ import { getSpanOp, collectStreamedSpansUntilSegment, } from '@sentry-internal/test-utils'; -import { APP_NAME, RUNTIME } from './constants'; +import { APP_NAME } from './constants'; test.describe('route handler errors', () => { test('captures error with mechanism and trace correlation', async ({ baseURL }) => { @@ -103,81 +103,37 @@ test.describe('HTTPException errors', () => { }); }); - // On Node/Bun, httpServerSpansIntegration drops transactions for 3xx/4xx responses (ignoreStatusCodes), so we just use a request guard. - // On Cloudflare the transaction is available, and we additionally verify its name. - [301, 302].forEach(code => { - test(`does not capture ${code} HTTPException`, async ({ baseURL }) => { - let errorEventOccurred = false; - - waitForError(APP_NAME, event => { - if (event.exception?.values?.[0]?.value === `HTTPException ${code}`) { - errorEventOccurred = true; - } - return false; - }); - - const segmentPromise = waitForStreamedSpan( - APP_NAME, - segment => - segment.is_segment && - (RUNTIME === 'cloudflare' - ? getSpanOp(segment) === 'http.server' && !!segment.name?.includes('/http-exception/') - : getSpanOp(segment) === 'http.server' && segment.name === 'GET /'), - ); - - const response = await fetch(`${baseURL}/http-exception/${code}`, { redirect: 'manual' }); - expect(response.status).toBe(code); - - if (RUNTIME !== 'cloudflare') { - // Simple request guard for non-Cloudflare runtimes since the other transaction is dropped for 4xx responses - await fetch(`${baseURL}/`); - } - - const segment = await segmentPromise; - - if (RUNTIME === 'cloudflare') { - expect(segment.name).toBe('GET /http-exception/:code'); + // 3xx/4xx responses must not be captured as errors. Some runtimes drop the transaction for those + // status codes (httpServerSpansIntegration's ignoreStatusCodes), so instead of waiting on the + // HTTPException route's own (possibly dropped) transaction, we wait on a defined 2xx route's + // transaction as a flush guard — that one is produced on every runtime — then assert no error was + // captured. (Parametrized route naming is covered by tracing.test.ts on 2xx routes.) + const expectHttpExceptionNotCaptured = async (baseURL: string, code: number): Promise => { + let errorEventOccurred = false; + waitForError(APP_NAME, event => { + if (event.exception?.values?.[0]?.value === `HTTPException ${code}`) { + errorEventOccurred = true; } - - expect(errorEventOccurred).toBe(false); + return false; }); - }); - [401, 403, 404].forEach(code => { - test(`does not capture ${code} HTTPException`, async ({ baseURL }) => { - let errorEventOccurred = false; - - waitForError(APP_NAME, event => { - if (event.exception?.values?.[0]?.value === `HTTPException ${code}`) { - errorEventOccurred = true; - } - return false; - }); - - const segmentPromise = waitForStreamedSpan( - APP_NAME, - segment => - segment.is_segment && - (RUNTIME === 'cloudflare' - ? getSpanOp(segment) === 'http.server' && !!segment.name?.includes('/http-exception/') - : getSpanOp(segment) === 'http.server' && segment.name === 'GET /'), - ); - - const response = await fetch(`${baseURL}/http-exception/${code}`); - expect(response.status).toBe(code); - - if (RUNTIME !== 'cloudflare') { - // Simple request guard for non-Cloudflare runtimes since the other transaction is dropped for 4xx responses - await fetch(`${baseURL}/`); - } + const guardPromise = waitForStreamedSpan( + APP_NAME, + segment => segment.is_segment && getSpanOp(segment) === 'http.server' && segment.name === 'GET /', + ); - const segment = await segmentPromise; + const response = await fetch(`${baseURL}/http-exception/${code}`, { redirect: 'manual' }); + expect(response.status).toBe(code); - if (RUNTIME === 'cloudflare') { - expect(segment.name).toBe('GET /http-exception/:code'); - } + await fetch(`${baseURL}/`); + await guardPromise; + + expect(errorEventOccurred).toBe(false); + }; - expect(errorEventOccurred).toBe(false); + [301, 302, 401, 403, 404].forEach(code => { + test(`does not capture ${code} HTTPException`, async ({ baseURL }) => { + await expectHttpExceptionNotCaptured(baseURL!, code); }); }); }); @@ -226,41 +182,18 @@ test.describe('middleware errors', () => { return false; }); - const segmentPromise = collectStreamedSpansUntilSegment(APP_NAME, segment => { - if (RUNTIME === 'cloudflare') { - return ( - getSpanOp(segment) === 'http.server' && !!segment.name?.includes('/test-errors/middleware-http-exception-4xx') - ); - } - return getSpanOp(segment) === 'http.server' && segment.name === 'GET /'; - }); + // Guard on a defined 2xx route's transaction (produced on every runtime) rather than the 4xx + // route's own transaction, which some runtimes drop — then assert no error was captured. + const guardPromise = waitForStreamedSpan( + APP_NAME, + segment => segment.is_segment && getSpanOp(segment) === 'http.server' && segment.name === 'GET /', + ); const response = await fetch(`${baseURL}/test-errors/middleware-http-exception-4xx`); expect(response.status).toBe(401); - if (RUNTIME !== 'cloudflare') { - await fetch(`${baseURL}/`); - } - - const segmentSpans = await segmentPromise; - const segment = segmentSpans.find(segment => { - if (!segment.is_segment) return false; - if (RUNTIME === 'cloudflare') { - return ( - getSpanOp(segment) === 'http.server' && !!segment.name?.includes('/test-errors/middleware-http-exception-4xx') - ); - } - return getSpanOp(segment) === 'http.server' && segment.name === 'GET /'; - })!; - - if (RUNTIME === 'cloudflare') { - expect(segment.name).toBe('GET /test-errors/middleware-http-exception-4xx'); - - const middlewareSpan = segmentSpans - .filter(span => !span.is_segment && span.attributes['sentry.segment.id']?.value === segment.span_id) - .find(s => getSpanOp(s) === 'middleware'); - expect(middlewareSpan?.status).not.toBe('error'); - } + await fetch(`${baseURL}/`); + await guardPromise; expect(errorEventOccurred).toBe(false); }); diff --git a/dev-packages/e2e-tests/test-applications/hono-4/tests/middleware.test.ts b/dev-packages/e2e-tests/test-applications/hono-4/tests/middleware.test.ts index 111ff3bfe34a..b9380ec4706d 100644 --- a/dev-packages/e2e-tests/test-applications/hono-4/tests/middleware.test.ts +++ b/dev-packages/e2e-tests/test-applications/hono-4/tests/middleware.test.ts @@ -115,14 +115,17 @@ for (const { name, prefix } of SCENARIOS) { test('captures error thrown in middleware', async ({ baseURL }) => { const errorPromise = waitForError(APP_NAME, event => { - return event.exception?.values?.[0]?.value === 'Middleware error'; + return ( + !!event.exception?.values?.[0]?.value?.startsWith('Middleware error') && + !!event.request?.url?.includes(prefix) + ); }); const response = await fetch(`${baseURL}${prefix}/error`); expect(response.status).toBe(500); const errorEvent = await errorPromise; - expect(errorEvent.exception?.values?.[0]?.value).toBe('Middleware error'); + expect(errorEvent.exception?.values?.[0]?.value).toMatch(/^Middleware error/); expect(errorEvent.exception?.values?.[0]?.mechanism).toEqual( expect.objectContaining({ handled: false, @@ -184,7 +187,10 @@ for (const { name, prefix } of SCENARIOS) { test('includes request data on error events from middleware', async ({ baseURL }) => { const errorPromise = waitForError(APP_NAME, event => { - return event.exception?.values?.[0]?.value === 'Middleware error' && !!event.request?.url?.includes(prefix); + return ( + !!event.exception?.values?.[0]?.value?.startsWith('Middleware error') && + !!event.request?.url?.includes(prefix) + ); }); await fetch(`${baseURL}${prefix}/error`); diff --git a/dev-packages/e2e-tests/test-applications/hono-4/tests/multi-fetch.test.ts b/dev-packages/e2e-tests/test-applications/hono-4/tests/multi-fetch.test.ts index 29298c7b8f89..4ed843fc2a7d 100644 --- a/dev-packages/e2e-tests/test-applications/hono-4/tests/multi-fetch.test.ts +++ b/dev-packages/e2e-tests/test-applications/hono-4/tests/multi-fetch.test.ts @@ -347,8 +347,11 @@ test.describe('multi-fetch: internal .request() calls between sub-apps', () => { }); test('error from failed internal fetch is correlated with the storefront trace', async ({ baseURL }) => { + // Use a param unique to this test: the `no error status` test above fetches `/ghost` too, and its + // identical `Failed to fetch product: ghost` error would otherwise be dropped by the Dedupe + // integration, so this test's `waitForError` would never fire. const errorPromise = waitForError(APP_NAME, event => { - return event.exception?.values?.[0]?.value === 'Failed to fetch product: ghost'; + return event.exception?.values?.[0]?.value === 'Failed to fetch product: phantom'; }); const segmentPromise = waitForStreamedSpan( @@ -359,7 +362,7 @@ test.describe('multi-fetch: internal .request() calls between sub-apps', () => { segment.name === `GET ${STOREFRONT}/product-or-throw/:productId`, ); - await fetch(`${baseURL}${STOREFRONT}/product-or-throw/ghost`); + await fetch(`${baseURL}${STOREFRONT}/product-or-throw/phantom`); const [errorEvent, segment] = await Promise.all([errorPromise, segmentPromise]); diff --git a/dev-packages/e2e-tests/test-applications/hono-4/tests/tracing.test.ts b/dev-packages/e2e-tests/test-applications/hono-4/tests/tracing.test.ts index 23ed5c7837fc..9350259d7e5c 100644 --- a/dev-packages/e2e-tests/test-applications/hono-4/tests/tracing.test.ts +++ b/dev-packages/e2e-tests/test-applications/hono-4/tests/tracing.test.ts @@ -1,6 +1,78 @@ import { expect, test } from '@playwright/test'; import { waitForStreamedSpan, getSpanOp } from '@sentry-internal/test-utils'; -import { APP_NAME, RUNTIME } from './constants'; +import { APP_NAME, RUNTIME, type Runtime } from './constants'; + +const anyString = expect.any(String) as unknown; +const anyNumber = expect.any(Number) as unknown; +const ipvType = expect.stringMatching(/^ipv[46]$/) as unknown; + +// Per-runtime `http.server` connection-info expectations. Each runtime's `getConnInfo` helper exposes +// a different set of fields, so the expected attribute values are declared here once (keyed by +// runtime) instead of branching inside the tests. `undefined` means the attribute must be absent. +// Relational checks (`network.peer.*` mirroring `client.*`) are asserted in the tests, since they +// hold on every runtime (including when both sides are absent). +// +// - `added`: attributes Hono's conninfo middleware contributes. +// - `baseline`: attributes the SDK sends independent of conninfo (regression guard that conninfo only +// adds, never clobbers). +const CONN_INFO: Record; baseline: Record }> = { + node: { + added: { + 'client.address': anyString, + 'client.port': anyNumber, + 'network.type': ipvType, + 'network.transport': undefined, + }, + baseline: { + 'server.address': 'localhost', + 'server.port': anyNumber, + 'client.port': anyNumber, + 'network.type': ipvType, + 'network.protocol.name': 'http', + 'network.protocol.version': '1.1', + 'network.transport': undefined, + 'network.local.address': anyString, + 'network.local.port': anyNumber, + }, + }, + bun: { + added: { + 'client.address': anyString, + 'client.port': anyNumber, + 'network.type': ipvType, + 'network.transport': undefined, + }, + baseline: { 'client.port': anyNumber, 'network.type': ipvType }, + }, + deno: { + // Only `hono/deno` exposes `network.transport`. + added: { 'client.address': anyString, 'client.port': anyNumber, 'network.transport': expect.stringMatching(/tcp/) }, + baseline: { + 'server.address': 'localhost', + 'client.port': anyNumber, + 'network.transport': 'tcp', + 'network.protocol.name': 'http', + }, + }, + cloudflare: { + // Cloudflare Workers expose no client address, port, address family, or transport: there is no + // `getConnInfo` on Workers and nothing else populates `client.address`, so all of these are + // absent. Asserting `undefined` here lets us notice if that ever changes. + added: { + 'client.address': undefined, + 'client.port': undefined, + 'network.type': undefined, + 'network.transport': undefined, + }, + baseline: { + 'server.address': 'localhost', + 'network.protocol.name': 'http', + 'network.protocol.version': '1.1', + }, + }, +}; + +const connInfo = CONN_INFO[RUNTIME]; test('sends a span for the index route', async ({ baseURL }) => { const segmentPromise = waitForStreamedSpan( @@ -45,33 +117,14 @@ test('attaches HTTP connection info to the server span', async ({ baseURL, page const segment = await segmentPromise; const data = segment.attributes ?? {}; - expect(data['client.address']?.value).toEqual(expect.any(String)); - expect(data['network.peer.address']?.value).toBe(data['client.address']?.value); - - if (RUNTIME !== 'deno') { - // Only exposed in `hono/deno` - expect(data['network.transport']?.value).toBeUndefined(); - } else { - expect(data['network.transport']?.value).toMatch(/tcp/); + for (const [key, expected] of Object.entries(connInfo.added)) { + expect(data[key]?.value).toEqual(expected); } - if (RUNTIME === 'node' || RUNTIME === 'bun') { - // Node (@hono/node-server) and Bun expose socket-level port and address family. - expect(data['client.port']?.value).toEqual(expect.any(Number)); - expect(data['network.peer.port']?.value).toBe(data['client.port']?.value); - expect(data['network.type']?.value).toMatch(/^ipv[46]$/); - } else if (RUNTIME === 'deno') { - expect(data['client.port']?.value).toEqual(expect.any(Number)); - expect(data['network.peer.port']?.value).toBe(data['client.port']?.value); - } else if (RUNTIME === 'cloudflare') { - // Cloudflare Workers expose no port, address family, or transport. - // This could change in the future and checking for the absence of these fields allows us to notice if/when that happens. - expect(data['client.port']?.value).toBeUndefined(); - expect(data['network.peer.port']?.value).toBeUndefined(); - expect(data['network.type']?.value).toBeUndefined(); - } else { - throw new Error(`No tests for runtime: ${RUNTIME}`); - } + // conninfo must only *add* attributes, never replace: peer mirrors client on every runtime + // (including when both are absent). + expect(data['network.peer.address']?.value).toBe(data['client.address']?.value); + expect(data['network.peer.port']?.value).toBe(data['client.port']?.value); }); // Regression guard against connection info attributes. @@ -91,42 +144,13 @@ test("preserves the baseline server.*, client.* and network.* server span attrib const segment = await segmentPromise; const data = segment.attributes ?? {}; - if (RUNTIME === 'node') { - expect(data['server.address']?.value).toBe('localhost'); - expect(data['server.port']?.value).toBe(Number(new URL(baseURL!).port)); - expect(data['client.address']?.value).toEqual(expect.any(String)); - expect(data['client.port']?.value).toEqual(expect.any(Number)); - expect(data['network.type']?.value).toMatch(/^ipv[46]$/); - expect(data['network.protocol.name']?.value).toBe('http'); - expect(data['network.protocol.version']?.value).toBe('1.1'); - expect(data['network.transport']?.value).toBeUndefined(); - expect(data['network.local.port']?.value).toBe(data['server.port']?.value); - expect(data['network.local.address']?.value).toEqual(expect.any(String)); - expect(data['network.peer.address']?.value).toBe(data['client.address']?.value); - expect(data['network.peer.port']?.value).toBe(data['client.port']?.value); - } else if (RUNTIME === 'bun') { - expect(data['client.address']?.value).toEqual(expect.any(String)); - expect(data['client.port']?.value).toEqual(expect.any(Number)); - expect(data['network.peer.address']?.value).toBe(data['client.address']?.value); - expect(data['network.peer.port']?.value).toBe(data['client.port']?.value); - expect(data['network.type']?.value).toMatch(/^ipv[46]$/); - } else if (RUNTIME === 'cloudflare') { - expect(data['server.address']?.value).toBe('localhost'); - expect(data['client.address']?.value).toBe('::1'); - expect(data['network.peer.address']?.value).toBe(data['client.address']?.value); - expect(data['network.protocol.name']?.value).toBe('http'); - expect(data['network.protocol.version']?.value).toBe('1.1'); - } else if (RUNTIME === 'deno') { - expect(data['server.address']?.value).toBe('localhost'); - expect(data['client.address']?.value).toEqual(expect.any(String)); - expect(data['client.port']?.value).toEqual(expect.any(Number)); - expect(data['network.peer.address']?.value).toBe(data['client.address']?.value); - expect(data['network.peer.port']?.value).toBe(data['client.port']?.value); - expect(data['network.transport']?.value).toBe('tcp'); - expect(data['network.protocol.name']?.value).toBe('http'); - } else { - throw new Error(`No tests for runtime: ${RUNTIME}`); + for (const [key, expected] of Object.entries(connInfo.baseline)) { + expect(data[key]?.value).toEqual(expected); } + + // Relational checks that hold on every runtime (both sides absent → still equal). + expect(data['network.peer.address']?.value).toBe(data['client.address']?.value); + expect(data['network.peer.port']?.value).toBe(data['client.port']?.value); }); test('sends a span for a route that throws', async ({ baseURL }) => { diff --git a/dev-packages/e2e-tests/test-applications/hono-4/vite.config.ts b/dev-packages/e2e-tests/test-applications/hono-4/vite.config.ts new file mode 100644 index 000000000000..289edf583602 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/hono-4/vite.config.ts @@ -0,0 +1,11 @@ +import { cloudflare } from '@cloudflare/vite-plugin'; +import { sentryCloudflareVitePlugin } from '@sentry/cloudflare/vite'; +import { defineConfig } from 'vite'; + +// `vite build` runs the Sentry auto-instrument transform over the worker entry: it wraps the default +// export (the Hono app) with `withSentry` and injects the orchestrion channels that the +// `honoIntegration` default subscribes to. `wrangler dev` (via the plugin's `.wrangler/deploy` +// redirect) then serves the built output. +export default defineConfig({ + plugins: [cloudflare(), sentryCloudflareVitePlugin()], +}); diff --git a/dev-packages/e2e-tests/test-applications/node-mastra/src/mastra/index.ts b/dev-packages/e2e-tests/test-applications/node-mastra/src/mastra/index.ts index 71ad1709ad55..b6f2dd975b39 100644 --- a/dev-packages/e2e-tests/test-applications/node-mastra/src/mastra/index.ts +++ b/dev-packages/e2e-tests/test-applications/node-mastra/src/mastra/index.ts @@ -26,6 +26,11 @@ export const mastra = new Mastra({ port: 4111, apiRoutes: [manualRoute], }, + // No `bundler.externals` override: `mastra build` defaults to externalizing all non-workspace deps, + // so Hono (and the other instrumented modules) stay unbundled and the `--import` orchestrion hook + // can transform them in prod. In `mastra dev` the bundler inlines its own Hono-based server + // framework into the entry regardless of any `externals` config, so Hono is not + // orchestrion-instrumented in dev — the tests assert the un-routed span name there. }); /** diff --git a/dev-packages/e2e-tests/test-applications/node-mastra/tests/manual-route.test.ts b/dev-packages/e2e-tests/test-applications/node-mastra/tests/manual-route.test.ts index e3aa759000cc..f287cc794217 100644 --- a/dev-packages/e2e-tests/test-applications/node-mastra/tests/manual-route.test.ts +++ b/dev-packages/e2e-tests/test-applications/node-mastra/tests/manual-route.test.ts @@ -3,6 +3,12 @@ import { collectStreamedSpans, getSpanOp, SerializedStreamedSpan } from '@sentry const APP = 'node-mastra'; +// In `mastra dev`, Mastra inlines its Hono-based server into the dev bundle, so the `--import` +// orchestrion hook cannot transform Hono and the request span is not route-enriched: its name is the +// bare method and it carries no `http.route`/route name source. In prod (`mastra build`/`start`) Hono +// is external and fully instrumented, so the span is named after the matched route. +const IS_DEV = process.env.TEST_ENV === 'development'; + const attrValue = (span: SerializedStreamedSpan, key: string): unknown => span.attributes?.[key]?.value; // A plain custom route registered on the Mastra (Hono) server — see the @@ -21,19 +27,17 @@ test('wraps a custom Mastra route in an http.server span with correct attributes await res.json(); const spans = await spansPromise; - const serverSpan = spans.find(isManualRouteServerSpan); + const serverSpan = spans.find(isManualRouteServerSpan)!; expect(serverSpan).toBeDefined(); - expect(getSpanOp(serverSpan!)).toBe('http.server'); - expect(serverSpan!.name).toBe('GET'); - expect(attrValue(serverSpan!, 'http.request.method')).toBe('GET'); - expect(attrValue(serverSpan!, 'http.response.status_code')).toBe(200); - expect(String(attrValue(serverSpan!, 'url.full') ?? '')).toContain('/manual-route'); - - // Codifies current behavior: the transaction name is derived from the URL path, - // not a route pattern. Mastra serves custom routes through Hono, which Sentry - // does not route-instrument the way it does Express — so there is no `http.route` - // attribute and the name source is `url` (an Express route would give `route`). - expect(attrValue(serverSpan!, 'sentry.segment.name.source')).toBe('url'); - expect(attrValue(serverSpan!, 'http.route')).toBeUndefined(); + expect(getSpanOp(serverSpan)).toBe('http.server'); + expect(attrValue(serverSpan, 'http.request.method')).toBe('GET'); + expect(attrValue(serverSpan, 'http.response.status_code')).toBe(200); + expect(String(attrValue(serverSpan, 'url.full') ?? '')).toContain('/manual-route'); + expect(serverSpan.name).toBe(IS_DEV ? 'GET' : 'GET /manual-route'); + + if (!IS_DEV) { + expect(attrValue(serverSpan, 'sentry.segment.name.source')).toBe('route'); + expect(attrValue(serverSpan, 'http.route')).toBe('/manual-route'); + } }); diff --git a/dev-packages/e2e-tests/test-applications/node-mastra/tests/mastra.test.ts b/dev-packages/e2e-tests/test-applications/node-mastra/tests/mastra.test.ts index ef68fdf82b9a..7927a8f9531a 100644 --- a/dev-packages/e2e-tests/test-applications/node-mastra/tests/mastra.test.ts +++ b/dev-packages/e2e-tests/test-applications/node-mastra/tests/mastra.test.ts @@ -4,6 +4,12 @@ import { runAgentTurn } from './utils'; const APP = 'node-mastra'; +// In `mastra dev`, Mastra inlines its Hono-based server into the dev bundle, so the `--import` +// orchestrion hook cannot transform Hono and the root request span is not route-enriched: its name is +// the bare method and it carries no `http.route`/route name source. In prod (`mastra build`/`start`) +// Hono is external and fully instrumented, so the span is named after the matched route. +const IS_DEV = process.env.TEST_ENV === 'development'; + const attrValue = (span: SerializedStreamedSpan, key: string): unknown => span.attributes?.[key]?.value; const MASTRA_ORIGIN = 'auto.ai.mastra'; @@ -34,11 +40,16 @@ test('captures Mastra agent spans (invoke_agent, chat, execute_tool) with inputs // across envelopes until the agent/model spans have also arrived. Tool spans are // matched by op alone (not origin), so a double-instrumented span is collected // too and caught by the assertions below. + const isGenerateServerSpan = (span: SerializedStreamedSpan): boolean => + isOp('http.server')(span) && String(attrValue(span, 'url.full') ?? '').includes('/api/agents/'); + const traceSpansPromise = collectStreamedSpans( APP, spansOfTrace => spansOfTrace.some(callsTool('get_weather')) && - ['gen_ai.invoke_agent', 'gen_ai.chat'].every(op => spansOfTrace.some(isOp(op))), + ['gen_ai.invoke_agent', 'gen_ai.chat'].every(op => spansOfTrace.some(isOp(op))) && + // The root `http.server` span ends (and streams) after its children, so wait for it too. + spansOfTrace.some(isGenerateServerSpan), ); await runAgentTurn(baseURL!, 'What is the weather in Paris?', { thread, resource: 'e2e-user' }); @@ -96,6 +107,24 @@ test('captures Mastra agent spans (invoke_agent, chat, execute_tool) with inputs expect(attrValue(invokeAgent!, 'gen_ai.conversation.id')).toBe(thread); expect(attrValue(chat!, 'gen_ai.conversation.id')).toBe(thread); expect(attrValue(executeTool!, 'gen_ai.conversation.id')).toBe(thread); + + // http.server span: Mastra serves the agent through its internal Hono server, so the incoming + // `POST /api/agents/weatherAgent/generate` request is the root `http.server` span of this trace, + // and the AI spans above are its children. + const serverSpan = traceSpans.find(isGenerateServerSpan); + expect(serverSpan).toBeDefined(); + expect(getSpanOp(serverSpan!)).toBe('http.server'); + expect(attrValue(serverSpan!, 'http.request.method')).toBe('POST'); + expect(attrValue(serverSpan!, 'http.response.status_code')).toBe(200); + expect(String(attrValue(serverSpan!, 'url.full') ?? '')).toContain('/api/agents/weatherAgent/generate'); + + if (IS_DEV) { + expect(serverSpan!.name).toBe('POST'); + } else { + expect(attrValue(serverSpan!, 'sentry.segment.name.source')).toBe('route'); + expect(attrValue(serverSpan!, 'http.route')).toMatch(/^\/api\/agents\/:[^/]+\/generate$/); + expect(serverSpan!.name).toMatch(/^POST \/api\/agents\/:[^/]+\/generate$/); + } }); test('captures a Mastra tool error as an issue and marks the tool span', async ({ baseURL }) => { diff --git a/dev-packages/node-integration-tests/suites/hono/instrument.mjs b/dev-packages/node-integration-tests/suites/hono/instrument.mjs new file mode 100644 index 000000000000..62e64bf8dc10 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/hono/instrument.mjs @@ -0,0 +1,8 @@ +import * as Sentry from '@sentry/node'; +import { loggingTransport } from '@sentry-internal/node-integration-tests'; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + tracesSampleRate: 1.0, + transport: loggingTransport, +}); diff --git a/dev-packages/node-integration-tests/suites/hono/scenario.mjs b/dev-packages/node-integration-tests/suites/hono/scenario.mjs new file mode 100644 index 000000000000..cf85fda9d9bd --- /dev/null +++ b/dev-packages/node-integration-tests/suites/hono/scenario.mjs @@ -0,0 +1,54 @@ +import { serve } from '@hono/node-server'; +import { sendPortToRunner } from '@sentry-internal/node-integration-tests'; +import { Hono } from 'hono'; + +// No `@sentry/hono` and no `sentry()` middleware: the app is instrumented automatically by the +// `honoIntegration` default in `@sentry/node` (via orchestrion hooking the `Hono` constructor). +const app = new Hono(); + +app.get('/', c => { + return c.text('Hello from Hono on Node!'); +}); + +app.get('/hello/:name', c => { + const name = c.req.param('name'); + return c.text(`Hello, ${name}!`); +}); + +app.get('/error/:param', () => { + throw new Error('Test error from Hono app'); +}); + +// A sub-app with a named middleware, mounted via `app.route()`. The sub-app is also +// auto-instrumented (every `new Hono()` is), so its own Sentry middleware must NOT show up as an +// `` middleware span when it is copied into the parent at mount time. +const subApp = new Hono(); +subApp.use(async function subMiddleware(_c, next) { + await next(); +}); +subApp.get('/hello', c => c.text('sub hello')); +app.route('/sub', subApp); + +// An inner app dispatched to via internal `.request()`. It runs in a fresh Hono context but the same +// isolation scope, so its auto-registered Sentry middleware must be deduplicated — it must not +// re-name the internal-request span, overwrite the request data, or add a middleware span. +const innerApp = new Hono(); +innerApp.get('/item/:itemId', c => c.json({ itemId: c.req.param('itemId') })); + +app.get('/outer/:itemId', async c => { + const res = await innerApp.request(`/item/${c.req.param('itemId')}`); + const data = await res.json(); + return c.json({ outer: c.req.param('itemId'), inner: data }); +}); + +app.get('/outer-error/:itemId', async c => { + // Do a successful internal dispatch first — this is what used to overwrite the request data on the + // isolation scope — then throw from the outer handler, so the captured error must still carry the + // outer request's data. + await innerApp.request(`/item/${c.req.param('itemId')}`); + throw new Error('Test error from outer Hono app after internal request'); +}); + +serve({ fetch: app.fetch, port: 0 }, info => { + sendPortToRunner(info.port); +}); diff --git a/dev-packages/node-integration-tests/suites/hono/test.ts b/dev-packages/node-integration-tests/suites/hono/test.ts new file mode 100644 index 000000000000..1aef2a022e2e --- /dev/null +++ b/dev-packages/node-integration-tests/suites/hono/test.ts @@ -0,0 +1,171 @@ +import { afterAll, describe, expect } from 'vitest'; +import { cleanupChildProcesses, createEsmAndCjsTests } from '../../utils/runner'; + +// Verifies that Hono is auto-instrumented out of the box by `@sentry/node` (the `honoIntegration` +// default), without importing `@sentry/hono` or registering the `sentry()` middleware manually. +// +// The SDK runs with the default `traceLifecycle` (span streaming), so the transaction is asserted via +// the streamed span container (`container.items`, root = `is_segment`) rather than a `transaction` +// envelope, and `.unordered()` lets the segment/child spans and error events arrive in any order +// while ignoring unrelated envelopes (client reports, etc.). + +// oxlint-disable-next-line typescript/no-explicit-any +type StreamedSpan = { name?: string; status?: string; is_segment?: boolean; attributes?: Record }; + +const attr = (span: StreamedSpan, key: string): unknown => span.attributes?.[key]?.value; +const op = (span: StreamedSpan): unknown => attr(span, 'sentry.op'); + +describe('hono auto-instrumentation', () => { + afterAll(() => { + cleanupChildProcesses(); + }); + + createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument.mjs', (createRunner, test) => { + test('creates a transaction for a basic GET request', async () => { + const runner = createRunner() + .unordered() + .expect({ + span: container => { + const segment = container.items.find(item => item.is_segment && item.name === 'GET /'); + if (!segment) { + throw new Error('segment for `GET /` not in this container'); + } + expect(op(segment)).toBe('http.server'); + expect(segment.status).toBe('ok'); + }, + }) + .start(); + runner.makeRequest('get', '/'); + await runner.completed(); + }); + + test('creates a transaction with a parametrized route name', async () => { + const runner = createRunner() + .unordered() + .expect({ + span: container => { + const segment = container.items.find(item => item.is_segment && item.name === 'GET /hello/:name'); + if (!segment) { + throw new Error('segment for `GET /hello/:name` not in this container'); + } + expect(op(segment)).toBe('http.server'); + expect(attr(segment, 'sentry.segment.name.source')).toBe('route'); + }, + }) + .start(); + runner.makeRequest('get', '/hello/world'); + await runner.completed(); + }); + + test('captures an error with the correct mechanism', async () => { + const runner = createRunner() + .unordered() + .expect({ + event: { + exception: { + values: [ + { + type: 'Error', + value: 'Test error from Hono app', + mechanism: { + type: 'auto.http.hono.context_error', + handled: false, + }, + }, + ], + }, + transaction: 'GET /error/:param', + }, + }) + .start(); + runner.makeRequest('get', '/error/param-123', { expectError: true }); + await runner.completed(); + }); + + test('creates a transaction with internal_error status when an error occurs', async () => { + const runner = createRunner() + .unordered() + .expect({ + span: container => { + const segment = container.items.find(item => item.is_segment && item.name === 'GET /error/:param'); + if (!segment) { + throw new Error('segment for `GET /error/:param` not in this container'); + } + expect(op(segment)).toBe('http.server'); + expect(segment.status).toBe('error'); + expect(attr(segment, 'http.response.status_code')).toBe(500); + }, + }) + .start(); + runner.makeRequest('get', '/error/param-456', { expectError: true }); + await runner.completed(); + }); + + test('does not create a middleware span for the Sentry middleware in a mounted sub-app', async () => { + const runner = createRunner() + .unordered() + .expect({ + span: container => { + const segment = container.items.find(item => item.is_segment && item.name === 'GET /sub/hello'); + if (!segment) { + throw new Error('segment for `GET /sub/hello` not in this container'); + } + const middlewareNames = container.items.filter(item => op(item) === 'middleware').map(item => item.name); + // The sub-app's user middleware is traced … + expect(middlewareNames).toContain('subMiddleware'); + // … but the sub-app's own auto-registered Sentry middleware must not appear as a span + // (it is copied into the parent at mount time and must stay unwrapped). + expect(middlewareNames).not.toContain(''); + }, + }) + .start(); + runner.makeRequest('get', '/sub/hello'); + await runner.completed(); + }); + + test('traces an internal .request() call without the inner app re-instrumenting the request', async () => { + const runner = createRunner() + .unordered() + .expect({ + span: container => { + // Transaction is named after the outer route, not the internal one. + const segment = container.items.find(item => item.is_segment && item.name === 'GET /outer/:itemId'); + if (!segment) { + throw new Error('segment for `GET /outer/:itemId` not in this container'); + } + + // The internal dispatch is traced with the raw inner path — the inner app's Sentry + // middleware must not re-name it to the parametrized route. + const internalRequestSpans = container.items.filter( + item => attr(item, 'sentry.origin') === 'auto.http.hono.internal_request', + ); + expect(internalRequestSpans).toHaveLength(1); + expect(internalRequestSpans[0]?.name).toBe('GET /item/self-watering-plant'); + + // The inner app's Sentry middleware must not add a middleware span. + const middlewareNames = container.items.filter(item => op(item) === 'middleware').map(item => item.name); + expect(middlewareNames).not.toContain(''); + }, + }) + .start(); + runner.makeRequest('get', '/outer/self-watering-plant'); + await runner.completed(); + }); + + test('captures an error thrown after an internal .request() with the outer request data', async () => { + const runner = createRunner() + .unordered() + .expect({ + event: event => { + expect(event.exception?.values?.[0]?.value).toBe('Test error from outer Hono app after internal request'); + // The internal `.request()` dispatch must not overwrite the request data with its own URL. + expect(event.request?.url).toContain('/outer-error/self-watering-plant'); + expect(event.request?.url).not.toContain('/item/'); + }, + }) + .start(); + runner.makeRequest('get', '/outer-error/self-watering-plant', { expectError: true }); + await runner.completed(); + }); + }); +}); diff --git a/packages/astro/src/index.server.ts b/packages/astro/src/index.server.ts index 66faa541489e..51f21b3dedbb 100644 --- a/packages/astro/src/index.server.ts +++ b/packages/astro/src/index.server.ts @@ -72,6 +72,8 @@ export { isInitialized, isEnabled, kafkaIntegration, + honoIntegration, + honoMiddleware, koaIntegration, knexIntegration, lastEventId, diff --git a/packages/aws-serverless/src/index.ts b/packages/aws-serverless/src/index.ts index d231a660a9f9..621cdfd8e72f 100644 --- a/packages/aws-serverless/src/index.ts +++ b/packages/aws-serverless/src/index.ts @@ -101,6 +101,8 @@ export { expressErrorHandler, // oxlint-disable-next-line typescript/no-deprecated setupExpressErrorHandler, + honoIntegration, + honoMiddleware, koaIntegration, // oxlint-disable-next-line typescript/no-deprecated setupKoaErrorHandler, diff --git a/packages/bun/src/index.ts b/packages/bun/src/index.ts index cda29a4beeb8..1bee175623c4 100644 --- a/packages/bun/src/index.ts +++ b/packages/bun/src/index.ts @@ -126,6 +126,8 @@ export { // oxlint-disable-next-line typescript/no-deprecated setupFastifyErrorHandler, firebaseIntegration, + honoIntegration, + honoMiddleware, koaIntegration, // oxlint-disable-next-line typescript/no-deprecated setupKoaErrorHandler, diff --git a/packages/cloudflare/src/index.ts b/packages/cloudflare/src/index.ts index 16ec6d6d9e24..e8d30155a046 100644 --- a/packages/cloudflare/src/index.ts +++ b/packages/cloudflare/src/index.ts @@ -136,6 +136,8 @@ export { instrumentStateGraph, instrumentCreateReactAgent, vercelAIIntegration, + honoIntegration, + honoMiddleware, eveConversationHook, getInstrumentedModuleNames, } from '@sentry/server-utils'; diff --git a/packages/deno/src/index.ts b/packages/deno/src/index.ts index 6b22ee9ee63a..118046f23c55 100644 --- a/packages/deno/src/index.ts +++ b/packages/deno/src/index.ts @@ -131,6 +131,8 @@ export { googleGenAIIntegration, graphqlIntegration, hapiIntegration, + honoIntegration, + honoMiddleware, kafkaIntegration, knexIntegration, koaIntegration, diff --git a/packages/deno/test/__snapshots__/mod.test.ts.snap b/packages/deno/test/__snapshots__/mod.test.ts.snap index d74fe9c036e2..3500fc9330c7 100644 --- a/packages/deno/test/__snapshots__/mod.test.ts.snap +++ b/packages/deno/test/__snapshots__/mod.test.ts.snap @@ -65,6 +65,7 @@ snapshot[`captureMessage 1`] = ` "Express", "Fastify", "Hapi", + "Hono", "Koa", ], name: "sentry.javascript.deno", @@ -153,6 +154,7 @@ snapshot[`captureMessage twice 1`] = ` "Express", "Fastify", "Hapi", + "Hono", "Koa", ], name: "sentry.javascript.deno", @@ -248,6 +250,7 @@ snapshot[`captureMessage twice 2`] = ` "Express", "Fastify", "Hapi", + "Hono", "Koa", ], name: "sentry.javascript.deno", diff --git a/packages/elysia/src/index.ts b/packages/elysia/src/index.ts index 28e6fc1ee2f3..f052a3d9196a 100644 --- a/packages/elysia/src/index.ts +++ b/packages/elysia/src/index.ts @@ -105,6 +105,7 @@ export { // oxlint-disable-next-line typescript/no-deprecated setupFastifyErrorHandler, firebaseIntegration, + honoIntegration, koaIntegration, // oxlint-disable-next-line typescript/no-deprecated setupKoaErrorHandler, diff --git a/packages/google-cloud-serverless/src/index.ts b/packages/google-cloud-serverless/src/index.ts index 9f36e06e9ed8..eb0f08a5be21 100644 --- a/packages/google-cloud-serverless/src/index.ts +++ b/packages/google-cloud-serverless/src/index.ts @@ -102,6 +102,8 @@ export { expressErrorHandler, // oxlint-disable-next-line typescript/no-deprecated setupExpressErrorHandler, + honoIntegration, + honoMiddleware, koaIntegration, // oxlint-disable-next-line typescript/no-deprecated setupKoaErrorHandler, diff --git a/packages/hono/package.json b/packages/hono/package.json index bea6f10f2034..50337c1adbf6 100644 --- a/packages/hono/package.json +++ b/packages/hono/package.json @@ -74,7 +74,8 @@ "dependencies": { "@opentelemetry/api": "^1.9.1", "@sentry/core": "10.67.0", - "@sentry/conventions": "^0.23.0" + "@sentry/conventions": "^0.23.0", + "@sentry/server-utils": "10.67.0" }, "peerDependencies": { "@cloudflare/workers-types": "^4.x", diff --git a/packages/hono/src/bun/middleware.ts b/packages/hono/src/bun/middleware.ts index 6a1fbaf5a53e..20c922c32c0c 100644 --- a/packages/hono/src/bun/middleware.ts +++ b/packages/hono/src/bun/middleware.ts @@ -1,10 +1,8 @@ import { type BaseTransportOptions, debug, type Options } from '@sentry/core'; import { init } from './sdk'; import { getConnInfo } from 'hono/bun'; +import { applyHonoPatches, createHonoRequestMiddleware, type SentryHonoMiddlewareOptions } from '@sentry/server-utils'; import type { Env, Hono, MiddlewareHandler } from 'hono'; -import { requestHandler, responseHandler } from '../shared/middlewareHandlers'; -import { applyPatches } from '../shared/applyPatches'; -import type { SentryHonoMiddlewareOptions } from '../shared/types'; export interface HonoBunOptions extends Options, SentryHonoMiddlewareOptions {} @@ -16,13 +14,7 @@ export const sentry = (app: Hono, options: HonoBunOptions): Mi init(options); - applyPatches(app); + applyHonoPatches(app); - return async (context, next) => { - requestHandler(context, getConnInfo); - - await next(); // Handler runs in between Request above ⤴ and Response below ⤵ - - responseHandler(context, options.shouldHandleError); - }; + return createHonoRequestMiddleware({ getConnInfo, shouldHandleError: options.shouldHandleError }); }; diff --git a/packages/hono/src/cloudflare/middleware.ts b/packages/hono/src/cloudflare/middleware.ts index 205b7c28129a..e80289a5572d 100644 --- a/packages/hono/src/cloudflare/middleware.ts +++ b/packages/hono/src/cloudflare/middleware.ts @@ -1,12 +1,10 @@ import { withSentry } from '@sentry/cloudflare'; import { applySdkMetadata, type BaseTransportOptions, debug, type Options } from '@sentry/core'; import { getConnInfo } from 'hono/cloudflare-workers'; +import { applyHonoPatches, createHonoRequestMiddleware, type SentryHonoMiddlewareOptions } from '@sentry/server-utils'; import type { Env, Hono, MiddlewareHandler } from 'hono'; import { buildFilteredIntegrations } from '../shared/buildFilteredIntegrations'; import { LOW_QUALITY_TRANSACTION_PATTERNS } from '../shared/lowQualityTransactionPatterns'; -import { requestHandler, responseHandler } from '../shared/middlewareHandlers'; -import { applyPatches } from '../shared/applyPatches'; -import type { SentryHonoMiddlewareOptions } from '../shared/types'; export interface HonoCloudflareOptions extends Options, SentryHonoMiddlewareOptions {} @@ -41,18 +39,15 @@ export function sentry( app as unknown as ExportedHandler, ); - applyPatches(app); + applyHonoPatches(app); - return async (context, next) => { - const shouldHandleError = + return createHonoRequestMiddleware({ + getConnInfo, + // Cloudflare accepts middleware options as a function of `env`, so `shouldHandleError` is only + // known per request. + resolveShouldHandleError: context => typeof options === 'function' ? options(context.env as E['Bindings']).shouldHandleError - : options.shouldHandleError; - - requestHandler(context, getConnInfo); - - await next(); // Handler runs in between Request above ⤴ and Response below ⤵ - - responseHandler(context, shouldHandleError); - }; + : options.shouldHandleError, + }); } diff --git a/packages/hono/src/debug-build.ts b/packages/hono/src/debug-build.ts deleted file mode 100644 index 60aa50940582..000000000000 --- a/packages/hono/src/debug-build.ts +++ /dev/null @@ -1,8 +0,0 @@ -declare const __DEBUG_BUILD__: boolean; - -/** - * This serves as a build time flag that will be true by default, but false in non-debug builds or if users replace `__SENTRY_DEBUG__` in their generated code. - * - * ATTENTION: This constant must never cross package boundaries (i.e. be exported) to guarantee that it can be used for tree shaking. - */ -export const DEBUG_BUILD = __DEBUG_BUILD__; diff --git a/packages/hono/src/deno/middleware.ts b/packages/hono/src/deno/middleware.ts index 081589925ee1..f74af1379e85 100644 --- a/packages/hono/src/deno/middleware.ts +++ b/packages/hono/src/deno/middleware.ts @@ -1,10 +1,8 @@ import { type BaseTransportOptions, debug, type Options } from '@sentry/core'; import { init } from './sdk'; -import type { Env, Hono, MiddlewareHandler } from 'hono'; import { getConnInfo } from 'hono/deno'; -import { requestHandler, responseHandler } from '../shared/middlewareHandlers'; -import { applyPatches } from '../shared/applyPatches'; -import type { SentryHonoMiddlewareOptions } from '../shared/types'; +import { applyHonoPatches, createHonoRequestMiddleware, type SentryHonoMiddlewareOptions } from '@sentry/server-utils'; +import type { Env, Hono, MiddlewareHandler } from 'hono'; export interface HonoDenoOptions extends Options, SentryHonoMiddlewareOptions {} @@ -16,13 +14,7 @@ export const sentry = (app: Hono, options: HonoDenoOptions): M init(options); - applyPatches(app); - - return async (context, next) => { - requestHandler(context, getConnInfo); - - await next(); // Handler runs in between Request above ⤴ and Response below ⤵ + applyHonoPatches(app); - responseHandler(context, options.shouldHandleError); - }; + return createHonoRequestMiddleware({ getConnInfo, shouldHandleError: options.shouldHandleError }); }; diff --git a/packages/hono/src/index.bun.ts b/packages/hono/src/index.bun.ts index 7c456a21c52e..917a160284f6 100644 --- a/packages/hono/src/index.bun.ts +++ b/packages/hono/src/index.bun.ts @@ -1,6 +1,7 @@ -import { earlyPatchHono } from './shared/applyPatches'; +import { earlyPatchHono } from '@sentry/server-utils'; +import { Hono } from 'hono'; -earlyPatchHono(); +earlyPatchHono(Hono); export { sentry } from './bun/middleware'; diff --git a/packages/hono/src/index.cloudflare.ts b/packages/hono/src/index.cloudflare.ts index e46e119c206c..3daa130b409b 100644 --- a/packages/hono/src/index.cloudflare.ts +++ b/packages/hono/src/index.cloudflare.ts @@ -1,6 +1,7 @@ -import { earlyPatchHono } from './shared/applyPatches'; +import { earlyPatchHono } from '@sentry/server-utils'; +import { Hono } from 'hono'; -earlyPatchHono(); +earlyPatchHono(Hono); export { sentry } from './cloudflare/middleware'; diff --git a/packages/hono/src/index.deno.ts b/packages/hono/src/index.deno.ts index aa407b9192ca..2439139e91df 100644 --- a/packages/hono/src/index.deno.ts +++ b/packages/hono/src/index.deno.ts @@ -1,6 +1,7 @@ -import { earlyPatchHono } from './shared/applyPatches'; +import { earlyPatchHono } from '@sentry/server-utils'; +import { Hono } from 'hono'; -earlyPatchHono(); +earlyPatchHono(Hono); export { sentry } from './deno/middleware'; diff --git a/packages/hono/src/index.node.ts b/packages/hono/src/index.node.ts index 761cd27a44a6..99c03230df7c 100644 --- a/packages/hono/src/index.node.ts +++ b/packages/hono/src/index.node.ts @@ -1,6 +1,7 @@ -import { earlyPatchHono } from './shared/applyPatches'; +import { earlyPatchHono } from '@sentry/server-utils'; +import { Hono } from 'hono'; -earlyPatchHono(); +earlyPatchHono(Hono); export { sentry } from './node/middleware'; diff --git a/packages/hono/src/node/middleware.ts b/packages/hono/src/node/middleware.ts index b46dfbbac489..0b4688ef72f4 100644 --- a/packages/hono/src/node/middleware.ts +++ b/packages/hono/src/node/middleware.ts @@ -1,9 +1,7 @@ import { type BaseTransportOptions, consoleSandbox, debug, getClient, type Options } from '@sentry/core'; import { getConnInfo } from '@hono/node-server/conninfo'; +import { applyHonoPatches, createHonoRequestMiddleware, type SentryHonoMiddlewareOptions } from '@sentry/server-utils'; import type { Env, Hono, MiddlewareHandler } from 'hono'; -import { requestHandler, responseHandler } from '../shared/middlewareHandlers'; -import { applyPatches } from '../shared/applyPatches'; -import type { SentryHonoMiddlewareOptions } from '../shared/types'; export interface HonoNodeOptions extends Options {} @@ -40,13 +38,7 @@ export const sentry = (app: Hono, options?: SentryHonoMiddlewa } } - applyPatches(app); + applyHonoPatches(app); - return async (context, next) => { - requestHandler(context, getConnInfo); - - await next(); // Handler runs in between Request above ⤴ and Response below ⤵ - - responseHandler(context, options?.shouldHandleError); - }; + return createHonoRequestMiddleware({ getConnInfo, shouldHandleError: options?.shouldHandleError }); }; diff --git a/packages/hono/src/shared/patchAppRequest.ts b/packages/hono/src/shared/patchAppRequest.ts deleted file mode 100644 index a066d00a3c01..000000000000 --- a/packages/hono/src/shared/patchAppRequest.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { SENTRY_OP } from '@sentry/conventions/attributes'; -import { HTTP_SERVER } from '@sentry/conventions/op'; -import { - debug, - getActiveSpan, - getOriginalFunction, - SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, - startSpan, - type WrappedFunction, -} from '@sentry/core'; -import type { Env, Hono } from 'hono'; -import { DEBUG_BUILD } from '../debug-build'; - -const INTERNAL_REQUEST_OP = HTTP_SERVER; -const INTERNAL_REQUEST_ORIGIN = 'auto.http.hono.internal_request'; - -function extractPathname(input: string | Request | URL): string { - if (typeof input === 'string') { - return /^https?:\/\//.test(input) ? new URL(input).pathname : input; - } - - return input instanceof Request ? new URL(input.url).pathname : input.pathname; -} - -/** - * Patches `app.request()` on a Hono instance so that each internal dispatch - * is traced as an `http.server` span — child of whatever span is active at - * the call site. - * - * `.request()` is a class field (arrow function), so this must run per-instance. - * Idempotent: safe to call multiple times on the same instance. - */ -export function patchAppRequest(app: Hono): void { - if (getOriginalFunction(app.request as unknown as WrappedFunction)) { - DEBUG_BUILD && debug.log('[hono] app.request already patched — skipping.'); - return; - } - - const originalRequest = app.request; - - app.request = new Proxy(originalRequest, { - apply(_target, thisArg, args: [string | Request | URL, RequestInit?, ...unknown[]]) { - const [input, requestInit, ...rest] = args; - - if (!getActiveSpan()) { - return Reflect.apply(_target, thisArg, args); - } - - let method = requestInit?.method ?? (input instanceof Request ? input.method : 'GET'); - method = method.toUpperCase(); - - const path = extractPathname(input); - - return startSpan( - { - name: `${method} ${path}`, - onlyIfParent: true, - attributes: { - [SENTRY_OP]: INTERNAL_REQUEST_OP, - [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: INTERNAL_REQUEST_ORIGIN, - }, - }, - () => Reflect.apply(_target, thisArg, [input, requestInit, ...rest]), - ); - }, - get(target, prop, receiver) { - if (prop === '__sentry_original__') { - return originalRequest; - } - return Reflect.get(target, prop, receiver); - }, - }); - - DEBUG_BUILD && debug.log('[hono] Patched app.request for internal dispatch tracing.'); -} diff --git a/packages/hono/test/shared/applyPatches.test.ts b/packages/hono/test/shared/applyPatches.test.ts deleted file mode 100644 index 129e83d341d2..000000000000 --- a/packages/hono/test/shared/applyPatches.test.ts +++ /dev/null @@ -1,609 +0,0 @@ -import * as SentryCore from '@sentry/core'; -import { Hono } from 'hono'; -import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'; -import { applyPatches } from '../../src/shared/applyPatches'; - -vi.mock('@sentry/core', async () => { - const actual = await vi.importActual('@sentry/core'); - return { - ...actual, - startInactiveSpan: vi.fn((_opts: unknown) => ({ - setStatus: vi.fn(), - end: vi.fn(), - })), - startSpan: vi.fn((_opts: unknown, callback: () => unknown) => callback()), - getActiveSpan: vi.fn(() => ({ spanId: 'fake-span' })), - }; -}); - -const startInactiveSpanMock = SentryCore.startInactiveSpan as ReturnType; -const startSpanMock = SentryCore.startSpan as ReturnType; - -const honoBaseProto = Object.getPrototypeOf(Object.getPrototypeOf(new Hono())); -const originalRoute = honoBaseProto.route; - -describe('applyPatches', () => { - beforeEach(() => { - vi.clearAllMocks(); - honoBaseProto.route = originalRoute; - }); - - afterAll(() => { - honoBaseProto.route = originalRoute; - }); - - describe('wrapSubAppMiddleware', () => { - it('does nothing when a sub-app has an empty routes array', async () => { - const app = new Hono(); - applyPatches(app); - - const emptySubApp = new Hono(); - app.route('/empty', emptySubApp); - - const res = await app.fetch(new Request('http://localhost/empty')); - expect(res.status).toBe(404); - expect(startInactiveSpanMock).not.toHaveBeenCalled(); - }); - - it('skips route entries whose handler is not a function', async () => { - const app = new Hono(); - applyPatches(app); - - const subApp = new Hono(); - subApp.get('/resource', () => new Response('ok')); - - (subApp.routes as unknown as Array<{ handler: unknown }>)[0]!.handler = 'not-a-function'; - - expect(() => app.route('/api', subApp)).not.toThrow(); - expect(startInactiveSpanMock).not.toHaveBeenCalled(); - }); - - it('treats same path with different HTTP methods as separate groups', async () => { - const app = new Hono(); - applyPatches(app); - - const subApp = new Hono(); - subApp.get('/resource', async function getHandler() { - return new Response('get'); - }); - subApp.post('/resource', async function postHandler() { - return new Response('post'); - }); - - app.route('/api', subApp); - - await app.fetch(new Request('http://localhost/api/resource', { method: 'GET' })); - await app.fetch(new Request('http://localhost/api/resource', { method: 'POST' })); - - expect(startInactiveSpanMock).not.toHaveBeenCalled(); - }); - - it('treats same HTTP method with different paths as separate groups', async () => { - const app = new Hono(); - applyPatches(app); - - const subApp = new Hono(); - subApp.get('/alpha', async function alphaHandler() { - return new Response('alpha'); - }); - subApp.get('/beta', async function betaHandler() { - return new Response('beta'); - }); - - app.route('/api', subApp); - - await app.fetch(new Request('http://localhost/api/alpha')); - await app.fetch(new Request('http://localhost/api/beta')); - - expect(startInactiveSpanMock).not.toHaveBeenCalled(); - }); - - it('wraps inline middleware for GET /alpha but not the sole handler for GET /beta', async () => { - const app = new Hono(); - applyPatches(app); - - const subApp = new Hono(); - subApp.get( - '/alpha', - async function alphaMw(_c: unknown, next: () => Promise) { - await next(); - }, - async function alphaHandler() { - return new Response('alpha'); - }, - ); - subApp.get('/beta', async function betaHandler() { - return new Response('beta'); - }); - - app.route('/api', subApp); - - await app.fetch(new Request('http://localhost/api/alpha')); - await app.fetch(new Request('http://localhost/api/beta')); - - const spanNames = startInactiveSpanMock.mock.calls.map((c: unknown[]) => (c[0] as { name: string }).name); - expect(spanNames).toHaveLength(1); - expect(spanNames).toContain('alphaMw'); - expect(spanNames).not.toContain('alphaHandler'); - expect(spanNames).not.toContain('betaHandler'); - }); - }); - - describe('route() patching', () => { - it('wraps middleware on sub-apps mounted via route()', async () => { - const app = new Hono(); - applyPatches(app); - - const subApp = new Hono(); - subApp.use(async function subMiddleware(_c: unknown, next: () => Promise) { - await next(); - }); - subApp.get('/', () => new Response('sub')); - - app.route('/sub', subApp); - - await app.fetch(new Request('http://localhost/sub')); - - expect(startInactiveSpanMock).toHaveBeenCalledWith(expect.objectContaining({ name: 'subMiddleware' })); - }); - - it('does not wrap sole route handlers on sub-apps', async () => { - const app = new Hono(); - applyPatches(app); - - const subApp = new Hono(); - subApp.get('/', () => new Response('sub')); - - app.route('/sub', subApp); - - await app.fetch(new Request('http://localhost/sub')); - - expect(startInactiveSpanMock).not.toHaveBeenCalled(); - }); - - it('does not double-wrap handlers already wrapped by patchAppUse on the main app', async () => { - const app = new Hono(); - applyPatches(app); - - app.use(async function mainMiddleware(_c: unknown, next: () => Promise) { - await next(); - }); - app.get('/', () => new Response('ok')); - - const parent = new Hono(); - parent.route('/', app); - - await parent.fetch(new Request('http://localhost/')); - - expect(startInactiveSpanMock).toHaveBeenCalledTimes(1); - expect(startInactiveSpanMock).toHaveBeenCalledWith(expect.objectContaining({ name: 'mainMiddleware' })); - }); - - it('does not patch route() twice when applyPatches is called multiple times', () => { - const app1 = new Hono(); - applyPatches(app1); - - const patchedRoute = honoBaseProto.route; - - const app2 = new Hono(); - applyPatches(app2); - - expect(honoBaseProto.route).toBe(patchedRoute); - }); - - it('stores the original route via __sentry_original__ for other libraries to unwrap', () => { - const app = new Hono(); - applyPatches(app); - - // oxlint-disable-next-line typescript/no-explicit-any - const sentryOriginal = (honoBaseProto.route as any).__sentry_original__; - expect(sentryOriginal).toBe(originalRoute); - }); - - it('wraps path-targeted .use("/path", handler) on sub-apps', async () => { - const app = new Hono(); - applyPatches(app); - - const subApp = new Hono(); - subApp.use('/admin/*', async function adminAuth(_c: unknown, next: () => Promise) { - await next(); - }); - subApp.get('/admin/dashboard', () => new Response('dashboard')); - - app.route('/api', subApp); - await app.fetch(new Request('http://localhost/api/admin/dashboard')); - - expect(startInactiveSpanMock).toHaveBeenCalledWith(expect.objectContaining({ name: 'adminAuth' })); - }); - - it('does not wrap .all() handlers with less than 2 params (they are route handlers, not middleware)', async () => { - const app = new Hono(); - applyPatches(app); - - const subApp = new Hono(); - subApp.all('/catch-all', async function allHandler() { - return new Response('catch-all'); - }); - - app.route('/api', subApp); - await app.fetch(new Request('http://localhost/api/catch-all')); - - expect(startInactiveSpanMock).not.toHaveBeenCalled(); - }); - - it('wraps .use() middleware but not .all() handlers on the same sub-app', async () => { - const app = new Hono(); - applyPatches(app); - - const subApp = new Hono(); - subApp.use(async function mw(_c: unknown, next: () => Promise) { - await next(); - }); - subApp.all('/wildcard', async function allRoute() { - return new Response('wildcard'); - }); - subApp.get('/specific', () => new Response('specific')); - - app.route('/mixed', subApp); - await app.fetch(new Request('http://localhost/mixed/wildcard')); - - const spanNames = startInactiveSpanMock.mock.calls.map((c: unknown[]) => (c[0] as { name: string }).name); - expect(spanNames).toContain('mw'); - expect(spanNames).not.toContain('allRoute'); - }); - - it('does not wrap sole .get()/.post()/.put()/.delete() handlers on sub-apps', async () => { - const app = new Hono(); - applyPatches(app); - - const subApp = new Hono(); - subApp.get('/resource', async function getHandler() { - return new Response('get'); - }); - subApp.post('/resource', async function postHandler() { - return new Response('post'); - }); - subApp.put('/resource', async function postHandler() { - return new Response('put'); - }); - subApp.delete('/resource', async function postHandler() { - return new Response('delete'); - }); - - app.route('/api', subApp); - await app.fetch(new Request('http://localhost/api/resource')); - - expect(startInactiveSpanMock).not.toHaveBeenCalled(); - }); - - it('wraps inline middleware in .get(path, mw, handler) on sub-apps', async () => { - const app = new Hono(); - applyPatches(app); - - const subApp = new Hono(); - subApp.get( - '/resource', - async function inlineMw(_c: unknown, next: () => Promise) { - await next(); - }, - async function getHandler() { - return new Response('get'); - }, - ); - - app.route('/api', subApp); - await app.fetch(new Request('http://localhost/api/resource')); - - const spanNames = startInactiveSpanMock.mock.calls.map((c: unknown[]) => (c[0] as { name: string }).name); - expect(spanNames).toContain('inlineMw'); - expect(spanNames).not.toContain('getHandler'); - }); - - it('wraps separately registered middleware for .get() on sub-apps', async () => { - const app = new Hono(); - applyPatches(app); - - const subApp = new Hono(); - subApp.get('/resource', async function separateMw(_c: unknown, next: () => Promise) { - await next(); - }); - subApp.get('/resource', async function getHandler() { - return new Response('get'); - }); - - app.route('/api', subApp); - await app.fetch(new Request('http://localhost/api/resource')); - - const spanNames = startInactiveSpanMock.mock.calls.map((c: unknown[]) => (c[0] as { name: string }).name); - expect(spanNames).toContain('separateMw'); - expect(spanNames).not.toContain('getHandler'); - }); - - it('wraps inline middleware registered via .on() on sub-apps', async () => { - const app = new Hono(); - applyPatches(app); - - const subApp = new Hono(); - subApp.on( - 'GET', - '/resource', - async function onMw(_c: unknown, next: () => Promise) { - await next(); - }, - async function onHandler() { - return new Response('on'); - }, - ); - - app.route('/api', subApp); - await app.fetch(new Request('http://localhost/api/resource')); - - const spanNames = startInactiveSpanMock.mock.calls.map((c: unknown[]) => (c[0] as { name: string }).name); - expect(spanNames).toContain('onMw'); - expect(spanNames).not.toContain('onHandler'); - }); - - it('wraps middleware in nested sub-apps (sub-app mounting another sub-app)', async () => { - const app = new Hono(); - applyPatches(app); - - const innerSub = new Hono(); - innerSub.use(async function innerMiddleware(_c: unknown, next: () => Promise) { - await next(); - }); - innerSub.get('/', () => new Response('inner')); - - const outerSub = new Hono(); - outerSub.use(async function outerMiddleware(_c: unknown, next: () => Promise) { - await next(); - }); - outerSub.route('/inner', innerSub); - - app.route('/outer', outerSub); - await app.fetch(new Request('http://localhost/outer/inner')); - - const spanNames = startInactiveSpanMock.mock.calls.map((c: unknown[]) => (c[0] as { name: string }).name); - expect(spanNames).toContain('outerMiddleware'); - expect(spanNames).toContain('innerMiddleware'); - }); - - it('handles sub-app with multiple path-targeted middleware for different paths', async () => { - const app = new Hono(); - applyPatches(app); - - const subApp = new Hono(); - subApp.use('/a/*', async function mwForA(_c: unknown, next: () => Promise) { - await next(); - }); - subApp.use('/b/*', async function mwForB(_c: unknown, next: () => Promise) { - await next(); - }); - subApp.get('/a/test', () => new Response('a')); - subApp.get('/b/test', () => new Response('b')); - - app.route('/sub', subApp); - - await app.fetch(new Request('http://localhost/sub/a/test')); - expect(startInactiveSpanMock).toHaveBeenCalledTimes(1); - expect(startInactiveSpanMock).toHaveBeenCalledWith(expect.objectContaining({ name: 'mwForA' })); - - startInactiveSpanMock.mockClear(); - - await app.fetch(new Request('http://localhost/sub/b/test')); - expect(startInactiveSpanMock).toHaveBeenCalledTimes(1); - expect(startInactiveSpanMock).toHaveBeenCalledWith(expect.objectContaining({ name: 'mwForB' })); - }); - }); - - describe('wrapSubAppMiddleware non-invasive patching', () => { - it('preserves symbol-keyed properties on sub-app middleware handlers', async () => { - const app = new Hono(); - applyPatches(app); - - const OPENAPI = Symbol('openapi'); - const META = Symbol('meta'); - const middleware = async function authMiddleware(_c: unknown, next: () => Promise) { - await next(); - }; - (middleware as any)[OPENAPI] = { security: [{ bearer: [] }] }; - (middleware as any)[META] = { rateLimit: 100 }; - (middleware as any).customProp = 'preserved'; - - const subApp = new Hono(); - subApp.use(middleware); - subApp.get('/', () => new Response('ok')); - - app.route('/api', subApp); - - const route = (subApp.routes as Array<{ handler: Function }>).find( - r => (r.handler as any).__sentry_original__ === middleware || r.handler === middleware, - ); - expect(route).toBeDefined(); - - const wrappedHandler = route!.handler; - const symbols = Object.getOwnPropertySymbols(wrappedHandler); - expect(symbols).toContain(OPENAPI); - expect(symbols).toContain(META); - expect((wrappedHandler as any)[OPENAPI]).toEqual({ security: [{ bearer: [] }] }); - expect((wrappedHandler as any)[META]).toEqual({ rateLimit: 100 }); - expect((wrappedHandler as any).customProp).toBe('preserved'); - }); - - it('preserves function.name on sub-app middleware after wrapping', async () => { - const app = new Hono(); - applyPatches(app); - - const subApp = new Hono(); - subApp.use(async function corsMiddleware(_c: unknown, next: () => Promise) { - await next(); - }); - subApp.get('/', () => new Response('ok')); - - app.route('/api', subApp); - - const route = (subApp.routes as Array<{ handler: Function; method: string }>).find( - r => r.method === 'ALL' && r.handler.name === 'corsMiddleware', - ); - expect(route).toBeDefined(); - expect(route!.handler.name).toBe('corsMiddleware'); - }); - - it('preserves function.length on sub-app middleware after wrapping', async () => { - const app = new Hono(); - applyPatches(app); - - const subApp = new Hono(); - const mw = async function twoArgMw(_c: unknown, next: () => Promise) { - await next(); - }; - const originalLength = mw.length; - subApp.use(mw); - subApp.get('/', () => new Response('ok')); - - app.route('/api', subApp); - - const route = (subApp.routes as Array<{ handler: Function; method: string }>).find( - r => r.method === 'ALL' && r.handler.length === originalLength, - ); - expect(route).toBeDefined(); - expect(route!.handler.length).toBe(originalLength); - }); - - it('does not alter the behavior of sub-app route handlers (non-middleware)', async () => { - const app = new Hono(); - applyPatches(app); - - const HANDLER_META = Symbol('handler-meta'); - const handler = async function getItems() { - return new Response('items'); - }; - (handler as any)[HANDLER_META] = { cached: true }; - - const subApp = new Hono(); - subApp.get('/items', handler); - - app.route('/api', subApp); - - const route = (subApp.routes as Array<{ handler: Function; path: string }>).find(r => r.path === '/items'); - expect(route).toBeDefined(); - expect((route!.handler as any)[HANDLER_META]).toEqual({ cached: true }); - - const res = await app.fetch(new Request('http://localhost/api/items')); - expect(res.status).toBe(200); - expect(await res.text()).toBe('items'); - }); - }); - - describe('main-app .get() routes after applyPatches', () => { - it('responds correctly from .get() routes registered after applyPatches', async () => { - const app = new Hono(); - applyPatches(app); - - app.get('/docs', c => c.text('API Documentation')); - app.get('/openapi.json', c => c.json({ openapi: '3.0.0', paths: {} })); - - const docsRes = await app.fetch(new Request('http://localhost/docs')); - expect(docsRes.status).toBe(200); - expect(await docsRes.text()).toBe('API Documentation'); - - const specRes = await app.fetch(new Request('http://localhost/openapi.json')); - expect(specRes.status).toBe(200); - expect(await specRes.json()).toEqual({ openapi: '3.0.0', paths: {} }); - }); - - it('preserves .get() routes registered after .basePath() and .route() chains', async () => { - const app = new Hono(); - applyPatches(app); - - const subApp = new Hono(); - subApp.use(async function authMiddleware(_c: unknown, next: () => Promise) { - await next(); - }); - subApp.get('/resource', () => new Response('resource')); - - app.basePath('/api').route('/v1', subApp); - - app.get('/docs', c => c.text('Docs page')); - app.get('/openapi.json', c => c.json({ openapi: '3.0.0' })); - - const resourceRes = await app.fetch(new Request('http://localhost/api/v1/resource')); - expect(resourceRes.status).toBe(200); - expect(await resourceRes.text()).toBe('resource'); - - const docsRes = await app.fetch(new Request('http://localhost/docs')); - expect(docsRes.status).toBe(200); - expect(await docsRes.text()).toBe('Docs page'); - - const specRes = await app.fetch(new Request('http://localhost/openapi.json')); - expect(specRes.status).toBe(200); - expect(await specRes.json()).toEqual({ openapi: '3.0.0' }); - }); - - it('does not corrupt app.routes for third-party route introspection', () => { - const app = new Hono(); - applyPatches(app); - - app.use(async function globalMw(_c: unknown, next: () => Promise) { - await next(); - }); - app.get('/users', () => new Response('users')); - app.post('/users', () => new Response('created')); - - const subApp = new Hono(); - subApp.get('/items', () => new Response('items')); - app.route('/api', subApp); - - const routes = app.routes as Array<{ method: string; path: string; handler: Function }>; - const getPaths = routes.filter(r => r.method === 'GET').map(r => r.path); - const postPaths = routes.filter(r => r.method === 'POST').map(r => r.path); - - expect(getPaths).toContain('/users'); - expect(getPaths).toContain('/api/items'); - expect(postPaths).toContain('/users'); - - for (const route of routes) { - expect(typeof route.handler).toBe('function'); - } - }); - }); - - describe('patchAppRequest integration', () => { - it('patches .request() on sub-apps when they are mounted via route()', async () => { - const app = new Hono(); - applyPatches(app); - - const subApp = new Hono(); - subApp.get('/hello', () => new Response('world')); - - app.route('/api', subApp); - - await subApp.request('/hello'); - - expect(startSpanMock).toHaveBeenCalledTimes(1); - expect(startSpanMock).toHaveBeenCalledWith( - expect.objectContaining({ - name: 'GET /hello', - attributes: expect.objectContaining({ - 'sentry.op': 'http.server', - 'sentry.origin': 'auto.http.hono.internal_request', - }), - }), - expect.any(Function), - ); - }); - - it('does not double-patch .request() on a sub-app mounted multiple times', () => { - const app = new Hono(); - applyPatches(app); - - const subApp = new Hono(); - subApp.get('/hello', () => new Response('world')); - - app.route('/api', subApp); - const patchedRequest = subApp.request; - - app.route('/api2', subApp); - expect(subApp.request).toBe(patchedRequest); - }); - }); -}); diff --git a/packages/hono/test/shared/earlyPatchRoute.test.ts b/packages/hono/test/shared/earlyPatchRoute.test.ts deleted file mode 100644 index 3e2eee6208a7..000000000000 --- a/packages/hono/test/shared/earlyPatchRoute.test.ts +++ /dev/null @@ -1,206 +0,0 @@ -import * as SentryCore from '@sentry/core'; -import { Hono } from 'hono'; -import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'; -import { applyPatches, earlyPatchHono } from '../../src/shared/applyPatches'; -import { installRouteHookOnPrototype } from '../../src/shared/patchRoute'; - -vi.mock('@sentry/core', async () => { - const actual = await vi.importActual('@sentry/core'); - return { - ...actual, - startInactiveSpan: vi.fn((_opts: unknown) => ({ - setStatus: vi.fn(), - end: vi.fn(), - })), - startSpan: vi.fn((_opts: unknown, callback: () => unknown) => callback()), - getActiveSpan: vi.fn(() => ({ spanId: 'fake-span' })), - }; -}); - -const startSpanMock = SentryCore.startSpan as ReturnType; - -const honoBaseProto = Object.getPrototypeOf(Hono.prototype) as { route: Function }; -const originalRoute = honoBaseProto.route; - -earlyPatchHono(); - -describe('earlyPatchHono (two-phase prototype hook)', () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - afterAll(() => { - honoBaseProto.route = originalRoute; - }); - - it('does NOT patch sub-app .request() at .route() time — only collects', async () => { - const subApp = new Hono(); - subApp.get('/hello', c => c.text('world')); - - const parent = new Hono(); - parent.route('/api', subApp); - - await subApp.request('/hello'); - expect(startSpanMock).not.toHaveBeenCalled(); - }); - - it('patches collected sub-apps when applyPatches activates', async () => { - const subApp = new Hono(); - subApp.get('/hello', c => c.text('world')); - - const parent = new Hono(); - parent.route('/api', subApp); - - applyPatches(parent); - - await subApp.request('/hello'); - - expect(startSpanMock).toHaveBeenCalledTimes(1); - expect(startSpanMock).toHaveBeenCalledWith(expect.objectContaining({ name: 'GET /hello' }), expect.any(Function)); - }); - - it('emits a debug log and applies patchAppRequest when sub-app was mounted before applyPatches', async () => { - const debugLogSpy = vi.spyOn(SentryCore.debug, 'log'); - - honoBaseProto.route = originalRoute; - installRouteHookOnPrototype(); - - const subApp = new Hono(); - subApp.get('/hello', c => c.text('world')); - - const parent = new Hono(); - parent.route('/api', subApp); - - applyPatches(parent); // retroactive instrumentation - - // The log warns the developer about the out-of-order setup. - expect(debugLogSpy).toHaveBeenCalledWith(expect.stringContaining('sub-app(s) were mounted before sentry()')); - - // patchAppRequest is applied retroactively - await subApp.request('/hello'); - - expect(startSpanMock).toHaveBeenCalledWith(expect.objectContaining({ name: 'GET /hello' }), expect.any(Function)); - }); - - it('preserves correct route behavior', async () => { - const subApp = new Hono(); - subApp.get('/hello', c => c.text('world')); - - const parent = new Hono(); - parent.route('/api', subApp); - - const res = await parent.fetch(new Request('http://localhost/api/hello')); - expect(res.status).toBe(200); - expect(await res.text()).toBe('world'); - }); -}); - -describe('installRouteHookOnPrototype idempotency', () => { - afterAll(() => { - honoBaseProto.route = originalRoute; - }); - - it('returns the same handle on repeated calls', () => { - const handle1 = installRouteHookOnPrototype(); - const handle2 = installRouteHookOnPrototype(); - - expect(handle1).toBe(handle2); - }); - - it('does not replace the patched route function on repeated calls', () => { - installRouteHookOnPrototype(); - const patchedRoute = honoBaseProto.route; - - installRouteHookOnPrototype(); - expect(honoBaseProto.route).toBe(patchedRoute); - }); - - it('calling activate() multiple times has no adverse effect', async () => { - const handle = installRouteHookOnPrototype(); - - handle.activate(); - handle.activate(); - handle.activate(); - - const app = new Hono(); - applyPatches(app); - - const subApp = new Hono(); - subApp.get('/hello', c => c.text('world')); - app.route('/api', subApp); - - await subApp.request('/hello'); - - expect(startSpanMock).toHaveBeenCalledTimes(1); - }); -}); - -describe('installRouteHookOnPrototype non-invasive patching', () => { - afterAll(() => { - honoBaseProto.route = originalRoute; - }); - - it('preserves function.name of the original route method', () => { - honoBaseProto.route = originalRoute; - const originalName = originalRoute.name; - - installRouteHookOnPrototype(); - - expect(honoBaseProto.route!.name).toBe(originalName); - }); - - it('preserves function.length of the original route method', () => { - honoBaseProto.route = originalRoute; - const originalLength = originalRoute.length; - - installRouteHookOnPrototype(); - - expect(honoBaseProto.route!.length).toBe(originalLength); - }); - - it('preserves symbol-keyed properties on the route method', () => { - honoBaseProto.route = originalRoute; - const ROUTER_META = Symbol('router-meta'); - (originalRoute as any)[ROUTER_META] = { version: 3 }; - - installRouteHookOnPrototype(); - - const symbols = Object.getOwnPropertySymbols(honoBaseProto.route!); - expect(symbols).toContain(ROUTER_META); - expect((honoBaseProto.route as any)[ROUTER_META]).toEqual({ version: 3 }); - }); - - it('preserves string-keyed custom properties on the route method', () => { - honoBaseProto.route = originalRoute; - (originalRoute as any).pluginId = 'openapi-router'; - (originalRoute as any).__patched_by_other_lib__ = true; - - installRouteHookOnPrototype(); - - expect((honoBaseProto.route as any).pluginId).toBe('openapi-router'); - expect((honoBaseProto.route as any).__patched_by_other_lib__).toBe(true); - }); - - it('preserves prototype chain of the original function', () => { - honoBaseProto.route = originalRoute; - const originalProto = Object.getPrototypeOf(originalRoute); - - installRouteHookOnPrototype(); - - expect(Object.getPrototypeOf(honoBaseProto.route!)).toBe(originalProto); - }); - - it('correctly calls the original route and preserves return value', () => { - honoBaseProto.route = originalRoute; - installRouteHookOnPrototype(); - - const app = new Hono(); - applyPatches(app); - - const subApp = new Hono(); - subApp.get('/test', c => c.text('ok')); - - const result = app.route('/api', subApp); - expect(result).toBe(app); - }); -}); diff --git a/packages/hono/test/shared/middlewareHandlers.test.ts b/packages/hono/test/shared/middlewareHandlers.test.ts deleted file mode 100644 index c52e4f62eb69..000000000000 --- a/packages/hono/test/shared/middlewareHandlers.test.ts +++ /dev/null @@ -1,334 +0,0 @@ -import * as SentryCore from '@sentry/core'; -import { SENTRY_SEGMENT_NAME_SOURCE, HTTP_ROUTE } from '@sentry/conventions/attributes'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { requestHandler, responseHandler } from '../../src/shared/middlewareHandlers'; - -vi.mock('hono/route', () => ({ - routePath: () => '/test', - matchedRoutes: () => [{ basePath: '/', path: '/test', method: 'GET', handler: (_c: unknown) => undefined }], -})); - -vi.mock('../../src/utils/hono-context', () => ({ - hasFetchEvent: () => false, -})); - -const mockSetTransactionName = vi.fn(); -const mockSetSDKProcessingMetadata = vi.fn(); -const mockSetUser = vi.fn(); -const mockGetUser = vi.fn<() => Record>(() => ({})); - -let rootSpanAttributes: Record = {}; -const mockRootSpan = { - setAttribute: vi.fn((key: string, value: unknown) => { - rootSpanAttributes[key] = value; - }), - setAttributes: vi.fn((attributes: Record) => { - for (const [key, value] of Object.entries(attributes)) { - rootSpanAttributes[key] = value; - } - }), - updateName: vi.fn(), -}; - -vi.mock('@sentry/core', async () => { - const actual = await vi.importActual('@sentry/core'); - return { - ...actual, - getActiveSpan: vi.fn(() => null), - getRootSpan: vi.fn(() => mockRootSpan), - getIsolationScope: vi.fn(() => ({ - setTransactionName: mockSetTransactionName, - setSDKProcessingMetadata: mockSetSDKProcessingMetadata, - setUser: mockSetUser, - getUser: mockGetUser, - })), - getClient: vi.fn(() => undefined), - captureException: vi.fn(), - }; -}); - -const getClientMock = SentryCore.getClient as ReturnType; -const captureExceptionMock = SentryCore.captureException as ReturnType; -const getActiveSpanMock = SentryCore.getActiveSpan as ReturnType; - -function createMockContext(status: number, error?: Error): unknown { - return { - req: { method: 'GET', routeIndex: 0, raw: new Request('http://localhost/test') }, - res: { status }, - error, - }; -} - -describe('responseHandler', () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - describe('error capture — default behavior (no shouldHandleError)', () => { - it('captures error when context.error is set', () => { - const error = new Error('server error'); - // oxlint-disable-next-line typescript/no-explicit-any - responseHandler(createMockContext(500, error) as any); - - expect(captureExceptionMock).toHaveBeenCalledWith(error, { - mechanism: { handled: false, type: 'auto.http.hono.context_error' }, - }); - }); - - it('captures plain Error with no status (not an HTTPException) regardless of response status', () => { - const error = new Error('plain error, no status property'); - // oxlint-disable-next-line typescript/no-explicit-any - responseHandler(createMockContext(404, error) as any); - - expect(captureExceptionMock).toHaveBeenCalledWith(error, { - mechanism: { handled: false, type: 'auto.http.hono.context_error' }, - }); - }); - - it('does not call captureException when there is no error', () => { - // oxlint-disable-next-line typescript/no-explicit-any - responseHandler(createMockContext(200) as any); - - expect(captureExceptionMock).not.toHaveBeenCalled(); - }); - - it('delegates deduplication to the public capture API', () => { - const error = new Error('already captured'); - Object.defineProperty(error, '__sentry_captured__', { value: true, writable: false }); - - // oxlint-disable-next-line typescript/no-explicit-any - responseHandler(createMockContext(500, error) as any); - - expect(captureExceptionMock).toHaveBeenCalledWith(error, { - mechanism: { handled: false, type: 'auto.http.hono.context_error' }, - }); - }); - - it('does not capture 4xx HTTPException (status on error object)', () => { - const error = Object.assign(new Error('Not Found'), { status: 404 }); - // oxlint-disable-next-line typescript/no-explicit-any - responseHandler(createMockContext(404, error) as any); - - expect(captureExceptionMock).not.toHaveBeenCalled(); - }); - - it('does not capture 3xx HTTPException (status on error object)', () => { - const error = Object.assign(new Error('Redirect'), { status: 301 }); - // oxlint-disable-next-line typescript/no-explicit-any - responseHandler(createMockContext(301, error) as any); - - expect(captureExceptionMock).not.toHaveBeenCalled(); - }); - - it('captures 5xx HTTPException (status on error object)', () => { - const error = Object.assign(new Error('Service Unavailable'), { status: 503 }); - // oxlint-disable-next-line typescript/no-explicit-any - responseHandler(createMockContext(503, error) as any); - - expect(captureExceptionMock).toHaveBeenCalledWith(error, { - mechanism: { handled: false, type: 'auto.http.hono.context_error' }, - }); - }); - }); - - describe('error capture — custom shouldHandleError', () => { - it('calls shouldHandleError with the error and captures when it returns true', () => { - const shouldHandleError = vi.fn().mockReturnValue(true); - const error = Object.assign(new Error('Not Found'), { status: 404 }); - - // oxlint-disable-next-line typescript/no-explicit-any - responseHandler(createMockContext(404, error) as any, shouldHandleError); - - expect(shouldHandleError).toHaveBeenCalledWith(error); - expect(captureExceptionMock).toHaveBeenCalledWith(error, { - mechanism: { handled: false, type: 'auto.http.hono.context_error' }, - }); - }); - - it('does not capture when shouldHandleError returns false', () => { - const shouldHandleError = vi.fn().mockReturnValue(false); - const error = new Error('suppressed 500 error'); - - // oxlint-disable-next-line typescript/no-explicit-any - responseHandler(createMockContext(500, error) as any, shouldHandleError); - - expect(shouldHandleError).toHaveBeenCalledWith(error); - expect(captureExceptionMock).not.toHaveBeenCalled(); - }); - - it('captures 4xx error that would normally be skipped when shouldHandleError returns true', () => { - const error = Object.assign(new Error('Unauthorized'), { status: 401 }); - // oxlint-disable-next-line typescript/no-explicit-any - responseHandler(createMockContext(401, error) as any, () => true); - - expect(captureExceptionMock).toHaveBeenCalledWith(error, { - mechanism: { handled: false, type: 'auto.http.hono.context_error' }, - }); - }); - - it('suppresses 5xx error when shouldHandleError returns false', () => { - const error = Object.assign(new Error('Internal Server Error'), { status: 500 }); - // oxlint-disable-next-line typescript/no-explicit-any - responseHandler(createMockContext(500, error) as any, () => false); - - expect(captureExceptionMock).not.toHaveBeenCalled(); - }); - - it('does not invoke shouldHandleError when context.error is absent', () => { - const shouldHandleError = vi.fn().mockReturnValue(true); - // oxlint-disable-next-line typescript/no-explicit-any - responseHandler(createMockContext(200) as any, shouldHandleError); - - expect(shouldHandleError).not.toHaveBeenCalled(); - expect(captureExceptionMock).not.toHaveBeenCalled(); - }); - }); - - describe('transaction name', () => { - it('sets transaction name on isolation scope', () => { - // oxlint-disable-next-line typescript/no-explicit-any - requestHandler(createMockContext(200) as any); - - expect(mockSetTransactionName).toHaveBeenCalledWith('GET /test'); - }); - - it('sets http.route and segment name source on the root span', () => { - getActiveSpanMock.mockReturnValue(mockRootSpan); - - // oxlint-disable-next-line typescript/no-explicit-any - requestHandler(createMockContext(200) as any); - - expect(mockRootSpan.setAttribute).toHaveBeenCalledWith(HTTP_ROUTE, '/test'); - expect(mockRootSpan.setAttribute).toHaveBeenCalledWith(SENTRY_SEGMENT_NAME_SOURCE, 'route'); - }); - }); -}); - -describe('requestHandler — connection info', () => { - const activeSpan = { updateName: vi.fn(), setAttribute: vi.fn() }; - - beforeEach(() => { - vi.clearAllMocks(); - rootSpanAttributes = {}; - mockGetUser.mockReturnValue({}); - getActiveSpanMock.mockReturnValue(activeSpan); - }); - - function getConnInfoStub(remote: Record): () => { remote: Record } { - return vi.fn(() => ({ remote })); - } - - function mockUserInfo(userInfo: boolean): void { - getClientMock.mockReturnValue({ - getDataCollectionOptions: () => ({ userInfo }), - }); - } - - it('sets non-PII attributes (port, transport, type) regardless of userInfo', () => { - mockUserInfo(false); - const getConnInfo = getConnInfoStub({ port: 54321, transport: 'tcp', addressType: 'IPv4' }); - - // oxlint-disable-next-line typescript/no-explicit-any - requestHandler(createMockContext(200) as any, getConnInfo as any); - - expect(rootSpanAttributes['client.port']).toBe(54321); - expect(rootSpanAttributes['network.peer.port']).toBe(54321); - expect(rootSpanAttributes['network.transport']).toBe('tcp'); - expect(rootSpanAttributes['network.type']).toBe('ipv4'); - expect(rootSpanAttributes['client.address']).toBeUndefined(); - }); - - it('sets IP-bearing attributes and user.ip_address when userInfo is true', () => { - mockUserInfo(true); - const getConnInfo = getConnInfoStub({ address: '203.0.113.5', port: 443, addressType: 'IPv6' }); - - // oxlint-disable-next-line typescript/no-explicit-any - requestHandler(createMockContext(200) as any, getConnInfo as any); - - expect(rootSpanAttributes['client.address']).toBe('203.0.113.5'); - expect(rootSpanAttributes['network.peer.address']).toBe('203.0.113.5'); - expect(rootSpanAttributes['network.type']).toBe('ipv6'); - expect(mockSetUser).toHaveBeenCalledWith({ ip_address: '203.0.113.5' }); - }); - - it('merges ip_address into the existing user without overwriting other fields', () => { - mockUserInfo(true); - mockGetUser.mockReturnValue({ id: 'user-123', email: 'jane@example.com' }); - const getConnInfo = getConnInfoStub({ address: '203.0.113.5', port: 443 }); - - // oxlint-disable-next-line typescript/no-explicit-any - requestHandler(createMockContext(200) as any, getConnInfo as any); - - expect(mockSetUser).toHaveBeenCalledWith({ - id: 'user-123', - email: 'jane@example.com', - ip_address: '203.0.113.5', - }); - }); - - it('omits IP-bearing attributes when userInfo is false', () => { - mockUserInfo(false); - const getConnInfo = getConnInfoStub({ address: '203.0.113.5', port: 8080 }); - - // oxlint-disable-next-line typescript/no-explicit-any - requestHandler(createMockContext(200) as any, getConnInfo as any); - - expect(rootSpanAttributes['client.address']).toBeUndefined(); - expect(rootSpanAttributes['network.peer.address']).toBeUndefined(); - expect(mockSetUser).not.toHaveBeenCalled(); - // Non-PII data is still recorded. - expect(rootSpanAttributes['client.port']).toBe(8080); - }); - - it('sets no connection attributes when remote info is empty', () => { - mockUserInfo(true); - const getConnInfo = getConnInfoStub({}); - - // oxlint-disable-next-line typescript/no-explicit-any - requestHandler(createMockContext(200) as any, getConnInfo as any); - - expect(rootSpanAttributes['client.port']).toBeUndefined(); - expect(rootSpanAttributes['network.peer.port']).toBeUndefined(); - expect(rootSpanAttributes['network.transport']).toBeUndefined(); - expect(rootSpanAttributes['network.type']).toBeUndefined(); - expect(rootSpanAttributes['client.address']).toBeUndefined(); - expect(mockSetUser).not.toHaveBeenCalled(); - }); - - it('does not throw or set attributes when getConnInfo throws', () => { - mockUserInfo(true); - const getConnInfo = vi.fn(() => { - throw new Error('socket unavailable'); - }); - - expect(() => - // oxlint-disable-next-line typescript/no-explicit-any - requestHandler(createMockContext(200) as any, getConnInfo as any), - ).not.toThrow(); - expect(rootSpanAttributes['client.port']).toBeUndefined(); - expect(rootSpanAttributes['client.address']).toBeUndefined(); - expect(mockSetUser).not.toHaveBeenCalled(); - }); - - it('does not set connection attributes when there is no active span', () => { - mockUserInfo(true); - getActiveSpanMock.mockReturnValue(null); - const getConnInfo = getConnInfoStub({ address: '203.0.113.5', port: 443 }); - - // oxlint-disable-next-line typescript/no-explicit-any - requestHandler(createMockContext(200) as any, getConnInfo as any); - - expect(getConnInfo).not.toHaveBeenCalled(); - expect(rootSpanAttributes).toEqual({}); - }); - - it('is a no-op when getConnInfo is not provided', () => { - mockUserInfo(true); - - // oxlint-disable-next-line typescript/no-explicit-any - requestHandler(createMockContext(200) as any); - - expect(rootSpanAttributes['client.port']).toBeUndefined(); - expect(mockSetUser).not.toHaveBeenCalled(); - }); -}); diff --git a/packages/hono/test/shared/patchAppRequest.test.ts b/packages/hono/test/shared/patchAppRequest.test.ts deleted file mode 100644 index bcd5ccf3a863..000000000000 --- a/packages/hono/test/shared/patchAppRequest.test.ts +++ /dev/null @@ -1,224 +0,0 @@ -import * as SentryCore from '@sentry/core'; -import { Hono } from 'hono'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { patchAppRequest } from '../../src/shared/patchAppRequest'; - -vi.mock('@sentry/core', async () => { - const actual = await vi.importActual('@sentry/core'); - return { - ...actual, - startSpan: vi.fn((_opts: unknown, callback: () => unknown) => callback()), - getActiveSpan: vi.fn(() => ({ spanId: 'fake-span' })), - }; -}); - -const startSpanMock = SentryCore.startSpan as ReturnType; -const getActiveSpanMock = SentryCore.getActiveSpan as ReturnType; - -describe('patchAppRequest', () => { - beforeEach(() => { - vi.clearAllMocks(); - getActiveSpanMock.mockReturnValue({ spanId: 'fake-span' }); - }); - - it('creates a hono.request span when .request() is called with an active parent span', async () => { - const app = new Hono(); - app.get('/hello', c => c.text('world')); - patchAppRequest(app); - - await app.request('/hello'); - - expect(startSpanMock).toHaveBeenCalledTimes(1); - expect(startSpanMock).toHaveBeenCalledWith( - expect.objectContaining({ - name: 'GET /hello', - onlyIfParent: true, - attributes: expect.objectContaining({ - 'sentry.op': 'http.server', - 'sentry.origin': 'auto.http.hono.internal_request', - }), - }), - expect.any(Function), - ); - }); - - it('skips span creation when there is no active span', async () => { - getActiveSpanMock.mockReturnValue(undefined); - - const app = new Hono(); - app.get('/hello', c => c.text('world')); - patchAppRequest(app); - - const res = await app.request('/hello'); - - expect(startSpanMock).not.toHaveBeenCalled(); - expect(await res.text()).toBe('world'); - }); - - it('uses the method from requestInit when provided', async () => { - const app = new Hono(); - app.post('/submit', c => c.text('ok')); - patchAppRequest(app); - - await app.request('/submit', { method: 'POST' }); - - expect(startSpanMock).toHaveBeenCalledWith(expect.objectContaining({ name: 'POST /submit' }), expect.any(Function)); - }); - - it('uses the method from a Request object when no requestInit is provided', async () => { - const app = new Hono(); - app.post('/submit', c => c.text('ok')); - patchAppRequest(app); - - await app.request(new Request('http://localhost/submit', { method: 'POST' })); - - expect(startSpanMock).toHaveBeenCalledWith(expect.objectContaining({ name: 'POST /submit' }), expect.any(Function)); - }); - - it('defaults to GET when no method info is available', async () => { - const app = new Hono(); - app.get('/hello', c => c.text('world')); - patchAppRequest(app); - - await app.request('/hello'); - - expect(startSpanMock).toHaveBeenCalledWith(expect.objectContaining({ name: 'GET /hello' }), expect.any(Function)); - }); - - it('does not double-patch when called twice on the same instance', async () => { - const app = new Hono(); - app.get('/hello', c => c.text('world')); - - patchAppRequest(app); - const firstPatched = app.request; - - patchAppRequest(app); - expect(app.request).toBe(firstPatched); - }); - - it('preserves the original .request() return value', async () => { - const app = new Hono(); - app.get('/hello', c => c.json({ message: 'world' })); - patchAppRequest(app); - - const res = await app.request('/hello'); - expect(res.status).toBe(200); - - const body = await res.json(); - expect(body).toEqual({ message: 'world' }); - }); - - it('stores the original request via __sentry_original__', () => { - const app = new Hono(); - const originalRequest = app.request; - patchAppRequest(app); - - // oxlint-disable-next-line typescript/no-explicit-any - const sentryOriginal = (app.request as any).__sentry_original__; - expect(sentryOriginal).toBe(originalRequest); - }); - - it('extracts pathname from a full URL string instead of using the raw string', async () => { - const app = new Hono(); - app.get('/api/hello', c => c.text('world')); - patchAppRequest(app); - - await app.request('http://localhost/api/hello'); - - expect(startSpanMock).toHaveBeenCalledWith( - expect.objectContaining({ name: 'GET /api/hello' }), - expect.any(Function), - ); - }); - - it('extracts pathname from an https URL string', async () => { - const app = new Hono(); - app.get('/secure', c => c.text('ok')); - patchAppRequest(app); - - await app.request('https://example.com/secure'); - - expect(startSpanMock).toHaveBeenCalledWith(expect.objectContaining({ name: 'GET /secure' }), expect.any(Function)); - }); - - it('extracts pathname from a Request object input', async () => { - const app = new Hono(); - app.get('/items/abc', c => c.text('found')); - patchAppRequest(app); - - await app.request(new Request('http://localhost/items/abc')); - - expect(startSpanMock).toHaveBeenCalledWith( - expect.objectContaining({ name: 'GET /items/abc' }), - expect.any(Function), - ); - }); - - describe('non-invasive patching (preserves existing properties)', () => { - it('preserves symbol-keyed properties on app.request', () => { - const app = new Hono(); - const CUSTOM_SYMBOL = Symbol('custom-meta'); - (app.request as any)[CUSTOM_SYMBOL] = { version: 2 }; - - patchAppRequest(app); - - const symbols = Object.getOwnPropertySymbols(app.request); - expect(symbols).toContain(CUSTOM_SYMBOL); - expect((app.request as any)[CUSTOM_SYMBOL]).toEqual({ version: 2 }); - }); - - it('preserves string-keyed custom properties on app.request', () => { - const app = new Hono(); - (app.request as any).customFlag = true; - (app.request as any).metadata = { wrapped: false }; - - patchAppRequest(app); - - expect((app.request as any).customFlag).toBe(true); - expect((app.request as any).metadata).toEqual({ wrapped: false }); - }); - - it('preserves function.name of the original request method', () => { - const app = new Hono(); - const originalName = app.request.name; - patchAppRequest(app); - - expect(app.request.name).toBe(originalName); - }); - - it('preserves function.length of the original request method', () => { - const app = new Hono(); - const originalLength = app.request.length; - patchAppRequest(app); - - expect(app.request.length).toBe(originalLength); - }); - - it('does not interfere with instanceof or typeof checks', () => { - const app = new Hono(); - patchAppRequest(app); - - expect(typeof app.request).toBe('function'); - }); - - it('preserves prototype chain of the original function', () => { - const app = new Hono(); - const originalProto = Object.getPrototypeOf(app.request); - patchAppRequest(app); - - expect(Object.getPrototypeOf(app.request)).toBe(originalProto); - }); - - it('preserves properties added by third-party libraries (e.g. OpenAPI metadata)', () => { - const app = new Hono(); - const OPENAPI = Symbol('openapi'); - (app.request as any)[OPENAPI] = { paths: { '/hello': { get: {} } } }; - (app.request as any).__middleware_chain__ = ['auth', 'cors']; - - patchAppRequest(app); - - expect((app.request as any)[OPENAPI]).toEqual({ paths: { '/hello': { get: {} } } }); - expect((app.request as any).__middleware_chain__).toEqual(['auth', 'cors']); - }); - }); -}); diff --git a/packages/hono/test/shared/patchAppUse.test.ts b/packages/hono/test/shared/patchAppUse.test.ts deleted file mode 100644 index 8773bb6961c6..000000000000 --- a/packages/hono/test/shared/patchAppUse.test.ts +++ /dev/null @@ -1,472 +0,0 @@ -import * as SentryCore from '@sentry/core'; -import { Hono } from 'hono'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { patchAppUse, patchHttpMethodHandlers } from '../../src/shared/patchAppUse'; - -vi.mock('@sentry/core', async () => { - const actual = await vi.importActual('@sentry/core'); - return { - ...actual, - startInactiveSpan: vi.fn((_opts: unknown) => ({ - setStatus: vi.fn(), - end: vi.fn(), - })), - }; -}); - -const startInactiveSpanMock = SentryCore.startInactiveSpan as ReturnType; - -describe('patchAppUse (middleware spans)', () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - it('wraps handlers in app.use(handler) so startInactiveSpan is called when middleware runs', async () => { - const app = new Hono(); - patchAppUse(app); - - const userHandler = vi.fn(async (_c: unknown, next: () => Promise) => { - await next(); - }); - app.use(userHandler); - - expect(startInactiveSpanMock).not.toHaveBeenCalled(); - - const fetchHandler = app.fetch; - const req = new Request('http://localhost/'); - await fetchHandler(req); - - expect(startInactiveSpanMock).toHaveBeenCalledTimes(1); - expect(startInactiveSpanMock).toHaveBeenCalledWith( - expect.objectContaining({ - onlyIfParent: true, - attributes: expect.objectContaining({ - 'sentry.op': 'middleware', - 'sentry.origin': 'auto.middleware.hono', - }), - }), - ); - expect(userHandler).toHaveBeenCalled(); - }); - - describe('span naming', () => { - it('uses handler.name for span when handler has a name', async () => { - const app = new Hono(); - patchAppUse(app); - - async function myNamedMiddleware(_c: unknown, next: () => Promise) { - await next(); - } - app.use(myNamedMiddleware); - - await app.fetch(new Request('http://localhost/')); - - expect(startInactiveSpanMock).toHaveBeenCalledWith(expect.objectContaining({ name: 'myNamedMiddleware' })); - }); - - it('uses for span when handler is anonymous', async () => { - const app = new Hono(); - patchAppUse(app); - - app.use(async (_c: unknown, next: () => Promise) => next()); - - await app.fetch(new Request('http://localhost/')); - - expect(startInactiveSpanMock).toHaveBeenCalledTimes(1); - const name = startInactiveSpanMock.mock.calls[0]![0].name; - expect(name).toMatch(''); - }); - }); - - it('wraps each handler in app.use(path, ...handlers) and passes path through', async () => { - const app = new Hono(); - patchAppUse(app); - - const handler = async (_c: unknown, next: () => Promise) => next(); - app.use('/api', handler); - app.get('/api', () => new Response('ok')); - - await app.fetch(new Request('http://localhost/api')); - - expect(startInactiveSpanMock).toHaveBeenCalled(); - }); - - it('sets span error status when middleware throws a 5xx-like error', async () => { - const app = new Hono(); - patchAppUse(app); - - const err = new Error('middleware error'); - app.use(async () => { - throw err; - }); - - const res = await app.fetch(new Request('http://localhost/')); - expect(res.status).toBe(500); - - const spanFromCall = startInactiveSpanMock.mock.results[0]?.value; - expect(spanFromCall?.setStatus).toHaveBeenCalledWith({ code: expect.any(Number), message: 'internal_error' }); - }); - - it('creates sibling spans for multiple middlewares (onion order, not parent-child)', async () => { - const app = new Hono(); - patchAppUse(app); - - app.use( - async (_c: unknown, next: () => Promise) => next(), - async function namedMiddleware(_c: unknown, next: () => Promise) { - await next(); - }, - async (_c: unknown, next: () => Promise) => next(), - ); - - await app.fetch(new Request('http://localhost/')); - - expect(startInactiveSpanMock).toHaveBeenCalledTimes(3); - const [firstCall, secondCall, thirdCall] = startInactiveSpanMock.mock.calls; - expect(firstCall![0]).toMatchObject({ attributes: { 'sentry.op': 'middleware' } }); - expect(secondCall![0]).toMatchObject({ attributes: { 'sentry.op': 'middleware' } }); - expect(firstCall![0].name).toMatch(''); - expect(secondCall![0].name).toBe('namedMiddleware'); - expect(thirdCall![0].name).toBe(''); - expect(firstCall![0].name).not.toBe(secondCall![0].name); - }); - - it('does not stack proxies when called twice on the same instance', () => { - const app = new Hono(); - patchAppUse(app); - const firstUse = app.use; - - patchAppUse(app); - expect(app.use).toBe(firstUse); - }); - - it('patches distinct instances independently', () => { - const app1 = new Hono(); - const app2 = new Hono(); - - patchAppUse(app1); - patchAppUse(app2); - - expect(app1.use).not.toBe(app2.use); - }); - - it('preserves symbol-keyed and string-keyed properties on wrapped handlers', async () => { - const app = new Hono(); - patchAppUse(app); - - const META = Symbol('test-meta'); - const OPENAPI = Symbol('openapi'); - - const handler = async (_c: unknown, next: () => Promise) => next(); - (handler as any)[META] = { summary: 'Get items' }; - (handler as any)[OPENAPI] = { responses: { 200: {} } }; - (handler as any).customProp = 'hello'; - - app.use('/test', handler); - - const route = (app.routes ?? []).find(r => r.path === '/test'); - expect(route).toBeDefined(); - - expect((route!.handler as any).__sentry_original__).toBe(handler); - - const symbols = Object.getOwnPropertySymbols(route!.handler); - expect(symbols).toContain(META); - expect(symbols).toContain(OPENAPI); - expect((route!.handler as any)[META]).toEqual({ summary: 'Get items' }); - expect((route!.handler as any)[OPENAPI]).toEqual({ responses: { 200: {} } }); - expect((route!.handler as any).customProp).toBe('hello'); - }); - - it('preserves this context when calling the original use (Proxy forwards thisArg)', () => { - type FakeApp = { - _capturedThis: unknown; - use: (...args: unknown[]) => FakeApp; - }; - const fakeApp: FakeApp = { - _capturedThis: null, - use(this: FakeApp, ..._args: unknown[]) { - this._capturedThis = this; - return this; - }, - }; - - patchAppUse(fakeApp as unknown as Parameters[0]); - - const noop = async (_c: unknown, next: () => Promise) => next(); - fakeApp.use(noop); - - expect(fakeApp._capturedThis).toBe(fakeApp); - }); -}); - -describe('patchHttpMethodHandlers (inline middleware spans on main app)', () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - it.each(['get', 'post', 'put', 'delete', 'options', 'patch', 'all'] as const)( - 'wraps inline middleware in app.%s(path, mw, handler)', - async method => { - const app = new Hono(); - patchHttpMethodHandlers(app); - - app[method]( - '/test', - async function inlineMw(_c: unknown, next: () => Promise) { - await next(); - }, - () => new Response('ok'), - ); - - const fetchMethod = method === 'all' ? 'GET' : method.toUpperCase(); - await app.fetch(new Request('http://localhost/test', { method: fetchMethod })); - - expect(startInactiveSpanMock).toHaveBeenCalledTimes(1); - expect(startInactiveSpanMock).toHaveBeenCalledWith({ - name: 'inlineMw', - onlyIfParent: true, - parentSpan: undefined, - attributes: { - 'sentry.op': 'middleware', - 'sentry.origin': 'auto.middleware.hono', - }, - }); - }, - ); - - it('does not wrap the sole handler when only one handler is passed', async () => { - const app = new Hono(); - patchHttpMethodHandlers(app); - - app.get('/test', async function onlyHandler() { - return new Response('ok'); - }); - - await app.fetch(new Request('http://localhost/test')); - - expect(startInactiveSpanMock).not.toHaveBeenCalled(); - }); - - it('wraps all handlers except the last when multiple handlers are passed', async () => { - const app = new Hono(); - patchHttpMethodHandlers(app); - - app.get( - '/test', - async function mw1(_c: unknown, next: () => Promise) { - await next(); - }, - async function mw2(_c: unknown, next: () => Promise) { - await next(); - }, - async function routeHandler() { - return new Response('ok'); - }, - ); - - await app.fetch(new Request('http://localhost/test')); - - const spanNames = startInactiveSpanMock.mock.calls.map((c: unknown[]) => (c[0] as { name: string }).name); - expect(spanNames).toHaveLength(2); - expect(spanNames).toContain('mw1'); - expect(spanNames).toContain('mw2'); - expect(spanNames).not.toContain('routeHandler'); - }); - - it('wraps inline middleware in app.on(method, path, mw, handler)', async () => { - const app = new Hono(); - patchHttpMethodHandlers(app); - - app.on( - 'QUERY', - '/test', - async function onMw(_c: unknown, next: () => Promise) { - await next(); - }, - async function onHandler() { - return new Response('ok'); - }, - ); - - await app.fetch(new Request('http://localhost/test', { method: 'QUERY' })); - - const spanNames = startInactiveSpanMock.mock.calls.map((c: unknown[]) => (c[0] as { name: string }).name); - expect(spanNames).toHaveLength(1); - expect(spanNames).toContain('onMw'); - expect(spanNames).not.toContain('onHandler'); - }); - - it('wraps app.query middleware when query is available (from 4.13.0)', async () => { - const context = { value: 'context' }; - const result = { value: 'result' }; - let registeredMiddleware: ((context: unknown, next: () => Promise) => Promise) | undefined; - let registeredHandler: ((context: unknown) => unknown) | undefined; - const query = vi.fn(function ( - this: unknown, - path: string, - middleware: (context: unknown, next: () => Promise) => Promise, - handler: (context: unknown) => unknown, - ) { - expect(this).toBe(fakeApp); - expect(path).toBe('/test'); - registeredMiddleware = middleware; - registeredHandler = handler; - return result; - }); - const fakeApp = Object.assign(new Hono(), { query }); - async function queryMiddleware(receivedContext: unknown, next: () => Promise) { - expect(receivedContext).toBe(context); - await next(); - } - const middleware = vi.fn(queryMiddleware); - const handler = vi.fn((receivedContext: unknown) => { - expect(receivedContext).toBe(context); - return 'handled'; - }); - - patchHttpMethodHandlers(fakeApp as unknown as Parameters[0]); - const registrationResult = fakeApp.query('/test', middleware, handler); - - expect(registrationResult).toBe(result); - if (!registeredMiddleware || !registeredHandler) { - throw new Error('query handlers were not registered'); - } - expect(registeredHandler).toBe(handler); - - const next = vi.fn(async () => undefined); - await registeredMiddleware(context, next); - const handlerResult = registeredHandler(context); - - expect(startInactiveSpanMock).toHaveBeenCalledTimes(1); - expect(startInactiveSpanMock).toHaveBeenCalledWith(expect.objectContaining({ name: 'queryMiddleware' })); - expect(middleware).toHaveBeenCalledWith(context, next); - expect(next).toHaveBeenCalledTimes(1); - expect(handler).toHaveBeenCalledWith(context); - expect(handlerResult).toBe('handled'); - }); - - it('patches apps without app.query', () => { - const app = new Hono(); - - expect(() => patchHttpMethodHandlers(app)).not.toThrow(); - }); - - it('does not wrap sole handler in app.on(method, path, handler)', async () => { - const app = new Hono(); - patchHttpMethodHandlers(app); - - app.on('GET', '/test', async function soleHandler() { - return new Response('ok'); - }); - - await app.fetch(new Request('http://localhost/test')); - - expect(startInactiveSpanMock).not.toHaveBeenCalled(); - }); - - it('does not double-wrap handlers already wrapped by patchAppUse', async () => { - const app = new Hono(); - patchAppUse(app); - patchHttpMethodHandlers(app); - - app.use(async function useMw(_c: unknown, next: () => Promise) { - await next(); - }); - app.get('/test', () => new Response('ok')); - - await app.fetch(new Request('http://localhost/test')); - - expect(startInactiveSpanMock).toHaveBeenCalledTimes(1); - expect((startInactiveSpanMock.mock.calls[0]![0] as { name: string }).name).toBe('useMw'); - }); - - it('produces exactly one span per middleware and does not stack Proxy layers when called multiple times on the same instance', async () => { - const app = new Hono(); - patchHttpMethodHandlers(app); - const firstGet = app.get; - const firstOn = app.on; - - patchHttpMethodHandlers(app); - expect(app.get).toBe(firstGet); - expect(app.on).toBe(firstOn); - - patchHttpMethodHandlers(app); - expect(app.get).toBe(firstGet); - expect(app.on).toBe(firstOn); - - app.get( - '/test', - async function inlineMw(_c: unknown, next: () => Promise) { - await next(); - }, - async function routeHandler() { - return new Response('ok'); - }, - ); - - await app.fetch(new Request('http://localhost/test')); - - const spanNames = startInactiveSpanMock.mock.calls.map((c: unknown[]) => (c[0] as { name: string }).name); - expect(spanNames).toHaveLength(1); - expect(spanNames[0]).toBe('inlineMw'); - }); - - it('creates spans for both app.use middleware and inline middleware in app.get', async () => { - const app = new Hono(); - patchAppUse(app); - patchHttpMethodHandlers(app); - - app.use('/test', async function globalMw(_c: unknown, next: () => Promise) { - await next(); - }); - app.get( - '/test', - async function inlineMw(_c: unknown, next: () => Promise) { - await next(); - }, - () => new Response('ok'), - ); - - await app.fetch(new Request('http://localhost/test')); - - const spanNames = startInactiveSpanMock.mock.calls.map((c: unknown[]) => (c[0] as { name: string }).name); - expect(spanNames).toContain('globalMw'); - expect(spanNames).toContain('inlineMw'); - expect(spanNames).toHaveLength(2); - }); - - it('preserves return value and chaining', () => { - const app = new Hono(); - patchHttpMethodHandlers(app); - - const result = app.get('/test', () => new Response('ok')); - - expect(result).toBe(app); - }); - - it('forwards thisArg to the original method', () => { - let capturedThis: unknown = null; - const fakeMethod = function (this: unknown) { - // oxlint-disable-next-line @typescript-eslint/no-this-alias - capturedThis = this; - return this; - }; - const fakeApp = { - get: fakeMethod, - post: fakeMethod, - put: fakeMethod, - delete: fakeMethod, - options: fakeMethod, - patch: fakeMethod, - all: fakeMethod, - on: fakeMethod, - }; - - patchHttpMethodHandlers(fakeApp as unknown as Parameters[0]); - - // @ts-expect-error - we're only testing that thisArg is forwarded, so the args don't need to be correct - fakeApp.get('/test', () => new Response('ok')); - - expect(capturedThis).toBe(fakeApp); - }); -}); diff --git a/packages/hono/test/shared/resolveRouteName.test.ts b/packages/hono/test/shared/resolveRouteName.test.ts deleted file mode 100644 index 4ca9f103db22..000000000000 --- a/packages/hono/test/shared/resolveRouteName.test.ts +++ /dev/null @@ -1,113 +0,0 @@ -import type { Context } from 'hono'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; - -const mockMatchedRoutes = vi.fn(); -const mockRoutePath = vi.fn(); - -vi.mock('hono/route', () => ({ - matchedRoutes: (c: unknown) => mockMatchedRoutes(c), - routePath: (c: unknown, index?: number) => mockRoutePath(c, index), -})); - -import { resolveRouteName } from '../../src/shared/resolveRouteName'; - -type Route = { - basePath: string; - path: string; - method: string; - handler: (...args: unknown[]) => unknown; -}; - -// Middleware has the signature `(context, next)` → arity 2 -// Route handlers are `(context)` → arity (no. of args) 1 -// `resolveRouteName` relies on this arity difference to tell them apart. -function mw(path: string, method = 'ALL'): Route { - return { basePath: '/', path, method, handler: (_c: unknown, _next: unknown) => undefined }; -} - -function handler(path: string, method = 'GET'): Route { - return { basePath: '/', path, method, handler: (_c: unknown) => undefined }; -} - -function ctx(routeIndex: number): Context { - return { req: { method: 'GET', routeIndex } } as unknown as Context; -} - -describe('resolveRouteName', () => { - beforeEach(() => { - vi.clearAllMocks(); - mockRoutePath.mockReturnValue('/fallback'); - }); - - it('returns the handler path when routeIndex points at a handler (normal flow)', () => { - mockMatchedRoutes.mockReturnValue([mw('/*'), handler('/users/:id')]); - - expect(resolveRouteName(ctx(1))).toBe('/users/:id'); - }); - - it('ignores a trailing catch-all middleware and uses the handler path', () => { - // app.use(fn) registered after the handlers → trailing `/*` is the last matched entry. - mockMatchedRoutes.mockReturnValue([mw('/*'), handler('/test-routes'), mw('/*')]); - - expect(resolveRouteName(ctx(1))).toBe('/test-routes'); - }); - - it('resolves the handler before dispatch when routeIndex still points at the sentry middleware', () => { - // Provisional pass: routeIndex is 0 (the sentry middleware) and `matchedRoutes` is already populated. - mockMatchedRoutes.mockReturnValue([mw('/*'), handler('/test-routes'), mw('/*')]); - - expect(resolveRouteName(ctx(0))).toBe('/test-routes'); - }); - - it('falls back to the matched handler when a middleware short-circuits (routeIndex on middleware)', () => { - // A scoped middleware throws before reaching the handler, so routeIndex stays on the middleware. - mockMatchedRoutes.mockReturnValue([mw('/*'), mw('/test/middleware/*'), handler('/test/middleware'), mw('/*')]); - - expect(resolveRouteName(ctx(1))).toBe('/test/middleware'); - }); - - it('prefers the responding handler over other matched handlers (overlap)', () => { - // Both `/users/:id` and a `/*` catch-all handler match; routeIndex disambiguates. - mockMatchedRoutes.mockReturnValue([mw('/*'), handler('/users/:id'), handler('/*')]); - - expect(resolveRouteName(ctx(1))).toBe('/users/:id'); - }); - - it('detects a sub-app handler wrapped by a custom onError (COMPOSED_HANDLER)', () => { - // Hono wraps the handler in an arity-2 closure but exposes the original via `__COMPOSED_HANDLER`. - const wrapped = ((_c: unknown, _next: unknown) => undefined) as Route['handler']; - (wrapped as unknown as Record).__COMPOSED_HANDLER = (_c: unknown) => undefined; - - mockMatchedRoutes.mockReturnValue([ - mw('/*'), - { basePath: '/', path: '/test/custom-on-error/fail', method: 'GET', handler: wrapped }, - mw('/*'), - ]); - - expect(resolveRouteName(ctx(1))).toBe('/test/custom-on-error/fail'); - }); - - it('falls back to routePath(c, -1) when only middleware matched', () => { - const context = ctx(1); - mockMatchedRoutes.mockReturnValue([mw('/*'), mw('/test-basepath/v1/*')]); - mockRoutePath.mockReturnValue('/test-basepath/v1/*'); - - expect(resolveRouteName(context)).toBe('/test-basepath/v1/*'); - expect(mockRoutePath).toHaveBeenCalledWith(context, -1); - }); - - it('falls back to routePath(c, -1) when no routes matched', () => { - const context = ctx(0); - mockMatchedRoutes.mockReturnValue([]); - mockRoutePath.mockReturnValue(''); - - expect(resolveRouteName(context)).toBe(''); - expect(mockRoutePath).toHaveBeenCalledWith(context, -1); - }); - - it('walks back to the last handler when routeIndex is out of range', () => { - mockMatchedRoutes.mockReturnValue([mw('/*'), handler('/test-late-get')]); - - expect(resolveRouteName(ctx(5))).toBe('/test-late-get'); - }); -}); diff --git a/packages/hono/test/shared/wrapMiddlewareSpan.test.ts b/packages/hono/test/shared/wrapMiddlewareSpan.test.ts deleted file mode 100644 index edf778188552..000000000000 --- a/packages/hono/test/shared/wrapMiddlewareSpan.test.ts +++ /dev/null @@ -1,127 +0,0 @@ -import * as SentryCore from '@sentry/core'; -import { SPAN_STATUS_ERROR } from '@sentry/core'; -import { type MiddlewareHandler } from 'hono'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { wrapMiddlewareWithSpan } from '../../src/shared/wrapMiddlewareSpan'; - -const mockSpan = { - setStatus: vi.fn(), - end: vi.fn(), -}; - -vi.mock('@sentry/core', async () => { - const actual = await vi.importActual('@sentry/core'); - return { - ...actual, - startInactiveSpan: vi.fn(() => mockSpan), - getActiveSpan: vi.fn(() => ({ spanId: 'root-span' })), - getRootSpan: vi.fn(span => span), - getOriginalFunction: vi.fn(() => undefined), - }; -}); - -const startInactiveSpanMock = SentryCore.startInactiveSpan as ReturnType; - -function makeContext(): unknown { - return { req: { method: 'GET' }, res: { status: 200 } }; -} - -const noop: () => Promise = async () => {}; - -describe('wrapMiddlewareWithSpan', () => { - beforeEach(() => { - vi.clearAllMocks(); - startInactiveSpanMock.mockReturnValue(mockSpan); - }); - - describe('span status', () => { - it('does not set span error status for a 4xx error', async () => { - const error = Object.assign(new Error('Not Found'), { status: 404 }); - const handler: MiddlewareHandler = async () => { - throw error; - }; - - const wrapped = wrapMiddlewareWithSpan(handler); - - await expect(wrapped(makeContext() as any, noop)).rejects.toThrow(error); - - expect(mockSpan.setStatus).not.toHaveBeenCalled(); - }); - - it('sets span status to error for a 5xx error', async () => { - const error = Object.assign(new Error('Server Error'), { status: 500 }); - const handler: MiddlewareHandler = async () => { - throw error; - }; - - const wrapped = wrapMiddlewareWithSpan(handler); - - await expect(wrapped(makeContext() as any, noop)).rejects.toThrow(error); - - expect(mockSpan.setStatus).toHaveBeenCalledWith({ code: SPAN_STATUS_ERROR, message: 'internal_error' }); - }); - - it('sets span status to error for a plain Error with no status', async () => { - const error = new Error('unexpected failure'); - const handler: MiddlewareHandler = async () => { - throw error; - }; - - const wrapped = wrapMiddlewareWithSpan(handler); - - await expect(wrapped(makeContext() as any, noop)).rejects.toThrow(error); - - expect(mockSpan.setStatus).toHaveBeenCalledWith({ code: SPAN_STATUS_ERROR, message: 'internal_error' }); - }); - - it('does not set span error status for a 3xx error', async () => { - const error = Object.assign(new Error('Redirect'), { status: 301 }); - const handler: MiddlewareHandler = async () => { - throw error; - }; - - const wrapped = wrapMiddlewareWithSpan(handler); - - await expect(wrapped(makeContext() as any, noop)).rejects.toThrow(error); - - expect(mockSpan.setStatus).not.toHaveBeenCalled(); - }); - }); - - describe('span lifecycle', () => { - it('always rethrows the error', async () => { - const error = new Error('must propagate'); - const handler: MiddlewareHandler = async () => { - throw error; - }; - - const wrapped = wrapMiddlewareWithSpan(handler); - - await expect(wrapped(makeContext() as any, noop)).rejects.toThrow('must propagate'); - }); - - it('ends the span even when the handler throws', async () => { - const handler: MiddlewareHandler = async () => { - throw new Error('boom'); - }; - - const wrapped = wrapMiddlewareWithSpan(handler); - - await expect(wrapped(makeContext() as any, noop)).rejects.toThrow(); - - expect(mockSpan.end).toHaveBeenCalledTimes(1); - }); - - it('ends the span when the handler succeeds', async () => { - const handler: MiddlewareHandler = async (_c, next) => { - await next(); - }; - - const wrapped = wrapMiddlewareWithSpan(handler); - - await wrapped(makeContext() as any, noop); - - expect(mockSpan.end).toHaveBeenCalledTimes(1); - }); - }); -}); diff --git a/packages/node/src/index.ts b/packages/node/src/index.ts index 3754f8408f86..ddaaa8922715 100644 --- a/packages/node/src/index.ts +++ b/packages/node/src/index.ts @@ -14,6 +14,8 @@ export { googleGenAIIntegration, graphqlIntegration, hapiIntegration, + honoIntegration, + honoMiddleware, kafkaIntegration, knexIntegration, koaIntegration, diff --git a/packages/remix/src/server/index.ts b/packages/remix/src/server/index.ts index f89345ae2de5..2f3397fad8cc 100644 --- a/packages/remix/src/server/index.ts +++ b/packages/remix/src/server/index.ts @@ -59,6 +59,7 @@ export { isEnabled, knexIntegration, kafkaIntegration, + honoIntegration, koaIntegration, lastEventId, linkedErrorsIntegration, diff --git a/packages/server-runtime-injection/package.json b/packages/server-runtime-injection/package.json index d78b70484685..65649b3f08a7 100644 --- a/packages/server-runtime-injection/package.json +++ b/packages/server-runtime-injection/package.json @@ -50,7 +50,7 @@ }, "devDependencies": { "@apm-js-collab/code-transformer": "^0.18.1", - "@apm-js-collab/tracing-hooks": "^0.13.0", + "@apm-js-collab/tracing-hooks": "^0.13.1", "@types/node": "^18.19.1", "@vercel/nft": "^1.3.0", "meriyah": "^6.1.4" diff --git a/packages/server-utils/src/index.ts b/packages/server-utils/src/index.ts index 57fa3f913cda..9ad8358b2c23 100644 --- a/packages/server-utils/src/index.ts +++ b/packages/server-utils/src/index.ts @@ -36,6 +36,11 @@ export { genericPoolIntegration } from './integrations/generic-pool'; export { googleGenAIIntegration } from './integrations/google-genai'; export { graphqlIntegration } from './integrations/graphql'; export { hapiIntegration } from './integrations/hapi'; +export { honoIntegration, honoMiddleware } from './integrations/hono'; +export type { HonoIntegrationOptions } from './integrations/hono'; +// Shared, runtime-agnostic Hono instrumentation re-used by the `@sentry/hono` SDK. +export { applyHonoPatches, earlyPatchHono, createHonoRequestMiddleware } from './integrations/hono'; +export type { CreateHonoRequestMiddlewareOptions, SentryHonoMiddlewareOptions } from './integrations/hono'; export { koaIntegration } from './integrations/koa'; export { redisIntegration } from './integrations/redis'; export { kafkaIntegration } from './integrations/kafkajs'; diff --git a/packages/hono/src/shared/applyPatches.ts b/packages/server-utils/src/integrations/hono/applyPatches.ts similarity index 68% rename from packages/hono/src/shared/applyPatches.ts rename to packages/server-utils/src/integrations/hono/applyPatches.ts index e3189f2f63f2..c3ef1be088b9 100644 --- a/packages/hono/src/shared/applyPatches.ts +++ b/packages/server-utils/src/integrations/hono/applyPatches.ts @@ -1,6 +1,6 @@ import { debug } from '@sentry/core'; -import type { Env, Hono } from 'hono'; -import { DEBUG_BUILD } from '../debug-build'; +import { DEBUG_BUILD } from '../../debug-build'; +import type { Env, Hono } from './honoTypes'; import { patchAppRequest } from './patchAppRequest'; import { patchAppUse, patchHttpMethodHandlers } from './patchAppUse'; import { type RouteHookHandle, installRouteHookOnPrototype, wrapSubAppMiddleware } from './patchRoute'; @@ -11,10 +11,13 @@ let _routeHook: RouteHookHandle | undefined; /** * Hooks `HonoBase.prototype.route` at import time, before `sentry()` runs. * - * Collecting sub-app references early ensures nothing is missed if sub-apps are mounted synchronously before the `sentry()` middleware is registered. + * Collecting sub-app references early ensures nothing is missed if sub-apps are mounted synchronously + * before the `sentry()` middleware is registered. The `Hono` class is passed in by the caller (the + * `@sentry/hono` SDK, where `hono` is a peer dependency) so this module never imports `hono` itself; + * `HonoBase.prototype` is one level above the class prototype. */ -export function earlyPatchHono(): void { - _routeHook ??= installRouteHookOnPrototype(); +export function earlyPatchHono(honoClass: { prototype: object }): void { + _routeHook ??= installRouteHookOnPrototype(Object.getPrototypeOf(honoClass.prototype)); } /** @@ -25,8 +28,10 @@ export function earlyPatchHono(): void { * - Retroactively instruments sub-apps mounted before `sentry()` was called. */ export function applyPatches(app: Hono): void { + // `HonoBase.prototype` (where `route` lives) is two levels up from the app instance: + // app → Hono.prototype → HonoBase.prototype. Deriving it from the live app avoids importing `hono`. // Always call — installRouteHookOnPrototype is idempotent and returns existing handle when prototype already patched - _routeHook = installRouteHookOnPrototype(); + _routeHook = installRouteHookOnPrototype(Object.getPrototypeOf(Object.getPrototypeOf(app))); // `app.use` (instance own property) — wraps middleware at registration time on this instance. patchAppUse(app); diff --git a/packages/server-utils/src/integrations/hono/createHonoMiddleware.ts b/packages/server-utils/src/integrations/hono/createHonoMiddleware.ts new file mode 100644 index 000000000000..a8be4a607f21 --- /dev/null +++ b/packages/server-utils/src/integrations/hono/createHonoMiddleware.ts @@ -0,0 +1,97 @@ +import { addNonEnumerableProperty, getDefaultIsolationScope, getIsolationScope } from '@sentry/core'; +import type { Context, GetConnInfo, MiddlewareHandler } from './honoTypes'; +import { requestHandler, responseHandler } from './middlewareHandlers'; +import type { SentryHonoMiddlewareOptions } from './types'; + +// Marks the Sentry request/response middleware so the span-wrapping patches never turn it into a +// middleware span — most importantly when an auto-instrumented sub-app carrying this middleware is +// mounted into a parent and `wrapSubAppMiddleware` wraps its handlers. Read by `wrapMiddlewareWithSpan`. +export const SENTRY_HONO_MIDDLEWARE = '__SENTRY_HONO_MIDDLEWARE__'; + +// `Symbol.for` (global registry) so the markers are shared even if this module is evaluated from more +// than one copy of `@sentry/server-utils`. +const HONO_REQUEST_HANDLED = Symbol.for('sentry.hono.requestHandled'); +// The effective `shouldHandleError` for the request, recorded even by a deduplicated middleware so a +// user-provided callback wins over the default (see below). +const HONO_SHOULD_HANDLE_ERROR = Symbol.for('sentry.hono.shouldHandleError'); + +export interface CreateHonoRequestMiddlewareOptions { + /** + * Runtime-specific `getConnInfo` helper (e.g. `@hono/node-server/conninfo`, `hono/bun`). + * Optional — connection-info attributes are simply skipped when it is not provided. + */ + getConnInfo?: GetConnInfo; + + /** Static `shouldHandleError` callback (Node/Bun/Deno). */ + shouldHandleError?: SentryHonoMiddlewareOptions['shouldHandleError']; + + /** + * Resolves `shouldHandleError` per request from the context. Cloudflare accepts + * middleware options as a function of `env`, so the callback is only known once a + * request comes in. When provided, this wins over the static `shouldHandleError`. + */ + resolveShouldHandleError?: (context: Context) => SentryHonoMiddlewareOptions['shouldHandleError']; +} + +/** + * The object that carries the per-request dedup/config markers. + * + * Prefer the per-request isolation scope over the Hono `Context`, so the request is treated as one + * even when several Sentry middlewares run for it: + * - a mounted sub-app that carries its own auto-registered middleware (same context, same scope), + * - an internal `app.request()` dispatch, which runs in a *new* Hono context but the *same* + * isolation scope (so it must not re-record the transaction name or overwrite the request data), + * - a manual `sentry()` middleware registered alongside the auto-instrumentation. + * + * When there is no per-request isolation scope (the default scope), fall back to the context so that + * at least same-context duplicates are still deduplicated. + */ +function getRequestScope(context: Context): Record { + const isolationScope = getIsolationScope(); + const target: object = isolationScope === getDefaultIsolationScope() ? context : isolationScope; + return target as Record; +} + +/** + * Builds the core Sentry request/response Hono middleware: it names the transaction, records the + * request, and captures unhandled context errors around `next()`. + * + * Idempotent per request (see {@link getRequestScope}), so duplicate registrations — a manual + * `sentry()` alongside the auto-instrumentation, mounted sub-apps, internal `.request()` dispatches — + * are all safe and run the handling exactly once. + * + * A user-provided `shouldHandleError` still takes effect even when the middleware carrying it is + * deduplicated behind the auto-instrumentation (which is registered first, in the `Hono` + * constructor): the deduplicated middleware records its callback on the request scope, and the + * middleware that actually runs `responseHandler` uses it in preference to its own default. + */ +export function createHonoRequestMiddleware(options: CreateHonoRequestMiddlewareOptions = {}): MiddlewareHandler { + const middleware: MiddlewareHandler = async (context, next) => { + const scope = getRequestScope(context); + + const shouldHandleError = options.resolveShouldHandleError + ? options.resolveShouldHandleError(context) + : options.shouldHandleError; + // Record a user-provided callback so it wins even if this middleware is deduplicated. Runs before + // the dedup check so a later manual `sentry({ shouldHandleError })` overrides the auto default. + if (shouldHandleError) { + addNonEnumerableProperty(scope, HONO_SHOULD_HANDLE_ERROR, shouldHandleError); + } + + if (scope[HONO_REQUEST_HANDLED]) { + return next(); + } + addNonEnumerableProperty(scope, HONO_REQUEST_HANDLED, true); + + requestHandler(context, options.getConnInfo); + + await next(); // Handler runs in between Request above ⤴ and Response below ⤵ + + const effectiveShouldHandleError = + (scope[HONO_SHOULD_HANDLE_ERROR] as SentryHonoMiddlewareOptions['shouldHandleError']) ?? shouldHandleError; + responseHandler(context, effectiveShouldHandleError); + }; + + addNonEnumerableProperty(middleware, SENTRY_HONO_MIDDLEWARE, true); + return middleware; +} diff --git a/packages/hono/src/shared/defaultShouldHandleError.ts b/packages/server-utils/src/integrations/hono/defaultShouldHandleError.ts similarity index 100% rename from packages/hono/src/shared/defaultShouldHandleError.ts rename to packages/server-utils/src/integrations/hono/defaultShouldHandleError.ts diff --git a/packages/hono/src/utils/hono-context.ts b/packages/server-utils/src/integrations/hono/hono-context.ts similarity index 87% rename from packages/hono/src/utils/hono-context.ts rename to packages/server-utils/src/integrations/hono/hono-context.ts index 96df44ee655a..f1529c6eef10 100644 --- a/packages/hono/src/utils/hono-context.ts +++ b/packages/server-utils/src/integrations/hono/hono-context.ts @@ -1,4 +1,4 @@ -import type { Context } from 'hono'; +import type { Context } from './honoTypes'; /** * Checks whether the given Hono context has a fetch event. diff --git a/packages/server-utils/src/integrations/hono/honoIntegration.ts b/packages/server-utils/src/integrations/hono/honoIntegration.ts new file mode 100644 index 000000000000..9b77c8e30165 --- /dev/null +++ b/packages/server-utils/src/integrations/hono/honoIntegration.ts @@ -0,0 +1,225 @@ +import * as diagnosticsChannel from 'node:diagnostics_channel'; +import { createRequire } from 'node:module'; +import { SENTRY_OP } from '@sentry/conventions/attributes'; +import { HTTP_SERVER } from '@sentry/conventions/op'; +import type { IntegrationFn } from '@sentry/core'; +import { debug, defineIntegration, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, startInactiveSpan } from '@sentry/core'; +import { DEBUG_BUILD } from '../../debug-build'; +import type { Env, GetConnInfo, Hono, MiddlewareHandler } from './honoTypes'; +import { CHANNELS } from '../../orchestrion/channels'; +import { honoModuleNames } from '../../orchestrion/config/hono'; +import { invokeOrchestrionInstrumentation } from '../../orchestrion/instrumentation'; +import { bindTracingChannelToSpan, safeChannelCallback } from '../../tracing-channel'; +import { applyPatches } from './applyPatches'; +import { createHonoRequestMiddleware } from './createHonoMiddleware'; +import { extractPathname, isInternalRequestSpanActive } from './patchAppRequest'; +import { wrapMiddlewareWithSpan } from './wrapMiddlewareSpan'; +import type { SentryHonoMiddlewareOptions } from './types'; + +// Same name as the Hono SDK's integration. When this default is enabled, the `@sentry/hono` SDK +// filters the `'Hono'` integration out of the defaults it forwards, so the two never stack. +const INTEGRATION_NAME = 'Hono' as const; + +const INTERNAL_REQUEST_ORIGIN = 'auto.http.hono.internal_request'; + +export interface HonoIntegrationOptions extends SentryHonoMiddlewareOptions {} + +const globalAny = globalThis as { Bun?: unknown; Deno?: unknown; navigator?: { userAgent?: string } }; +const isBun = typeof globalAny.Bun !== 'undefined'; +const isDeno = typeof globalAny.Deno !== 'undefined'; +const isCloudflare = globalAny.navigator?.userAgent === 'Cloudflare-Workers'; + +let connInfoResolved = false; +let cachedGetConnInfo: GetConnInfo | undefined; + +/** + * Resolve the runtime-specific `getConnInfo` helper, once, best-effort. + * + * Each runtime ships it from a different subpackage. Cloudflare Workers cannot `require()` a module + * out of a bundle at runtime, so there conn-info is left to the platform's `requestDataIntegration`; + * `createRequire` covers the require-capable runtimes (Node/Bun/Deno) under both ESM and CJS. Any + * failure (optional peer dependency not installed) degrades to `undefined`, which just skips the + * connection-info attributes. + */ +function resolveGetConnInfo(): GetConnInfo | undefined { + if (connInfoResolved) { + return cachedGetConnInfo; + } + connInfoResolved = true; + + const specifier = isBun ? 'hono/bun' : isDeno ? 'hono/deno' : isCloudflare ? undefined : '@hono/node-server/conninfo'; + + if (!specifier) { + return undefined; + } + + try { + // `createRequire` treats its argument as a filename and resolves from its directory, so a dummy + // file (never loaded) roots resolution at the app directory — where the runtime helper lives. + const appRequire = createRequire(`${process.cwd()}/noop.js`); + cachedGetConnInfo = (appRequire(specifier) as { getConnInfo?: GetConnInfo }).getConnInfo; + } catch { + DEBUG_BUILD && debug.log(`[instrumentation:hono] could not resolve \`getConnInfo\` from "${specifier}"`); + cachedGetConnInfo = undefined; + } + + return cachedGetConnInfo; +} + +/** + * Manually instruments a Hono app for Sentry tracing and returns the Sentry request/response + * middleware to register — `app.use(honoMiddleware(app))`, as the FIRST middleware. + * + * The {@link honoIntegration} default instruments Hono automatically (see below), so this is only + * needed for setups where neither the Sentry runtime hook nor the bundler plugin is active. It is + * config- and DSN-free: `Sentry.init(...)` must still be called separately. + * + * Safe to combine with the automatic instrumentation: the request handling is deduplicated per + * request and `applyPatches` is idempotent per app. + * + * `getConnInfo` is resolved for the current runtime (Node/Bun/Deno); on Cloudflare it is left to the + * platform's request-data handling. + */ +export function honoMiddleware(app: Hono, options: HonoIntegrationOptions = {}): MiddlewareHandler { + applyPatches(app); + + return createHonoRequestMiddleware({ + getConnInfo: resolveGetConnInfo(), + shouldHandleError: options.shouldHandleError, + }); +} + +// A Hono `matchResult[0]` entry: `[[handler, routeMeta], paramIndexMap]`. `compose` reads the handler +// at `entry[0][0]`; the `matchedRoutes` getter reads `routeMeta` at `entry[0][1]`. +// oxlint-disable-next-line typescript/no-explicit-any +type MatchedHandlerEntry = [[any, any], any]; + +// Match-result handler lists we've already injected into. `router.match` may return a cached array +// for a given route, so guard against prepending the Sentry middleware more than once. +const _injectedHandlerLists = new WeakSet(); + +// The Sentry request/response middleware is stateless (all per-request state lives on the request +// scope), so build it once and reuse it across every dispatched Context instead of recreating it on +// each `new Context()`. `options` is fixed for the single channel subscription, so a single cached +// instance is always correct. +let cachedRequestMiddleware: MiddlewareHandler | undefined; + +/** + * Per-request Context hook: the heart of the automatic instrumentation. + * + * `#dispatch` builds `new Context(req, { matchResult })` before its single-handler fast-path check, + * passing the live `matchResult` array. We: + * 1. wrap the already-matched MIDDLEWARE handlers (arity ≥ 2) for spans — route handlers (arity < 2) + * are covered by the request span and left as-is; + * 2. prepend the Sentry request/response middleware, so it runs first in the composed chain. That + * both drives route naming / request data / error capture (from inside the chain, with the + * Context) and forces the ≥2-handler `compose` path, so there is no fast-path gap. + * + * All of this runs per request, so it works on Cloudflare (no module-scope publish) and needs no + * app-instance patching or app-construction hook. + */ +function injectHonoInstrumentation( + // oxlint-disable-next-line typescript/no-explicit-any + message: { arguments?: any[] }, + options: HonoIntegrationOptions, +): void { + const ctorOptions = message.arguments?.[1] as { matchResult?: [MatchedHandlerEntry[], unknown] } | undefined; + const handlers = ctorOptions?.matchResult?.[0]; + if (!Array.isArray(handlers) || _injectedHandlerLists.has(handlers)) { + return; + } + _injectedHandlerLists.add(handlers); + + // Wrap matched middleware handlers (arity ≥ 2). `wrapMiddlewareWithSpan` is idempotent and skips + // Sentry's own middleware, so this is safe even if a handler is shared across routes. + for (const entry of handlers) { + const pair = entry?.[0]; + const handler = pair?.[0]; + if (typeof handler === 'function' && (handler as { length: number }).length >= 2) { + pair[0] = wrapMiddlewareWithSpan(handler as MiddlewareHandler); + } + } + + // Prepend the Sentry request/response middleware. `routeMeta` is what the `matchedRoutes` getter + // reads; a middleware-arity handler means route-name resolution skips it. + const middleware = (cachedRequestMiddleware ??= createHonoRequestMiddleware({ + getConnInfo: resolveGetConnInfo(), + shouldHandleError: options.shouldHandleError, + })); + const routeMeta = { basePath: '/', path: '/*', method: 'ALL', handler: middleware }; + handlers.unshift([[middleware, routeMeta], {}]); +} + +/** + * Traces Hono's internal `app.request(...)` dispatches (sub-app-to-sub-app fetches) as `http.server` + * child spans, but only when there is a parent span (so a top-level `.request()` is not traced). + */ +function instrumentInternalRequests(): void { + bindTracingChannelToSpan( + // oxlint-disable-next-line typescript/no-explicit-any + diagnosticsChannel.tracingChannel<{ arguments: any[] }>(CHANNELS.HONO_REQUEST), + data => { + // When the manual middleware is used alongside this default integration, the instance + // `app.request` Proxy already opened this span and is calling through to us — don't nest a + // duplicate. `getSpan` returning `undefined` opts the payload out cleanly. + if (isInternalRequestSpanActive()) { + return undefined; + } + + const [input, requestInit] = data.arguments; + const method = ( + (requestInit as RequestInit | undefined)?.method ?? (input instanceof Request ? input.method : 'GET') + ).toUpperCase(); + return startInactiveSpan({ + name: `${method} ${extractPathname(input)}`, + attributes: { + [SENTRY_OP]: HTTP_SERVER, + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: INTERNAL_REQUEST_ORIGIN, + }, + }); + }, + { requiresParentSpan: true }, + ); +} + +function instrumentHono(options: HonoIntegrationOptions): void { + // Per-request Context hook — injects the Sentry middleware and wraps matched middleware handlers. + // The `end` of the Context constructor fires synchronously during `new Context()`, before + // `#dispatch` reads `matchResult[0].length`, so the prepend takes effect for the same request. + diagnosticsChannel + // oxlint-disable-next-line typescript/no-explicit-any + .tracingChannel<{ arguments: any[] }>(CHANNELS.HONO_CONTEXT) + .end.subscribe(message => { + // oxlint-disable-next-line typescript/no-explicit-any + safeChannelCallback(() => injectHonoInstrumentation(message as { arguments?: any[] }, options)); + }); + + instrumentInternalRequests(); +} + +const _honoIntegration = ((options: HonoIntegrationOptions = {}) => { + return { + name: INTEGRATION_NAME, + setup(client) { + invokeOrchestrionInstrumentation(client, honoModuleNames, instrumentHono, [options]); + }, + }; +}) satisfies IntegrationFn; + +/** + * Automatically instruments Hono applications for Sentry. + * + * Instruments Hono through its per-request internals (the `Context` constructor and `app.request`) + * via the orchestrion diagnostics channel: on each request it injects the Sentry request/response + * middleware into the matched handler chain, names the transaction from the matched route, records + * request data, captures unhandled errors, and creates middleware and internal-request spans. + * Enabled by default in the Node, Bun, Deno and Cloudflare SDKs. Requires the Sentry runtime hook or + * bundler plugin. + * + * Because everything happens per request (never at module scope), it works on Cloudflare Workers + * out of the box, with no manual middleware registration. + * + * Registering the `sentry()` middleware from `@sentry/hono` manually alongside this is safe — the + * request handling is deduplicated per request, so it runs exactly once. + */ +export const honoIntegration = defineIntegration(_honoIntegration); diff --git a/packages/server-utils/src/integrations/hono/honoTypes.ts b/packages/server-utils/src/integrations/hono/honoTypes.ts new file mode 100644 index 000000000000..a4dc01e14152 --- /dev/null +++ b/packages/server-utils/src/integrations/hono/honoTypes.ts @@ -0,0 +1,71 @@ +/** + * Vendored subset of `hono`'s public types used by the Sentry Hono instrumentation. + * + * The instrumentation lives in `@sentry/server-utils`, a dependency of every server SDK — including + * apps that do not use Hono. We therefore declare no dependency on `hono` at all: not at runtime + * (the instrumentation never statically imports it; the `Hono` prototype is derived from a live app + * instance and matched routes are read from the request's own getters), and not at build/type time + * (these minimal structural types stand in for `hono`'s). + * + * ATTENTION: keep these permissive. Values cross the boundary to the `@sentry/hono` SDK, which uses + * the real `hono` types, so these must stay assignable from them at those call sites. + */ + +/* oxlint-disable typescript/no-explicit-any -- vendored, deliberately permissive types */ + +export interface Env { + Bindings?: any; + Variables?: any; +} + +export type Next = () => Promise; + +export interface HonoRoute { + method: string; + path: string; + // Loose on purpose: Hono's own route handler union (`Handler | MiddlewareHandler`) is wider than a + // middleware handler, and this must stay assignable from it so the real `Context` flows into the + // vendored one at the `@sentry/hono` boundary. + handler: (...args: any[]) => any; +} + +export interface HonoRequest { + raw: Request; + method: string; + path: string; + routeIndex: number; + // These are public (though deprecated) getters on Hono's request. Reading them avoids a runtime + // import of the `hono/route` helpers, which are their non-deprecated replacements. + matchedRoutes: HonoRoute[]; + routePath: string; + [key: string]: any; +} + +export interface Context { + req: HonoRequest; + env: unknown; + error?: Error; + event: { request: Request }; + [key: string]: any; +} + +// Return type is `Promise` (not the wider sync union) to stay assignable to the real +// `hono` `MiddlewareHandler` at the `@sentry/hono` boundary; our handlers are always async. +export type MiddlewareHandler = (context: Context, next: Next) => Promise; + +export interface Hono { + // `use` is chainable (returns the app), which is also where the `E` type parameter is threaded. + use: (...args: any[]) => Hono; + request: (...args: any[]) => Response | Promise; + routes: HonoRoute[]; + [key: string]: any; +} + +export interface ConnInfoRemote { + address?: string; + port?: number; + transport?: string; + addressType?: string; +} + +export type GetConnInfo = (context: any) => { remote?: ConnInfoRemote }; diff --git a/packages/server-utils/src/integrations/hono/index.ts b/packages/server-utils/src/integrations/hono/index.ts new file mode 100644 index 000000000000..c58dd5df0f9f --- /dev/null +++ b/packages/server-utils/src/integrations/hono/index.ts @@ -0,0 +1,28 @@ +import { applyPatches } from './applyPatches'; +import type { Hono } from './honoTypes'; + +// The auto-instrumentation integration (uses `node:diagnostics_channel`). +export { honoIntegration } from './honoIntegration'; +export type { HonoIntegrationOptions } from './honoIntegration'; + +// Manual counterpart of the auto-instrumentation, for setups where the automatic constructor hook +// can't run (most notably Cloudflare Workers): `app.use(honoMiddleware(app))`. +export { honoMiddleware } from './honoIntegration'; + +// Shared, runtime-agnostic Hono instrumentation, re-used by the `@sentry/hono` SDK across all of its +// runtimes (Node, Bun, Cloudflare, Deno). None of these modules import `hono` (at runtime or type +// level), so they stay safe to load in every server SDK — including apps that do not use Hono. +export { earlyPatchHono } from './applyPatches'; +export { createHonoRequestMiddleware } from './createHonoMiddleware'; +export type { CreateHonoRequestMiddlewareOptions } from './createHonoMiddleware'; +export type { SentryHonoMiddlewareOptions } from './types'; + +/** + * Applies Sentry's Hono span patches to an app instance. + * + * Typed loosely (`object`) so the real `hono` `Hono` type used by the `@sentry/hono` SDK is + * accepted without a cast; internally it is treated as the vendored {@link Hono} shape. + */ +export function applyHonoPatches(app: object): void { + applyPatches(app as Hono); +} diff --git a/packages/hono/src/utils/isMiddleware.ts b/packages/server-utils/src/integrations/hono/isMiddleware.ts similarity index 100% rename from packages/hono/src/utils/isMiddleware.ts rename to packages/server-utils/src/integrations/hono/isMiddleware.ts diff --git a/packages/hono/src/shared/middlewareHandlers.ts b/packages/server-utils/src/integrations/hono/middlewareHandlers.ts similarity index 94% rename from packages/hono/src/shared/middlewareHandlers.ts rename to packages/server-utils/src/integrations/hono/middlewareHandlers.ts index ffba879603ea..ab1f3f000199 100644 --- a/packages/hono/src/shared/middlewareHandlers.ts +++ b/packages/server-utils/src/integrations/hono/middlewareHandlers.ts @@ -10,12 +10,11 @@ import { type Scope, winterCGRequestToRequestData, } from '@sentry/core'; -import type { Context } from 'hono'; -import { hasFetchEvent } from '../utils/hono-context'; +import type { Context, GetConnInfo } from './honoTypes'; +import { hasFetchEvent } from './hono-context'; import { defaultShouldHandleError } from './defaultShouldHandleError'; import { resolveRouteName } from './resolveRouteName'; -import { type SentryHonoMiddlewareOptions } from '../shared/types'; -import { type GetConnInfo } from 'hono/conninfo'; +import { type SentryHonoMiddlewareOptions } from './types'; import { HTTP_ROUTE } from '@sentry/conventions/attributes'; /** diff --git a/packages/server-utils/src/integrations/hono/patchAppRequest.ts b/packages/server-utils/src/integrations/hono/patchAppRequest.ts new file mode 100644 index 000000000000..69f073eb2185 --- /dev/null +++ b/packages/server-utils/src/integrations/hono/patchAppRequest.ts @@ -0,0 +1,132 @@ +import { SENTRY_OP } from '@sentry/conventions/attributes'; +import { HTTP_SERVER } from '@sentry/conventions/op'; +import { + debug, + getActiveSpan, + getOriginalFunction, + SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, + startSpan, + type WrappedFunction, +} from '@sentry/core'; +import type { Env, Hono } from './honoTypes'; +import { DEBUG_BUILD } from '../../debug-build'; + +const INTERNAL_REQUEST_OP = HTTP_SERVER; +const INTERNAL_REQUEST_ORIGIN = 'auto.http.hono.internal_request'; + +// Re-entrancy guard shared with the orchestrion channel subscriber +// (`instrumentInternalRequests` in `honoIntegration`). Both wrap the same `app.request`: this Proxy +// calls through to the original, which — when the default integration is active — is orchestrion- +// instrumented and publishes to the request channel. Without this guard the two stack a second, +// identical internal-request span inside the first. The Proxy owns the span; the channel subscriber +// reads this flag and opts out. `Symbol.for` on `globalThis` keeps it shared across copies of +// `@sentry/server-utils`, matching the request-handling dedup markers. +const INTERNAL_REQUEST_SPAN_ACTIVE = Symbol.for('sentry.hono.internalRequestSpanActive'); +type GuardCarrier = { [INTERNAL_REQUEST_SPAN_ACTIVE]?: boolean }; + +/** Whether the instance `app.request` Proxy is currently opening an internal-request span. */ +export function isInternalRequestSpanActive(): boolean { + return !!(globalThis as GuardCarrier)[INTERNAL_REQUEST_SPAN_ACTIVE]; +} + +function setInternalRequestSpanActive(active: boolean): void { + (globalThis as GuardCarrier)[INTERNAL_REQUEST_SPAN_ACTIVE] = active; +} + +function stripQueryAndHash(path: string): string { + const end = path.search(/[?#]/); + return end === -1 ? path : path.slice(0, end); +} + +/** + * Derive the span-name path from an `app.request()` argument, mirroring Hono's own handling so the + * name matches the path actually dispatched, with the query/hash stripped so they can't leak into + * span names or inflate cardinality. + * + * Hono treats an absolute `http(s)://` input as a full URL and everything else as a path under + * `http://localhost` (see `hono-base`'s `request`). We prepend that same fixed host rather than + * resolving the string as a URL reference: resolution rewrites protocol-relative inputs + * (`//example.com/foo` → host `example.com`, dropping the segment Hono keeps in the path) and throws + * on inputs Hono accepts (`//`, `http:`). This runs before the underlying dispatch, so it must never + * throw — the `catch` is a final guard against any remaining malformed input. + */ +export function extractPathname(input: unknown): string { + if (typeof input === 'string') { + try { + const url = /^https?:\/\//.test(input) + ? new URL(input) + : new URL(`http://localhost${input.startsWith('/') ? '' : '/'}${input}`); + return url.pathname; + } catch { + return stripQueryAndHash(input); + } + } + + if (input instanceof Request) { + return new URL(input.url).pathname; + } + + return input instanceof URL ? input.pathname : '/'; +} + +/** + * Patches `app.request()` on a Hono instance so that each internal dispatch + * is traced as an `http.server` span — child of whatever span is active at + * the call site. + * + * `.request()` is a class field (arrow function), so this must run per-instance. + * Idempotent: safe to call multiple times on the same instance. + */ +export function patchAppRequest(app: Hono): void { + if (getOriginalFunction(app.request as unknown as WrappedFunction)) { + DEBUG_BUILD && debug.log('[hono] app.request already patched — skipping.'); + return; + } + + const originalRequest = app.request; + + app.request = new Proxy(originalRequest, { + apply(_target, thisArg, args: [string | Request | URL, RequestInit?, ...unknown[]]) { + const [input, requestInit, ...rest] = args; + + if (!getActiveSpan()) { + return Reflect.apply(_target, thisArg, args); + } + + let method = requestInit?.method ?? (input instanceof Request ? input.method : 'GET'); + method = method.toUpperCase(); + + const path = extractPathname(input); + + return startSpan( + { + name: `${method} ${path}`, + onlyIfParent: true, + attributes: { + [SENTRY_OP]: INTERNAL_REQUEST_OP, + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: INTERNAL_REQUEST_ORIGIN, + }, + }, + () => { + // The flag only needs to cover the synchronous dispatch start, where the orchestrion + // channel would fire and open its duplicate span. `Reflect.apply` returns the pending + // promise synchronously, so `finally` clears it before any nested `.request()` runs. + setInternalRequestSpanActive(true); + try { + return Reflect.apply(_target, thisArg, [input, requestInit, ...rest]); + } finally { + setInternalRequestSpanActive(false); + } + }, + ); + }, + get(target, prop, receiver) { + if (prop === '__sentry_original__') { + return originalRequest; + } + return Reflect.get(target, prop, receiver); + }, + }); + + DEBUG_BUILD && debug.log('[hono] Patched app.request for internal dispatch tracing.'); +} diff --git a/packages/hono/src/shared/patchAppUse.ts b/packages/server-utils/src/integrations/hono/patchAppUse.ts similarity index 96% rename from packages/hono/src/shared/patchAppUse.ts rename to packages/server-utils/src/integrations/hono/patchAppUse.ts index 05bb058bf499..7268151072b5 100644 --- a/packages/hono/src/shared/patchAppUse.ts +++ b/packages/server-utils/src/integrations/hono/patchAppUse.ts @@ -1,6 +1,6 @@ import { debug } from '@sentry/core'; -import type { Env, Hono, MiddlewareHandler } from 'hono'; -import { DEBUG_BUILD } from '../debug-build'; +import type { Env, Hono, MiddlewareHandler } from './honoTypes'; +import { DEBUG_BUILD } from '../../debug-build'; import { wrapMiddlewareWithSpan } from './wrapMiddlewareSpan'; // oxlint-disable-next-line typescript/no-explicit-any diff --git a/packages/hono/src/shared/patchRoute.ts b/packages/server-utils/src/integrations/hono/patchRoute.ts similarity index 87% rename from packages/hono/src/shared/patchRoute.ts rename to packages/server-utils/src/integrations/hono/patchRoute.ts index 67e6d83d52f3..4f13b1b760ad 100644 --- a/packages/hono/src/shared/patchRoute.ts +++ b/packages/server-utils/src/integrations/hono/patchRoute.ts @@ -1,17 +1,12 @@ import { debug, getOriginalFunction } from '@sentry/core'; import type { WrappedFunction } from '@sentry/core'; -import type { Hono, MiddlewareHandler } from 'hono'; -import { Hono as HonoClass } from 'hono'; -import { DEBUG_BUILD } from '../debug-build'; -import { isMiddleware } from '../utils/isMiddleware'; +import { DEBUG_BUILD } from '../../debug-build'; +import type { Hono, HonoRoute } from './honoTypes'; +import { isMiddleware } from './isMiddleware'; import { patchAppRequest } from './patchAppRequest'; import { wrapMiddlewareWithSpan } from './wrapMiddlewareSpan'; -export type HonoRoute = { - method: string; - path: string; - handler: MiddlewareHandler; -}; +export type { HonoRoute }; // oxlint-disable-next-line typescript/no-explicit-any type HonoAny = Hono; @@ -60,15 +55,17 @@ function createRouteHook(): { handle: RouteHookHandle; onSubAppMounted: (subApp: /** * Installs a hook on `HonoBase.prototype.route` to intercept sub-app mounting. * + * `honoBaseProto` is `HonoBase.prototype`, where `route` is defined — one level above the concrete + * subclass. Callers derive it from a live app instance (`Object.getPrototypeOf(Object.getPrototypeOf(app))`) + * or from the `Hono` class (`Object.getPrototypeOf(Hono.prototype)`), so the instrumentation never + * imports `hono` itself. + * * Returns a handle with `activate()` and `getPendingSubApps()`. * Idempotent: subsequent calls return the same handle */ -export function installRouteHookOnPrototype(): RouteHookHandle { +export function installRouteHookOnPrototype(honoBaseProto: HonoBaseProto): RouteHookHandle { const noopHandle: RouteHookHandle = { activate: () => {}, getPendingSubApps: () => new Set() }; - // `route` is defined on HonoBase.prototype, one level above the concrete subclass - const honoBaseProto = Object.getPrototypeOf(HonoClass.prototype) as HonoBaseProto; - if (!honoBaseProto || typeof honoBaseProto.route !== 'function') { DEBUG_BUILD && debug.warn('[hono] Could not find HonoBase.prototype.route — sub-app instrumentation disabled.'); return noopHandle; diff --git a/packages/hono/src/shared/resolveRouteName.ts b/packages/server-utils/src/integrations/hono/resolveRouteName.ts similarity index 65% rename from packages/hono/src/shared/resolveRouteName.ts rename to packages/server-utils/src/integrations/hono/resolveRouteName.ts index 1fb720c22e5b..97b1283722ed 100644 --- a/packages/hono/src/shared/resolveRouteName.ts +++ b/packages/server-utils/src/integrations/hono/resolveRouteName.ts @@ -1,6 +1,5 @@ -import type { Context } from 'hono'; -import { matchedRoutes, routePath } from 'hono/route'; -import { isMiddleware } from '../utils/isMiddleware'; +import type { Context, HonoRoute } from './honoTypes'; +import { isMiddleware } from './isMiddleware'; // Arity alone is enough here (unlike `wrapSubAppMiddleware` in patchRoute.ts, which also needs position) // We only want the path, and inline middleware shares its handler's path. @@ -8,6 +7,19 @@ function isRouteHandler(handler: unknown): boolean { return typeof handler === 'function' && !isMiddleware(handler); } +// Read the request's own matched routes rather than importing the `hono/route` helpers, so the +// instrumentation needs no runtime import from `hono`. `c.req.matchedRoutes` and `c.req.routePath` +// are the (deprecated but public) getters those helpers wrap; `routePath(c, index)` is reimplemented +// here as `matchedRoutes(c).at(index)?.path` so an arbitrary index (e.g. -1) can be resolved. +function matchedRoutes(context: Context): HonoRoute[] { + return context.req.matchedRoutes ?? []; +} + +function routePath(context: Context, index?: number): string { + const routes = matchedRoutes(context); + return routes.at(index ?? context.req.routeIndex)?.path ?? ''; +} + /** * Resolves the route path of the matched handler for the transaction name. * diff --git a/packages/hono/src/shared/types.ts b/packages/server-utils/src/integrations/hono/types.ts similarity index 100% rename from packages/hono/src/shared/types.ts rename to packages/server-utils/src/integrations/hono/types.ts diff --git a/packages/hono/src/shared/wrapMiddlewareSpan.ts b/packages/server-utils/src/integrations/hono/wrapMiddlewareSpan.ts similarity index 83% rename from packages/hono/src/shared/wrapMiddlewareSpan.ts rename to packages/server-utils/src/integrations/hono/wrapMiddlewareSpan.ts index d1141000e137..e3e451247614 100644 --- a/packages/hono/src/shared/wrapMiddlewareSpan.ts +++ b/packages/server-utils/src/integrations/hono/wrapMiddlewareSpan.ts @@ -9,7 +9,8 @@ import { startInactiveSpan, type WrappedFunction, } from '@sentry/core'; -import { type MiddlewareHandler } from 'hono'; +import { type MiddlewareHandler } from './honoTypes'; +import { SENTRY_HONO_MIDDLEWARE } from './createHonoMiddleware'; import { defaultShouldHandleError } from './defaultShouldHandleError'; const MIDDLEWARE_ORIGIN = 'auto.middleware.hono'; @@ -21,6 +22,12 @@ const MIDDLEWARE_ORIGIN = 'auto.middleware.hono'; * (onion order: A → B → handler → B → A would otherwise nest B under A). */ export function wrapMiddlewareWithSpan(handler: MiddlewareHandler): MiddlewareHandler { + // Never turn Sentry's own request/response middleware into a middleware span — e.g. when an + // auto-instrumented sub-app carrying it is mounted into a parent and its handlers get wrapped. + if ((handler as unknown as Record)[SENTRY_HONO_MIDDLEWARE]) { + return handler; + } + if (getOriginalFunction(handler as unknown as WrappedFunction)) { return handler; } diff --git a/packages/server-utils/src/integrations/index.ts b/packages/server-utils/src/integrations/index.ts index 6e319bfc5e2b..dca41c7fb520 100644 --- a/packages/server-utils/src/integrations/index.ts +++ b/packages/server-utils/src/integrations/index.ts @@ -26,6 +26,7 @@ import { firebaseIntegration } from './firebase'; import { expressIntegration } from './express'; import { fastifyIntegration } from './fastify'; import { hapiIntegration } from './hapi'; +import { honoIntegration } from './hono'; import { koaIntegration } from './koa'; import type { Integration } from '@sentry/core'; import { awsIntegration } from './aws-sdk'; @@ -66,5 +67,5 @@ export function getTracingIntegrations(): Integration[] { /** These are integrations that cover error capture, in addition to tracing. */ export function getErrorIntegrations(): Integration[] { - return [expressIntegration(), fastifyIntegration(), hapiIntegration(), koaIntegration()]; + return [expressIntegration(), fastifyIntegration(), hapiIntegration(), honoIntegration(), koaIntegration()]; } diff --git a/packages/server-utils/src/orchestrion/bundler/bun.ts b/packages/server-utils/src/orchestrion/bundler/bun.ts index e61814beed39..f8f154028e13 100644 --- a/packages/server-utils/src/orchestrion/bundler/bun.ts +++ b/packages/server-utils/src/orchestrion/bundler/bun.ts @@ -39,6 +39,7 @@ export function sentryOrchestrionPlugin(options: PluginOptions = {}): UnknownPlu // Route through the shared assembly point so any future option reaches Bun too, but opt out of // the transformer's own `injectDiagnostics` — Bun injects the marker banner via its native // `banner` config below (which, unlike the upstream path, needs no `outdir`). + // // Typed upstream as an esbuild `Plugin`, but Bun passes its own `PluginBuilder` (which has the // `onLoad` the transform uses) to `setup`. Cast to the Bun-compatible shape so we can forward // Bun's builder to its `setup`. diff --git a/packages/server-utils/src/orchestrion/channels.ts b/packages/server-utils/src/orchestrion/channels.ts index 65ab3e56899e..7d128f69ef31 100644 --- a/packages/server-utils/src/orchestrion/channels.ts +++ b/packages/server-utils/src/orchestrion/channels.ts @@ -8,6 +8,7 @@ import { genericPoolChannels } from './config/generic-pool'; import { googleGenAiChannels } from './config/google-genai'; import { graphqlChannels } from './config/graphql'; import { hapiChannels } from './config/hapi'; +import { honoChannels } from './config/hono'; import { ioredisChannels } from './config/ioredis'; import { kafkajsChannels } from './config/kafkajs'; import { knexChannels } from './config/knex'; @@ -57,6 +58,7 @@ export const CHANNELS = { ...googleGenAiChannels, ...graphqlChannels, ...hapiChannels, + ...honoChannels, ...ioredisChannels, ...kafkajsChannels, ...knexChannels, diff --git a/packages/server-utils/src/orchestrion/config/channel-integration-definitions.ts b/packages/server-utils/src/orchestrion/config/channel-integration-definitions.ts index 73d7c164cfe7..71c11dce0b86 100644 --- a/packages/server-utils/src/orchestrion/config/channel-integration-definitions.ts +++ b/packages/server-utils/src/orchestrion/config/channel-integration-definitions.ts @@ -47,6 +47,7 @@ export const CHANNEL_INTEGRATION_DEFINITIONS = [ { exportName: 'firebaseIntegration', modules: ['@firebase/firestore', 'firebase-functions'] }, { exportName: 'amqplibIntegration', modules: ['amqplib'] }, { exportName: 'hapiIntegration', modules: ['@hapi/hapi'] }, + { exportName: 'honoIntegration', modules: ['hono'] }, { exportName: 'koaIntegration', modules: ['koa'] }, { exportName: 'expressIntegration', modules: ['express', 'router'] }, { exportName: 'graphqlIntegration', modules: ['graphql'] }, diff --git a/packages/server-utils/src/orchestrion/config/hono.ts b/packages/server-utils/src/orchestrion/config/hono.ts new file mode 100644 index 000000000000..f9923e011628 --- /dev/null +++ b/packages/server-utils/src/orchestrion/config/hono.ts @@ -0,0 +1,40 @@ +import type { InstrumentationConfig } from '../apmTypes'; +import { getModuleNames } from './module-names'; + +// Hono is instrumented through its PER-REQUEST internals rather than at app construction or route +// registration. Two functions are wrapped, both of which only ever run while a request is being +// handled — never at module scope. That is what makes this work on Cloudflare Workers (workerd +// forbids `diagnostics_channel` publish/`runStores` at module scope) with no registration-time +// patching, while behaving identically under the Node/Deno runtime hook and the bundler plugins. +// The subscribers live in `honoIntegration`. +const honoInstrumentationConfig: InstrumentationConfig[] = [ + { + // `new Context(req, { matchResult })` is created once per dispatched request, inside `#dispatch` + // and BEFORE Hono's single-handler fast-path check. The subscriber injects the Sentry + // request/response middleware into `matchResult[0]` (so it runs first, in the composed chain — + // which also forces the ≥2-handler path, giving uniform route naming and error capture) and + // wraps the matched middleware handlers for spans. Copied sub-app handlers are already present in + // `matchResult`, so they are covered automatically. + channelName: 'context', + module: { name: 'hono', versionRange: '>=4.0.0 <5', filePath: /^dist\/(?:cjs\/)?context\.js$/ }, + functionQuery: { className: 'Context' }, + }, + { + // `app.request(...)` is Hono's internal dispatch entry (a class-field arrow, so it needs an + // `astQuery` rather than a `methodName`). Sub-app-to-sub-app internal fetches go through it; each + // gets an `http.server` child span (only when there is a parent span). + channelName: 'request', + module: { name: 'hono', versionRange: '>=4.0.0 <5', filePath: /^dist\/(?:cjs\/)?hono-base\.js$/ }, + astQuery: "PropertyDefinition[key.name='request'] > ArrowFunctionExpression", + functionQuery: { kind: 'Auto' }, + }, +]; + +export const honoConfig = honoInstrumentationConfig satisfies InstrumentationConfig[]; + +export const honoModuleNames = getModuleNames(honoConfig); + +export const honoChannels = { + HONO_CONTEXT: 'orchestrion:hono:context', + HONO_REQUEST: 'orchestrion:hono:request', +} as const; diff --git a/packages/server-utils/src/orchestrion/config/index.ts b/packages/server-utils/src/orchestrion/config/index.ts index 723c5857b8f1..1c78bb81f80c 100644 --- a/packages/server-utils/src/orchestrion/config/index.ts +++ b/packages/server-utils/src/orchestrion/config/index.ts @@ -12,6 +12,7 @@ import { genericPoolConfig } from './generic-pool'; import { googleGenAiConfig } from './google-genai'; import { graphqlConfig } from './graphql'; import { hapiConfig } from './hapi'; +import { honoConfig } from './hono'; import { ioredisConfig } from './ioredis'; import { kafkajsConfig } from './kafkajs'; import { knexConfig } from './knex'; @@ -60,6 +61,7 @@ export const SENTRY_INSTRUMENTATIONS: InstrumentationConfig[] = [ ...googleGenAiConfig, ...graphqlConfig, ...hapiConfig, + ...honoConfig, ...ioredisConfig, ...kafkajsConfig, ...knexConfig, diff --git a/packages/server-utils/test/integrations/hono/createHonoMiddleware.test.ts b/packages/server-utils/test/integrations/hono/createHonoMiddleware.test.ts new file mode 100644 index 000000000000..cf2062390fe9 --- /dev/null +++ b/packages/server-utils/test/integrations/hono/createHonoMiddleware.test.ts @@ -0,0 +1,83 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +// Spy on the request/response handlers so we can assert exactly how many times they run. +const requestHandler = vi.fn(); +const responseHandler = vi.fn(); +vi.mock('../../../src/integrations/hono/middlewareHandlers', () => ({ + requestHandler: (...args: unknown[]) => requestHandler(...args), + responseHandler: (...args: unknown[]) => responseHandler(...args), +})); + +// eslint-disable-next-line import/first +import { createHonoRequestMiddleware } from '../../../src/integrations/hono/createHonoMiddleware'; + +// Minimal fake Hono context — dedup only needs a stable object to mark, not a real Hono app. +// oxlint-disable-next-line typescript/no-explicit-any +const fakeContext = (): any => ({ req: {} }); + +describe('createHonoRequestMiddleware — duplicate registration handling', () => { + beforeEach(() => { + requestHandler.mockClear(); + responseHandler.mockClear(); + }); + + it('runs request/response handling exactly once when two Sentry middlewares wrap the same request', async () => { + const outer = createHonoRequestMiddleware(); + const inner = createHonoRequestMiddleware(); + const context = fakeContext(); + + // Onion order: outer → inner → handler → inner → outer. + await outer(context, async () => { + await inner(context, async () => {}); + }); + + expect(requestHandler).toHaveBeenCalledTimes(1); + expect(responseHandler).toHaveBeenCalledTimes(1); + }); + + it('passes an already-handled context straight through to next()', async () => { + const context = fakeContext(); + + // First middleware handles the request and marks the context. + await createHonoRequestMiddleware()(context, async () => {}); + requestHandler.mockClear(); + responseHandler.mockClear(); + + // A second (duplicate) middleware on the same context must not re-run the handlers. + let nextCalled = false; + await createHonoRequestMiddleware()(context, async () => { + nextCalled = true; + }); + + expect(nextCalled).toBe(true); + expect(requestHandler).not.toHaveBeenCalled(); + expect(responseHandler).not.toHaveBeenCalled(); + }); + + it('handles independent requests independently (marker is per-context)', async () => { + const middleware = createHonoRequestMiddleware(); + + await middleware(fakeContext(), async () => {}); + await middleware(fakeContext(), async () => {}); + + expect(requestHandler).toHaveBeenCalledTimes(2); + expect(responseHandler).toHaveBeenCalledTimes(2); + }); + + it("uses a deduplicated middleware's shouldHandleError over the outer (auto) default", async () => { + const userShouldHandleError = (): boolean => true; + // Outer middleware mirrors the auto-instrumentation: registered first, no shouldHandleError. + const auto = createHonoRequestMiddleware(); + // Inner middleware mirrors a manual `sentry({ shouldHandleError })`: deduplicated behind `auto`. + const manual = createHonoRequestMiddleware({ shouldHandleError: userShouldHandleError }); + const context = fakeContext(); + + await auto(context, async () => { + await manual(context, async () => {}); + }); + + // The request is still handled exactly once, but with the user's callback, not the default. + expect(responseHandler).toHaveBeenCalledTimes(1); + expect(responseHandler).toHaveBeenCalledWith(context, userShouldHandleError); + }); +}); diff --git a/packages/hono/test/shared/defaultShouldHandleError.test.ts b/packages/server-utils/test/integrations/hono/defaultShouldHandleError.test.ts similarity index 88% rename from packages/hono/test/shared/defaultShouldHandleError.test.ts rename to packages/server-utils/test/integrations/hono/defaultShouldHandleError.test.ts index 85a29493c752..2b7705554f1e 100644 --- a/packages/hono/test/shared/defaultShouldHandleError.test.ts +++ b/packages/server-utils/test/integrations/hono/defaultShouldHandleError.test.ts @@ -1,6 +1,15 @@ -import { HTTPException } from 'hono/http-exception'; import { describe, expect, it } from 'vitest'; -import { defaultShouldHandleError } from '../../src/shared/defaultShouldHandleError'; +import { defaultShouldHandleError } from '../../../src/integrations/hono/defaultShouldHandleError'; + +// Minimal stand-in for hono's `HTTPException` (which carries a numeric `status`), so this unit test +// stays free of a `hono` dependency in `@sentry/server-utils`. +class HTTPException extends Error { + public status: number; + public constructor(status: number, options?: { message?: string }) { + super(options?.message); + this.status = status; + } +} describe('defaultShouldHandleError', () => { describe('HTTPException', () => { diff --git a/packages/hono/test/utils/isMiddleware.test.ts b/packages/server-utils/test/integrations/hono/isMiddleware.test.ts similarity index 95% rename from packages/hono/test/utils/isMiddleware.test.ts rename to packages/server-utils/test/integrations/hono/isMiddleware.test.ts index 6266d14e86db..ee193ac5ac8d 100644 --- a/packages/hono/test/utils/isMiddleware.test.ts +++ b/packages/server-utils/test/integrations/hono/isMiddleware.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { isMiddleware } from '../../src/utils/isMiddleware'; +import { isMiddleware } from '../../../src/integrations/hono/isMiddleware'; describe('isMiddleware', () => { it.each([ diff --git a/packages/solidstart/src/server/index.ts b/packages/solidstart/src/server/index.ts index da4972e07590..c8ef72468c0d 100644 --- a/packages/solidstart/src/server/index.ts +++ b/packages/solidstart/src/server/index.ts @@ -62,6 +62,7 @@ export { isEnabled, knexIntegration, kafkaIntegration, + honoIntegration, koaIntegration, lastEventId, linkedErrorsIntegration, diff --git a/packages/sveltekit/src/server/index.ts b/packages/sveltekit/src/server/index.ts index b850e256ba38..92524abbf6a9 100644 --- a/packages/sveltekit/src/server/index.ts +++ b/packages/sveltekit/src/server/index.ts @@ -60,6 +60,7 @@ export { isEnabled, knexIntegration, kafkaIntegration, + honoIntegration, koaIntegration, lastEventId, linkedErrorsIntegration, diff --git a/yarn.lock b/yarn.lock index 2c1b930b29dd..421c833ec2d7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -426,10 +426,10 @@ semifies "^1.0.0" source-map "^0.6.0" -"@apm-js-collab/tracing-hooks@^0.13.0": - version "0.13.0" - resolved "https://registry.yarnpkg.com/@apm-js-collab/tracing-hooks/-/tracing-hooks-0.13.0.tgz#20b2f77ec7a0e5dfd9fbf2215b56e2c2b7f41e4b" - integrity sha512-mTvWz9rnQwx1U3h0XPTHaX7bgfkpipLLTQyjlC2cdhQpQEuoLT0AGzoydeoq2NxfEVv6fWOOETcSbb2nptleyw== +"@apm-js-collab/tracing-hooks@^0.13.1": + version "0.13.1" + resolved "https://sfw.security.sentry.io/npm/@apm-js-collab/tracing-hooks/-/tracing-hooks-0.13.1.tgz#e043a1b2d3c2571c6e5c6cb11a2e85a0cdda7025" + integrity sha512-1FFkZsodZvZI6OpNMnvNEO2zvXvPTNyaEu/vahjcjxEAneaKaAjTPujQ02YxCvCUqW56CplN/101ikoxBwn33g== dependencies: "@apm-js-collab/code-transformer" "^0.18.0" debug "^4.4.1"