diff --git a/CHANGELOG.md b/CHANGELOG.md index 0e1bac4c474d..a0b161d35104 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,19 @@ - "You miss 100 percent of the chances you don't take. — Wayne Gretzky" — Michael Scott +## 10.53.1 + +- fix(core): Don't gate user data for streamed spans at scope read time ([#20827](https://github.com/getsentry/sentry-javascript/pull/20827)) +- fix(core): Include subpath type shims in published package ([#20835](https://github.com/getsentry/sentry-javascript/pull/20835)) +- ref(hono): Consolidate route patching and add clarification comments ([#20829](https://github.com/getsentry/sentry-javascript/pull/20829)) + +
+ Internal Changes + +- chore(deps): Bump next from 15.5.15 to 15.5.18 in /dev-packages/e2e-tests/test-applications/nextjs-15-intl ([#20821](https://github.com/getsentry/sentry-javascript/pull/20821)) + +
+ ## 10.53.0 ### Important Changes diff --git a/dev-packages/e2e-tests/test-applications/nextjs-15-intl/package.json b/dev-packages/e2e-tests/test-applications/nextjs-15-intl/package.json index fc3c7f813b5f..d935f67fa39e 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-15-intl/package.json +++ b/dev-packages/e2e-tests/test-applications/nextjs-15-intl/package.json @@ -15,7 +15,7 @@ "@types/node": "^18.19.1", "@types/react": "18.0.26", "@types/react-dom": "18.0.9", - "next": "15.5.15", + "next": "15.5.18", "next-intl": "^4.3.12", "react": "latest", "react-dom": "latest", diff --git a/packages/core/browser.d.ts b/packages/core/browser.d.ts new file mode 100644 index 000000000000..752c159c689d --- /dev/null +++ b/packages/core/browser.d.ts @@ -0,0 +1,4 @@ +// This file is a compatibility shim for TypeScript compilers that do not +// support the package.json `exports` field for resolving subpath exports. +// Note: `typesVersions` in package.json may redirect this to the downleveled variant. +export * from './build/types/browser'; diff --git a/packages/core/package.json b/packages/core/package.json index 2c24d45a3a1a..2aa3e941176a 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -12,7 +12,9 @@ "files": [ "/build", "browser.js", - "server.js" + "browser.d.ts", + "server.js", + "server.d.ts" ], "main": "build/cjs/index.js", "module": "build/esm/index.js", @@ -54,6 +56,12 @@ "<5.0": { "build/types/index.d.ts": [ "build/types-ts3.8/index.d.ts" + ], + "browser": [ + "build/types-ts3.8/browser.d.ts" + ], + "server": [ + "build/types-ts3.8/server.d.ts" ] } }, diff --git a/packages/core/server.d.ts b/packages/core/server.d.ts new file mode 100644 index 000000000000..7be138fe726e --- /dev/null +++ b/packages/core/server.d.ts @@ -0,0 +1,4 @@ +// This file is a compatibility shim for TypeScript compilers that do not +// support the package.json `exports` field for resolving subpath exports. +// Note: `typesVersions` in package.json may redirect this to the downleveled variant. +export * from './build/types/server'; diff --git a/packages/core/src/semanticAttributes.ts b/packages/core/src/semanticAttributes.ts index 02b6a4ec08a6..9eeed6378a5f 100644 --- a/packages/core/src/semanticAttributes.ts +++ b/packages/core/src/semanticAttributes.ts @@ -53,13 +53,13 @@ export const SEMANTIC_ATTRIBUTE_SENTRY_SDK_NAME = 'sentry.sdk.name'; /** The version of the Sentry SDK */ export const SEMANTIC_ATTRIBUTE_SENTRY_SDK_VERSION = 'sentry.sdk.version'; -/** The user ID (gated by sendDefaultPii) */ +/** The user ID */ export const SEMANTIC_ATTRIBUTE_USER_ID = 'user.id'; -/** The user email (gated by sendDefaultPii) */ +/** The user email */ export const SEMANTIC_ATTRIBUTE_USER_EMAIL = 'user.email'; -/** The user IP address (gated by sendDefaultPii) */ +/** The user IP address */ export const SEMANTIC_ATTRIBUTE_USER_IP_ADDRESS = 'user.ip_address'; -/** The user username (gated by sendDefaultPii) */ +/** The user username */ export const SEMANTIC_ATTRIBUTE_USER_USERNAME = 'user.name'; /** diff --git a/packages/core/src/tracing/spans/captureSpan.ts b/packages/core/src/tracing/spans/captureSpan.ts index bed3f1790740..afbc8ad60358 100644 --- a/packages/core/src/tracing/spans/captureSpan.ts +++ b/packages/core/src/tracing/spans/captureSpan.ts @@ -125,7 +125,7 @@ function applyCommonSpanAttributes( scopeData: ScopeData, ): void { const sdk = client.getSdkMetadata(); - const { release, environment, sendDefaultPii } = client.getOptions(); + const { release, environment } = client.getOptions(); // avoid overwriting any previously set attributes (from users or potentially our SDK instrumentation) safeSetSpanJSONAttributes(spanJSON, { @@ -135,14 +135,10 @@ function applyCommonSpanAttributes( [SEMANTIC_ATTRIBUTE_SENTRY_SEGMENT_ID]: serializedSegmentSpan.span_id, [SEMANTIC_ATTRIBUTE_SENTRY_SDK_NAME]: sdk?.sdk?.name, [SEMANTIC_ATTRIBUTE_SENTRY_SDK_VERSION]: sdk?.sdk?.version, - ...(sendDefaultPii - ? { - [SEMANTIC_ATTRIBUTE_USER_ID]: scopeData.user?.id, - [SEMANTIC_ATTRIBUTE_USER_EMAIL]: scopeData.user?.email, - [SEMANTIC_ATTRIBUTE_USER_IP_ADDRESS]: scopeData.user?.ip_address, - [SEMANTIC_ATTRIBUTE_USER_USERNAME]: scopeData.user?.username, - } - : {}), + [SEMANTIC_ATTRIBUTE_USER_ID]: scopeData.user?.id, + [SEMANTIC_ATTRIBUTE_USER_EMAIL]: scopeData.user?.email, + [SEMANTIC_ATTRIBUTE_USER_IP_ADDRESS]: scopeData.user?.ip_address, + [SEMANTIC_ATTRIBUTE_USER_USERNAME]: scopeData.user?.username, ...scopeData.attributes, }); } diff --git a/packages/core/test/lib/tracing/spans/captureSpan.test.ts b/packages/core/test/lib/tracing/spans/captureSpan.test.ts index 186f7f23a536..524f3287e82c 100644 --- a/packages/core/test/lib/tracing/spans/captureSpan.test.ts +++ b/packages/core/test/lib/tracing/spans/captureSpan.test.ts @@ -25,179 +25,102 @@ import { inferSpanDataFromOtelAttributes, safeSetSpanJSONAttributes } from '../. import { getDefaultTestClientOptions, TestClient } from '../../../mocks/client'; describe('captureSpan', () => { - it('captures user attributes iff sendDefaultPii is true', () => { - const client = new TestClient( - getDefaultTestClientOptions({ - dsn: 'https://dsn@ingest.f00.f00/1', - tracesSampleRate: 1, - release: '1.0.0', - environment: 'staging', - sendDefaultPii: true, - }), - ); - - const span = withScope(scope => { - scope.setClient(client); - scope.setUser({ - id: '123', - email: 'user@example.com', - username: 'testuser', - ip_address: '127.0.0.1', - }); - - const span = startInactiveSpan({ name: 'my-span', attributes: { 'sentry.op': 'http.client' } }); - span.end(); - - return span; - }); - - const serializedSpan = captureSpan(span, client); + it.each([true, false, undefined])( + 'always applies scope user attributes to spans (sendDefaultPii: %s)', + sendDefaultPii => { + const client = new TestClient( + getDefaultTestClientOptions({ + dsn: 'https://dsn@ingest.f00.f00/1', + tracesSampleRate: 1, + release: '1.0.0', + environment: 'staging', + sendDefaultPii, + }), + ); - expect(serializedSpan).toStrictEqual({ - span_id: expect.stringMatching(/^[\da-f]{16}$/), - trace_id: expect.stringMatching(/^[\da-f]{32}$/), - parent_span_id: undefined, - links: undefined, - start_timestamp: expect.any(Number), - name: 'my-span', - end_timestamp: expect.any(Number), - status: 'ok', - is_segment: true, - attributes: { - [SEMANTIC_ATTRIBUTE_SENTRY_OP]: { - type: 'string', - value: 'http.client', - }, - [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: { - type: 'string', - value: 'manual', - }, - [SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE]: { - type: 'integer', - value: 1, - }, - [SEMANTIC_ATTRIBUTE_SENTRY_SEGMENT_NAME]: { - value: 'my-span', - type: 'string', - }, - [SEMANTIC_ATTRIBUTE_SENTRY_SEGMENT_ID]: { - value: span.spanContext().spanId, - type: 'string', - }, - 'sentry.span.source': { - value: 'custom', - type: 'string', - }, - [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: { - value: 'custom', - type: 'string', - }, - [SEMANTIC_ATTRIBUTE_SENTRY_RELEASE]: { - value: '1.0.0', - type: 'string', - }, - [SEMANTIC_ATTRIBUTE_SENTRY_ENVIRONMENT]: { - value: 'staging', - type: 'string', - }, - [SEMANTIC_ATTRIBUTE_USER_ID]: { - value: '123', - type: 'string', - }, - [SEMANTIC_ATTRIBUTE_USER_EMAIL]: { - value: 'user@example.com', - type: 'string', - }, - [SEMANTIC_ATTRIBUTE_USER_USERNAME]: { - value: 'testuser', - type: 'string', - }, - [SEMANTIC_ATTRIBUTE_USER_IP_ADDRESS]: { - value: '127.0.0.1', - type: 'string', - }, - }, - _segmentSpan: span, - }); - }); + const span = withScope(scope => { + scope.setClient(client); + scope.setUser({ + id: '123', + email: 'user@example.com', + username: 'testuser', + ip_address: '127.0.0.1', + }); - it.each([false, undefined])("doesn't capture user attributes if sendDefaultPii is %s", sendDefaultPii => { - const client = new TestClient( - getDefaultTestClientOptions({ - dsn: 'https://dsn@ingest.f00.f00/1', - tracesSampleRate: 1, - release: '1.0.0', - environment: 'staging', - sendDefaultPii, - }), - ); + const span = startInactiveSpan({ name: 'my-span', attributes: { 'sentry.op': 'http.client' } }); + span.end(); - const span = withScope(scope => { - scope.setClient(client); - scope.setUser({ - id: '123', - email: 'user@example.com', - username: 'testuser', - ip_address: '127.0.0.1', + return span; }); - const span = startInactiveSpan({ name: 'my-span', attributes: { 'sentry.op': 'http.client' } }); - span.end(); - - return span; - }); - - expect(captureSpan(span, client)).toStrictEqual({ - span_id: expect.stringMatching(/^[\da-f]{16}$/), - trace_id: expect.stringMatching(/^[\da-f]{32}$/), - parent_span_id: undefined, - links: undefined, - start_timestamp: expect.any(Number), - name: 'my-span', - end_timestamp: expect.any(Number), - status: 'ok', - is_segment: true, - attributes: { - [SEMANTIC_ATTRIBUTE_SENTRY_OP]: { - type: 'string', - value: 'http.client', - }, - [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: { - type: 'string', - value: 'manual', - }, - [SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE]: { - type: 'integer', - value: 1, - }, - [SEMANTIC_ATTRIBUTE_SENTRY_SEGMENT_NAME]: { - value: 'my-span', - type: 'string', - }, - [SEMANTIC_ATTRIBUTE_SENTRY_SEGMENT_ID]: { - value: span.spanContext().spanId, - type: 'string', - }, - 'sentry.span.source': { - value: 'custom', - type: 'string', - }, - [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: { - value: 'custom', - type: 'string', - }, - [SEMANTIC_ATTRIBUTE_SENTRY_RELEASE]: { - value: '1.0.0', - type: 'string', - }, - [SEMANTIC_ATTRIBUTE_SENTRY_ENVIRONMENT]: { - value: 'staging', - type: 'string', + expect(captureSpan(span, client)).toStrictEqual({ + span_id: expect.stringMatching(/^[\da-f]{16}$/), + trace_id: expect.stringMatching(/^[\da-f]{32}$/), + parent_span_id: undefined, + links: undefined, + start_timestamp: expect.any(Number), + name: 'my-span', + end_timestamp: expect.any(Number), + status: 'ok', + is_segment: true, + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: { + type: 'string', + value: 'http.client', + }, + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: { + type: 'string', + value: 'manual', + }, + [SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE]: { + type: 'integer', + value: 1, + }, + [SEMANTIC_ATTRIBUTE_SENTRY_SEGMENT_NAME]: { + value: 'my-span', + type: 'string', + }, + [SEMANTIC_ATTRIBUTE_SENTRY_SEGMENT_ID]: { + value: span.spanContext().spanId, + type: 'string', + }, + 'sentry.span.source': { + value: 'custom', + type: 'string', + }, + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: { + value: 'custom', + type: 'string', + }, + [SEMANTIC_ATTRIBUTE_SENTRY_RELEASE]: { + value: '1.0.0', + type: 'string', + }, + [SEMANTIC_ATTRIBUTE_SENTRY_ENVIRONMENT]: { + value: 'staging', + type: 'string', + }, + [SEMANTIC_ATTRIBUTE_USER_ID]: { + value: '123', + type: 'string', + }, + [SEMANTIC_ATTRIBUTE_USER_EMAIL]: { + value: 'user@example.com', + type: 'string', + }, + [SEMANTIC_ATTRIBUTE_USER_USERNAME]: { + value: 'testuser', + type: 'string', + }, + [SEMANTIC_ATTRIBUTE_USER_IP_ADDRESS]: { + value: '127.0.0.1', + type: 'string', + }, }, - }, - _segmentSpan: span, - }); - }); + _segmentSpan: span, + }); + }, + ); it('captures sdk name and version if available', () => { const client = new TestClient( @@ -286,6 +209,22 @@ describe('captureSpan', () => { value: '1.0.0', type: 'string', }, + [SEMANTIC_ATTRIBUTE_USER_ID]: { + value: '123', + type: 'string', + }, + [SEMANTIC_ATTRIBUTE_USER_EMAIL]: { + value: 'user@example.com', + type: 'string', + }, + [SEMANTIC_ATTRIBUTE_USER_USERNAME]: { + value: 'testuser', + type: 'string', + }, + [SEMANTIC_ATTRIBUTE_USER_IP_ADDRESS]: { + value: '127.0.0.1', + type: 'string', + }, }, _segmentSpan: span, }); diff --git a/packages/hono/src/shared/applyPatches.ts b/packages/hono/src/shared/applyPatches.ts index 1b694ca7cfa5..9ad70c1dd62a 100644 --- a/packages/hono/src/shared/applyPatches.ts +++ b/packages/hono/src/shared/applyPatches.ts @@ -1,14 +1,18 @@ import type { Env, Hono } from 'hono'; -import { patchAppUse } from '../shared/patchAppUse'; -import { patchRoute } from '../shared/patchRoute'; +import { patchAppUse } from './patchAppUse'; +import { installRouteHookOnPrototype } from './patchRoute'; /** - * Applies necessary patches to the Hono app to ensure that Sentry can properly trace middleware and route handlers. + * Instruments a Hono app instance for Sentry tracing in middleware and route handlers. + * + * Two strategies are needed because Hono mixes instance fields and prototype methods: + * - `use` is a per-instance class field (instance own property) → must be patched on the instance + * - `route` is a prototype method → patched once globally, covers all instances */ export function applyPatches(app: Hono): void { // `app.use` (instance own property) — wraps middleware at registration time on this instance. patchAppUse(app); - //`HonoBase.prototype.route` — wraps sub-app middleware at mount time so that route groups (`app.route('/prefix', subApp)`) are also instrumented. - patchRoute(app); + // `route()` lives on the shared prototype and is patched once globally. + installRouteHookOnPrototype(); } diff --git a/packages/hono/src/shared/middlewareHandlers.ts b/packages/hono/src/shared/middlewareHandlers.ts index 03bb6e16da58..7f961928df49 100644 --- a/packages/hono/src/shared/middlewareHandlers.ts +++ b/packages/hono/src/shared/middlewareHandlers.ts @@ -43,6 +43,8 @@ export function responseHandler(context: Context): void { function updateSpanRouteName(isolationScope: Scope, context: Context): void { const activeSpan = getActiveSpan(); + + // Final matched route: https://hono.dev/docs/helpers/route#using-with-index-parameter const lastMatchedRoute = routePath(context, -1); if (activeSpan) { diff --git a/packages/hono/src/shared/patchAppUse.ts b/packages/hono/src/shared/patchAppUse.ts index c0d620692278..8a6c4f46c40a 100644 --- a/packages/hono/src/shared/patchAppUse.ts +++ b/packages/hono/src/shared/patchAppUse.ts @@ -2,7 +2,9 @@ import { wrapMiddlewareWithSpan } from './wrapMiddlewareSpan'; import type { Env, Hono, MiddlewareHandler } from 'hono'; /** - * Patches the Hono app so that middleware is automatically traced as Sentry spans. + * Patches `app.use` (instance own property) on a Hono instance to instrument middleware at registration time. + * + * Must be per-instance because `use` is a class field, not a prototype method. */ export function patchAppUse(app: Hono): void { app.use = new Proxy(app.use, { diff --git a/packages/hono/src/shared/patchRoute.ts b/packages/hono/src/shared/patchRoute.ts index d3f732e30793..bdc1822dad4e 100644 --- a/packages/hono/src/shared/patchRoute.ts +++ b/packages/hono/src/shared/patchRoute.ts @@ -1,6 +1,7 @@ import { getOriginalFunction, markFunctionWrapped } from '@sentry/core'; import type { WrappedFunction } from '@sentry/core'; -import type { Env, Hono, MiddlewareHandler } from 'hono'; +import type { Hono, MiddlewareHandler } from 'hono'; +import { Hono as HonoClass } from 'hono'; import { wrapMiddlewareWithSpan } from './wrapMiddlewareSpan'; interface HonoRoute { @@ -15,18 +16,20 @@ interface HonoBaseProto { } /** - * Patches `HonoBase.prototype.route` so that when a sub-app is mounted via `app.route('/prefix', subApp)`, its middleware handlers - * are retroactively wrapped in Sentry spans before the parent copies them. + * Patches `route()` on the Hono base prototype once, globally. * - * `route` lives on the prototype (unlike `use` which is a class field) + * Wraps sub-app middleware at mount time so that `app.route('/prefix', subApp)` is traced. + * Idempotent: safe to call multiple times. */ -export function patchRoute(app: Hono): void { - const honoBaseProto = Object.getPrototypeOf(Object.getPrototypeOf(app)) as HonoBaseProto; +export function installRouteHookOnPrototype(): void { + // `route` is on the base prototype, not the concrete subclass, walk up one level + const honoBaseProto = Object.getPrototypeOf(HonoClass.prototype) as HonoBaseProto; if (!honoBaseProto || typeof honoBaseProto?.route !== 'function') { return; } - if (getOriginalFunction(honoBaseProto.route as WrappedFunction)) { + // Already patched: return + if (getOriginalFunction(honoBaseProto.route as unknown as WrappedFunction)) { return; } @@ -45,18 +48,13 @@ export function patchRoute(app: Hono): void { } /** - * Figures out which handlers in a sub-app's flat routes array are middleware (and should get a span), then wraps them. + * Identifies middleware handlers in a sub-app's flat routes array and wraps them in spans. * - * The challenge: Hono stores every handler as a plain { method, path, handler } entry. There is no "isMiddleware" flag. - * Two heuristics identify middleware: - * - * 1. Position within a group. `app.get('/path', mw, handler)` produces two entries with the same method+path. - * All but the last one must be middleware, because only middleware calls `next()` to pass control to the next handler. - * - * 2. Function arity (# of params) for method 'ALL'. Both `.use()` and `.all()` store their handlers under method 'ALL', - * so we can't use position alone to tell them apart when one is the last (or only) entry in its group. - * The deciding factor: Hono's `.use()` only accepts `(context, next)` (handlers with 2+ params). While `.all()` route - * handlers typically only accept `(context)`. + * 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 */ export function wrapSubAppMiddleware(routes: HonoRoute[]): void { diff --git a/packages/hono/test/shared/applyPatches.test.ts b/packages/hono/test/shared/applyPatches.test.ts new file mode 100644 index 000000000000..f5ea5a27cbc6 --- /dev/null +++ b/packages/hono/test/shared/applyPatches.test.ts @@ -0,0 +1,394 @@ +import * as SentryCore from '@sentry/core'; +import { Hono } from 'hono'; +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'; +import { applyPatches } from '../../src/shared/applyPatches'; + +vi.mock('@sentry/core', async () => { + const actual = await vi.importActual('@sentry/core'); + return { + ...actual, + startInactiveSpan: vi.fn((_opts: unknown) => ({ + setStatus: vi.fn(), + end: vi.fn(), + })), + }; +}); + +const startInactiveSpanMock = SentryCore.startInactiveSpan as ReturnType; + +const honoBaseProto = Object.getPrototypeOf(Object.getPrototypeOf(new Hono())); +const originalRoute = honoBaseProto.route; + +describe('applyPatches', () => { + beforeEach(() => { + vi.clearAllMocks(); + honoBaseProto.route = originalRoute; + }); + + afterAll(() => { + honoBaseProto.route = originalRoute; + }); + + describe('wrapSubAppMiddleware', () => { + it('does nothing when a sub-app has an empty routes array', async () => { + const app = new Hono(); + applyPatches(app); + + const emptySubApp = new Hono(); + app.route('/empty', emptySubApp); + + const res = await app.fetch(new Request('http://localhost/empty')); + expect(res.status).toBe(404); + expect(startInactiveSpanMock).not.toHaveBeenCalled(); + }); + + it('skips route entries whose handler is not a function', async () => { + const app = new Hono(); + applyPatches(app); + + const subApp = new Hono(); + subApp.get('/resource', () => new Response('ok')); + + (subApp.routes as unknown as Array<{ handler: unknown }>)[0]!.handler = 'not-a-function'; + + expect(() => app.route('/api', subApp)).not.toThrow(); + expect(startInactiveSpanMock).not.toHaveBeenCalled(); + }); + + it('treats same path with different HTTP methods as separate groups', async () => { + const app = new Hono(); + applyPatches(app); + + const subApp = new Hono(); + subApp.get('/resource', async function getHandler() { + return new Response('get'); + }); + subApp.post('/resource', async function postHandler() { + return new Response('post'); + }); + + app.route('/api', subApp); + + await app.fetch(new Request('http://localhost/api/resource', { method: 'GET' })); + await app.fetch(new Request('http://localhost/api/resource', { method: 'POST' })); + + expect(startInactiveSpanMock).not.toHaveBeenCalled(); + }); + + it('treats same HTTP method with different paths as separate groups', async () => { + const app = new Hono(); + applyPatches(app); + + const subApp = new Hono(); + subApp.get('/alpha', async function alphaHandler() { + return new Response('alpha'); + }); + subApp.get('/beta', async function betaHandler() { + return new Response('beta'); + }); + + app.route('/api', subApp); + + await app.fetch(new Request('http://localhost/api/alpha')); + await app.fetch(new Request('http://localhost/api/beta')); + + expect(startInactiveSpanMock).not.toHaveBeenCalled(); + }); + + it('wraps inline middleware for GET /alpha but not the sole handler for GET /beta', async () => { + const app = new Hono(); + applyPatches(app); + + const subApp = new Hono(); + subApp.get( + '/alpha', + async function alphaMw(_c: unknown, next: () => Promise) { + await next(); + }, + async function alphaHandler() { + return new Response('alpha'); + }, + ); + subApp.get('/beta', async function betaHandler() { + return new Response('beta'); + }); + + app.route('/api', subApp); + + await app.fetch(new Request('http://localhost/api/alpha')); + await app.fetch(new Request('http://localhost/api/beta')); + + const spanNames = startInactiveSpanMock.mock.calls.map((c: unknown[]) => (c[0] as { name: string }).name); + expect(spanNames).toHaveLength(1); + expect(spanNames).toContain('alphaMw'); + expect(spanNames).not.toContain('alphaHandler'); + expect(spanNames).not.toContain('betaHandler'); + }); + }); + + describe('route() patching', () => { + it('wraps middleware on sub-apps mounted via route()', async () => { + const app = new Hono(); + applyPatches(app); + + const subApp = new Hono(); + subApp.use(async function subMiddleware(_c: unknown, next: () => Promise) { + await next(); + }); + subApp.get('/', () => new Response('sub')); + + app.route('/sub', subApp); + + await app.fetch(new Request('http://localhost/sub')); + + expect(startInactiveSpanMock).toHaveBeenCalledWith(expect.objectContaining({ name: 'subMiddleware' })); + }); + + it('does not wrap sole route handlers on sub-apps', async () => { + const app = new Hono(); + applyPatches(app); + + const subApp = new Hono(); + subApp.get('/', () => new Response('sub')); + + app.route('/sub', subApp); + + await app.fetch(new Request('http://localhost/sub')); + + expect(startInactiveSpanMock).not.toHaveBeenCalled(); + }); + + it('does not double-wrap handlers already wrapped by patchAppUse on the main app', async () => { + const app = new Hono(); + applyPatches(app); + + app.use(async function mainMiddleware(_c: unknown, next: () => Promise) { + await next(); + }); + app.get('/', () => new Response('ok')); + + const parent = new Hono(); + parent.route('/', app); + + await parent.fetch(new Request('http://localhost/')); + + expect(startInactiveSpanMock).toHaveBeenCalledTimes(1); + expect(startInactiveSpanMock).toHaveBeenCalledWith(expect.objectContaining({ name: 'mainMiddleware' })); + }); + + it('does not patch route() twice when applyPatches is called multiple times', () => { + const app1 = new Hono(); + applyPatches(app1); + + const patchedRoute = honoBaseProto.route; + + const app2 = new Hono(); + applyPatches(app2); + + expect(honoBaseProto.route).toBe(patchedRoute); + }); + + it('stores the original route via __sentry_original__ for other libraries to unwrap', () => { + const app = new Hono(); + applyPatches(app); + + // oxlint-disable-next-line typescript/no-explicit-any + const sentryOriginal = (honoBaseProto.route as any).__sentry_original__; + expect(sentryOriginal).toBe(originalRoute); + }); + + it('wraps path-targeted .use("/path", handler) on sub-apps', async () => { + const app = new Hono(); + applyPatches(app); + + const subApp = new Hono(); + subApp.use('/admin/*', async function adminAuth(_c: unknown, next: () => Promise) { + await next(); + }); + subApp.get('/admin/dashboard', () => new Response('dashboard')); + + app.route('/api', subApp); + await app.fetch(new Request('http://localhost/api/admin/dashboard')); + + expect(startInactiveSpanMock).toHaveBeenCalledWith(expect.objectContaining({ name: 'adminAuth' })); + }); + + it('does not wrap .all() handlers with less than 2 params (they are route handlers, not middleware)', async () => { + const app = new Hono(); + applyPatches(app); + + const subApp = new Hono(); + subApp.all('/catch-all', async function allHandler() { + return new Response('catch-all'); + }); + + app.route('/api', subApp); + await app.fetch(new Request('http://localhost/api/catch-all')); + + expect(startInactiveSpanMock).not.toHaveBeenCalled(); + }); + + it('wraps .use() middleware but not .all() handlers on the same sub-app', async () => { + const app = new Hono(); + applyPatches(app); + + const subApp = new Hono(); + subApp.use(async function mw(_c: unknown, next: () => Promise) { + await next(); + }); + subApp.all('/wildcard', async function allRoute() { + return new Response('wildcard'); + }); + subApp.get('/specific', () => new Response('specific')); + + app.route('/mixed', subApp); + await app.fetch(new Request('http://localhost/mixed/wildcard')); + + const spanNames = startInactiveSpanMock.mock.calls.map((c: unknown[]) => (c[0] as { name: string }).name); + expect(spanNames).toContain('mw'); + expect(spanNames).not.toContain('allRoute'); + }); + + it('does not wrap sole .get()/.post()/.put()/.delete() handlers on sub-apps', async () => { + const app = new Hono(); + applyPatches(app); + + const subApp = new Hono(); + subApp.get('/resource', async function getHandler() { + return new Response('get'); + }); + subApp.post('/resource', async function postHandler() { + return new Response('post'); + }); + subApp.put('/resource', async function postHandler() { + return new Response('put'); + }); + subApp.delete('/resource', async function postHandler() { + return new Response('delete'); + }); + + app.route('/api', subApp); + await app.fetch(new Request('http://localhost/api/resource')); + + expect(startInactiveSpanMock).not.toHaveBeenCalled(); + }); + + it('wraps inline middleware in .get(path, mw, handler) on sub-apps', async () => { + const app = new Hono(); + applyPatches(app); + + const subApp = new Hono(); + subApp.get( + '/resource', + async function inlineMw(_c: unknown, next: () => Promise) { + await next(); + }, + async function getHandler() { + return new Response('get'); + }, + ); + + app.route('/api', subApp); + await app.fetch(new Request('http://localhost/api/resource')); + + const spanNames = startInactiveSpanMock.mock.calls.map((c: unknown[]) => (c[0] as { name: string }).name); + expect(spanNames).toContain('inlineMw'); + expect(spanNames).not.toContain('getHandler'); + }); + + it('wraps separately registered middleware for .get() on sub-apps', async () => { + const app = new Hono(); + applyPatches(app); + + const subApp = new Hono(); + subApp.get('/resource', async function separateMw(_c: unknown, next: () => Promise) { + await next(); + }); + subApp.get('/resource', async function getHandler() { + return new Response('get'); + }); + + app.route('/api', subApp); + await app.fetch(new Request('http://localhost/api/resource')); + + const spanNames = startInactiveSpanMock.mock.calls.map((c: unknown[]) => (c[0] as { name: string }).name); + expect(spanNames).toContain('separateMw'); + expect(spanNames).not.toContain('getHandler'); + }); + + it('wraps inline middleware registered via .on() on sub-apps', async () => { + const app = new Hono(); + applyPatches(app); + + const subApp = new Hono(); + subApp.on( + 'GET', + '/resource', + async function onMw(_c: unknown, next: () => Promise) { + await next(); + }, + async function onHandler() { + return new Response('on'); + }, + ); + + app.route('/api', subApp); + await app.fetch(new Request('http://localhost/api/resource')); + + const spanNames = startInactiveSpanMock.mock.calls.map((c: unknown[]) => (c[0] as { name: string }).name); + expect(spanNames).toContain('onMw'); + expect(spanNames).not.toContain('onHandler'); + }); + + it('wraps middleware in nested sub-apps (sub-app mounting another sub-app)', async () => { + const app = new Hono(); + applyPatches(app); + + const innerSub = new Hono(); + innerSub.use(async function innerMiddleware(_c: unknown, next: () => Promise) { + await next(); + }); + innerSub.get('/', () => new Response('inner')); + + const outerSub = new Hono(); + outerSub.use(async function outerMiddleware(_c: unknown, next: () => Promise) { + await next(); + }); + outerSub.route('/inner', innerSub); + + app.route('/outer', outerSub); + await app.fetch(new Request('http://localhost/outer/inner')); + + const spanNames = startInactiveSpanMock.mock.calls.map((c: unknown[]) => (c[0] as { name: string }).name); + expect(spanNames).toContain('outerMiddleware'); + expect(spanNames).toContain('innerMiddleware'); + }); + + it('handles sub-app with multiple path-targeted middleware for different paths', async () => { + const app = new Hono(); + applyPatches(app); + + const subApp = new Hono(); + subApp.use('/a/*', async function mwForA(_c: unknown, next: () => Promise) { + await next(); + }); + subApp.use('/b/*', async function mwForB(_c: unknown, next: () => Promise) { + await next(); + }); + subApp.get('/a/test', () => new Response('a')); + subApp.get('/b/test', () => new Response('b')); + + app.route('/sub', subApp); + + await app.fetch(new Request('http://localhost/sub/a/test')); + expect(startInactiveSpanMock).toHaveBeenCalledTimes(1); + expect(startInactiveSpanMock).toHaveBeenCalledWith(expect.objectContaining({ name: 'mwForA' })); + + startInactiveSpanMock.mockClear(); + + await app.fetch(new Request('http://localhost/sub/b/test')); + expect(startInactiveSpanMock).toHaveBeenCalledTimes(1); + expect(startInactiveSpanMock).toHaveBeenCalledWith(expect.objectContaining({ name: 'mwForB' })); + }); + }); +}); diff --git a/packages/hono/test/shared/patchAppUse.test.ts b/packages/hono/test/shared/patchAppUse.test.ts index ee376127baaa..a65c9c526a83 100644 --- a/packages/hono/test/shared/patchAppUse.test.ts +++ b/packages/hono/test/shared/patchAppUse.test.ts @@ -1,8 +1,7 @@ import * as SentryCore from '@sentry/core'; import { Hono } from 'hono'; -import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; import { patchAppUse } from '../../src/shared/patchAppUse'; -import { patchRoute } from '../../src/shared/patchRoute'; vi.mock('@sentry/core', async () => { const actual = await vi.importActual('@sentry/core'); @@ -19,18 +18,11 @@ vi.mock('@sentry/core', async () => { const startInactiveSpanMock = SentryCore.startInactiveSpan as ReturnType; const captureExceptionMock = SentryCore.captureException as ReturnType; -const honoBaseProto = Object.getPrototypeOf(Object.getPrototypeOf(new Hono())); -const originalRoute = honoBaseProto.route; - describe('patchAppUse (middleware spans)', () => { beforeEach(() => { vi.clearAllMocks(); }); - afterAll(() => { - honoBaseProto.route = originalRoute; - }); - it('wraps handlers in app.use(handler) so startInactiveSpan is called when middleware runs', async () => { const app = new Hono(); patchAppUse(app); @@ -84,7 +76,7 @@ describe('patchAppUse (middleware spans)', () => { await app.fetch(new Request('http://localhost/')); expect(startInactiveSpanMock).toHaveBeenCalledTimes(1); - const name = startInactiveSpanMock.mock.calls[0][0].name; + const name = startInactiveSpanMock.mock.calls[0]![0].name; expect(name).toMatch(''); }); }); @@ -135,12 +127,12 @@ describe('patchAppUse (middleware spans)', () => { expect(startInactiveSpanMock).toHaveBeenCalledTimes(3); const [firstCall, secondCall, thirdCall] = startInactiveSpanMock.mock.calls; - expect(firstCall[0]).toMatchObject({ op: 'middleware.hono' }); - expect(secondCall[0]).toMatchObject({ op: 'middleware.hono' }); - expect(firstCall[0].name).toMatch(''); - expect(secondCall[0].name).toBe('namedMiddleware'); - expect(thirdCall[0].name).toBe(''); - expect(firstCall[0].name).not.toBe(secondCall[0].name); + expect(firstCall![0]).toMatchObject({ op: 'middleware.hono' }); + expect(secondCall![0]).toMatchObject({ op: 'middleware.hono' }); + expect(firstCall![0].name).toMatch(''); + expect(secondCall![0].name).toBe('namedMiddleware'); + expect(thirdCall![0].name).toBe(''); + expect(firstCall![0].name).not.toBe(secondCall![0].name); }); it('preserves this context when calling the original use (Proxy forwards thisArg)', () => { @@ -163,289 +155,4 @@ describe('patchAppUse (middleware spans)', () => { expect(fakeApp._capturedThis).toBe(fakeApp); }); - - describe('route() patching (sub-app / route group support)', () => { - beforeEach(() => { - honoBaseProto.route = originalRoute; - }); - - it('wraps middleware on sub-apps mounted via route()', async () => { - const app = new Hono(); - patchAppUse(app); - patchRoute(app); - - const subApp = new Hono(); - subApp.use(async function subMiddleware(_c: unknown, next: () => Promise) { - await next(); - }); - subApp.get('/', () => new Response('sub')); - - app.route('/sub', subApp); - - await app.fetch(new Request('http://localhost/sub')); - - expect(startInactiveSpanMock).toHaveBeenCalledWith(expect.objectContaining({ name: 'subMiddleware' })); - }); - - it('does not wrap sole route handlers on sub-apps', async () => { - const app = new Hono(); - patchAppUse(app); - patchRoute(app); - - const subApp = new Hono(); - subApp.get('/', () => new Response('sub')); - - app.route('/sub', subApp); - - await app.fetch(new Request('http://localhost/sub')); - - expect(startInactiveSpanMock).not.toHaveBeenCalled(); - }); - - it('does not double-wrap handlers already wrapped by patchAppUse on the main app', async () => { - const app = new Hono(); - patchAppUse(app); - patchRoute(app); - - app.use(async function mainMiddleware(_c: unknown, next: () => Promise) { - await next(); - }); - app.get('/', () => new Response('ok')); - - // Mount the main app as a sub-app of another app (contrived but tests the guard) - const parent = new Hono(); - parent.route('/', app); - - await parent.fetch(new Request('http://localhost/')); - - expect(startInactiveSpanMock).toHaveBeenCalledTimes(1); - expect(startInactiveSpanMock).toHaveBeenCalledWith(expect.objectContaining({ name: 'mainMiddleware' })); - }); - - it('does not patch route() twice when patchRoute is called multiple times', () => { - const app1 = new Hono(); - patchRoute(app1); - - const patchedRoute = honoBaseProto.route; - - const app2 = new Hono(); - patchRoute(app2); - - expect(honoBaseProto.route).toBe(patchedRoute); - }); - - it('stores the original route via __sentry_original__ for other libraries to unwrap', () => { - const app = new Hono(); - patchRoute(app); - - // oxlint-disable-next-line typescript/no-explicit-any - const sentryOriginal = (honoBaseProto.route as any).__sentry_original__; - expect(sentryOriginal).toBe(originalRoute); - }); - - it('wraps path-targeted .use("/path", handler) on sub-apps', async () => { - const app = new Hono(); - patchAppUse(app); - patchRoute(app); - - const subApp = new Hono(); - subApp.use('/admin/*', async function adminAuth(_c: unknown, next: () => Promise) { - await next(); - }); - subApp.get('/admin/dashboard', () => new Response('dashboard')); - - app.route('/api', subApp); - await app.fetch(new Request('http://localhost/api/admin/dashboard')); - - expect(startInactiveSpanMock).toHaveBeenCalledWith(expect.objectContaining({ name: 'adminAuth' })); - }); - - it('does not wrap .all() handlers with less than 2 params (they are route handlers, not middleware)', async () => { - const app = new Hono(); - patchAppUse(app); - patchRoute(app); - - const subApp = new Hono(); - subApp.all('/catch-all', async function allHandler() { - return new Response('catch-all'); - }); - - app.route('/api', subApp); - await app.fetch(new Request('http://localhost/api/catch-all')); - - expect(startInactiveSpanMock).not.toHaveBeenCalled(); - }); - - it('wraps .use() middleware but not .all() handlers on the same sub-app', async () => { - const app = new Hono(); - patchAppUse(app); - patchRoute(app); - - const subApp = new Hono(); - subApp.use(async function mw(_c: unknown, next: () => Promise) { - await next(); - }); - subApp.all('/wildcard', async function allRoute() { - return new Response('wildcard'); - }); - subApp.get('/specific', () => new Response('specific')); - - app.route('/mixed', subApp); - await app.fetch(new Request('http://localhost/mixed/wildcard')); - - const spanNames = startInactiveSpanMock.mock.calls.map((c: unknown[]) => (c[0] as { name: string }).name); - expect(spanNames).toContain('mw'); - expect(spanNames).not.toContain('allRoute'); - }); - - it('does not wrap sole .get()/.post()/.put()/.delete() handlers on sub-apps (they are final handlers, not middleware)', async () => { - const app = new Hono(); - patchAppUse(app); - patchRoute(app); - - const subApp = new Hono(); - subApp.get('/resource', async function getHandler() { - return new Response('get'); - }); - subApp.post('/resource', async function postHandler() { - return new Response('post'); - }); - subApp.put('/resource', async function postHandler() { - return new Response('put'); - }); - subApp.delete('/resource', async function postHandler() { - return new Response('delete'); - }); - - app.route('/api', subApp); - await app.fetch(new Request('http://localhost/api/resource')); - - expect(startInactiveSpanMock).not.toHaveBeenCalled(); - }); - - it('wraps inline middleware in .get(path, mw, handler) on sub-apps', async () => { - const app = new Hono(); - patchAppUse(app); - patchRoute(app); - - const subApp = new Hono(); - subApp.get( - '/resource', - async function inlineMw(_c: unknown, next: () => Promise) { - await next(); - }, - async function getHandler() { - return new Response('get'); - }, - ); - - app.route('/api', subApp); - await app.fetch(new Request('http://localhost/api/resource')); - - const spanNames = startInactiveSpanMock.mock.calls.map((c: unknown[]) => (c[0] as { name: string }).name); - expect(spanNames).toContain('inlineMw'); - expect(spanNames).not.toContain('getHandler'); - }); - - it('wraps separately registered middleware for .get() on sub-apps', async () => { - const app = new Hono(); - patchAppUse(app); - patchRoute(app); - - const subApp = new Hono(); - subApp.get('/resource', async function separateMw(_c: unknown, next: () => Promise) { - await next(); - }); - subApp.get('/resource', async function getHandler() { - return new Response('get'); - }); - - app.route('/api', subApp); - await app.fetch(new Request('http://localhost/api/resource')); - - const spanNames = startInactiveSpanMock.mock.calls.map((c: unknown[]) => (c[0] as { name: string }).name); - expect(spanNames).toContain('separateMw'); - expect(spanNames).not.toContain('getHandler'); - }); - - it('wraps inline middleware registered via .on() on sub-apps', async () => { - const app = new Hono(); - patchAppUse(app); - patchRoute(app); - - const subApp = new Hono(); - subApp.on( - 'GET', - '/resource', - async function onMw(_c: unknown, next: () => Promise) { - await next(); - }, - async function onHandler() { - return new Response('on'); - }, - ); - - app.route('/api', subApp); - await app.fetch(new Request('http://localhost/api/resource')); - - const spanNames = startInactiveSpanMock.mock.calls.map((c: unknown[]) => (c[0] as { name: string }).name); - expect(spanNames).toContain('onMw'); - expect(spanNames).not.toContain('onHandler'); - }); - - it('wraps middleware in nested sub-apps (sub-app mounting another sub-app)', async () => { - const app = new Hono(); - patchAppUse(app); - patchRoute(app); - - const innerSub = new Hono(); - innerSub.use(async function innerMiddleware(_c: unknown, next: () => Promise) { - await next(); - }); - innerSub.get('/', () => new Response('inner')); - - const outerSub = new Hono(); - outerSub.use(async function outerMiddleware(_c: unknown, next: () => Promise) { - await next(); - }); - outerSub.route('/inner', innerSub); - - app.route('/outer', outerSub); - await app.fetch(new Request('http://localhost/outer/inner')); - - const spanNames = startInactiveSpanMock.mock.calls.map((c: unknown[]) => (c[0] as { name: string }).name); - expect(spanNames).toContain('outerMiddleware'); - expect(spanNames).toContain('innerMiddleware'); - }); - - it('handles sub-app with multiple path-targeted middleware for different paths', async () => { - const app = new Hono(); - patchAppUse(app); - patchRoute(app); - - const subApp = new Hono(); - subApp.use('/a/*', async function mwForA(_c: unknown, next: () => Promise) { - await next(); - }); - subApp.use('/b/*', async function mwForB(_c: unknown, next: () => Promise) { - await next(); - }); - subApp.get('/a/test', () => new Response('a')); - subApp.get('/b/test', () => new Response('b')); - - app.route('/sub', subApp); - - // Hit path /a — only mwForA should fire - await app.fetch(new Request('http://localhost/sub/a/test')); - expect(startInactiveSpanMock).toHaveBeenCalledTimes(1); - expect(startInactiveSpanMock).toHaveBeenCalledWith(expect.objectContaining({ name: 'mwForA' })); - - startInactiveSpanMock.mockClear(); - - // Hit path /b — only mwForB should fire - await app.fetch(new Request('http://localhost/sub/b/test')); - expect(startInactiveSpanMock).toHaveBeenCalledTimes(1); - expect(startInactiveSpanMock).toHaveBeenCalledWith(expect.objectContaining({ name: 'mwForB' })); - }); - }); }); diff --git a/packages/hono/test/shared/patchRoute.test.ts b/packages/hono/test/shared/patchRoute.test.ts deleted file mode 100644 index d9dd4d6795ad..000000000000 --- a/packages/hono/test/shared/patchRoute.test.ts +++ /dev/null @@ -1,141 +0,0 @@ -import * as SentryCore from '@sentry/core'; -import { Hono } from 'hono'; -import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'; -import { patchRoute } from '../../src/shared/patchRoute'; - -vi.mock('@sentry/core', async () => { - const actual = await vi.importActual('@sentry/core'); - return { - ...actual, - startInactiveSpan: vi.fn((_opts: unknown) => ({ - setStatus: vi.fn(), - end: vi.fn(), - })), - }; -}); - -const startInactiveSpanMock = SentryCore.startInactiveSpan as ReturnType; - -const honoBaseProto = Object.getPrototypeOf(Object.getPrototypeOf(new Hono())); -const originalRoute = honoBaseProto.route; - -describe('patchRoute', () => { - beforeEach(() => { - vi.clearAllMocks(); - honoBaseProto.route = originalRoute; - }); - - afterAll(() => { - honoBaseProto.route = originalRoute; - }); - - it('is a no-op when honoBaseProto.route is not a function', () => { - const fakeApp = Object.create({ notRoute: () => {} }) as Hono; - // Should not throw even when the expected method shape is missing - expect(() => patchRoute(fakeApp)).not.toThrow(); - expect(honoBaseProto.route).toBe(originalRoute); - }); - - describe('wrapSubAppMiddleware', () => { - it('does nothing when a sub-app has an empty routes array', async () => { - const app = new Hono(); - patchRoute(app); - - const emptySubApp = new Hono(); - // routes is an empty array — nothing to wrap, nothing should throw - app.route('/empty', emptySubApp); - - const res = await app.fetch(new Request('http://localhost/empty')); - expect(res.status).toBe(404); - expect(startInactiveSpanMock).not.toHaveBeenCalled(); - }); - - it('skips route entries whose handler is not a function', async () => { - const app = new Hono(); - patchRoute(app); - - const subApp = new Hono(); - subApp.get('/resource', () => new Response('ok')); - - // Corrupt one handler to a non-function to simulate unexpected route shapes - (subApp.routes as unknown as Array<{ handler: unknown }>)[0]!.handler = 'not-a-function'; - - // Should not throw when iterating over the corrupted routes - expect(() => app.route('/api', subApp)).not.toThrow(); - expect(startInactiveSpanMock).not.toHaveBeenCalled(); - }); - - it('treats same path with different HTTP methods as separate groups', async () => { - const app = new Hono(); - patchRoute(app); - - const subApp = new Hono(); - // Each of these is the sole (last) handler for its method+path group, - // so none should be wrapped as middleware. - subApp.get('/resource', async function getHandler() { - return new Response('get'); - }); - subApp.post('/resource', async function postHandler() { - return new Response('post'); - }); - - app.route('/api', subApp); - - await app.fetch(new Request('http://localhost/api/resource', { method: 'GET' })); - await app.fetch(new Request('http://localhost/api/resource', { method: 'POST' })); - - expect(startInactiveSpanMock).not.toHaveBeenCalled(); - }); - - it('treats same HTTP method with different paths as separate groups', async () => { - const app = new Hono(); - patchRoute(app); - - const subApp = new Hono(); - // Each is the sole handler for its own method+path group — neither is middleware. - subApp.get('/alpha', async function alphaHandler() { - return new Response('alpha'); - }); - subApp.get('/beta', async function betaHandler() { - return new Response('beta'); - }); - - app.route('/api', subApp); - - await app.fetch(new Request('http://localhost/api/alpha')); - await app.fetch(new Request('http://localhost/api/beta')); - - expect(startInactiveSpanMock).not.toHaveBeenCalled(); - }); - - it('wraps inline middleware for GET /alpha but not the sole handler for GET /beta', async () => { - const app = new Hono(); - patchRoute(app); - - const subApp = new Hono(); - subApp.get( - '/alpha', - async function alphaMw(_c: unknown, next: () => Promise) { - await next(); - }, - async function alphaHandler() { - return new Response('alpha'); - }, - ); - subApp.get('/beta', async function betaHandler() { - return new Response('beta'); - }); - - app.route('/api', subApp); - - await app.fetch(new Request('http://localhost/api/alpha')); - await app.fetch(new Request('http://localhost/api/beta')); - - const spanNames = startInactiveSpanMock.mock.calls.map((c: unknown[]) => (c[0] as { name: string }).name); - expect(spanNames).toHaveLength(1); - expect(spanNames).toContain('alphaMw'); - expect(spanNames).not.toContain('alphaHandler'); - expect(spanNames).not.toContain('betaHandler'); - }); - }); -});