From 75b563cb308e0cc6b1897969c736617de958da9b Mon Sep 17 00:00:00 2001 From: Francesco Novy Date: Fri, 18 Sep 2026 12:29:30 +0200 Subject: [PATCH 1/8] feat(hono): add orchestrion-based auto-instrumentation Add `honoIntegration`, the auto-instrumentation that hooks Hono through the orchestrion module transform (`node:diagnostics_channel`) so requests are route-enriched without a manual `sentry()` middleware, plus its manual counterpart `honoMiddleware`. Register it in `getTracingIntegrations()` and re-export both from the server runtimes (node, cloudflare, bun, deno, and the serverless/meta-framework packages). Adds the orchestrion transform config for `hono`, node-integration-tests for the auto-instrumentation, and a new orchestrion-based `hono-4` e2e app (the middleware-based app now lives as `hono-4-legacy`). node-mastra now asserts route-enriched Hono spans in prod, where Hono is external and instrumented. Co-Authored-By: Claude Opus 4.8 (1M context) --- dev-packages/e2e-tests/lib/getTestMatrix.mjs | 10 +- dev-packages/e2e-tests/run.ts | 10 +- .../test-applications/hono-4/.gitignore | 36 ++ .../test-applications/hono-4/build-bun.ts | 28 ++ .../test-applications/hono-4/deno.json | 9 + .../test-applications/hono-4/package.json | 57 +++ .../hono-4/playwright.config.ts | 30 ++ .../test-applications/hono-4/src/entry.bun.ts | 16 + .../hono-4/src/entry.cloudflare.ts | 8 + .../hono-4/src/entry.deno.ts | 13 + .../hono-4/src/entry.node.ts | 14 + .../hono-4/src/instrument.bun.ts | 8 + .../hono-4/src/instrument.deno.ts | 9 + .../hono-4/src/instrument.node.ts | 8 + .../hono-4/src/instrument.server.ts | 7 + .../hono-4/src/middleware.ts | 26 ++ .../hono-4/src/route-groups/test-errors.ts | 45 ++ .../src/route-groups/test-middleware.ts | 83 ++++ .../src/route-groups/test-multi-fetch.ts | 99 +++++ .../src/route-groups/test-route-patterns.ts | 33 ++ .../test-applications/hono-4/src/routes.ts | 133 ++++++ .../hono-4/start-event-proxy.mjs | 6 + .../tests/basepath-and-late-routes.test.ts | 91 ++++ .../hono-4/tests/constants.ts | 5 + .../hono-4/tests/errors.test.ts | 262 +++++++++++ .../hono-4/tests/middleware.test.ts | 410 ++++++++++++++++++ .../hono-4/tests/multi-fetch.test.ts | 373 ++++++++++++++++ .../hono-4/tests/route-patterns.test.ts | 178 ++++++++ .../hono-4/tests/tracing.test.ts | 168 +++++++ .../test-applications/hono-4/tsconfig.json | 13 + .../test-applications/hono-4/vite.config.ts | 11 + .../test-applications/hono-4/wrangler.jsonc | 7 + .../node-mastra/src/mastra/index.ts | 5 + .../node-mastra/tests/manual-route.test.ts | 30 +- .../node-mastra/tests/mastra.test.ts | 31 +- .../suites/hono/instrument.mjs | 8 + .../suites/hono/scenario.mjs | 54 +++ .../suites/hono/test.ts | 171 ++++++++ packages/astro/src/index.server.ts | 2 + packages/aws-serverless/src/index.ts | 2 + packages/bun/src/index.ts | 2 + packages/cloudflare/src/index.ts | 2 + packages/deno/src/index.ts | 2 + .../deno/test/__snapshots__/mod.test.ts.snap | 3 + packages/elysia/src/index.ts | 1 + packages/google-cloud-serverless/src/index.ts | 2 + packages/node/src/index.ts | 2 + packages/remix/src/server/index.ts | 1 + .../server-runtime-injection/package.json | 2 +- packages/server-utils/src/index.ts | 2 + .../src/integrations/hono/honoIntegration.ts | 225 ++++++++++ .../src/integrations/hono/index.ts | 9 + .../server-utils/src/integrations/index.ts | 3 +- .../src/orchestrion/bundler/bun.ts | 1 + .../server-utils/src/orchestrion/channels.ts | 2 + .../config/channel-integration-definitions.ts | 1 + .../src/orchestrion/config/hono.ts | 40 ++ .../src/orchestrion/config/index.ts | 2 + packages/solidstart/src/server/index.ts | 1 + packages/sveltekit/src/server/index.ts | 1 + yarn.lock | 8 +- 61 files changed, 2799 insertions(+), 22 deletions(-) create mode 100644 dev-packages/e2e-tests/test-applications/hono-4/.gitignore create mode 100644 dev-packages/e2e-tests/test-applications/hono-4/build-bun.ts create mode 100644 dev-packages/e2e-tests/test-applications/hono-4/deno.json create mode 100644 dev-packages/e2e-tests/test-applications/hono-4/package.json create mode 100644 dev-packages/e2e-tests/test-applications/hono-4/playwright.config.ts create mode 100644 dev-packages/e2e-tests/test-applications/hono-4/src/entry.bun.ts create mode 100644 dev-packages/e2e-tests/test-applications/hono-4/src/entry.cloudflare.ts create mode 100644 dev-packages/e2e-tests/test-applications/hono-4/src/entry.deno.ts create mode 100644 dev-packages/e2e-tests/test-applications/hono-4/src/entry.node.ts create mode 100644 dev-packages/e2e-tests/test-applications/hono-4/src/instrument.bun.ts create mode 100644 dev-packages/e2e-tests/test-applications/hono-4/src/instrument.deno.ts create mode 100644 dev-packages/e2e-tests/test-applications/hono-4/src/instrument.node.ts create mode 100644 dev-packages/e2e-tests/test-applications/hono-4/src/instrument.server.ts create mode 100644 dev-packages/e2e-tests/test-applications/hono-4/src/middleware.ts create mode 100644 dev-packages/e2e-tests/test-applications/hono-4/src/route-groups/test-errors.ts create mode 100644 dev-packages/e2e-tests/test-applications/hono-4/src/route-groups/test-middleware.ts create mode 100644 dev-packages/e2e-tests/test-applications/hono-4/src/route-groups/test-multi-fetch.ts create mode 100644 dev-packages/e2e-tests/test-applications/hono-4/src/route-groups/test-route-patterns.ts create mode 100644 dev-packages/e2e-tests/test-applications/hono-4/src/routes.ts create mode 100644 dev-packages/e2e-tests/test-applications/hono-4/start-event-proxy.mjs create mode 100644 dev-packages/e2e-tests/test-applications/hono-4/tests/basepath-and-late-routes.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/hono-4/tests/constants.ts create mode 100644 dev-packages/e2e-tests/test-applications/hono-4/tests/errors.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/hono-4/tests/middleware.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/hono-4/tests/multi-fetch.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/hono-4/tests/route-patterns.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/hono-4/tests/tracing.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/hono-4/tsconfig.json create mode 100644 dev-packages/e2e-tests/test-applications/hono-4/vite.config.ts create mode 100644 dev-packages/e2e-tests/test-applications/hono-4/wrangler.jsonc create mode 100644 dev-packages/node-integration-tests/suites/hono/instrument.mjs create mode 100644 dev-packages/node-integration-tests/suites/hono/scenario.mjs create mode 100644 dev-packages/node-integration-tests/suites/hono/test.ts create mode 100644 packages/server-utils/src/integrations/hono/honoIntegration.ts create mode 100644 packages/server-utils/src/orchestrion/config/hono.ts 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/.gitignore b/dev-packages/e2e-tests/test-applications/hono-4/.gitignore new file mode 100644 index 000000000000..534f51704346 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/hono-4/.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/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 new file mode 100644 index 000000000000..7a7e681c4a82 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/hono-4/deno.json @@ -0,0 +1,9 @@ +{ + "imports": { + "@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/package.json b/dev-packages/e2e-tests/test-applications/hono-4/package.json new file mode 100644 index 000000000000..a3fd78b9ec67 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/hono-4/package.json @@ -0,0 +1,57 @@ +{ + "name": "hono-4", + "type": "module", + "version": "0.0.0", + "private": true, + "scripts": { + "dev:node": "node --import tsx/esm --import ./src/instrument.node.ts src/entry.node.ts", + "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/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", + "vite": "^8.1.5", + "wrangler": "^4.114.0" + }, + "volta": { + "node": "24.15.0", + "extends": "../../package.json" + }, + "sentryTest": { + "variants": [ + { + "assert-command": "pnpm test:assert:bun", + "label": "hono-4 (bun)" + }, + { + "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 new file mode 100644 index 000000000000..b8c04df05aee --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/hono-4/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:cloudflare --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/src/entry.bun.ts b/dev-packages/e2e-tests/test-applications/hono-4/src/entry.bun.ts new file mode 100644 index 000000000000..b47f15997480 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/hono-4/src/entry.bun.ts @@ -0,0 +1,16 @@ +// Import the Sentry init first so `honoIntegration` is set up (and subscribed to Hono's per-request +// Context channel) before any request is handled. +import './instrument.bun'; +import { Hono } from 'hono'; +import { addRoutes } from './routes'; + +const app = new Hono(); + +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/src/entry.cloudflare.ts b/dev-packages/e2e-tests/test-applications/hono-4/src/entry.cloudflare.ts new file mode 100644 index 000000000000..7fb84f59947a --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/hono-4/src/entry.cloudflare.ts @@ -0,0 +1,8 @@ +import { Hono } from 'hono'; +import { addRoutes } from './routes'; + +const app = new Hono<{ Bindings: { E2E_TEST_DSN: string } }>(); + +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 new file mode 100644 index 000000000000..cc2411fd6cf9 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/hono-4/src/entry.deno.ts @@ -0,0 +1,13 @@ +// Import the Sentry init first so `honoIntegration` is set up (and subscribed to Hono's per-request +// Context channel) before any request is handled. +import './instrument.deno'; +import { Hono } from 'hono'; +import { addRoutes } from './routes'; + +const app = new Hono(); + +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/src/entry.node.ts b/dev-packages/e2e-tests/test-applications/hono-4/src/entry.node.ts new file mode 100644 index 000000000000..c67b3a206ef2 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/hono-4/src/entry.node.ts @@ -0,0 +1,14 @@ +import { serve } from '@hono/node-server'; +import { Hono } from 'hono'; +import { addRoutes } from './routes'; + +const app = new Hono(); + +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 new file mode 100644 index 000000000000..5c3e586fd48b --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/hono-4/src/instrument.node.ts @@ -0,0 +1,8 @@ +import * as Sentry from '@sentry/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/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 new file mode 100644 index 000000000000..6f3a8a98a727 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/hono-4/src/middleware.ts @@ -0,0 +1,26 @@ +import type { MiddlewareHandler } from 'hono'; + +// 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 (_c, next) { + // Add some delay + await new Promise(resolve => setTimeout(resolve, 60)); + await next(); +}; + +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/src/route-groups/test-errors.ts b/dev-packages/e2e-tests/test-applications/hono-4/src/route-groups/test-errors.ts new file mode 100644 index 000000000000..b8f2fd96fe93 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/hono-4/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/src/route-groups/test-middleware.ts b/dev-packages/e2e-tests/test-applications/hono-4/src/route-groups/test-middleware.ts new file mode 100644 index 000000000000..d82201b7cdb3 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/hono-4/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/src/route-groups/test-multi-fetch.ts b/dev-packages/e2e-tests/test-applications/hono-4/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/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/src/route-groups/test-route-patterns.ts b/dev-packages/e2e-tests/test-applications/hono-4/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/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/src/routes.ts b/dev-packages/e2e-tests/test-applications/hono-4/src/routes.ts new file mode 100644 index 000000000000..b095618fb52b --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/hono-4/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/start-event-proxy.mjs b/dev-packages/e2e-tests/test-applications/hono-4/start-event-proxy.mjs new file mode 100644 index 000000000000..cd6f91b3455d --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/hono-4/start-event-proxy.mjs @@ -0,0 +1,6 @@ +import { startEventProxyServer } from '@sentry-internal/test-utils'; + +startEventProxyServer({ + port: 3031, + proxyServerName: 'hono-4', +}); diff --git a/dev-packages/e2e-tests/test-applications/hono-4/tests/basepath-and-late-routes.test.ts b/dev-packages/e2e-tests/test-applications/hono-4/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/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/tests/constants.ts b/dev-packages/e2e-tests/test-applications/hono-4/tests/constants.ts new file mode 100644 index 000000000000..0bd38ea85aa3 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/hono-4/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'; 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 new file mode 100644 index 000000000000..b315233ec15b --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/hono-4/tests/errors.test.ts @@ -0,0 +1,262 @@ +import { expect, test } from '@playwright/test'; +import { + waitForError, + waitForStreamedSpan, + getSpanOp, + collectStreamedSpansUntilSegment, +} from '@sentry-internal/test-utils'; +import { APP_NAME } 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', + }); + }); + + // 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; + } + return false; + }); + + const guardPromise = waitForStreamedSpan( + APP_NAME, + segment => segment.is_segment && getSpanOp(segment) === 'http.server' && segment.name === 'GET /', + ); + + const response = await fetch(`${baseURL}/http-exception/${code}`, { redirect: 'manual' }); + expect(response.status).toBe(code); + + await fetch(`${baseURL}/`); + await guardPromise; + + expect(errorEventOccurred).toBe(false); + }; + + [301, 302, 401, 403, 404].forEach(code => { + test(`does not capture ${code} HTTPException`, async ({ baseURL }) => { + await expectHttpExceptionNotCaptured(baseURL!, code); + }); + }); +}); + +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; + }); + + // 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); + + await fetch(`${baseURL}/`); + await guardPromise; + + 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/tests/middleware.test.ts b/dev-packages/e2e-tests/test-applications/hono-4/tests/middleware.test.ts new file mode 100644 index 000000000000..b9380ec4706d --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/hono-4/tests/middleware.test.ts @@ -0,0 +1,410 @@ +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?.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).toMatch(/^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?.startsWith('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/tests/multi-fetch.test.ts b/dev-packages/e2e-tests/test-applications/hono-4/tests/multi-fetch.test.ts new file mode 100644 index 000000000000..4ed843fc2a7d --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/hono-4/tests/multi-fetch.test.ts @@ -0,0 +1,373 @@ +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 }) => { + // 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: phantom'; + }); + + 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/phantom`); + + 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/tests/route-patterns.test.ts b/dev-packages/e2e-tests/test-applications/hono-4/tests/route-patterns.test.ts new file mode 100644 index 000000000000..bfebb9f5b6ab --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/hono-4/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/tests/tracing.test.ts b/dev-packages/e2e-tests/test-applications/hono-4/tests/tracing.test.ts new file mode 100644 index 000000000000..9350259d7e5c --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/hono-4/tests/tracing.test.ts @@ -0,0 +1,168 @@ +import { expect, test } from '@playwright/test'; +import { waitForStreamedSpan, getSpanOp } from '@sentry-internal/test-utils'; +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( + 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 ?? {}; + + for (const [key, expected] of Object.entries(connInfo.added)) { + expect(data[key]?.value).toEqual(expected); + } + + // 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. +// 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 ?? {}; + + 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 }) => { + 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/tsconfig.json b/dev-packages/e2e-tests/test-applications/hono-4/tsconfig.json new file mode 100644 index 000000000000..3c4abeff44d6 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/hono-4/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/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/hono-4/wrangler.jsonc b/dev-packages/e2e-tests/test-applications/hono-4/wrangler.jsonc new file mode 100644 index 000000000000..d4344dfa198a --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/hono-4/wrangler.jsonc @@ -0,0 +1,7 @@ +{ + "$schema": "node_modules/wrangler/config-schema.json", + "name": "hono-4", + "main": "src/entry.cloudflare.ts", + "compatibility_date": "2026-04-20", + "compatibility_flags": ["nodejs_compat"], +} 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 116c06dab64a..07016e9f3c79 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 4f3b416066b5..4d234da6b8cb 100644 --- a/packages/aws-serverless/src/index.ts +++ b/packages/aws-serverless/src/index.ts @@ -103,6 +103,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 e412d01db9ae..8ea42cefc6f6 100644 --- a/packages/bun/src/index.ts +++ b/packages/bun/src/index.ts @@ -128,6 +128,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 187eb0b5cbf7..3f24e02febe2 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 1f65611a6b6b..70f6e2f8e146 100644 --- a/packages/elysia/src/index.ts +++ b/packages/elysia/src/index.ts @@ -107,6 +107,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 a53893c979e9..7ffc5c64340d 100644 --- a/packages/google-cloud-serverless/src/index.ts +++ b/packages/google-cloud-serverless/src/index.ts @@ -104,6 +104,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/node/src/index.ts b/packages/node/src/index.ts index de280e57f11c..2c54c59cbc82 100644 --- a/packages/node/src/index.ts +++ b/packages/node/src/index.ts @@ -15,6 +15,8 @@ export { graphqlIntegration, groqIntegration, 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 62f74e3675ef..232796759f98 100644 --- a/packages/server-utils/src/index.ts +++ b/packages/server-utils/src/index.ts @@ -36,6 +36,8 @@ 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'; export { koaIntegration } from './integrations/koa'; export { redisIntegration } from './integrations/redis'; export { kafkaIntegration } from './integrations/kafkajs'; 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/index.ts b/packages/server-utils/src/integrations/hono/index.ts index 7f6d3b13e19d..9b09e7de57ec 100644 --- a/packages/server-utils/src/integrations/hono/index.ts +++ b/packages/server-utils/src/integrations/hono/index.ts @@ -1,3 +1,12 @@ +// 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 neither the Sentry runtime hook +// nor the bundler plugin is active (so the per-request Context hook never fires): +// `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` or `node:diagnostics_channel`, // so they stay safe to load in every server SDK — including the Node-free (`no-diagnostic-channels`) entry. diff --git a/packages/server-utils/src/integrations/index.ts b/packages/server-utils/src/integrations/index.ts index fa6cf1759906..2e9266096d9d 100644 --- a/packages/server-utils/src/integrations/index.ts +++ b/packages/server-utils/src/integrations/index.ts @@ -28,6 +28,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'; @@ -70,5 +71,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 a88495d6ed8c..e309e968d6b9 100644 --- a/packages/server-utils/src/orchestrion/channels.ts +++ b/packages/server-utils/src/orchestrion/channels.ts @@ -9,6 +9,7 @@ import { googleGenAiChannels } from './config/google-genai'; import { graphqlChannels } from './config/graphql'; import { groqChannels } from './config/groq'; 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'; @@ -60,6 +61,7 @@ export const CHANNELS = { ...graphqlChannels, ...groqChannels, ...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 a1c51f6008fa..48651f78c77a 100644 --- a/packages/server-utils/src/orchestrion/config/channel-integration-definitions.ts +++ b/packages/server-utils/src/orchestrion/config/channel-integration-definitions.ts @@ -49,6 +49,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 dced48dc622d..72e781c2ce35 100644 --- a/packages/server-utils/src/orchestrion/config/index.ts +++ b/packages/server-utils/src/orchestrion/config/index.ts @@ -13,6 +13,7 @@ import { googleGenAiConfig } from './google-genai'; import { graphqlConfig } from './graphql'; import { groqConfig } from './groq'; import { hapiConfig } from './hapi'; +import { honoConfig } from './hono'; import { ioredisConfig } from './ioredis'; import { kafkajsConfig } from './kafkajs'; import { knexConfig } from './knex'; @@ -63,6 +64,7 @@ export const SENTRY_INSTRUMENTATIONS: InstrumentationConfig[] = [ ...graphqlConfig, ...groqConfig, ...hapiConfig, + ...honoConfig, ...ioredisConfig, ...kafkajsConfig, ...knexConfig, 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 f8a780a37590..5b2b588e4265 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" From 277348c39887aa263ab7a446a47990be2c599ef9 Mon Sep 17 00:00:00 2001 From: Francesco Novy Date: Mon, 21 Sep 2026 09:44:06 +0200 Subject: [PATCH 2/8] fix(hono): capture errors thrown inside internal .request() dispatches An internal app.request() runs in a new Hono context but the same isolation scope, so the request-handling dedup short-circuited the inner Sentry middleware before it could capture context.error. When an outer handler swallowed a failed internal response (degrading to a 200), the inner route's error was never reported. The deduplicated middleware now still captures its own context's error, without re-naming the transaction or overwriting request data. Also streamline the orchestrion hono config comments, add a named-function middleware span test, and add node-integration coverage for the inner-error case. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../hono-4/src/middleware.ts | 8 +++++ .../src/route-groups/test-middleware.ts | 4 ++- .../src/route-groups/test-multi-fetch.ts | 19 +++++++++++ .../test-applications/hono-4/src/routes.ts | 3 +- .../hono-4/tests/middleware.test.ts | 32 +++++++++++++++++++ .../hono-4/tests/multi-fetch.test.ts | 18 +++++++++++ .../suites/hono/scenario.mjs | 13 ++++++++ .../suites/hono/test.ts | 16 ++++++++++ .../integrations/hono/createHonoMiddleware.ts | 12 +++++-- .../integrations/hono/middlewareHandlers.ts | 15 +++++++++ .../src/orchestrion/config/hono.ts | 20 ++++-------- 11 files changed, 142 insertions(+), 18 deletions(-) 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 6f3a8a98a727..9304a980dff3 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 @@ -24,3 +24,11 @@ export const failingMiddleware: MiddlewareHandler = async function (_c, _next) { // stable `Middleware error` prefix. throw new Error(`Middleware error #${(failingMiddlewareCount += 1)}`); }; + +// Intentionally a NAMED function expression (unlike the anonymous ones above) so the named-function +// path stays covered: the span name is taken from the function's own name. Under bundled Bun the inner +// name collides with the `const` binding and is suffixed (→ `namedMiddleware2`), so the test matches on +// the `namedMiddleware` prefix rather than an exact name. +export const namedMiddleware: MiddlewareHandler = async function namedMiddleware(_c, next) { + await next(); +}; diff --git a/dev-packages/e2e-tests/test-applications/hono-4/src/route-groups/test-middleware.ts b/dev-packages/e2e-tests/test-applications/hono-4/src/route-groups/test-middleware.ts index d82201b7cdb3..6f21c1cac33b 100644 --- a/dev-packages/e2e-tests/test-applications/hono-4/src/route-groups/test-middleware.ts +++ b/dev-packages/e2e-tests/test-applications/hono-4/src/route-groups/test-middleware.ts @@ -1,5 +1,5 @@ import { Hono } from 'hono'; -import { failingMiddleware, middlewareA, middlewareB } from '../middleware'; +import { failingMiddleware, middlewareA, middlewareB, namedMiddleware } from '../middleware'; const middlewareRoutes = new Hono(); @@ -8,11 +8,13 @@ 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') })); +middlewareRoutes.get('/declared', c => c.json({ middleware: 'declared' })); // Self-contained sub-app registering its own middleware via .use() const subAppWithMiddleware = new Hono(); subAppWithMiddleware.use('/named/*', middlewareA); +subAppWithMiddleware.use('/declared/*', namedMiddleware); subAppWithMiddleware.use('/anonymous/*', async (c, next) => { c.header('X-Custom', 'anonymous'); await next(); diff --git a/dev-packages/e2e-tests/test-applications/hono-4/src/route-groups/test-multi-fetch.ts b/dev-packages/e2e-tests/test-applications/hono-4/src/route-groups/test-multi-fetch.ts index 58eb5b504640..ed9f0a3e131b 100644 --- a/dev-packages/e2e-tests/test-applications/hono-4/src/route-groups/test-multi-fetch.ts +++ b/dev-packages/e2e-tests/test-applications/hono-4/src/route-groups/test-multi-fetch.ts @@ -28,6 +28,14 @@ inventoryApp.get('/item/:productId/stock', c => { return c.json({ productId, inStock: item.stock > 0, quantity: item.stock }); }); +// Simulates an inner-route failure (a plain 5xx Error, not an HTTPException) reached only via an +// internal .request() — used by the storefront's `/degraded` route below. Each throw gets a unique +// suffix so the Dedupe integration doesn't collapse repeats across test retries. +let dbErrorCount = 0; +inventoryApp.get('/item/:productId/db-error', () => { + throw new Error(`inventory db is down #${(dbErrorCount += 1)}`); +}); + // Storefront service — orchestrates internal .request() calls to inventoryApp. const storefrontApp = new Hono(); @@ -83,6 +91,17 @@ storefrontApp.get('/product/:productId/availability', async c => { }); }); +// Degraded response: the inner route throws, but the outer handler swallows the failed internal +// response and returns a 200 instead of propagating. The inner error should still reach Sentry (auto +// instrumentation); the outer request stays healthy. +storefrontApp.get('/product/:productId/degraded', async c => { + const res = await inventoryApp.request(`/item/${c.req.param('productId')}/db-error`); + if (!res.ok) { + return c.json({ product: null, degraded: true }, 200); + } + return c.json({ product: await res.json() }); +}); + // 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')}`); diff --git a/dev-packages/e2e-tests/test-applications/hono-4/src/routes.ts b/dev-packages/e2e-tests/test-applications/hono-4/src/routes.ts index b095618fb52b..008a1ca6fd53 100644 --- a/dev-packages/e2e-tests/test-applications/hono-4/src/routes.ts +++ b/dev-packages/e2e-tests/test-applications/hono-4/src/routes.ts @@ -1,6 +1,6 @@ import { type Hono as HonoType, Hono } from 'hono'; import { HTTPException } from 'hono/http-exception'; -import { failingMiddleware, middlewareA, middlewareB } from './middleware'; +import { failingMiddleware, middlewareA, middlewareB, namedMiddleware } 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'; @@ -42,6 +42,7 @@ export function addRoutes(app: HonoType<{ Bindings?: { E2E_TEST_DSN: string } }> app.use('/test-middleware/multi/*', middlewareA, middlewareB); app.use('/test-middleware/error/*', failingMiddleware); app.use('/test-middleware/param/*', middlewareA); + app.use('/test-middleware/declared/*', namedMiddleware); app.route('/test-middleware', middlewareRoutes); // Sub-app middleware: registered on the sub-app, wrapped at mount time by route() patching 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 b9380ec4706d..d760f12e6451 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 @@ -78,6 +78,38 @@ for (const { name, prefix } of SCENARIOS) { expect(anonymousSpan?.status).not.toBe('error'); }); + test('creates a span for a named-function middleware (name from the function binding)', async ({ baseURL }) => { + const segmentPromise = collectStreamedSpansUntilSegment( + APP_NAME, + segment => getSpanOp(segment) === 'http.server' && !!segment.name?.includes(`${prefix}/declared`), + ); + + const response = await fetch(`${baseURL}${prefix}/declared`); + 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}/declared`), + )!; + expect(segment.name).toBe(`GET ${prefix}/declared`); + + const spans = segmentSpans.filter( + span => !span.is_segment && span.attributes['sentry.segment.id']?.value === segment.span_id, + ); + + // Named function expression: the span name comes from the function's own name. A bundler (Bun) + // may append a numeric suffix when the inner name collides with the `const` binding, so match on + // the `namedMiddleware` prefix rather than an exact name. + const middlewareSpan = spans.find( + span => getSpanOp(span) === 'middleware' && !!span.name?.match(/^namedMiddleware\d*$/), + ); + + expect(middlewareSpan).toBeDefined(); + expect(middlewareSpan?.attributes['sentry.origin']?.value).toBe('auto.middleware.hono'); + expect(middlewareSpan?.status).not.toBe('error'); + }); + test('multiple middleware are sibling spans under the same parent', async ({ baseURL }) => { const segmentPromise = collectStreamedSpansUntilSegment( APP_NAME, 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 4ed843fc2a7d..b98a7e6df7ee 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 @@ -183,6 +183,24 @@ test.describe('multi-fetch: internal .request() calls between sub-apps', () => { }); }); + test.describe('error inside internal fetch (degraded response)', () => { + test('reports the inner-route error to Sentry even though the outer handler returns 200', async ({ baseURL }) => { + const errorPromise = waitForError(APP_NAME, event => { + return !!event.exception?.values?.[0]?.value?.startsWith('inventory db is down'); + }); + + const response = await fetch(`${baseURL}${STOREFRONT}/product/self-watering-plant/degraded`); + expect(response.status).toBe(200); + await expect(response.json()).resolves.toEqual({ product: null, degraded: true }); + + const errorEvent = await errorPromise; + expect(errorEvent.exception?.values?.[0]?.value).toMatch(/^inventory db is down/); + expect(errorEvent.exception?.values?.[0]?.mechanism).toEqual( + expect.objectContaining({ handled: false, type: 'auto.http.hono.context_error' }), + ); + }); + }); + test.describe('inventory sub-app direct access', () => { test('creates its own span when accessed directly via HTTP', async ({ baseURL }) => { const segmentPromise = waitForStreamedSpan( diff --git a/dev-packages/node-integration-tests/suites/hono/scenario.mjs b/dev-packages/node-integration-tests/suites/hono/scenario.mjs index cf85fda9d9bd..48a484c16179 100644 --- a/dev-packages/node-integration-tests/suites/hono/scenario.mjs +++ b/dev-packages/node-integration-tests/suites/hono/scenario.mjs @@ -34,6 +34,9 @@ app.route('/sub', subApp); // 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') })); +innerApp.get('/flaky/:itemId', () => { + throw new Error('inventory db is down'); +}); app.get('/outer/:itemId', async c => { const res = await innerApp.request(`/item/${c.req.param('itemId')}`); @@ -41,6 +44,16 @@ app.get('/outer/:itemId', async c => { return c.json({ outer: c.req.param('itemId'), inner: data }); }); +// The inner route throws, but the outer handler catches the failed internal response and degrades to +// a 200 instead of propagating. The inner route's error must still reach Sentry. +app.get('/degraded/:itemId', async c => { + const res = await innerApp.request(`/flaky/${c.req.param('itemId')}`); + if (!res.ok) { + return c.json({ item: null, degraded: true }, 200); + } + return c.json({ item: await res.json() }); +}); + 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 diff --git a/dev-packages/node-integration-tests/suites/hono/test.ts b/dev-packages/node-integration-tests/suites/hono/test.ts index 1aef2a022e2e..cf4f8eaa1eca 100644 --- a/dev-packages/node-integration-tests/suites/hono/test.ts +++ b/dev-packages/node-integration-tests/suites/hono/test.ts @@ -152,6 +152,22 @@ describe('hono auto-instrumentation', () => { await runner.completed(); }); + test('captures an error thrown inside an internal .request() even when the outer handler degrades to 200', async () => { + const runner = createRunner() + .unordered() + .expect({ + event: event => { + expect(event.exception?.values?.[0]?.value).toBe('inventory db is down'); + expect(event.exception?.values?.[0]?.mechanism).toEqual( + expect.objectContaining({ type: 'auto.http.hono.context_error', handled: false }), + ); + }, + }) + .start(); + runner.makeRequest('get', '/degraded/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() diff --git a/packages/server-utils/src/integrations/hono/createHonoMiddleware.ts b/packages/server-utils/src/integrations/hono/createHonoMiddleware.ts index 952b45743590..3661a5d04298 100644 --- a/packages/server-utils/src/integrations/hono/createHonoMiddleware.ts +++ b/packages/server-utils/src/integrations/hono/createHonoMiddleware.ts @@ -1,6 +1,6 @@ import { addNonEnumerableProperty, getDefaultIsolationScope, getIsolationScope } from '@sentry/core'; import type { Context, GetConnInfo, MiddlewareHandler } from './honoTypes'; -import { requestHandler, responseHandler } from './middlewareHandlers'; +import { captureContextError, 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 @@ -79,7 +79,15 @@ export function createHonoRequestMiddleware(options: CreateHonoRequestMiddleware } if (scope[HONO_REQUEST_HANDLED]) { - return next(); + await next(); + // A deduplicated middleware still reports errors from its own context — e.g. an inner + // `.request()` whose route threw but whose failed response the outer handler swallowed, so the + // outer context never sees the error. Route naming and request data stay owned by the request + // that ran first, so only the error is captured here. + const dedupShouldHandleError = + (scope[HONO_SHOULD_HANDLE_ERROR] as SentryHonoMiddlewareOptions['shouldHandleError']) ?? shouldHandleError; + captureContextError(context, dedupShouldHandleError); + return; } addNonEnumerableProperty(scope, HONO_REQUEST_HANDLED, true); diff --git a/packages/server-utils/src/integrations/hono/middlewareHandlers.ts b/packages/server-utils/src/integrations/hono/middlewareHandlers.ts index ab1f3f000199..d72d357b4c86 100644 --- a/packages/server-utils/src/integrations/hono/middlewareHandlers.ts +++ b/packages/server-utils/src/integrations/hono/middlewareHandlers.ts @@ -90,6 +90,21 @@ export function responseHandler( // Overwrite the route name now that the middleware chain has run: `routeIndex` is accurate here updateSpanRouteName(getCurrentIsolationScope(), context); + captureContextError(context, shouldHandleError); +} + +/** + * Captures an unhandled context error, gated by the user's `shouldHandleError` (or the default). + * + * Split out from {@link responseHandler} so a deduplicated middleware can still report its own + * context's error without re-resolving the route name or overwriting request data — e.g. the inner + * context of an internal `.request()` whose route threw but whose failed response the outer handler + * swallowed. + */ +export function captureContextError( + context: Context, + shouldHandleError?: SentryHonoMiddlewareOptions['shouldHandleError'], +): void { if (context.error) { if ((shouldHandleError ?? defaultShouldHandleError)(context.error)) { captureException(context.error, { diff --git a/packages/server-utils/src/orchestrion/config/hono.ts b/packages/server-utils/src/orchestrion/config/hono.ts index f9923e011628..eda1a8084ff3 100644 --- a/packages/server-utils/src/orchestrion/config/hono.ts +++ b/packages/server-utils/src/orchestrion/config/hono.ts @@ -1,28 +1,20 @@ 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. +// Hono is hooked through per-request internals only — never at module scope — which is what lets the +// same config run on Cloudflare Workers, where workerd forbids `diagnostics_channel` at module scope. // 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. + // `new Context()` runs once per request: where the Sentry middleware is injected and matched + // middleware handlers are wrapped for spans. 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). + // `app.request(...)` — Hono's internal dispatch for sub-app-to-sub-app fetches. A class-field + // arrow, hence `astQuery` instead of `methodName`. channelName: 'request', module: { name: 'hono', versionRange: '>=4.0.0 <5', filePath: /^dist\/(?:cjs\/)?hono-base\.js$/ }, astQuery: "PropertyDefinition[key.name='request'] > ArrowFunctionExpression", From 8529de9d17e6b0fc456b5c34b64436226f9966ff Mon Sep 17 00:00:00 2001 From: Francesco Novy Date: Mon, 21 Sep 2026 09:49:13 +0200 Subject: [PATCH 3/8] fix(hono): classify matched handlers with isMiddleware before wrapping MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The auto-instrumentation injection loop used a raw arity check to decide which matched handlers to wrap as middleware spans. Use the shared isMiddleware helper instead, which unwraps onError-composed sub-app handlers before checking arity — a case the inline check missed. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../server-utils/src/integrations/hono/honoIntegration.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/packages/server-utils/src/integrations/hono/honoIntegration.ts b/packages/server-utils/src/integrations/hono/honoIntegration.ts index 9b77c8e30165..36cca6959f42 100644 --- a/packages/server-utils/src/integrations/hono/honoIntegration.ts +++ b/packages/server-utils/src/integrations/hono/honoIntegration.ts @@ -12,6 +12,7 @@ import { invokeOrchestrionInstrumentation } from '../../orchestrion/instrumentat import { bindTracingChannelToSpan, safeChannelCallback } from '../../tracing-channel'; import { applyPatches } from './applyPatches'; import { createHonoRequestMiddleware } from './createHonoMiddleware'; +import { isMiddleware } from './isMiddleware'; import { extractPathname, isInternalRequestSpanActive } from './patchAppRequest'; import { wrapMiddlewareWithSpan } from './wrapMiddlewareSpan'; import type { SentryHonoMiddlewareOptions } from './types'; @@ -130,12 +131,13 @@ function injectHonoInstrumentation( } _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. + // Wrap matched middleware handlers. `isMiddleware` also unwraps `onError`-composed sub-app handlers + // before checking, which a raw arity check would miss. `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) { + if (isMiddleware(handler)) { pair[0] = wrapMiddlewareWithSpan(handler as MiddlewareHandler); } } From 8941894115629aaf34ce7d7dedb346686dbb75d5 Mon Sep 17 00:00:00 2001 From: Francesco Novy Date: Mon, 21 Sep 2026 09:53:51 +0200 Subject: [PATCH 4/8] fix(hono): classify matched handlers by position, not arity alone Arity alone cannot tell a middleware from a route handler declared with an unused `next` param. The matched entries carry their registration routeMeta, so apply the same positional heuristic as wrapSubAppMiddleware: within a method+path group the last handler is the route handler and earlier ones are middleware; `.use()` (method 'ALL') falls back to arity. Adds node-integration coverage for the arity-2 route handler case. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../suites/hono/scenario.mjs | 5 +++ .../suites/hono/test.ts | 20 +++++++++++ .../src/integrations/hono/honoIntegration.ts | 36 ++++++++++++++++--- 3 files changed, 56 insertions(+), 5 deletions(-) diff --git a/dev-packages/node-integration-tests/suites/hono/scenario.mjs b/dev-packages/node-integration-tests/suites/hono/scenario.mjs index 48a484c16179..f678c0e0d793 100644 --- a/dev-packages/node-integration-tests/suites/hono/scenario.mjs +++ b/dev-packages/node-integration-tests/suites/hono/scenario.mjs @@ -19,6 +19,11 @@ app.get('/error/:param', () => { throw new Error('Test error from Hono app'); }); +// A route handler declared with an unused `next` param (arity 2). It is the last handler in its +// method+path group, so it must be classified as the route handler — not wrapped as a middleware +// span — even though arity alone would misclassify it. +app.get('/arity-two-handler', (c, _next) => c.text('handler with two params')); + // 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. diff --git a/dev-packages/node-integration-tests/suites/hono/test.ts b/dev-packages/node-integration-tests/suites/hono/test.ts index cf4f8eaa1eca..78b5606fbae4 100644 --- a/dev-packages/node-integration-tests/suites/hono/test.ts +++ b/dev-packages/node-integration-tests/suites/hono/test.ts @@ -101,6 +101,26 @@ describe('hono auto-instrumentation', () => { await runner.completed(); }); + test('does not wrap a route handler declared with an unused next param as middleware', async () => { + const runner = createRunner() + .unordered() + .expect({ + span: container => { + const segment = container.items.find(item => item.is_segment && item.name === 'GET /arity-two-handler'); + if (!segment) { + throw new Error('segment for `GET /arity-two-handler` not in this container'); + } + // The arity-2 handler is the route handler (last in its method+path group), so it must not + // produce a middleware span. + const middlewareSpans = container.items.filter(item => op(item) === 'middleware'); + expect(middlewareSpans).toHaveLength(0); + }, + }) + .start(); + runner.makeRequest('get', '/arity-two-handler'); + await runner.completed(); + }); + test('does not create a middleware span for the Sentry middleware in a mounted sub-app', async () => { const runner = createRunner() .unordered() diff --git a/packages/server-utils/src/integrations/hono/honoIntegration.ts b/packages/server-utils/src/integrations/hono/honoIntegration.ts index 36cca6959f42..ff133d3be920 100644 --- a/packages/server-utils/src/integrations/hono/honoIntegration.ts +++ b/packages/server-utils/src/integrations/hono/honoIntegration.ts @@ -131,13 +131,39 @@ function injectHonoInstrumentation( } _injectedHandlerLists.add(handlers); - // Wrap matched middleware handlers. `isMiddleware` also unwraps `onError`-composed sub-app handlers - // before checking, which a raw arity check would miss. `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) { + // Wrap the matched middleware handlers for spans, leaving the route handler alone. Each entry + // carries its registration `routeMeta` (`entry[0][1]`: `{ method, path, … }`), so we use the same + // positional heuristic as `wrapSubAppMiddleware`: within a method+path group the LAST matched + // handler is the route handler and earlier ones are middleware (`app.get(path, mw, handler)`); + // `.use()` registers as method 'ALL' where the sole entry is genuinely middleware, so fall back to + // arity (via `isMiddleware`, which also unwraps `onError`-composed handlers) there. Position matters + // because arity alone would misclassify a route handler declared with an unused `next` param. + // `wrapMiddlewareWithSpan` is idempotent and skips Sentry's own middleware, so this is safe even + // when a handler is shared across routes. + const lastIndexByKey = new Map(); + for (const [i, entry] of handlers.entries()) { + const routeMeta = entry?.[0]?.[1] as { method?: string; path?: string } | undefined; + if (routeMeta?.method != null && routeMeta.path != null) { + // \0 is a collision-free delimiter: it cannot appear in a valid HTTP method or URL path. + lastIndexByKey.set(`${routeMeta.method}\0${routeMeta.path}`, i); + } + } + + for (const [i, entry] of handlers.entries()) { const pair = entry?.[0]; const handler = pair?.[0]; - if (isMiddleware(handler)) { + if (typeof handler !== 'function') { + continue; + } + + const routeMeta = pair?.[1] as { method?: string; path?: string } | undefined; + const isMW = + routeMeta?.method != null && routeMeta.path != null + ? lastIndexByKey.get(`${routeMeta.method}\0${routeMeta.path}`) !== i || + (routeMeta.method === 'ALL' && isMiddleware(handler)) + : isMiddleware(handler); + + if (isMW) { pair[0] = wrapMiddlewareWithSpan(handler as MiddlewareHandler); } } From 1465586a1a1fb5174b47786b4735a8fd823e0e87 Mon Sep 17 00:00:00 2001 From: Francesco Novy Date: Mon, 21 Sep 2026 14:15:02 +0200 Subject: [PATCH 5/8] test(hono): cover deleted unit-test behavior in node-integration tests Restores coverage lost when the shared Hono unit tests were removed during the move to @sentry/server-utils. The observable route-name and middleware span-status behaviors move into the node-integration `hono` suite; the route-hook's non-invasive prototype patching (invisible to spans/events) is restored as a focused unit test. - resolveRouteName: overlapping catch-all handler and middleware-only path fallback - wrapMiddlewareWithSpan: 3xx/4xx middleware errors do not set an error span status, 5xx does - installRouteHookOnPrototype: preserves name/length/symbols/prototype of the original route method Co-Authored-By: Claude Opus 4.8 (1M context) --- .../suites/hono/scenario.mjs | 19 ++++ .../suites/hono/test.ts | 93 +++++++++++++++++++ .../hono/test/shared/earlyPatchRoute.test.ts | 54 +++++++++++ 3 files changed, 166 insertions(+) diff --git a/dev-packages/node-integration-tests/suites/hono/scenario.mjs b/dev-packages/node-integration-tests/suites/hono/scenario.mjs index f678c0e0d793..b5fe8fbef8d2 100644 --- a/dev-packages/node-integration-tests/suites/hono/scenario.mjs +++ b/dev-packages/node-integration-tests/suites/hono/scenario.mjs @@ -67,6 +67,25 @@ app.get('/outer-error/:itemId', async c => { throw new Error('Test error from outer Hono app after internal request'); }); +// Overlapping handlers: a parameterized handler and a catch-all handler both match `/overlap/:id`. +// The parameterized handler is registered first and responds, so `routeIndex` lands on it and the +// transaction must resolve to `/overlap/:id` — not the catch-all `/*`. +app.get('/overlap/:id', c => c.json({ id: c.req.param('id') })); +app.get('/overlap/*', c => c.text('catch-all handler')); + +// Middleware-only match: a scoped middleware short-circuits with its own response and no route +// handler matches. The transaction name must fall back to the matched middleware path `/mw-only/*`. +app.use('/mw-only/*', async (_c, _next) => new Response('handled by middleware')); + +// Middleware span status by thrown error (see `wrapMiddlewareWithSpan` + `defaultShouldHandleError`): +// only 5xx and status-less errors set the middleware span to `error` — 3xx and 4xx do not, even +// though they still fail the request. +app.use('/mw-throw/:code', async function throwingMiddleware(c, _next) { + const status = Number(c.req.param('code')); + throw Object.assign(new Error(`middleware failed (${status})`), { status }); +}); +app.get('/mw-throw/:code', c => c.text('never reached')); + 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 index 78b5606fbae4..a30cfaefa459 100644 --- a/dev-packages/node-integration-tests/suites/hono/test.ts +++ b/dev-packages/node-integration-tests/suites/hono/test.ts @@ -203,5 +203,98 @@ describe('hono auto-instrumentation', () => { runner.makeRequest('get', '/outer-error/self-watering-plant', { expectError: true }); await runner.completed(); }); + + test('resolves overlapping handlers to the responding route, not the catch-all', async () => { + const runner = createRunner() + .unordered() + .expect({ + span: container => { + const segment = container.items.find(item => item.is_segment && item.name === 'GET /overlap/:id'); + if (!segment) { + throw new Error('segment for `GET /overlap/:id` not in this container'); + } + // The catch-all `GET /overlap/*` handler also matched, but the parameterized handler + // responded — the route name must not collapse to `/overlap/*`. + expect(attr(segment, 'sentry.segment.name.source')).toBe('route'); + }, + }) + .start(); + runner.makeRequest('get', '/overlap/123'); + await runner.completed(); + }); + + test('falls back to the matched middleware path when only middleware matched', async () => { + const runner = createRunner() + .unordered() + .expect({ + span: container => { + const segment = container.items.find(item => item.is_segment && item.name === 'GET /mw-only/*'); + if (!segment) { + throw new Error('segment for `GET /mw-only/*` not in this container'); + } + expect(op(segment)).toBe('http.server'); + }, + }) + .start(); + runner.makeRequest('get', '/mw-only/anything'); + await runner.completed(); + }); + + test('does not set the middleware span to error for a 3xx middleware error', async () => { + const runner = createRunner() + .unordered() + .expect({ + span: container => { + const middlewareSpan = container.items.find( + item => op(item) === 'middleware' && item.name === 'throwingMiddleware', + ); + if (!middlewareSpan) { + throw new Error('middleware span `throwingMiddleware` not in this container'); + } + expect(middlewareSpan.status).not.toBe('error'); + }, + }) + .start(); + runner.makeRequest('get', '/mw-throw/301', { expectError: true }); + await runner.completed(); + }); + + test('does not set the middleware span to error for a 4xx middleware error', async () => { + const runner = createRunner() + .unordered() + .expect({ + span: container => { + const middlewareSpan = container.items.find( + item => op(item) === 'middleware' && item.name === 'throwingMiddleware', + ); + if (!middlewareSpan) { + throw new Error('middleware span `throwingMiddleware` not in this container'); + } + expect(middlewareSpan.status).not.toBe('error'); + }, + }) + .start(); + runner.makeRequest('get', '/mw-throw/404', { expectError: true }); + await runner.completed(); + }); + + test('sets the middleware span to error for a 5xx middleware error', async () => { + const runner = createRunner() + .unordered() + .expect({ + span: container => { + const middlewareSpan = container.items.find( + item => op(item) === 'middleware' && item.name === 'throwingMiddleware', + ); + if (!middlewareSpan) { + throw new Error('middleware span `throwingMiddleware` not in this container'); + } + expect(middlewareSpan.status).toBe('error'); + }, + }) + .start(); + runner.makeRequest('get', '/mw-throw/503', { expectError: true }); + await runner.completed(); + }); }); }); diff --git a/packages/hono/test/shared/earlyPatchRoute.test.ts b/packages/hono/test/shared/earlyPatchRoute.test.ts index 6cc210a7d6b7..e5c07c844bc8 100644 --- a/packages/hono/test/shared/earlyPatchRoute.test.ts +++ b/packages/hono/test/shared/earlyPatchRoute.test.ts @@ -72,3 +72,57 @@ describe('earlyPatchHono (two-phase prototype hook)', () => { expect(await res.text()).toBe('world'); }); }); + +// The prototype `route` hook must patch `HonoBase.prototype.route` without stripping metadata another +// library (e.g. an OpenAPI router) may have attached to it. This behaviour is not observable through +// spans/events, so it lives here rather than in the node-integration tests. +describe('route hook non-invasive patching', () => { + const ROUTER_META = Symbol('router-meta'); + + afterAll(() => { + honoBaseProto.route = originalRoute; + Reflect.deleteProperty(originalRoute, ROUTER_META); + Reflect.deleteProperty(originalRoute, 'pluginId'); + }); + + // Re-install the hook over a pristine `route` (carrying any pre-set metadata) via the public entry. + function reinstallOverPristineRoute(): void { + honoBaseProto.route = originalRoute; + applyPatches(new Hono()); + } + + it('preserves function name and length of the original route method', () => { + reinstallOverPristineRoute(); + + const patched = honoBaseProto.route as (...args: unknown[]) => unknown; + expect(patched.name).toBe(originalRoute.name); + expect(patched.length).toBe(originalRoute.length); + }); + + it('preserves symbol- and string-keyed properties on the route method', () => { + (originalRoute as unknown as Record)[ROUTER_META] = { version: 3 }; + (originalRoute as unknown as Record).pluginId = 'openapi-router'; + + reinstallOverPristineRoute(); + + expect(Object.getOwnPropertySymbols(honoBaseProto.route)).toContain(ROUTER_META); + expect((honoBaseProto.route as unknown as Record)[ROUTER_META]).toEqual({ version: 3 }); + expect((honoBaseProto.route as unknown as Record).pluginId).toBe('openapi-router'); + }); + + it('preserves the prototype chain of the original route method', () => { + reinstallOverPristineRoute(); + + expect(Object.getPrototypeOf(honoBaseProto.route)).toBe(Object.getPrototypeOf(originalRoute)); + }); + + it('still mounts sub-apps and returns the parent app', () => { + reinstallOverPristineRoute(); + + const parent = new Hono(); + const subApp = new Hono(); + subApp.get('/test', c => c.text('ok')); + + expect(parent.route('/api', subApp)).toBe(parent); + }); +}); From 14c7d0d116abb7c69f0fcab3c1bd865ce785c6bb Mon Sep 17 00:00:00 2001 From: Francesco Novy Date: Mon, 21 Sep 2026 14:52:20 +0200 Subject: [PATCH 6/8] fix(hono): add captureContextError to the middlewareHandlers test mock The dedup branch of createHonoRequestMiddleware calls captureContextError, but the mock only stubbed requestHandler/responseHandler, so the deduplication tests threw "No captureContextError export is defined on the mock". Co-Authored-By: Claude Opus 4.8 (1M context) --- .../test/integrations/hono/createHonoMiddleware.test.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/server-utils/test/integrations/hono/createHonoMiddleware.test.ts b/packages/server-utils/test/integrations/hono/createHonoMiddleware.test.ts index cf2062390fe9..d390561fecb9 100644 --- a/packages/server-utils/test/integrations/hono/createHonoMiddleware.test.ts +++ b/packages/server-utils/test/integrations/hono/createHonoMiddleware.test.ts @@ -3,9 +3,11 @@ 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(); +const captureContextError = vi.fn(); vi.mock('../../../src/integrations/hono/middlewareHandlers', () => ({ requestHandler: (...args: unknown[]) => requestHandler(...args), responseHandler: (...args: unknown[]) => responseHandler(...args), + captureContextError: (...args: unknown[]) => captureContextError(...args), })); // eslint-disable-next-line import/first @@ -19,6 +21,7 @@ describe('createHonoRequestMiddleware — duplicate registration handling', () = beforeEach(() => { requestHandler.mockClear(); responseHandler.mockClear(); + captureContextError.mockClear(); }); it('runs request/response handling exactly once when two Sentry middlewares wrap the same request', async () => { From 4862fd6f73c9d53b6308ea289e4251fad88358dd Mon Sep 17 00:00:00 2001 From: Francesco Novy Date: Mon, 21 Sep 2026 14:52:25 +0200 Subject: [PATCH 7/8] docs(hono): tighten honoIntegration comments Trim the comments in honoIntegration to the non-obvious reasoning (Hono's internal matchResult shape, the cached-array injection guard, the synchronous Context-constructor timing, the compose fast-path) and drop the narration of what the code already shows. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/integrations/hono/honoIntegration.ts | 111 +++++++----------- 1 file changed, 43 insertions(+), 68 deletions(-) diff --git a/packages/server-utils/src/integrations/hono/honoIntegration.ts b/packages/server-utils/src/integrations/hono/honoIntegration.ts index ff133d3be920..04b0ce7ea5fd 100644 --- a/packages/server-utils/src/integrations/hono/honoIntegration.ts +++ b/packages/server-utils/src/integrations/hono/honoIntegration.ts @@ -17,8 +17,8 @@ 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. +// Matches the `@sentry/hono` SDK's integration name, so the SDK filters its own out of the +// forwarded defaults and the two never stack. const INTEGRATION_NAME = 'Hono' as const; const INTERNAL_REQUEST_ORIGIN = 'auto.http.hono.internal_request'; @@ -34,13 +34,11 @@ let connInfoResolved = false; let cachedGetConnInfo: GetConnInfo | undefined; /** - * Resolve the runtime-specific `getConnInfo` helper, once, best-effort. + * Resolves the runtime's `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. + * Cloudflare Workers can't `require()` out of a bundle at runtime, so there conn-info is left to the + * platform's `requestDataIntegration`. On Node/Bun/Deno a missing optional peer dependency degrades + * to `undefined`, which just skips the connection-info attributes. */ function resolveGetConnInfo(): GetConnInfo | undefined { if (connInfoResolved) { @@ -55,8 +53,8 @@ function resolveGetConnInfo(): GetConnInfo | 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. + // `createRequire` resolves relative to its argument's directory, so a never-loaded dummy path + // roots resolution at the app directory, where the runtime helper is installed. const appRequire = createRequire(`${process.cwd()}/noop.js`); cachedGetConnInfo = (appRequire(specifier) as { getConnInfo?: GetConnInfo }).getConnInfo; } catch { @@ -68,18 +66,13 @@ function resolveGetConnInfo(): GetConnInfo | undefined { } /** - * 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. + * Manually instruments a Hono app and returns the Sentry request/response middleware to register + * first: `app.use(honoMiddleware(app))`. * - * 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. + * Only needed when neither the Sentry runtime hook nor the bundler plugin is active — otherwise + * {@link honoIntegration} does this automatically. Safe to combine with the automatic instrumentation + * (request handling is deduplicated per request, `applyPatches` is idempotent). `Sentry.init(...)` + * must still be called separately. */ export function honoMiddleware(app: Hono, options: HonoIntegrationOptions = {}): MiddlewareHandler { applyPatches(app); @@ -90,34 +83,28 @@ export function honoMiddleware(app: Hono, options: HonoIntegra }); } -// 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]`. +// A Hono `matchResult[0]` entry: `[[handler, routeMeta], paramIndexMap]` — `compose` runs the +// handler (`entry[0][0]`) and the `matchedRoutes` getter reads `routeMeta` (`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. +// `router.match` may hand back a cached handler array for a route, so track the lists we've already +// prepended into and never inject the Sentry middleware twice. 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. +// The request/response middleware is stateless (per-request state lives on the request scope) and +// `options` is fixed, so build it once and reuse it across every dispatched Context. let cachedRequestMiddleware: MiddlewareHandler | undefined; /** - * Per-request Context hook: the heart of the automatic instrumentation. + * Per-request Context hook. `#dispatch` builds `new Context(req, { matchResult })` before its + * single-handler fast-path check, so from the live `matchResult` we: + * 1. wrap the matched middleware handlers for spans (route handlers are covered by the request span); + * 2. prepend the Sentry request/response middleware — it drives route naming, request data and error + * capture from inside the chain, and forces the ≥2-handler `compose` path so the fast-path is + * never taken. * - * `#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. + * Running per request (no module-scope state) is what lets this work on Cloudflare. */ function injectHonoInstrumentation( // oxlint-disable-next-line typescript/no-explicit-any @@ -131,15 +118,10 @@ function injectHonoInstrumentation( } _injectedHandlerLists.add(handlers); - // Wrap the matched middleware handlers for spans, leaving the route handler alone. Each entry - // carries its registration `routeMeta` (`entry[0][1]`: `{ method, path, … }`), so we use the same - // positional heuristic as `wrapSubAppMiddleware`: within a method+path group the LAST matched - // handler is the route handler and earlier ones are middleware (`app.get(path, mw, handler)`); - // `.use()` registers as method 'ALL' where the sole entry is genuinely middleware, so fall back to - // arity (via `isMiddleware`, which also unwraps `onError`-composed handlers) there. Position matters - // because arity alone would misclassify a route handler declared with an unused `next` param. - // `wrapMiddlewareWithSpan` is idempotent and skips Sentry's own middleware, so this is safe even - // when a handler is shared across routes. + // Classify matched handlers with the same positional heuristic as `wrapSubAppMiddleware`: within a + // method+path group the last handler is the route handler and earlier ones are middleware; `.use()` + // registers as method 'ALL' with a lone genuine-middleware entry, so fall back to arity there. + // Position is needed because a route handler declared with an unused `next` param has middleware arity. const lastIndexByKey = new Map(); for (const [i, entry] of handlers.entries()) { const routeMeta = entry?.[0]?.[1] as { method?: string; path?: string } | undefined; @@ -169,7 +151,7 @@ function injectHonoInstrumentation( } // Prepend the Sentry request/response middleware. `routeMeta` is what the `matchedRoutes` getter - // reads; a middleware-arity handler means route-name resolution skips it. + // exposes; its middleware arity keeps route-name resolution from picking it. const middleware = (cachedRequestMiddleware ??= createHonoRequestMiddleware({ getConnInfo: resolveGetConnInfo(), shouldHandleError: options.shouldHandleError, @@ -187,9 +169,8 @@ function instrumentInternalRequests(): void { // 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. + // Alongside the manual middleware, the instance `app.request` Proxy has already opened this + // span and is calling through — returning `undefined` (no span) avoids nesting a duplicate. if (isInternalRequestSpanActive()) { return undefined; } @@ -211,9 +192,8 @@ function instrumentInternalRequests(): void { } 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. + // The Context constructor's `end` fires synchronously inside `new Context()`, before `#dispatch` + // reads `matchResult[0].length`, so the injected middleware is in place for the same request. diagnosticsChannel // oxlint-disable-next-line typescript/no-explicit-any .tracingChannel<{ arguments: any[] }>(CHANNELS.HONO_CONTEXT) @@ -235,19 +215,14 @@ const _honoIntegration = ((options: HonoIntegrationOptions = {}) => { }) 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. + * Automatically instruments Hono applications for Sentry tracing. * - * Because everything happens per request (never at module scope), it works on Cloudflare Workers - * out of the box, with no manual middleware registration. + * Hooks Hono's per-request internals (the `Context` constructor and `app.request`) via the + * orchestrion diagnostics channel — so instrumentation happens per request, never at module scope, + * which is what lets it work on Cloudflare Workers with no manual middleware. Enabled by default in + * the Node, Bun, Deno and Cloudflare SDKs; requires the Sentry runtime hook or bundler plugin. * - * Registering the `sentry()` middleware from `@sentry/hono` manually alongside this is safe — the - * request handling is deduplicated per request, so it runs exactly once. + * Registering `@sentry/hono`'s `sentry()` middleware manually alongside it is safe — request handling + * is deduplicated per request. */ export const honoIntegration = defineIntegration(_honoIntegration); From d7880fd9ee6a3fb957372c21ffc7ce9d3c814b86 Mon Sep 17 00:00:00 2001 From: Francesco Novy Date: Mon, 21 Sep 2026 15:04:06 +0200 Subject: [PATCH 8/8] chore: bump @sentry/node size limit to 136 KB The Hono auto-instrumentation added to the Node SDK defaults pushes the ESM bundle to 135.3 KB, just over the 135 KB limit. Co-Authored-By: Claude Opus 4.8 (1M context) --- .size-limit.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.size-limit.js b/.size-limit.js index 06e1f6e30b8a..7086250ae5e8 100644 --- a/.size-limit.js +++ b/.size-limit.js @@ -406,7 +406,7 @@ module.exports = [ import: createImport('init'), ignore: [...builtinModules, ...nodePrefixedBuiltinModules], gzip: true, - limit: '135 KB', + limit: '136 KB', disablePlugins: ['@size-limit/esbuild'], }, {