From 1e729ec2d9cdfb2898a78f5ff06b0a5eb4be3b2a Mon Sep 17 00:00:00 2001 From: Francesco Novy Date: Fri, 18 Sep 2026 12:29:30 +0200 Subject: [PATCH] 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 66faa541489e..51f21b3dedbb 100644 --- a/packages/astro/src/index.server.ts +++ b/packages/astro/src/index.server.ts @@ -72,6 +72,8 @@ export { isInitialized, isEnabled, kafkaIntegration, + honoIntegration, + honoMiddleware, koaIntegration, knexIntegration, lastEventId, diff --git a/packages/aws-serverless/src/index.ts b/packages/aws-serverless/src/index.ts index d231a660a9f9..621cdfd8e72f 100644 --- a/packages/aws-serverless/src/index.ts +++ b/packages/aws-serverless/src/index.ts @@ -101,6 +101,8 @@ export { expressErrorHandler, // oxlint-disable-next-line typescript/no-deprecated setupExpressErrorHandler, + honoIntegration, + honoMiddleware, koaIntegration, // oxlint-disable-next-line typescript/no-deprecated setupKoaErrorHandler, diff --git a/packages/bun/src/index.ts b/packages/bun/src/index.ts index cda29a4beeb8..1bee175623c4 100644 --- a/packages/bun/src/index.ts +++ b/packages/bun/src/index.ts @@ -126,6 +126,8 @@ export { // oxlint-disable-next-line typescript/no-deprecated setupFastifyErrorHandler, firebaseIntegration, + honoIntegration, + honoMiddleware, koaIntegration, // oxlint-disable-next-line typescript/no-deprecated setupKoaErrorHandler, diff --git a/packages/cloudflare/src/index.ts b/packages/cloudflare/src/index.ts index 16ec6d6d9e24..e8d30155a046 100644 --- a/packages/cloudflare/src/index.ts +++ b/packages/cloudflare/src/index.ts @@ -136,6 +136,8 @@ export { instrumentStateGraph, instrumentCreateReactAgent, vercelAIIntegration, + honoIntegration, + honoMiddleware, eveConversationHook, getInstrumentedModuleNames, } from '@sentry/server-utils'; diff --git a/packages/deno/src/index.ts b/packages/deno/src/index.ts index 6b22ee9ee63a..118046f23c55 100644 --- a/packages/deno/src/index.ts +++ b/packages/deno/src/index.ts @@ -131,6 +131,8 @@ export { googleGenAIIntegration, graphqlIntegration, hapiIntegration, + honoIntegration, + honoMiddleware, kafkaIntegration, knexIntegration, koaIntegration, diff --git a/packages/deno/test/__snapshots__/mod.test.ts.snap b/packages/deno/test/__snapshots__/mod.test.ts.snap index d74fe9c036e2..3500fc9330c7 100644 --- a/packages/deno/test/__snapshots__/mod.test.ts.snap +++ b/packages/deno/test/__snapshots__/mod.test.ts.snap @@ -65,6 +65,7 @@ snapshot[`captureMessage 1`] = ` "Express", "Fastify", "Hapi", + "Hono", "Koa", ], name: "sentry.javascript.deno", @@ -153,6 +154,7 @@ snapshot[`captureMessage twice 1`] = ` "Express", "Fastify", "Hapi", + "Hono", "Koa", ], name: "sentry.javascript.deno", @@ -248,6 +250,7 @@ snapshot[`captureMessage twice 2`] = ` "Express", "Fastify", "Hapi", + "Hono", "Koa", ], name: "sentry.javascript.deno", diff --git a/packages/elysia/src/index.ts b/packages/elysia/src/index.ts index 28e6fc1ee2f3..f052a3d9196a 100644 --- a/packages/elysia/src/index.ts +++ b/packages/elysia/src/index.ts @@ -105,6 +105,7 @@ export { // oxlint-disable-next-line typescript/no-deprecated setupFastifyErrorHandler, firebaseIntegration, + honoIntegration, koaIntegration, // oxlint-disable-next-line typescript/no-deprecated setupKoaErrorHandler, diff --git a/packages/google-cloud-serverless/src/index.ts b/packages/google-cloud-serverless/src/index.ts index 9f36e06e9ed8..eb0f08a5be21 100644 --- a/packages/google-cloud-serverless/src/index.ts +++ b/packages/google-cloud-serverless/src/index.ts @@ -102,6 +102,8 @@ export { expressErrorHandler, // oxlint-disable-next-line typescript/no-deprecated setupExpressErrorHandler, + honoIntegration, + honoMiddleware, koaIntegration, // oxlint-disable-next-line typescript/no-deprecated setupKoaErrorHandler, diff --git a/packages/node/src/index.ts b/packages/node/src/index.ts index 3754f8408f86..ddaaa8922715 100644 --- a/packages/node/src/index.ts +++ b/packages/node/src/index.ts @@ -14,6 +14,8 @@ export { googleGenAIIntegration, graphqlIntegration, hapiIntegration, + honoIntegration, + honoMiddleware, kafkaIntegration, knexIntegration, koaIntegration, diff --git a/packages/remix/src/server/index.ts b/packages/remix/src/server/index.ts index f89345ae2de5..2f3397fad8cc 100644 --- a/packages/remix/src/server/index.ts +++ b/packages/remix/src/server/index.ts @@ -59,6 +59,7 @@ export { isEnabled, knexIntegration, kafkaIntegration, + honoIntegration, koaIntegration, lastEventId, linkedErrorsIntegration, diff --git a/packages/server-runtime-injection/package.json b/packages/server-runtime-injection/package.json index d78b70484685..65649b3f08a7 100644 --- a/packages/server-runtime-injection/package.json +++ b/packages/server-runtime-injection/package.json @@ -50,7 +50,7 @@ }, "devDependencies": { "@apm-js-collab/code-transformer": "^0.18.1", - "@apm-js-collab/tracing-hooks": "^0.13.0", + "@apm-js-collab/tracing-hooks": "^0.13.1", "@types/node": "^18.19.1", "@vercel/nft": "^1.3.0", "meriyah": "^6.1.4" diff --git a/packages/server-utils/src/index.ts b/packages/server-utils/src/index.ts index 667c8efc12cd..9ad8358b2c23 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'; // Shared, runtime-agnostic Hono instrumentation re-used by the `@sentry/hono` SDK. export { applyHonoPatches, earlyPatchHono, createHonoRequestMiddleware } from './integrations/hono'; export type { CreateHonoRequestMiddlewareOptions, SentryHonoMiddlewareOptions } from './integrations/hono'; 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 7f71e2bada2e..3ed2d77e9add 100644 --- a/packages/server-utils/src/integrations/hono/index.ts +++ b/packages/server-utils/src/integrations/hono/index.ts @@ -1,6 +1,15 @@ import { applyPatches } from './applyPatches'; import type { Hono } from './honoTypes'; +// The auto-instrumentation integration (uses `node:diagnostics_channel`). +export { honoIntegration } from './honoIntegration'; +export type { HonoIntegrationOptions } from './honoIntegration'; + +// Manual counterpart of the auto-instrumentation, for setups where 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` (at runtime or type // level), so they stay safe to load in every server SDK — including apps that do not use Hono. diff --git a/packages/server-utils/src/integrations/index.ts b/packages/server-utils/src/integrations/index.ts index 6e319bfc5e2b..dca41c7fb520 100644 --- a/packages/server-utils/src/integrations/index.ts +++ b/packages/server-utils/src/integrations/index.ts @@ -26,6 +26,7 @@ import { firebaseIntegration } from './firebase'; import { expressIntegration } from './express'; import { fastifyIntegration } from './fastify'; import { hapiIntegration } from './hapi'; +import { honoIntegration } from './hono'; import { koaIntegration } from './koa'; import type { Integration } from '@sentry/core'; import { awsIntegration } from './aws-sdk'; @@ -66,5 +67,5 @@ export function getTracingIntegrations(): Integration[] { /** These are integrations that cover error capture, in addition to tracing. */ export function getErrorIntegrations(): Integration[] { - return [expressIntegration(), fastifyIntegration(), hapiIntegration(), koaIntegration()]; + return [expressIntegration(), fastifyIntegration(), hapiIntegration(), honoIntegration(), koaIntegration()]; } diff --git a/packages/server-utils/src/orchestrion/bundler/bun.ts b/packages/server-utils/src/orchestrion/bundler/bun.ts index e61814beed39..f8f154028e13 100644 --- a/packages/server-utils/src/orchestrion/bundler/bun.ts +++ b/packages/server-utils/src/orchestrion/bundler/bun.ts @@ -39,6 +39,7 @@ export function sentryOrchestrionPlugin(options: PluginOptions = {}): UnknownPlu // Route through the shared assembly point so any future option reaches Bun too, but opt out of // the transformer's own `injectDiagnostics` — Bun injects the marker banner via its native // `banner` config below (which, unlike the upstream path, needs no `outdir`). + // // Typed upstream as an esbuild `Plugin`, but Bun passes its own `PluginBuilder` (which has the // `onLoad` the transform uses) to `setup`. Cast to the Bun-compatible shape so we can forward // Bun's builder to its `setup`. diff --git a/packages/server-utils/src/orchestrion/channels.ts b/packages/server-utils/src/orchestrion/channels.ts index 65ab3e56899e..7d128f69ef31 100644 --- a/packages/server-utils/src/orchestrion/channels.ts +++ b/packages/server-utils/src/orchestrion/channels.ts @@ -8,6 +8,7 @@ import { genericPoolChannels } from './config/generic-pool'; import { googleGenAiChannels } from './config/google-genai'; import { graphqlChannels } from './config/graphql'; import { hapiChannels } from './config/hapi'; +import { honoChannels } from './config/hono'; import { ioredisChannels } from './config/ioredis'; import { kafkajsChannels } from './config/kafkajs'; import { knexChannels } from './config/knex'; @@ -57,6 +58,7 @@ export const CHANNELS = { ...googleGenAiChannels, ...graphqlChannels, ...hapiChannels, + ...honoChannels, ...ioredisChannels, ...kafkajsChannels, ...knexChannels, diff --git a/packages/server-utils/src/orchestrion/config/channel-integration-definitions.ts b/packages/server-utils/src/orchestrion/config/channel-integration-definitions.ts index 73d7c164cfe7..71c11dce0b86 100644 --- a/packages/server-utils/src/orchestrion/config/channel-integration-definitions.ts +++ b/packages/server-utils/src/orchestrion/config/channel-integration-definitions.ts @@ -47,6 +47,7 @@ export const CHANNEL_INTEGRATION_DEFINITIONS = [ { exportName: 'firebaseIntegration', modules: ['@firebase/firestore', 'firebase-functions'] }, { exportName: 'amqplibIntegration', modules: ['amqplib'] }, { exportName: 'hapiIntegration', modules: ['@hapi/hapi'] }, + { exportName: 'honoIntegration', modules: ['hono'] }, { exportName: 'koaIntegration', modules: ['koa'] }, { exportName: 'expressIntegration', modules: ['express', 'router'] }, { exportName: 'graphqlIntegration', modules: ['graphql'] }, diff --git a/packages/server-utils/src/orchestrion/config/hono.ts b/packages/server-utils/src/orchestrion/config/hono.ts new file mode 100644 index 000000000000..f9923e011628 --- /dev/null +++ b/packages/server-utils/src/orchestrion/config/hono.ts @@ -0,0 +1,40 @@ +import type { InstrumentationConfig } from '../apmTypes'; +import { getModuleNames } from './module-names'; + +// Hono is instrumented through its PER-REQUEST internals rather than at app construction or route +// registration. Two functions are wrapped, both of which only ever run while a request is being +// handled — never at module scope. That is what makes this work on Cloudflare Workers (workerd +// forbids `diagnostics_channel` publish/`runStores` at module scope) with no registration-time +// patching, while behaving identically under the Node/Deno runtime hook and the bundler plugins. +// The subscribers live in `honoIntegration`. +const honoInstrumentationConfig: InstrumentationConfig[] = [ + { + // `new Context(req, { matchResult })` is created once per dispatched request, inside `#dispatch` + // and BEFORE Hono's single-handler fast-path check. The subscriber injects the Sentry + // request/response middleware into `matchResult[0]` (so it runs first, in the composed chain — + // which also forces the ≥2-handler path, giving uniform route naming and error capture) and + // wraps the matched middleware handlers for spans. Copied sub-app handlers are already present in + // `matchResult`, so they are covered automatically. + channelName: 'context', + module: { name: 'hono', versionRange: '>=4.0.0 <5', filePath: /^dist\/(?:cjs\/)?context\.js$/ }, + functionQuery: { className: 'Context' }, + }, + { + // `app.request(...)` is Hono's internal dispatch entry (a class-field arrow, so it needs an + // `astQuery` rather than a `methodName`). Sub-app-to-sub-app internal fetches go through it; each + // gets an `http.server` child span (only when there is a parent span). + channelName: 'request', + module: { name: 'hono', versionRange: '>=4.0.0 <5', filePath: /^dist\/(?:cjs\/)?hono-base\.js$/ }, + astQuery: "PropertyDefinition[key.name='request'] > ArrowFunctionExpression", + functionQuery: { kind: 'Auto' }, + }, +]; + +export const honoConfig = honoInstrumentationConfig satisfies InstrumentationConfig[]; + +export const honoModuleNames = getModuleNames(honoConfig); + +export const honoChannels = { + HONO_CONTEXT: 'orchestrion:hono:context', + HONO_REQUEST: 'orchestrion:hono:request', +} as const; diff --git a/packages/server-utils/src/orchestrion/config/index.ts b/packages/server-utils/src/orchestrion/config/index.ts index 723c5857b8f1..1c78bb81f80c 100644 --- a/packages/server-utils/src/orchestrion/config/index.ts +++ b/packages/server-utils/src/orchestrion/config/index.ts @@ -12,6 +12,7 @@ import { genericPoolConfig } from './generic-pool'; import { googleGenAiConfig } from './google-genai'; import { graphqlConfig } from './graphql'; import { hapiConfig } from './hapi'; +import { honoConfig } from './hono'; import { ioredisConfig } from './ioredis'; import { kafkajsConfig } from './kafkajs'; import { knexConfig } from './knex'; @@ -60,6 +61,7 @@ export const SENTRY_INSTRUMENTATIONS: InstrumentationConfig[] = [ ...googleGenAiConfig, ...graphqlConfig, ...hapiConfig, + ...honoConfig, ...ioredisConfig, ...kafkajsConfig, ...knexConfig, diff --git a/packages/solidstart/src/server/index.ts b/packages/solidstart/src/server/index.ts index da4972e07590..c8ef72468c0d 100644 --- a/packages/solidstart/src/server/index.ts +++ b/packages/solidstart/src/server/index.ts @@ -62,6 +62,7 @@ export { isEnabled, knexIntegration, kafkaIntegration, + honoIntegration, koaIntegration, lastEventId, linkedErrorsIntegration, diff --git a/packages/sveltekit/src/server/index.ts b/packages/sveltekit/src/server/index.ts index b850e256ba38..92524abbf6a9 100644 --- a/packages/sveltekit/src/server/index.ts +++ b/packages/sveltekit/src/server/index.ts @@ -60,6 +60,7 @@ export { isEnabled, knexIntegration, kafkaIntegration, + honoIntegration, koaIntegration, lastEventId, linkedErrorsIntegration, diff --git a/yarn.lock b/yarn.lock index 2c1b930b29dd..421c833ec2d7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -426,10 +426,10 @@ semifies "^1.0.0" source-map "^0.6.0" -"@apm-js-collab/tracing-hooks@^0.13.0": - version "0.13.0" - resolved "https://registry.yarnpkg.com/@apm-js-collab/tracing-hooks/-/tracing-hooks-0.13.0.tgz#20b2f77ec7a0e5dfd9fbf2215b56e2c2b7f41e4b" - integrity sha512-mTvWz9rnQwx1U3h0XPTHaX7bgfkpipLLTQyjlC2cdhQpQEuoLT0AGzoydeoq2NxfEVv6fWOOETcSbb2nptleyw== +"@apm-js-collab/tracing-hooks@^0.13.1": + version "0.13.1" + resolved "https://sfw.security.sentry.io/npm/@apm-js-collab/tracing-hooks/-/tracing-hooks-0.13.1.tgz#e043a1b2d3c2571c6e5c6cb11a2e85a0cdda7025" + integrity sha512-1FFkZsodZvZI6OpNMnvNEO2zvXvPTNyaEu/vahjcjxEAneaKaAjTPujQ02YxCvCUqW56CplN/101ikoxBwn33g== dependencies: "@apm-js-collab/code-transformer" "^0.18.0" debug "^4.4.1"