diff --git a/dev-packages/e2e-tests/test-applications/hono-4-legacy/package.json b/dev-packages/e2e-tests/test-applications/hono-4-legacy/package.json index c707ee5f2608..4cca12406302 100644 --- a/dev-packages/e2e-tests/test-applications/hono-4-legacy/package.json +++ b/dev-packages/e2e-tests/test-applications/hono-4-legacy/package.json @@ -9,12 +9,14 @@ "dev:bun": "bun src/entry.bun.ts", "dev:deno": "deno run --allow-net --allow-env --allow-read src/entry.deno.ts", "build": "wrangler deploy --dry-run", - "test:build": "pnpm install && pnpm build", + "test:build": "pnpm install && pnpm typecheck && pnpm build", + "typecheck": "tsc --noEmit -p tsconfig.typecheck.json", "test:assert": "TEST_ENV=production playwright test" }, "dependencies": { "@sentry/bun": "latest || *", "@sentry/cloudflare": "latest || *", + "@sentry/core": "latest || *", "@sentry/deno": "latest || *", "@sentry/hono": "latest || *", "@sentry/node": "latest || *", diff --git a/dev-packages/e2e-tests/test-applications/hono-4-legacy/src/route-groups/test-route-patterns.ts b/dev-packages/e2e-tests/test-applications/hono-4-legacy/src/route-groups/test-route-patterns.ts index ac6fea1320cf..0cc0aa43ed23 100644 --- a/dev-packages/e2e-tests/test-applications/hono-4-legacy/src/route-groups/test-route-patterns.ts +++ b/dev-packages/e2e-tests/test-applications/hono-4-legacy/src/route-groups/test-route-patterns.ts @@ -1,3 +1,4 @@ +import { captureMessage } from '@sentry/core'; import { Hono } from 'hono'; const routePatterns = new Hono(); @@ -30,4 +31,24 @@ METHODS.forEach(method => { routePatterns.on(method, '/on', c => c.text(`${method} on response`)); }); +// The catch-all below also matches. An event from inside this handler must still get this route. +routePatterns.get('/capture/:id', c => { + captureMessage('Message from a route handler'); + return c.text('captured'); +}); +routePatterns.get('/capture/*', c => c.text('capture catch-all')); + +// Passes `draft` on to the catch-all below with `next()`. The catch-all responds, so it names the request. +routePatterns.get('/posts/:id', async (c, next) => { + if (c.req.param('id') === 'draft') { + await next(); + return; + } + return c.text('post'); +}); +routePatterns.get('/posts/*', c => { + captureMessage('Message from a route handler after next()'); + return c.text('fallback'); +}); + export { routePatterns }; diff --git a/dev-packages/e2e-tests/test-applications/hono-4-legacy/src/sentry.typecheck.ts b/dev-packages/e2e-tests/test-applications/hono-4-legacy/src/sentry.typecheck.ts new file mode 100644 index 000000000000..9df86b4b4b85 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/hono-4-legacy/src/sentry.typecheck.ts @@ -0,0 +1,19 @@ +// Only `tsc` in `test:build` reads this file. Nothing runs it. +import { sentry as sentryBun } from '@sentry/hono/bun'; +import { sentry as sentryCloudflare } from '@sentry/hono/cloudflare'; +import { sentry as sentryDeno } from '@sentry/hono/deno'; +import { sentry as sentryNode } from '@sentry/hono/node'; +import { Hono } from 'hono'; + +type AppEnv = { Bindings: { E2E_TEST_DSN: string }; Variables: { user: string } }; + +const dsn = 'https://public@dsn.ingest.sentry.io/1337'; + +const app = new Hono(); +app.use(sentryNode(app)); +app.use(sentryBun(app, { dsn })); +app.use(sentryDeno(app, { dsn })); +app.use(sentryCloudflare(app, env => ({ dsn: env.E2E_TEST_DSN }))); + +const api = app.basePath('/api'); +api.use(sentryNode(api)); diff --git a/dev-packages/e2e-tests/test-applications/hono-4-legacy/tests/route-patterns.test.ts b/dev-packages/e2e-tests/test-applications/hono-4-legacy/tests/route-patterns.test.ts index bfebb9f5b6ab..d1251fad340e 100644 --- a/dev-packages/e2e-tests/test-applications/hono-4-legacy/tests/route-patterns.test.ts +++ b/dev-packages/e2e-tests/test-applications/hono-4-legacy/tests/route-patterns.test.ts @@ -1,5 +1,10 @@ import { expect, test } from '@playwright/test'; -import { waitForStreamedSpan, getSpanOp, collectStreamedSpansUntilSegment } from '@sentry-internal/test-utils'; +import { + waitForError, + waitForStreamedSpan, + getSpanOp, + collectStreamedSpansUntilSegment, +} from '@sentry-internal/test-utils'; import { APP_NAME } from './constants'; const PREFIX = '/test-routes'; @@ -176,3 +181,23 @@ test('async handler sends span', async ({ baseURL }) => { const middlewareSpans = spans.filter(s => getSpanOp(s) === 'middleware'); expect(middlewareSpans).toEqual([]); }); + +test('names an event captured in a route handler after that route', async ({ baseURL }) => { + const eventPromise = waitForError(APP_NAME, event => event.message === 'Message from a route handler'); + + const response = await fetch(`${baseURL}${PREFIX}/capture/42`); + expect(response.status).toBe(200); + + const event = await eventPromise; + expect(event.transaction).toBe(`GET ${PREFIX}/capture/:id`); +}); + +test('names an event from the route handler that runs after next() after that route', async ({ baseURL }) => { + const eventPromise = waitForError(APP_NAME, event => event.message === 'Message from a route handler after next()'); + + const response = await fetch(`${baseURL}${PREFIX}/posts/draft`); + expect(response.status).toBe(200); + + const event = await eventPromise; + expect(event.transaction).toBe(`GET ${PREFIX}/posts/*`); +}); diff --git a/dev-packages/e2e-tests/test-applications/hono-4-legacy/tsconfig.typecheck.json b/dev-packages/e2e-tests/test-applications/hono-4-legacy/tsconfig.typecheck.json new file mode 100644 index 000000000000..9131a4be8ad3 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/hono-4-legacy/tsconfig.typecheck.json @@ -0,0 +1,5 @@ +{ + // The rest of `src` needs Node, Bun and Deno types, which this app does not install. + "extends": "./tsconfig.json", + "include": ["src/*.typecheck.ts"] +} diff --git a/dev-packages/e2e-tests/test-applications/hono-4/package.json b/dev-packages/e2e-tests/test-applications/hono-4/package.json index 4efa27a21f54..a608d1775ef6 100644 --- a/dev-packages/e2e-tests/test-applications/hono-4/package.json +++ b/dev-packages/e2e-tests/test-applications/hono-4/package.json @@ -8,7 +8,8 @@ "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:build": "pnpm install && pnpm typecheck", + "typecheck": "tsc --noEmit -p tsconfig.typecheck.json", "test": "TEST_ENV=production playwright test", "test:assert": "pnpm test:assert:node", "test:assert:node": "RUNTIME=node pnpm test", @@ -19,6 +20,7 @@ "dependencies": { "@sentry/bun": "latest || *", "@sentry/cloudflare": "latest || *", + "@sentry/core": "latest || *", "@sentry/deno": "latest || *", "@sentry/node": "latest || *", "@hono/node-server": "^2.0.5", diff --git a/dev-packages/e2e-tests/test-applications/hono-4/src/honoMiddleware.typecheck.ts b/dev-packages/e2e-tests/test-applications/hono-4/src/honoMiddleware.typecheck.ts new file mode 100644 index 000000000000..396d90942d11 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/hono-4/src/honoMiddleware.typecheck.ts @@ -0,0 +1,12 @@ +// Only `tsc` in `test:build` reads this file. Nothing runs it. +import * as Sentry from '@sentry/node'; +import { Hono } from 'hono'; + +type AppEnv = { Bindings: { E2E_TEST_DSN: string }; Variables: { user: string } }; + +const app = new Hono(); +app.use(Sentry.honoMiddleware(app)); +app.use('*', Sentry.honoMiddleware(app)); + +const api = app.basePath('/api'); +api.use(Sentry.honoMiddleware(api)); 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 index ac6fea1320cf..0cc0aa43ed23 100644 --- 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 @@ -1,3 +1,4 @@ +import { captureMessage } from '@sentry/core'; import { Hono } from 'hono'; const routePatterns = new Hono(); @@ -30,4 +31,24 @@ METHODS.forEach(method => { routePatterns.on(method, '/on', c => c.text(`${method} on response`)); }); +// The catch-all below also matches. An event from inside this handler must still get this route. +routePatterns.get('/capture/:id', c => { + captureMessage('Message from a route handler'); + return c.text('captured'); +}); +routePatterns.get('/capture/*', c => c.text('capture catch-all')); + +// Passes `draft` on to the catch-all below with `next()`. The catch-all responds, so it names the request. +routePatterns.get('/posts/:id', async (c, next) => { + if (c.req.param('id') === 'draft') { + await next(); + return; + } + return c.text('post'); +}); +routePatterns.get('/posts/*', c => { + captureMessage('Message from a route handler after next()'); + return c.text('fallback'); +}); + export { routePatterns }; 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 index bfebb9f5b6ab..d1251fad340e 100644 --- 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 @@ -1,5 +1,10 @@ import { expect, test } from '@playwright/test'; -import { waitForStreamedSpan, getSpanOp, collectStreamedSpansUntilSegment } from '@sentry-internal/test-utils'; +import { + waitForError, + waitForStreamedSpan, + getSpanOp, + collectStreamedSpansUntilSegment, +} from '@sentry-internal/test-utils'; import { APP_NAME } from './constants'; const PREFIX = '/test-routes'; @@ -176,3 +181,23 @@ test('async handler sends span', async ({ baseURL }) => { const middlewareSpans = spans.filter(s => getSpanOp(s) === 'middleware'); expect(middlewareSpans).toEqual([]); }); + +test('names an event captured in a route handler after that route', async ({ baseURL }) => { + const eventPromise = waitForError(APP_NAME, event => event.message === 'Message from a route handler'); + + const response = await fetch(`${baseURL}${PREFIX}/capture/42`); + expect(response.status).toBe(200); + + const event = await eventPromise; + expect(event.transaction).toBe(`GET ${PREFIX}/capture/:id`); +}); + +test('names an event from the route handler that runs after next() after that route', async ({ baseURL }) => { + const eventPromise = waitForError(APP_NAME, event => event.message === 'Message from a route handler after next()'); + + const response = await fetch(`${baseURL}${PREFIX}/posts/draft`); + expect(response.status).toBe(200); + + const event = await eventPromise; + expect(event.transaction).toBe(`GET ${PREFIX}/posts/*`); +}); diff --git a/dev-packages/e2e-tests/test-applications/hono-4/tsconfig.typecheck.json b/dev-packages/e2e-tests/test-applications/hono-4/tsconfig.typecheck.json new file mode 100644 index 000000000000..9131a4be8ad3 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/hono-4/tsconfig.typecheck.json @@ -0,0 +1,5 @@ +{ + // The rest of `src` needs Node, Bun and Deno types, which this app does not install. + "extends": "./tsconfig.json", + "include": ["src/*.typecheck.ts"] +} diff --git a/dev-packages/node-integration-tests/suites/hono-sdk/scenario.mjs b/dev-packages/node-integration-tests/suites/hono-sdk/scenario.mjs index d27dc20bbc30..0456605b5a13 100644 --- a/dev-packages/node-integration-tests/suites/hono-sdk/scenario.mjs +++ b/dev-packages/node-integration-tests/suites/hono-sdk/scenario.mjs @@ -1,5 +1,5 @@ import { serve } from '@hono/node-server'; -import { sentry } from '@sentry/hono/node'; +import { captureMessage, sentry } from '@sentry/hono/node'; import { sendPortToRunner } from '@sentry-internal/node-integration-tests'; import { Hono } from 'hono'; @@ -20,6 +20,62 @@ app.get('/error/:param', () => { throw new Error('Test error from Hono app'); }); +// Both routes match `/users/me`. The name must come from the handler that responded. +app.get('/users/me', c => c.text('me')); +app.get('/users/:id', c => c.text(`user ${c.req.param('id')}`)); + +// The catch-all below also matches. An event from inside this handler must still get this route. +app.get('/users/:id/capture', c => { + captureMessage('Message from a route handler'); + return c.text('captured'); +}); + +// A middleware that returns early. No route handler runs, so the name falls back to the last matched +// route handler. +app.use('/early/*', async (c, _next) => c.text('blocked by middleware')); +app.get('/early/:id', c => c.text('never reached')); + +// Only a middleware matches this path. +app.use('/mw-only/*', async (_c, _next) => new Response('handled by middleware')); + +const subApp = new Hono(); +subApp.get('/hello', c => c.text('sub hello')); +app.route('/sub', subApp); + +app.basePath('/base').get('/hello', c => c.text('base hello')); + +app.all('/any-method', c => c.text(`any method: ${c.req.method}`)); + +// Hono answers `HEAD` with the `GET` route. The SDK records no server span for `HEAD`, so the test +// checks the name on the error event. +app.get('/head-error/:id', () => { + throw new Error('Test error from a HEAD request'); +}); + +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')}`); + return c.json({ outer: c.req.param('itemId'), inner: await res.json() }); +}); + +// Passes `draft` on to the catch-all below with `next()`. The catch-all responds, so it names the request. +app.get('/posts/:id', async (c, next) => { + if (c.req.param('id') === 'draft') { + await next(); + return; + } + return c.text('post'); +}); +app.get('/posts/*', c => { + captureMessage('Message from a route handler after next()'); + return c.text('fallback'); +}); + +// A catch-all registered last. It also matches `/users/me` and `/users/42`, but must not name them. +app.get('/users/*', c => c.text('users catch-all')); +app.get('/early/*', c => c.text('never reached')); + serve({ fetch: app.fetch, port: 0 }, info => { sendPortToRunner(info.port); }); diff --git a/dev-packages/node-integration-tests/suites/hono-sdk/test.ts b/dev-packages/node-integration-tests/suites/hono-sdk/test.ts index 1366be964a95..ca34ff88baf8 100644 --- a/dev-packages/node-integration-tests/suites/hono-sdk/test.ts +++ b/dev-packages/node-integration-tests/suites/hono-sdk/test.ts @@ -1,12 +1,97 @@ import { afterAll, describe, expect } from 'vitest'; import { cleanupChildProcesses, createEsmAndCjsTests } from '../../utils/runner'; +const ROUTE_NAME_CASES: Array<{ method: 'get' | 'post'; path: string; name: string; route: string }> = [ + { method: 'get', path: '/users/me', name: 'GET /users/me', route: '/users/me' }, + { method: 'get', path: '/users/42', name: 'GET /users/:id', route: '/users/:id' }, + { method: 'get', path: '/users/42/posts', name: 'GET /users/*', route: '/users/*' }, + { method: 'get', path: '/early/1', name: 'GET /early/:id', route: '/early/:id' }, + { method: 'get', path: '/posts/1', name: 'GET /posts/:id', route: '/posts/:id' }, + { method: 'get', path: '/posts/draft', name: 'GET /posts/*', route: '/posts/*' }, + { method: 'get', path: '/mw-only/anything', name: 'GET /mw-only/*', route: '/mw-only/*' }, + { method: 'get', path: '/sub/hello', name: 'GET /sub/hello', route: '/sub/hello' }, + { method: 'get', path: '/base/hello', name: 'GET /base/hello', route: '/base/hello' }, + { method: 'post', path: '/any-method', name: 'POST /any-method', route: '/any-method' }, + { method: 'get', path: '/outer/self-watering-plant', name: 'GET /outer/:itemId', route: '/outer/:itemId' }, +]; + describe('hono-sdk (Node)', () => { afterAll(() => { cleanupChildProcesses(); }); createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument.mjs', (createRunner, test) => { + // All cases in one test, because each `test()` starts its own Node process. + test('names each request after the route of the handler that responded', async () => { + const runner = createRunner().unordered(); + for (const { name, route } of ROUTE_NAME_CASES) { + runner.expect({ + span: container => { + const segment = container.items.find(item => item.is_segment && item.name === name); + if (!segment) { + throw new Error(`segment for \`${name}\` not in this container`); + } + expect(segment.attributes?.['http.route']).toEqual({ type: 'string', value: route }); + expect(segment.attributes?.['sentry.segment.name.source']).toEqual({ type: 'string', value: 'route' }); + }, + }); + } + + const started = runner.start(); + for (const { method, path } of ROUTE_NAME_CASES) { + await started.makeRequest(method, path); + } + await started.completed(); + }); + + test('names an event captured in a route handler after that route', async () => { + const runner = createRunner() + .ignore('span') + .expect({ event: { message: 'Message from a route handler', transaction: 'GET /users/:id/capture' } }) + .start(); + runner.makeRequest('get', '/users/42/capture'); + await runner.completed(); + }); + + test('keeps the runtime name for a request without a route', async () => { + const runner = createRunner() + .expect({ + span: container => { + const segment = container.items.find(item => item.is_segment); + expect(segment?.name).toBe('GET'); + expect(segment?.attributes?.['sentry.segment.name.source']).toEqual({ type: 'string', value: 'url' }); + expect(segment?.attributes?.['url.path']).toEqual({ type: 'string', value: '/does-not-exist' }); + expect(segment?.attributes?.['http.route']).toBeUndefined(); + }, + }) + .start(); + runner.makeRequest('get', '/does-not-exist', { expectError: true }); + await runner.completed(); + }); + + test('names an event from the route handler that runs after next() after that route', async () => { + const runner = createRunner() + .ignore('span') + .expect({ event: { message: 'Message from a route handler after next()', transaction: 'GET /posts/*' } }) + .start(); + runner.makeRequest('get', '/posts/draft'); + await runner.completed(); + }); + + test('names a HEAD request after the GET route', async () => { + const runner = createRunner() + .ignore('span') + .expect({ + event: { + exception: { values: [{ value: 'Test error from a HEAD request' }] }, + transaction: 'HEAD /head-error/:id', + }, + }) + .start(); + runner.makeRequest('head', '/head-error/1', { expectError: true }); + await runner.completed(); + }); + test('creates a segment span for a basic GET request', async () => { const runner = createRunner() .expect({ diff --git a/dev-packages/node-integration-tests/suites/hono/scenario-honoMiddleware.mjs b/dev-packages/node-integration-tests/suites/hono/scenario-honoMiddleware.mjs new file mode 100644 index 000000000000..d6d6c32a9a1d --- /dev/null +++ b/dev-packages/node-integration-tests/suites/hono/scenario-honoMiddleware.mjs @@ -0,0 +1,39 @@ +import { serve } from '@hono/node-server'; +import * as Sentry from '@sentry/node'; +import { sendPortToRunner } from '@sentry-internal/node-integration-tests'; +import { Hono } from 'hono'; + +// The auto-instrumentation and the manual `honoMiddleware(app)` are both active. +const app = new Hono(); +app.use(Sentry.honoMiddleware(app)); + +// Counts the Sentry wrappers around the handler that runs. Both setups wrap route handlers, and the +// router reuses the handler list for each request, so without a guard the wrappers would pile up. +// It reads the match result like `hono/route` does. +function wrapperCount(c) { + const [matchResultKey] = Object.getOwnPropertySymbols(Object.getPrototypeOf(c.req)); + let handler = c.req[matchResultKey][0][c.req.routeIndex][0][0]; + let count = 0; + while (handler.__sentry_original__) { + handler = handler.__sentry_original__; + count++; + } + return count; +} + +// No route handler here declares `next`, so the SDK does not wrap them. +app.get('/users/me', c => c.json({ wrappers: wrapperCount(c) })); + +// A route handler that declares `next`, so the SDK wraps the route handlers of this request. +app.get('/posts/:id', async (c, next) => { + if (c.req.param('id') === 'draft') { + await next(); + return; + } + return c.json({ wrappers: wrapperCount(c) }); +}); +app.get('/posts/*', c => c.json({ wrappers: wrapperCount(c) })); + +serve({ fetch: app.fetch, port: 0 }, info => { + sendPortToRunner(info.port); +}); diff --git a/dev-packages/node-integration-tests/suites/hono/scenario.mjs b/dev-packages/node-integration-tests/suites/hono/scenario.mjs index ed0f816c03cf..e1c609d1a454 100644 --- a/dev-packages/node-integration-tests/suites/hono/scenario.mjs +++ b/dev-packages/node-integration-tests/suites/hono/scenario.mjs @@ -1,5 +1,6 @@ import { serve } from '@hono/node-server'; import { sendPortToRunner } from '@sentry-internal/node-integration-tests'; +import { captureMessage, startSpan } from '@sentry/node'; import { Hono } from 'hono'; // No `@sentry/hono` and no `sentry()` middleware: the app is instrumented automatically by the @@ -97,6 +98,51 @@ app }) .get('/hello', c => c.json({ greeting: 'world' })); +// Both routes match `/users/me`. The name must come from the handler that responded. +app.get('/users/me', c => c.text('me')); +app.get('/users/:id', c => c.text(`user ${c.req.param('id')}`)); + +// The catch-all below also matches. An event from inside this handler must still get this route. +app.get('/users/:id/capture', c => { + captureMessage('Message from a route handler'); + return c.text('captured'); +}); + +// A middleware that returns early. No route handler runs, so the name falls back to the last matched +// route handler. +app.use('/early/*', async (c, _next) => c.text('blocked by middleware')); +app.get('/early/:id', c => c.text('never reached')); + +app.all('/any-method', c => c.text(`any method: ${c.req.method}`)); + +// Hono answers `HEAD` with the `GET` route. The SDK records no server span for `HEAD`, so the test +// checks the name on the error event. +app.get('/head-error/:id', () => { + throw new Error('Test error from a HEAD request'); +}); + +// Runs the rest of the chain in its own span. The SDK must name the segment, not this span. The route +// handler declares `next`, so the SDK names the request when the handler starts, inside this span. +app.use('/traced/*', (_c, next) => startSpan({ name: 'user span' }, () => next())); +app.get('/traced/:id', (c, _next) => c.text('traced')); + +// Passes `draft` on to the catch-all below with `next()`. The catch-all responds, so it names the request. +app.get('/posts/:id', async (c, next) => { + if (c.req.param('id') === 'draft') { + await next(); + return; + } + return c.text('post'); +}); +app.get('/posts/*', c => { + captureMessage('Message from a route handler after next()'); + return c.text('fallback'); +}); + +// A catch-all registered last. It also matches `/users/me` and `/users/42`, but must not name them. +app.get('/users/*', c => c.text('users catch-all')); +app.get('/early/*', c => c.text('never reached')); + serve({ fetch: app.fetch, port: 0 }, info => { sendPortToRunner(info.port); }); diff --git a/dev-packages/node-integration-tests/suites/hono/test.ts b/dev-packages/node-integration-tests/suites/hono/test.ts index 20a16f7e1eba..511ea3a83cf7 100644 --- a/dev-packages/node-integration-tests/suites/hono/test.ts +++ b/dev-packages/node-integration-tests/suites/hono/test.ts @@ -15,12 +15,130 @@ type StreamedSpan = { name?: string; status?: string; is_segment?: boolean; attr const attr = (span: StreamedSpan, key: string): unknown => span.attributes?.[key]?.value; const op = (span: StreamedSpan): unknown => attr(span, 'sentry.op'); +const ROUTE_NAME_CASES: Array<{ method: 'get' | 'post'; path: string; name: string; route: string }> = [ + { method: 'get', path: '/users/me', name: 'GET /users/me', route: '/users/me' }, + { method: 'get', path: '/users/42', name: 'GET /users/:id', route: '/users/:id' }, + { method: 'get', path: '/users/42/posts', name: 'GET /users/*', route: '/users/*' }, + { method: 'get', path: '/overlap/123', name: 'GET /overlap/:id', route: '/overlap/:id' }, + { method: 'get', path: '/early/1', name: 'GET /early/:id', route: '/early/:id' }, + { method: 'get', path: '/posts/1', name: 'GET /posts/:id', route: '/posts/:id' }, + { method: 'get', path: '/posts/draft', name: 'GET /posts/*', route: '/posts/*' }, + { method: 'get', path: '/mw-only/anything', name: 'GET /mw-only/*', route: '/mw-only/*' }, + { method: 'get', path: '/sub/hello', name: 'GET /sub/hello', route: '/sub/hello' }, + { + method: 'get', + path: '/test-basepath-mw/hello', + name: 'GET /test-basepath-mw/hello', + route: '/test-basepath-mw/hello', + }, + { method: 'post', path: '/any-method', name: 'POST /any-method', route: '/any-method' }, + { method: 'get', path: '/outer/self-watering-plant', name: 'GET /outer/:itemId', route: '/outer/:itemId' }, +]; + +function segmentNamed(name: string, route: string): { span: (container: { items: StreamedSpan[] }) => void } { + return { + span: container => { + const segment = container.items.find(item => item.is_segment && item.name === name); + if (!segment) { + throw new Error(`segment for \`${name}\` not in this container`); + } + expect(op(segment)).toBe('http.server'); + expect(attr(segment, 'http.route')).toBe(route); + expect(attr(segment, 'sentry.segment.name.source')).toBe('route'); + }, + }; +} + describe('hono auto-instrumentation', () => { afterAll(() => { cleanupChildProcesses(); }); createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument.mjs', (createRunner, test) => { + // All cases in one test, because each `test()` starts its own Node process. + test('names each request after the route of the handler that responded', async () => { + const runner = createRunner().unordered(); + for (const { name, route } of ROUTE_NAME_CASES) { + runner.expect(segmentNamed(name, route)); + } + + const started = runner.start(); + for (const { method, path } of ROUTE_NAME_CASES) { + await started.makeRequest(method, path); + } + await started.completed(); + }); + + test('names an event captured in a route handler after that route', async () => { + const runner = createRunner() + .ignore('span') + .expect({ event: { message: 'Message from a route handler', transaction: 'GET /users/:id/capture' } }) + .start(); + runner.makeRequest('get', '/users/42/capture'); + await runner.completed(); + }); + + test('does not rename a span that a middleware starts around the route handler', async () => { + const runner = createRunner() + .unordered() + .expect({ + span: container => { + const segment = container.items.find(item => item.is_segment && item.name === 'GET /traced/:id'); + if (!segment) { + throw new Error('segment for `GET /traced/:id` not in this container'); + } + expect(container.items.filter(item => item.name === 'user span')).toHaveLength(1); + }, + }) + .start(); + runner.makeRequest('get', '/traced/1'); + await runner.completed(); + }); + + test('keeps the runtime name for a request without a route', async () => { + const runner = createRunner() + .unordered() + .expect({ + span: container => { + const segment = container.items.find( + item => item.is_segment && attr(item, 'url.path') === '/does-not-exist', + ); + if (!segment) { + throw new Error('segment for `/does-not-exist` not in this container'); + } + expect(segment.name).toBe('GET'); + expect(attr(segment, 'sentry.segment.name.source')).toBe('url'); + expect(attr(segment, 'http.route')).toBeUndefined(); + }, + }) + .start(); + runner.makeRequest('get', '/does-not-exist', { expectError: true }); + await runner.completed(); + }); + + test('names an event from the route handler that runs after next() after that route', async () => { + const runner = createRunner() + .ignore('span') + .expect({ event: { message: 'Message from a route handler after next()', transaction: 'GET /posts/*' } }) + .start(); + runner.makeRequest('get', '/posts/draft'); + await runner.completed(); + }); + + test('names a HEAD request after the GET route', async () => { + const runner = createRunner() + .unordered() + .expect({ + event: { + exception: { values: [{ value: 'Test error from a HEAD request' }] }, + transaction: 'HEAD /head-error/:id', + }, + }) + .start(); + runner.makeRequest('head', '/head-error/1', { expectError: true }); + await runner.completed(); + }); + test('creates a transaction for a basic GET request', async () => { const runner = createRunner() .unordered() @@ -227,42 +345,6 @@ describe('hono auto-instrumentation', () => { await runner.completed(); }); - test('resolves overlapping handlers to the responding route, not the catch-all', async () => { - const runner = createRunner() - .unordered() - .expect({ - span: container => { - const segment = container.items.find(item => item.is_segment && item.name === 'GET /overlap/:id'); - if (!segment) { - throw new Error('segment for `GET /overlap/:id` not in this container'); - } - // The catch-all `GET /overlap/*` handler also matched, but the parameterized handler - // responded — the route name must not collapse to `/overlap/*`. - expect(attr(segment, 'sentry.segment.name.source')).toBe('route'); - }, - }) - .start(); - runner.makeRequest('get', '/overlap/123'); - await runner.completed(); - }); - - test('falls back to the matched middleware path when only middleware matched', async () => { - const runner = createRunner() - .unordered() - .expect({ - span: container => { - const segment = container.items.find(item => item.is_segment && item.name === 'GET /mw-only/*'); - if (!segment) { - throw new Error('segment for `GET /mw-only/*` not in this container'); - } - expect(op(segment)).toBe('http.server'); - }, - }) - .start(); - runner.makeRequest('get', '/mw-only/anything'); - await runner.completed(); - }); - test('does not set the middleware span to error for a 3xx middleware error', async () => { const runner = createRunner() .unordered() @@ -321,3 +403,28 @@ describe('hono auto-instrumentation', () => { }); }); }); + +describe('hono auto-instrumentation with honoMiddleware(app)', () => { + afterAll(() => { + cleanupChildProcesses(); + }); + + createEsmAndCjsTests(__dirname, 'scenario-honoMiddleware.mjs', 'instrument.mjs', (createRunner, test) => { + test('wraps route handlers only when one declares next, and only once', async () => { + const runner = createRunner() + .unordered() + .expect(segmentNamed('GET /users/me', '/users/me')) + .expect(segmentNamed('GET /posts/:id', '/posts/:id')) + .expect(segmentNamed('GET /posts/:id', '/posts/:id')) + .expect(segmentNamed('GET /posts/*', '/posts/*')) + .start(); + + expect(await runner.makeRequest('get', '/users/me')).toEqual({ wrappers: 0 }); + // The second request gets the same cached handler list. + expect(await runner.makeRequest('get', '/posts/1')).toEqual({ wrappers: 1 }); + expect(await runner.makeRequest('get', '/posts/1')).toEqual({ wrappers: 1 }); + expect(await runner.makeRequest('get', '/posts/draft')).toEqual({ wrappers: 1 }); + await runner.completed(); + }); + }); +}); diff --git a/dev-packages/node-integration-tests/utils/runner/createRunner.ts b/dev-packages/node-integration-tests/utils/runner/createRunner.ts index d9fd1d5f149b..edf0c076a6de 100644 --- a/dev-packages/node-integration-tests/utils/runner/createRunner.ts +++ b/dev-packages/node-integration-tests/utils/runner/createRunner.ts @@ -103,7 +103,7 @@ type StartResult = { getPort(): number | undefined; sendSignal(signal: NodeJS.Signals): void; makeRequest( - method: 'get' | 'post' | 'put' | 'delete' | 'patch', + method: 'get' | 'head' | 'post' | 'put' | 'delete' | 'patch', path: string, options?: { headers?: Record; data?: BodyInit; expectError?: boolean; timeout?: number }, ): Promise; @@ -775,7 +775,7 @@ export function createRunner(...paths: string[]) { child?.kill(signal); }, makeRequest: async function ( - method: 'get' | 'post' | 'put' | 'delete' | 'patch', + method: 'get' | 'head' | 'post' | 'put' | 'delete' | 'patch', path: string, options: { headers?: Record; diff --git a/packages/hono/package.json b/packages/hono/package.json index 982ee76c2748..f3b00ef9b8c1 100644 --- a/packages/hono/package.json +++ b/packages/hono/package.json @@ -110,6 +110,8 @@ "@cloudflare/workers-types": "4.20250922.0", "@hono/node-server": "^2.0.10", "@types/node": "^18.19.1", + "hono": "4.13.9", + "hono-5": "npm:hono@5.0.0-rc.1", "wrangler": "4.62.0" }, "scripts": { diff --git a/packages/hono/test/shared/middlewareHandlers.test.ts b/packages/hono/test/shared/middlewareHandlers.test.ts index 9253cf56cbd6..b8e3f51c0b67 100644 --- a/packages/hono/test/shared/middlewareHandlers.test.ts +++ b/packages/hono/test/shared/middlewareHandlers.test.ts @@ -47,13 +47,17 @@ const getActiveSpanMock = SentryCore.getActiveSpan as ReturnType; // `event` throws on access so `hasFetchEvent` reports `false` (the Node/Bun/Deno path), which makes // the request handler read request data from `req.raw` rather than a Cloudflare fetch event. -function createMockContext(status: number, error?: Error): unknown { +function createMockContext( + status: number, + error?: Error, + matchedRoutes: unknown[] = [{ basePath: '/', path: '/test', method: 'GET', handler: (_c: unknown) => undefined }], +): unknown { return { req: { method: 'GET', routeIndex: 0, // `resolveRouteName` reads the matched routes straight off the request. - matchedRoutes: [{ basePath: '/', path: '/test', method: 'GET', handler: (_c: unknown) => undefined }], + matchedRoutes, raw: new Request('http://localhost/test'), }, get event(): never { @@ -201,6 +205,16 @@ describe('responseHandler (via createHonoRequestMiddleware)', () => { expect(mockRootSpan.setAttribute).toHaveBeenCalledWith(HTTP_ROUTE, '/test'); expect(mockRootSpan.setAttribute).toHaveBeenCalledWith(SENTRY_SEGMENT_NAME_SOURCE, 'route'); }); + + it('keeps the runtime name when no route matched', async () => { + getActiveSpanMock.mockReturnValue(mockRootSpan); + + await runMiddleware(createMockContext(404, undefined, [])); + + expect(mockRootSpan.updateName).not.toHaveBeenCalled(); + expect(mockRootSpan.setAttribute).not.toHaveBeenCalledWith(HTTP_ROUTE, expect.anything()); + expect(mockSetTransactionName).not.toHaveBeenCalled(); + }); }); }); diff --git a/packages/hono/test/shared/resolveRouteName.test.ts b/packages/hono/test/shared/resolveRouteName.test.ts new file mode 100644 index 000000000000..7ecd4e337502 --- /dev/null +++ b/packages/hono/test/shared/resolveRouteName.test.ts @@ -0,0 +1,197 @@ +import * as SentryCore from '@sentry/core'; +import { applyHonoPatches, createHonoRequestMiddleware } from '@sentry/server-utils'; +import { Hono } from 'hono'; +import { HonoRequest } from 'hono/request'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const mockSetTransactionName = vi.fn(); + +vi.mock('@sentry/core', async () => { + const actual = await vi.importActual('@sentry/core'); + return { + ...actual, + getActiveSpan: vi.fn(() => undefined), + getIsolationScope: vi.fn(() => ({ + setTransactionName: mockSetTransactionName, + setSDKProcessingMetadata: vi.fn(), + })), + }; +}); + +const getIsolationScopeMock = SentryCore.getIsolationScope as ReturnType; + +// One isolation scope per request, as in a server. A shared scope keeps the dedup marker of the +// Sentry middleware, which then skips the next request. +function freshIsolationScope(): void { + getIsolationScopeMock.mockReturnValue({ + setTransactionName: mockSetTransactionName, + setSDKProcessingMetadata: vi.fn(), + }); +} + +function createApp({ restoreRouteIndex }: { restoreRouteIndex: boolean }): Hono { + const app = new Hono(); + applyHonoPatches(app); + app.use(createHonoRequestMiddleware()); + + if (restoreRouteIndex) { + // Like Hono 5, which resets `routeIndex` after `next()` (honojs/hono#4424). + app.use(async (c, next) => { + const routeIndex = c.req.routeIndex; + await next(); + c.req.routeIndex = routeIndex; + }); + } + + app.use('/users/*', async (_c, next) => { + await next(); + }); + // A route handler from `.use()`, which also gets a middleware span wrapper. + app.use('/users/via-use', async c => c.text('via use')); + app.get('/users/me', c => c.text('me')); + app.get('/users/:id', c => c.text('user')); + app.use('/mw-only/*', async (_c, _next) => new Response('handled by middleware')); + + // A middleware that returns early. Of the matched route handlers, Hono would have run `/blocked/me`. + app.use('/blocked/*', async (c, _next) => c.text('blocked', 401)); + app.get('/blocked/me', c => c.text('me')); + app.get('/blocked/:id', c => c.text('user')); + app.get('/blocked/*', c => c.text('catch-all')); + + const subApp = new Hono(); + subApp.get('/items/:itemId', c => c.text('item')); + app.route('/sub', subApp); + + // Passes `draft` on to the catch-all below with `next()`. The catch-all responds, so it names the request. + app.get('/posts/:id', async (c, next) => { + if (c.req.param('id') === 'draft') { + await next(); + return; + } + return c.text('post'); + }); + app.get('/posts/*', c => c.text('fallback')); + + // A catch-all registered last. It also matches `/users/me` and `/users/42`, but never responds. + app.get('/users/*', c => c.text('catch-all')); + return app; +} + +// Returns a function that restores the property. +function overrideRequestProperty(key: PropertyKey, descriptor: PropertyDescriptor): () => void { + const original = Object.getOwnPropertyDescriptor(HonoRequest.prototype, key); + Object.defineProperty(HonoRequest.prototype, key, { configurable: true, ...descriptor }); + return () => { + if (original) { + Object.defineProperty(HonoRequest.prototype, key, original); + } + }; +} + +const hideRequestProperty = (key: PropertyKey): (() => void) => overrideRequestProperty(key, { value: undefined }); + +// The symbol of the match result getter, which `hono/route` reads. +const [matchResultKey] = Object.getOwnPropertySymbols(HonoRequest.prototype); +if (!matchResultKey) { + throw new Error('The match result getter is missing on `HonoRequest.prototype`.'); +} + +const SOURCES: Array<{ source: string; hidden: PropertyKey[]; restoreRouteIndex: boolean }> = [ + { source: 'Hono 4', hidden: [], restoreRouteIndex: false }, + // Hono 5 has no `c.req.matchedRoutes`. + { source: 'Hono 5 (simulated)', hidden: ['matchedRoutes'], restoreRouteIndex: true }, +]; + +const CASES: Array<[path: string, expected: string]> = [ + ['/users/me', 'GET /users/me'], + ['/users/42', 'GET /users/:id'], + ['/users/via-use', 'GET /users/via-use'], + ['/users/all/the/things', 'GET /users/*'], + ['/sub/items/7', 'GET /sub/items/:itemId'], + ['/mw-only/anything', 'GET /mw-only/*'], + ['/blocked/me', 'GET /blocked/me'], + ['/posts/1', 'GET /posts/:id'], + ['/posts/draft', 'GET /posts/*'], +]; + +describe.each(SOURCES)('route name on $source', ({ hidden, restoreRouteIndex }) => { + let restores: Array<() => void> = []; + + beforeEach(() => { + vi.clearAllMocks(); + restores = hidden.map(hideRequestProperty); + }); + + afterEach(() => { + restores.forEach(restore => restore()); + }); + + it.each(CASES)('names %s as %s', async (path, expected) => { + freshIsolationScope(); + const app = createApp({ restoreRouteIndex }); + + await app.request(path); + + expect(mockSetTransactionName).toHaveBeenLastCalledWith(expected); + }); + + it('does not name a request without a route', async () => { + freshIsolationScope(); + const app = createApp({ restoreRouteIndex }); + + await app.request('/does-not-exist'); + + expect(mockSetTransactionName).not.toHaveBeenCalled(); + }); +}); + +describe('route name with another symbol-keyed getter on the request', () => { + let restores: Array<() => void> = []; + + beforeEach(() => { + vi.clearAllMocks(); + restores = [ + hideRequestProperty('matchedRoutes'), + overrideRequestProperty(Symbol('requestId'), { get: () => 'req-7f3a' }), + ]; + }); + + afterEach(() => { + restores.forEach(restore => restore()); + }); + + it('still finds the match result getter', async () => { + freshIsolationScope(); + const app = createApp({ restoreRouteIndex: true }); + + await app.request('/users/me'); + + expect(mockSetTransactionName).toHaveBeenLastCalledWith('GET /users/me'); + }); +}); + +describe('route name from a match result of an unknown shape', () => { + let restores: Array<() => void> = []; + + beforeEach(() => { + vi.clearAllMocks(); + restores = [ + hideRequestProperty('matchedRoutes'), + overrideRequestProperty(matchResultKey, { get: () => [[[null]], {}] }), + ]; + }); + + afterEach(() => { + restores.forEach(restore => restore()); + }); + + it('keeps the request working and does not name it', async () => { + freshIsolationScope(); + const app = createApp({ restoreRouteIndex: true }); + + const response = await app.request('/users/me'); + + expect(response.status).toBe(200); + expect(mockSetTransactionName).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/hono/test/types.test-d.ts b/packages/hono/test/types.test-d.ts new file mode 100644 index 000000000000..3d069bd46138 --- /dev/null +++ b/packages/hono/test/types.test-d.ts @@ -0,0 +1,57 @@ +import { honoMiddleware } from '@sentry/server-utils'; +import { Hono } from 'hono'; +import type { Hono as Hono5 } from 'hono-5'; +import { describe, it } from 'vitest'; +import { sentry as sentryBun } from '../src/bun/middleware'; +import { sentry as sentryCloudflare } from '../src/cloudflare/middleware'; +import { sentry as sentryDeno } from '../src/deno/middleware'; +import { sentry as sentryNode } from '../src/node/middleware'; + +type AppEnv = { Bindings: { SENTRY_DSN: string }; Variables: { user: string } }; + +declare const app5: Hono5; + +const dsn = 'https://public@dsn.ingest.sentry.io/1337'; + +describe('Hono 4', () => { + it('accepts honoMiddleware(app)', () => { + const app = new Hono(); + app.use(honoMiddleware(app)); + + const api = app.basePath('/api'); + api.use(honoMiddleware(api)); + }); + + it('accepts sentry(app) from each runtime entry', () => { + const app = new Hono(); + app.use(sentryNode(app)); + app.use(sentryBun(app, { dsn })); + app.use(sentryDeno(app, { dsn })); + app.use(sentryCloudflare(app, env => ({ dsn: env.SENTRY_DSN }))); + + const api = app.basePath('/api'); + api.use(sentryNode(api)); + }); +}); + +describe('Hono 5', () => { + it('accepts honoMiddleware(app)', () => { + app5.use(honoMiddleware(app5)); + + const api = app5.basePath('/api'); + api.use(honoMiddleware(api)); + }); + + // In this package, `sentry()` gets its `Hono` type from Hono 4. That class has private members, so a + // Hono 5 app cannot match it. In a real Hono 5 app, `sentry()` gets the Hono 5 types. + it('rejects sentry(app) here, because `hono` is Hono 4 in this package', () => { + // @ts-expect-error -- Hono 5 app, Hono 4 types + app5.use(sentryNode(app5)); + // @ts-expect-error -- Hono 5 app, Hono 4 types + app5.use(sentryBun(app5, { dsn })); + // @ts-expect-error -- Hono 5 app, Hono 4 types + app5.use(sentryDeno(app5, { dsn })); + // @ts-expect-error -- Hono 5 app, Hono 4 types + app5.use(sentryCloudflare(app5, env => ({ dsn: env.SENTRY_DSN }))); + }); +}); diff --git a/packages/hono/vite.config.ts b/packages/hono/vite.config.ts index b2150cd225a4..bac0e296db14 100644 --- a/packages/hono/vite.config.ts +++ b/packages/hono/vite.config.ts @@ -3,4 +3,12 @@ import baseConfig from '../../vite/vite.config'; export default defineConfig({ ...baseConfig, + test: { + ...baseConfig.test, + typecheck: { + enabled: true, + tsconfig: './tsconfig.test.json', + ignoreSourceErrors: true, + }, + }, }); diff --git a/packages/server-utils/.oxlintrc.json b/packages/server-utils/.oxlintrc.json index d9e07c9dd0cd..001bf4e0b298 100644 --- a/packages/server-utils/.oxlintrc.json +++ b/packages/server-utils/.oxlintrc.json @@ -31,6 +31,16 @@ "name": "diagnostics_channel", "allowTypeImports": true, "message": "Bun garbage-collects a channel that no code references, together with its subscribers. Import the wrappers from `src/utils/diagnosticsChannel.ts` instead. See https://github.com/oven-sh/bun/issues/43086" + }, + { + "name": "hono", + "message": "`@sentry/server-utils` has no `hono` dependency and must work on every Hono major. Use the vendored types in `src/integrations/hono/honoTypes.ts`." + } + ], + "patterns": [ + { + "group": ["hono/*"], + "message": "`@sentry/server-utils` has no `hono` dependency and must work on every Hono major. Use the vendored types in `src/integrations/hono/honoTypes.ts`." } ] } diff --git a/packages/server-utils/src/integrations/hono/constants.ts b/packages/server-utils/src/integrations/hono/constants.ts index 297f3a469271..243b4eaebb57 100644 --- a/packages/server-utils/src/integrations/hono/constants.ts +++ b/packages/server-utils/src/integrations/hono/constants.ts @@ -2,3 +2,9 @@ // Shared by the `app.request` Proxy (`patchAppRequest`) and the orchestrion channel subscriber // (`instrumentInternalRequests` in `honoIntegration`), which both open this span. export const INTERNAL_REQUEST_ORIGIN = 'auto.http.hono.internal_request'; + +// Marks the Sentry request/response middleware so the span-wrapping patches never turn it into a +// middleware span — most importantly when an auto-instrumented sub-app carrying this middleware is +// mounted into a parent and `wrapSubAppMiddleware` wraps its handlers. Read by `wrapMiddlewareWithSpan` +// and by `resolveRouteName`, which does not use its path as a route. +export const SENTRY_HONO_MIDDLEWARE = '__SENTRY_HONO_MIDDLEWARE__'; diff --git a/packages/server-utils/src/integrations/hono/createHonoMiddleware.ts b/packages/server-utils/src/integrations/hono/createHonoMiddleware.ts index 74f0a96ca809..3f1a2a78892c 100644 --- a/packages/server-utils/src/integrations/hono/createHonoMiddleware.ts +++ b/packages/server-utils/src/integrations/hono/createHonoMiddleware.ts @@ -1,13 +1,9 @@ import { addNonEnumerableProperty, getDefaultIsolationScope, getIsolationScope } from '@sentry/core'; +import { SENTRY_HONO_MIDDLEWARE } from './constants'; import type { Context, GetConnInfo, MiddlewareHandler } from './honoTypes'; import { captureContextError, requestHandler, responseHandler } from './middlewareHandlers'; import type { SentryHonoMiddlewareOptions } from './types'; -// Marks the Sentry request/response middleware so the span-wrapping patches never turn it into a -// middleware span — most importantly when an auto-instrumented sub-app carrying this middleware is -// mounted into a parent and `wrapSubAppMiddleware` wraps its handlers. Read by `wrapMiddlewareWithSpan`. -export const SENTRY_HONO_MIDDLEWARE = '__SENTRY_HONO_MIDDLEWARE__'; - // `Symbol.for` (global registry) so the markers are shared even if this module is evaluated from more // than one copy of `@sentry/server-utils`. const HONO_REQUEST_HANDLED = Symbol.for('sentry.hono.requestHandled'); diff --git a/packages/server-utils/src/integrations/hono/honoIntegration.ts b/packages/server-utils/src/integrations/hono/honoIntegration.ts index c2b504db2dab..7fd32383796a 100644 --- a/packages/server-utils/src/integrations/hono/honoIntegration.ts +++ b/packages/server-utils/src/integrations/hono/honoIntegration.ts @@ -5,7 +5,7 @@ import { HTTP_SERVER } from '@sentry/conventions/op'; import type { IntegrationFn } from '@sentry/core'; import { debug, defineIntegration, startInactiveSpan } from '@sentry/core'; import { DEBUG_BUILD } from '../../debug-build'; -import type { Env, GetConnInfo, Hono, MiddlewareHandler } from './honoTypes'; +import type { Env, GetConnInfo, Hono, MatchedHandler, MiddlewareHandler } from './honoTypes'; import type { OrchestrionChannelContext } from '../../orchestrion/types'; import { CHANNELS } from '../../orchestrion/channels'; import { honoModuleNames } from '../../orchestrion/config/hono'; @@ -14,7 +14,8 @@ import { bindTracingChannelToSpan, safeChannelCallback } from '../../tracing-cha import { applyPatches } from './applyPatches'; import { INTERNAL_REQUEST_ORIGIN } from './constants'; import { createHonoRequestMiddleware } from './createHonoMiddleware'; -import { isMiddleware } from './isMiddleware'; +import { getMiddlewareFlags } from './isMiddleware'; +import { isMatchedHandlerList, wrapRouteHandlers } from './matchResult'; import { extractPathname, isInternalRequestSpanActive } from './patchAppRequest'; import { wrapMiddlewareWithSpan } from './wrapMiddlewareSpan'; import type { SentryHonoMiddlewareOptions } from './types'; @@ -83,16 +84,20 @@ export function honoMiddleware(app: Hono, options: HonoIntegra }); } -// A Hono `matchResult[0]` entry: `[[handler, routeMeta], paramIndexMap]` — `compose` runs the -// handler (`entry[0][0]`) and the `matchedRoutes` getter reads `routeMeta` (`entry[0][1]`). -// oxlint-disable-next-line typescript/no-explicit-any -type MatchedHandlerEntry = [[any, any], any]; +function wrapMatchedMiddleware(handlers: MatchedHandler[], middlewareFlags: boolean[]): void { + for (const [i, [pair]] of handlers.entries()) { + if (middlewareFlags[i]) { + pair[0] = wrapMiddlewareWithSpan(pair[0]); + } + } +} /** * Per-request Context hook. `#dispatch` builds `new Context(req, { matchResult })` before its * single-handler fast-path check, so from the live `matchResult` we: * 1. wrap the matched middleware handlers for spans (route handlers are covered by the request span); - * 2. prepend the Sentry request/response middleware — it drives route naming, request data and error + * 2. wrap the matched route handlers to record which one responded, for the route name; + * 3. prepend the Sentry request/response middleware — it drives route naming, request data and error * capture from inside the chain, and forces the ≥2-handler `compose` path so the fast-path is * never taken. * @@ -103,47 +108,21 @@ function injectHonoInstrumentation( requestMiddleware: MiddlewareHandler, injectedHandlerLists: WeakSet, ): void { - const ctorOptions = message.arguments?.[1] as { matchResult?: [MatchedHandlerEntry[], unknown] } | undefined; + const ctorOptions = message.arguments?.[1] as { matchResult?: [unknown[], unknown] } | undefined; const handlers = ctorOptions?.matchResult?.[0]; if (!Array.isArray(handlers) || injectedHandlerLists.has(handlers)) { return; } injectedHandlerLists.add(handlers); - // Classify matched handlers with the same positional heuristic as `wrapSubAppMiddleware`: within a - // method+path group the last handler is the route handler and earlier ones are middleware; `.use()` - // registers as method 'ALL' with a lone genuine-middleware entry, so fall back to arity there. - // Position is needed because a route handler declared with an unused `next` param has middleware arity. - const lastIndexByKey = new Map(); - for (const [i, entry] of handlers.entries()) { - const routeMeta = entry?.[0]?.[1] as { method?: string; path?: string } | undefined; - if (routeMeta?.method != null && routeMeta.path != null) { - // \0 is a collision-free delimiter: it cannot appear in a valid HTTP method or URL path. - lastIndexByKey.set(`${routeMeta.method}\0${routeMeta.path}`, i); - } - } - - for (const [i, entry] of handlers.entries()) { - const pair = entry?.[0]; - const handler = pair?.[0]; - if (typeof handler !== 'function') { - continue; - } - - const routeMeta = pair?.[1] as { method?: string; path?: string } | undefined; - const isMW = - routeMeta?.method != null && routeMeta.path != null - ? lastIndexByKey.get(`${routeMeta.method}\0${routeMeta.path}`) !== i || - (routeMeta.method === 'ALL' && isMiddleware(handler)) - : isMiddleware(handler); - - if (isMW) { - pair[0] = wrapMiddlewareWithSpan(handler as MiddlewareHandler); - } + if (isMatchedHandlerList(handlers)) { + const middlewareFlags = getMiddlewareFlags(handlers.map(([[, route]]) => route)); + wrapMatchedMiddleware(handlers, middlewareFlags); + wrapRouteHandlers(handlers, middlewareFlags); } - // Prepend the Sentry request/response middleware. `routeMeta` is what the `matchedRoutes` getter - // exposes; its middleware arity keeps route-name resolution from picking it. + // Prepend the Sentry request/response middleware. Its arity marks it as a middleware, so its `/*` + // never becomes the route name. const routeMeta = { basePath: '/', path: '/*', method: 'ALL', handler: requestMiddleware }; handlers.unshift([[requestMiddleware, routeMeta], {}]); } diff --git a/packages/server-utils/src/integrations/hono/honoTypes.ts b/packages/server-utils/src/integrations/hono/honoTypes.ts index a4dc01e14152..6ace7bb8f595 100644 --- a/packages/server-utils/src/integrations/hono/honoTypes.ts +++ b/packages/server-utils/src/integrations/hono/honoTypes.ts @@ -29,15 +29,16 @@ export interface HonoRoute { handler: (...args: any[]) => any; } +// One entry of Hono's `matchResult[0]`. +export type MatchedHandler = [[handler: HonoRoute['handler'], route: HonoRoute], paramIndexMap: unknown]; + export interface HonoRequest { raw: Request; method: string; path: string; routeIndex: number; - // These are public (though deprecated) getters on Hono's request. Reading them avoids a runtime - // import of the `hono/route` helpers, which are their non-deprecated replacements. - matchedRoutes: HonoRoute[]; - routePath: string; + // Deprecated getter that Hono 5 removes. + matchedRoutes?: HonoRoute[]; [key: string]: any; } diff --git a/packages/server-utils/src/integrations/hono/isMiddleware.ts b/packages/server-utils/src/integrations/hono/isMiddleware.ts index 9c18e43e8602..ee7ad8d08afa 100644 --- a/packages/server-utils/src/integrations/hono/isMiddleware.ts +++ b/packages/server-utils/src/integrations/hono/isMiddleware.ts @@ -1,3 +1,5 @@ +import type { HonoRoute } from './honoTypes'; + // Hono stores the original (unwrapped) handler under this key when it wraps a sub-app's handlers // for a custom `onError`. Unwrapping it lets us inspect the real handler instead of the wrapper. // See https://github.com/honojs/hono/blob/9f0dadf141a3242a6c3b77462c7d33c6ce0f599d/src/hono-base.ts#L224-L226 @@ -12,7 +14,7 @@ const COMPOSED_HANDLER = '__COMPOSED_HANDLER'; * https://github.com/honojs/hono/blob/97c6fe1f12298c715eb7b2da65b4b6e0d81682bb/src/utils/handler.ts#L8 * * This is only one signal — callers that must classify inline middleware sharing a method+path with - * their handler (e.g. `wrapSubAppMiddleware`) additionally need positional information. + * their handler additionally need positional information (`getMiddlewareFlags`). */ export function isMiddleware(handler: unknown): boolean { if (typeof handler !== 'function') { @@ -24,3 +26,19 @@ export function isMiddleware(handler: unknown): boolean { return (original as (...args: unknown[]) => unknown).length >= 2; } + +/** + * Tells for each route if it is a middleware. Hono has no flag for this. In a method+path group, the + * last handler is the route handler (`app.get('/path', mw, handler)`). `.use()` adds middleware with + * method `ALL` and no route handler, so for `ALL` routes the arity also counts. Arity alone is not + * enough, because a route handler can declare an unused `next`. + */ +export function getMiddlewareFlags(routes: HonoRoute[]): boolean[] { + // `\0` cannot appear in an HTTP method or a URL path. + const groupKey = (route: HonoRoute): string => `${route.method}\0${route.path}`; + const lastIndexByKey = new Map(routes.map((route, i) => [groupKey(route), i])); + + return routes.map( + (route, i) => lastIndexByKey.get(groupKey(route)) !== i || (route.method === 'ALL' && isMiddleware(route.handler)), + ); +} diff --git a/packages/server-utils/src/integrations/hono/matchResult.ts b/packages/server-utils/src/integrations/hono/matchResult.ts new file mode 100644 index 000000000000..a277fe117c5e --- /dev/null +++ b/packages/server-utils/src/integrations/hono/matchResult.ts @@ -0,0 +1,124 @@ +import { addNonEnumerableProperty, isPlainObject } from '@sentry/core'; +import type { Context, HonoRoute, MatchedHandler } from './honoTypes'; +import { isMiddleware } from './isMiddleware'; + +// The `Context` key for the `routeIndex` of the last route handler that started. `Symbol.for`, so that +// other copies of `@sentry/server-utils` can read it. +const ROUTE_HANDLER_INDEX = Symbol.for('sentry.hono.routeHandlerIndex'); + +// Marks the index wrapper, so that a handler gets it only once. The router reuses the same match result +// for each request to a route. Span wrappers do not count, because a route handler from `app.use()` +// has a span wrapper and still needs the index wrapper. +const RECORDS_ROUTE_HANDLER_INDEX = Symbol.for('sentry.hono.recordsRouteHandlerIndex'); + +// The `Context` key for the callback that runs when a route handler starts. +const ON_ROUTE_HANDLER_START = Symbol.for('sentry.hono.onRouteHandlerStart'); + +function isMatchedHandler(entry: unknown): entry is MatchedHandler { + const pair: unknown = Array.isArray(entry) ? entry[0] : undefined; + if (!Array.isArray(pair)) { + return false; + } + + const [handler, route]: unknown[] = pair; + return ( + typeof handler === 'function' && + isPlainObject(route) && + typeof route.method === 'string' && + typeof route.path === 'string' && + typeof route.handler === 'function' + ); +} + +/** + * Checks the entry shape that `compose` and `hono/route` read. The match result is a Hono internal, so + * the SDK leaves other shapes alone. + */ +export function isMatchedHandlerList(handlers: unknown): handlers is MatchedHandler[] { + return Array.isArray(handlers) && handlers.every(isMatchedHandler); +} + +/** + * Reads the match result through the getter that `hono/route` uses (Hono 4.8 and later). `hono` does + * not export the getter's symbol, so this finds it as the symbol-keyed getter that returns a match + * result. + */ +export function getMatchedHandlers(context: Context): MatchedHandler[] | undefined { + const requestProto: object | null = Object.getPrototypeOf(context.req); + if (!requestProto) { + return undefined; + } + + for (const key of Object.getOwnPropertySymbols(requestProto)) { + if (typeof Object.getOwnPropertyDescriptor(requestProto, key)?.get !== 'function') { + continue; + } + const matchResult: unknown = Reflect.get(context.req, key); + if (Array.isArray(matchResult) && isMatchedHandlerList(matchResult[0])) { + return matchResult[0]; + } + } + return undefined; +} + +/** Runs `callback` each time a route handler of this request starts. */ +export function onRouteHandlerStart(context: Context, callback: () => void): void { + addNonEnumerableProperty(context, ON_ROUTE_HANDLER_START, callback); +} + +/** + * Wraps each route handler, so that it stores `c.req.routeIndex` when it starts. Hono 5 resets the + * index after `next()`, so the Sentry middleware cannot read it there. + * + * Only a route handler that declares `next` can pass the request on to a later one. If none does, the + * first route handler that runs also responds, so this wraps nothing and returns `false`. + * + * Calling this from the Sentry middleware itself works, because `compose` reads each handler from the + * match result only when it calls it. + */ +export function wrapRouteHandlers(handlers: MatchedHandler[], middlewareFlags: boolean[]): boolean { + // `isMiddleware()` checks the arity, so for a route handler it tells if the handler declares `next`. + const canPassOn = handlers.some(([[handler]], i) => !middlewareFlags[i] && isMiddleware(handler)); + if (!canPassOn) { + return false; + } + + for (const [i, [pair]] of handlers.entries()) { + if (!middlewareFlags[i]) { + pair[0] = recordRouteHandlerIndex(pair[0]); + } + } + return true; +} + +function recordRouteHandlerIndex(handler: HonoRoute['handler']): HonoRoute['handler'] { + if (Reflect.get(handler, RECORDS_ROUTE_HANDLER_INDEX)) { + return handler; + } + + return new Proxy(handler, { + apply(target, thisArg, args: [Context, ...unknown[]]) { + const [context] = args; + addNonEnumerableProperty(context, ROUTE_HANDLER_INDEX, context.req.routeIndex); + const onStart: unknown = Reflect.get(context, ON_ROUTE_HANDLER_START); + if (typeof onStart === 'function') { + onStart(); + } + return Reflect.apply(target, thisArg, args); + }, + get(target, prop, receiver) { + if (prop === RECORDS_ROUTE_HANDLER_INDEX) { + return true; + } + if (prop === '__sentry_original__') { + return handler; + } + return Reflect.get(target, prop, receiver); + }, + }); +} + +export function getRecordedRouteHandlerIndex(context: Context): number | undefined { + const index: unknown = Reflect.get(context, ROUTE_HANDLER_INDEX); + return typeof index === 'number' ? index : undefined; +} diff --git a/packages/server-utils/src/integrations/hono/middlewareHandlers.ts b/packages/server-utils/src/integrations/hono/middlewareHandlers.ts index d72d357b4c86..b2701495f12c 100644 --- a/packages/server-utils/src/integrations/hono/middlewareHandlers.ts +++ b/packages/server-utils/src/integrations/hono/middlewareHandlers.ts @@ -8,11 +8,14 @@ import { INTERNAL_setSegmentNameSourceIfSegment, updateSpanName, type Scope, + type Span, winterCGRequestToRequestData, } from '@sentry/core'; import type { Context, GetConnInfo } from './honoTypes'; import { hasFetchEvent } from './hono-context'; import { defaultShouldHandleError } from './defaultShouldHandleError'; +import { getMiddlewareFlags } from './isMiddleware'; +import { getMatchedHandlers, onRouteHandlerStart, wrapRouteHandlers } from './matchResult'; import { resolveRouteName } from './resolveRouteName'; import { type SentryHonoMiddlewareOptions } from './types'; import { HTTP_ROUTE } from '@sentry/conventions/attributes'; @@ -22,10 +25,25 @@ import { HTTP_ROUTE } from '@sentry/conventions/attributes'; */ export function requestHandler(context: Context, getConnInfo?: GetConnInfo): void { const isolationScope = getCurrentIsolationScope(); - - // Set a provisional route name as early as possible so events captured during the request already carry the route. - // It is re-resolved in `responseHandler` once the middleware chain has run. - updateSpanRouteName(isolationScope, context); + // The span to name. When a route handler starts, a span of the user can be the active one. + const span = getActiveSpan(); + + // Hono 5 removes `c.req.matchedRoutes`. Before Hono 4.8, it is the only source. + const matchedHandlers = getMatchedHandlers(context); + const routes = matchedHandlers?.map(([[, route]]) => route) ?? context.req.matchedRoutes ?? []; + const middlewareFlags = getMiddlewareFlags(routes); + const nameSpan = (): void => + updateSpanRouteName(isolationScope, span, context, resolveRouteName(context, routes, middlewareFlags)); + + // Name the span before the handlers run, so that events captured during the request carry the route. + nameSpan(); + + // The manual setup has no Context hook, so wrap the route handlers here. Without the getter, the + // Context hook may still have wrapped them. + if (!matchedHandlers || wrapRouteHandlers(matchedHandlers, middlewareFlags)) { + // A route handler can pass the request on with `next()`, so the one that responds names it. + onRouteHandlerStart(context, nameSpan); + } isolationScope.setSDKProcessingMetadata({ normalizedRequest: winterCGRequestToRequestData(hasFetchEvent(context) ? context.event.request : context.req.raw), @@ -87,17 +105,13 @@ export function responseHandler( context: Context, shouldHandleError?: SentryHonoMiddlewareOptions['shouldHandleError'], ): void { - // Overwrite the route name now that the middleware chain has run: `routeIndex` is accurate here - updateSpanRouteName(getCurrentIsolationScope(), context); - captureContextError(context, shouldHandleError); } /** * Captures an unhandled context error, gated by the user's `shouldHandleError` (or the default). * - * Split out from {@link responseHandler} so a deduplicated middleware can still report its own - * context's error without re-resolving the route name or overwriting request data — e.g. the inner + * A deduplicated middleware calls it directly to report the error of its own context — e.g. the inner * context of an internal `.request()` whose route threw but whose failed response the outer handler * swallowed. */ @@ -114,10 +128,18 @@ export function captureContextError( } } -function updateSpanRouteName(isolationScope: Scope, context: Context): void { - const route = resolveRouteName(context); +function updateSpanRouteName( + isolationScope: Scope, + activeSpan: Span | undefined, + context: Context, + route: string | undefined, +): void { + // Without a route, keep the span name from the runtime. + if (route === undefined) { + return; + } + const routeName = `${context.req.method} ${route}`; - const activeSpan = getActiveSpan(); if (activeSpan) { activeSpan.updateName(routeName); diff --git a/packages/server-utils/src/integrations/hono/patchRoute.ts b/packages/server-utils/src/integrations/hono/patchRoute.ts index 4f13b1b760ad..398df72fb924 100644 --- a/packages/server-utils/src/integrations/hono/patchRoute.ts +++ b/packages/server-utils/src/integrations/hono/patchRoute.ts @@ -2,7 +2,7 @@ import { debug, getOriginalFunction } from '@sentry/core'; import type { WrappedFunction } from '@sentry/core'; import { DEBUG_BUILD } from '../../debug-build'; import type { Hono, HonoRoute } from './honoTypes'; -import { isMiddleware } from './isMiddleware'; +import { getMiddlewareFlags } from './isMiddleware'; import { patchAppRequest } from './patchAppRequest'; import { wrapMiddlewareWithSpan } from './wrapMiddlewareSpan'; @@ -103,31 +103,12 @@ export function installRouteHookOnPrototype(honoBaseProto: HonoBaseProto): Route } /** - * Identifies middleware handlers in a sub-app's flat routes array and wraps them in spans. - * - * Heuristics (since Hono has no "isMiddleware" flag): - * 1. Position: `app.get('/path', mw, handler)` produces entries with the same method+path. - * All but the LAST are middleware (they call `next()`). - * 2. Arity (# of params) for method 'ALL': `.use()` handlers always have 2+ params (context, next), - * while `.all()` route handlers typically have 1 (`context` only). - * See: https://github.com/honojs/hono/blob/18fe604c8cefc2628240651b1af219692e1918c1/src/hono-base.ts#L156-L168 + * Wraps the middleware handlers of a sub-app's routes in spans. */ export function wrapSubAppMiddleware(routes: HonoRoute[]): void { - const lastIndexByKey = new Map(); - for (const [i, route] of routes.entries()) { - // \0 (null byte) is a collision-free delimiter: it cannot appear in a valid HTTP method name or URL path - lastIndexByKey.set(`${route.method}\0${route.path}`, i); - } - + const middlewareFlags = getMiddlewareFlags(routes); for (const [i, route] of routes.entries()) { - if (typeof route.handler !== 'function') { - continue; - } - - const isLastForGroup = lastIndexByKey.get(`${route.method}\0${route.path}`) === i; - - const isMW = !isLastForGroup || (route.method === 'ALL' && isMiddleware(route.handler)); - if (isMW) { + if (middlewareFlags[i] && typeof route.handler === 'function') { route.handler = wrapMiddlewareWithSpan(route.handler); } } diff --git a/packages/server-utils/src/integrations/hono/resolveRouteName.ts b/packages/server-utils/src/integrations/hono/resolveRouteName.ts index 97b1283722ed..1b9877586d0d 100644 --- a/packages/server-utils/src/integrations/hono/resolveRouteName.ts +++ b/packages/server-utils/src/integrations/hono/resolveRouteName.ts @@ -1,50 +1,42 @@ import type { Context, HonoRoute } from './honoTypes'; -import { isMiddleware } from './isMiddleware'; - -// Arity alone is enough here (unlike `wrapSubAppMiddleware` in patchRoute.ts, which also needs position) -// We only want the path, and inline middleware shares its handler's path. -function isRouteHandler(handler: unknown): boolean { - return typeof handler === 'function' && !isMiddleware(handler); -} - -// Read the request's own matched routes rather than importing the `hono/route` helpers, so the -// instrumentation needs no runtime import from `hono`. `c.req.matchedRoutes` and `c.req.routePath` -// are the (deprecated but public) getters those helpers wrap; `routePath(c, index)` is reimplemented -// here as `matchedRoutes(c).at(index)?.path` so an arbitrary index (e.g. -1) can be resolved. -function matchedRoutes(context: Context): HonoRoute[] { - return context.req.matchedRoutes ?? []; -} - -function routePath(context: Context, index?: number): string { - const routes = matchedRoutes(context); - return routes.at(index ?? context.req.routeIndex)?.path ?? ''; -} +import { SENTRY_HONO_MIDDLEWARE } from './constants'; +import { getRecordedRouteHandlerIndex } from './matchResult'; /** - * Resolves the route path of the matched handler for the transaction name. + * Returns the route path of the handler that responded, or `undefined` if the request has no route. * - * Picking the handler (not just `routePath`) avoids two failure modes: a catch-all middleware - * registered after the handlers (`routePath(c, -1)` would return just `/*`), and a middleware that - * short-circuits before the handler runs (`routePath(c)` would return the middleware's path). + * The last matched route is not enough, because a catch-all registered later would name every + * request `/*`. */ -export function resolveRouteName(context: Context): string { - const routes = matchedRoutes(context); +export function resolveRouteName( + context: Context, + routes: HonoRoute[], + middlewareFlags: boolean[], +): string | undefined { + const recordedIndex = getRecordedRouteHandlerIndex(context); + const recorded = recordedIndex === undefined ? undefined : routes[recordedIndex]; + if (recorded) { + return recorded.path; + } - // Trust routeIndex when it lands on a handler - to disambiguate overlapping handlers. - const current = routes[context.req.routeIndex]; - if (current && isRouteHandler(current.handler)) { - return current.path; + // No route handler that can pass the request on ran. Hono runs the first matched route handler, or would + // have run it if a middleware had not returned early. + const firstRouteHandler = routes.find((_route, i) => !middlewareFlags[i]); + if (firstRouteHandler) { + return firstRouteHandler.path; } - // A middleware short-circuited, so routeIndex is stuck on it: fall back to the last matched handler. + // Only middleware matched, for example `app.use('/static/*', serveStatic())`. Its path is a route of + // the app. The Sentry middleware and a middleware for every path (`/*`) say nothing about the route. for (let i = routes.length - 1; i >= 0; i--) { const route = routes[i]; - if (route && isRouteHandler(route.handler)) { + if (route && route.path !== '/*' && !isSentryMiddleware(route.handler)) { return route.path; } } + return undefined; +} - // No handler matched (middleware-only path) - // Final matched route: https://hono.dev/docs/helpers/route#using-with-index-parameter - return routePath(context, -1); +function isSentryMiddleware(handler: unknown): boolean { + return typeof handler === 'function' && Boolean(Reflect.get(handler, SENTRY_HONO_MIDDLEWARE)); } diff --git a/packages/server-utils/src/integrations/hono/wrapMiddlewareSpan.ts b/packages/server-utils/src/integrations/hono/wrapMiddlewareSpan.ts index 86f96e05039c..29f8d97286a4 100644 --- a/packages/server-utils/src/integrations/hono/wrapMiddlewareSpan.ts +++ b/packages/server-utils/src/integrations/hono/wrapMiddlewareSpan.ts @@ -9,7 +9,7 @@ import { type WrappedFunction, } from '@sentry/core'; import { type MiddlewareHandler } from './honoTypes'; -import { SENTRY_HONO_MIDDLEWARE } from './createHonoMiddleware'; +import { SENTRY_HONO_MIDDLEWARE } from './constants'; import { defaultShouldHandleError } from './defaultShouldHandleError'; const MIDDLEWARE_ORIGIN = 'auto.middleware.hono'; diff --git a/packages/server-utils/src/orchestrion/config/hono.ts b/packages/server-utils/src/orchestrion/config/hono.ts index eda1a8084ff3..f56ff5a12039 100644 --- a/packages/server-utils/src/orchestrion/config/hono.ts +++ b/packages/server-utils/src/orchestrion/config/hono.ts @@ -8,6 +8,8 @@ const honoInstrumentationConfig: InstrumentationConfig[] = [ { // `new Context()` runs once per request: where the Sentry middleware is injected and matched // middleware handlers are wrapped for spans. + // TODO(v12): Raise the minimum Hono version to 4.4.9, here and in the `@sentry/hono` peer range. + // Before 4.4.9, Hono passes `matchResult` to `new HonoRequest()`, so this hook does nothing. channelName: 'context', module: { name: 'hono', versionRange: '>=4.0.0 <5', filePath: /^dist\/(?:cjs\/)?context\.js$/ }, functionQuery: { className: 'Context' }, diff --git a/yarn.lock b/yarn.lock index ef8f10db812c..a01932a72162 100644 --- a/yarn.lock +++ b/yarn.lock @@ -17184,10 +17184,15 @@ homedir-polyfill@^1.0.1: dependencies: parse-passwd "^1.0.0" -hono@^4.13.7: - version "4.13.7" - resolved "https://registry.yarnpkg.com/hono/-/hono-4.13.7.tgz#80fdab8326f03d02f049117fd968101933493600" - integrity sha512-c8/gF9ac8Y78/agExVocyLevgR+JlpNB444Py0FSX8pJoPdYUfUzRcXtYEYGwt6l19qIlVZPN5Mfsw9jFShmQQ== +"hono-5@npm:hono@5.0.0-rc.1": + version "5.0.0-rc.1" + resolved "https://sfw.security.sentry.io/npm/hono/-/hono-5.0.0-rc.1.tgz#7115abe957dd0a5b794e999267dec3a6b217196f" + integrity sha512-vTcfU9yAggXh/+ZSi8zpYbzYDubCgFnSt8d5afCAlgkd38dyYb3CVtOh8YWhS65NkEqBseCsTj/P8mceJ+7KNA== + +hono@4.13.9, hono@^4.13.7: + version "4.13.9" + resolved "https://sfw.security.sentry.io/npm/hono/-/hono-4.13.9.tgz#682943308ab2f228012460d596a9af58090a1528" + integrity sha512-7dMkQmZoC4E6F7AtaQSPhlWAdnBti+j7rreMZl8QB4jFiEhP9TWbGWUMi8WYzBCgmgulxuvLQupKqo+Co6Omyg== hookable@^5.5.3: version "5.5.3"