diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 70256b2a3276..21b00a0f5bb7 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -1028,8 +1028,8 @@ jobs: node-version-file: 'dev-packages/e2e-tests/test-applications/${{ matrix.test-application }}/package.json' - name: Set up Bun if: - contains(fromJSON('["node-exports-test-app","nextjs-16-bun", "elysia-bun", "elysia-bun-static", "hono-4", - "bun-bytecode", "bun-mysql"]'), matrix.test-application) + matrix.test-application == 'node-exports-test-app' || contains(matrix.test-application, 'bun') || + contains(matrix.label, 'bun') uses: oven-sh/setup-bun@v2 with: bun-version: '1.3.14' @@ -1040,10 +1040,7 @@ jobs: use-installer: true token: ${{ secrets.GITHUB_TOKEN }} - name: Set up Deno - if: - matrix.test-application == 'deno' || matrix.test-application == 'deno-static' || matrix.test-application == - 'deno-redis' || matrix.test-application == 'hono-4' || matrix.test-application == 'deno-mysql' || - matrix.test-application == 'deno-pg' + if: contains(matrix.test-application, 'deno') || contains(matrix.label, 'deno') uses: denoland/setup-deno@v2.0.5 with: deno-version: ${{ matrix.deno-version || 'v2.8.3' }} diff --git a/dev-packages/e2e-tests/test-applications/hono-4/.gitignore b/dev-packages/e2e-tests/test-applications/hono-4-legacy/.gitignore similarity index 100% rename from dev-packages/e2e-tests/test-applications/hono-4/.gitignore rename to dev-packages/e2e-tests/test-applications/hono-4-legacy/.gitignore diff --git a/dev-packages/e2e-tests/test-applications/hono-4/deno.json b/dev-packages/e2e-tests/test-applications/hono-4-legacy/deno.json similarity index 100% rename from dev-packages/e2e-tests/test-applications/hono-4/deno.json rename to dev-packages/e2e-tests/test-applications/hono-4-legacy/deno.json diff --git a/dev-packages/e2e-tests/test-applications/hono-4/package.json b/dev-packages/e2e-tests/test-applications/hono-4-legacy/package.json similarity index 90% rename from dev-packages/e2e-tests/test-applications/hono-4/package.json rename to dev-packages/e2e-tests/test-applications/hono-4-legacy/package.json index db3b18166812..ab3b68ce1f5d 100644 --- a/dev-packages/e2e-tests/test-applications/hono-4/package.json +++ b/dev-packages/e2e-tests/test-applications/hono-4-legacy/package.json @@ -1,5 +1,5 @@ { - "name": "hono-4", + "name": "hono-4-legacy", "type": "module", "version": "0.0.0", "private": true, @@ -37,15 +37,15 @@ "variants": [ { "assert-command": "RUNTIME=node pnpm test:assert", - "label": "hono-4 (node)" + "label": "hono-4-legacy (node)" }, { "assert-command": "RUNTIME=bun pnpm test:assert", - "label": "hono-4 (bun)" + "label": "hono-4-legacy (bun)" }, { "assert-command": "RUNTIME=deno pnpm test:assert", - "label": "hono-4 (deno)" + "label": "hono-4-legacy (deno)" } ] } diff --git a/dev-packages/e2e-tests/test-applications/hono-4/playwright.config.ts b/dev-packages/e2e-tests/test-applications/hono-4-legacy/playwright.config.ts similarity index 100% rename from dev-packages/e2e-tests/test-applications/hono-4/playwright.config.ts rename to dev-packages/e2e-tests/test-applications/hono-4-legacy/playwright.config.ts diff --git a/dev-packages/e2e-tests/test-applications/hono-4/src/entry.bun.ts b/dev-packages/e2e-tests/test-applications/hono-4-legacy/src/entry.bun.ts similarity index 100% rename from dev-packages/e2e-tests/test-applications/hono-4/src/entry.bun.ts rename to dev-packages/e2e-tests/test-applications/hono-4-legacy/src/entry.bun.ts diff --git a/dev-packages/e2e-tests/test-applications/hono-4/src/entry.cloudflare.ts b/dev-packages/e2e-tests/test-applications/hono-4-legacy/src/entry.cloudflare.ts similarity index 100% rename from dev-packages/e2e-tests/test-applications/hono-4/src/entry.cloudflare.ts rename to dev-packages/e2e-tests/test-applications/hono-4-legacy/src/entry.cloudflare.ts diff --git a/dev-packages/e2e-tests/test-applications/hono-4/src/entry.deno.ts b/dev-packages/e2e-tests/test-applications/hono-4-legacy/src/entry.deno.ts similarity index 100% rename from dev-packages/e2e-tests/test-applications/hono-4/src/entry.deno.ts rename to dev-packages/e2e-tests/test-applications/hono-4-legacy/src/entry.deno.ts diff --git a/dev-packages/e2e-tests/test-applications/hono-4/src/entry.node.ts b/dev-packages/e2e-tests/test-applications/hono-4-legacy/src/entry.node.ts similarity index 100% rename from dev-packages/e2e-tests/test-applications/hono-4/src/entry.node.ts rename to dev-packages/e2e-tests/test-applications/hono-4-legacy/src/entry.node.ts diff --git a/dev-packages/e2e-tests/test-applications/hono-4/src/instrument.node.ts b/dev-packages/e2e-tests/test-applications/hono-4-legacy/src/instrument.node.ts similarity index 100% rename from dev-packages/e2e-tests/test-applications/hono-4/src/instrument.node.ts rename to dev-packages/e2e-tests/test-applications/hono-4-legacy/src/instrument.node.ts diff --git a/dev-packages/e2e-tests/test-applications/hono-4/src/middleware.ts b/dev-packages/e2e-tests/test-applications/hono-4-legacy/src/middleware.ts similarity index 100% rename from dev-packages/e2e-tests/test-applications/hono-4/src/middleware.ts rename to dev-packages/e2e-tests/test-applications/hono-4-legacy/src/middleware.ts diff --git a/dev-packages/e2e-tests/test-applications/hono-4/src/route-groups/test-errors.ts b/dev-packages/e2e-tests/test-applications/hono-4-legacy/src/route-groups/test-errors.ts similarity index 100% rename from dev-packages/e2e-tests/test-applications/hono-4/src/route-groups/test-errors.ts rename to dev-packages/e2e-tests/test-applications/hono-4-legacy/src/route-groups/test-errors.ts diff --git a/dev-packages/e2e-tests/test-applications/hono-4/src/route-groups/test-middleware.ts b/dev-packages/e2e-tests/test-applications/hono-4-legacy/src/route-groups/test-middleware.ts similarity index 100% rename from dev-packages/e2e-tests/test-applications/hono-4/src/route-groups/test-middleware.ts rename to dev-packages/e2e-tests/test-applications/hono-4-legacy/src/route-groups/test-middleware.ts diff --git a/dev-packages/e2e-tests/test-applications/hono-4/src/route-groups/test-multi-fetch.ts b/dev-packages/e2e-tests/test-applications/hono-4-legacy/src/route-groups/test-multi-fetch.ts similarity index 100% rename from dev-packages/e2e-tests/test-applications/hono-4/src/route-groups/test-multi-fetch.ts rename to dev-packages/e2e-tests/test-applications/hono-4-legacy/src/route-groups/test-multi-fetch.ts 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-legacy/src/route-groups/test-route-patterns.ts similarity index 100% rename from dev-packages/e2e-tests/test-applications/hono-4/src/route-groups/test-route-patterns.ts rename to dev-packages/e2e-tests/test-applications/hono-4-legacy/src/route-groups/test-route-patterns.ts diff --git a/dev-packages/e2e-tests/test-applications/hono-4/src/routes.ts b/dev-packages/e2e-tests/test-applications/hono-4-legacy/src/routes.ts similarity index 100% rename from dev-packages/e2e-tests/test-applications/hono-4/src/routes.ts rename to dev-packages/e2e-tests/test-applications/hono-4-legacy/src/routes.ts diff --git a/dev-packages/e2e-tests/test-applications/hono-4/start-event-proxy.mjs b/dev-packages/e2e-tests/test-applications/hono-4-legacy/start-event-proxy.mjs similarity index 75% rename from dev-packages/e2e-tests/test-applications/hono-4/start-event-proxy.mjs rename to dev-packages/e2e-tests/test-applications/hono-4-legacy/start-event-proxy.mjs index cd6f91b3455d..8f6b794b1827 100644 --- a/dev-packages/e2e-tests/test-applications/hono-4/start-event-proxy.mjs +++ b/dev-packages/e2e-tests/test-applications/hono-4-legacy/start-event-proxy.mjs @@ -2,5 +2,5 @@ import { startEventProxyServer } from '@sentry-internal/test-utils'; startEventProxyServer({ port: 3031, - proxyServerName: 'hono-4', + proxyServerName: 'hono-4-legacy', }); diff --git a/dev-packages/e2e-tests/test-applications/hono-4/tests/basepath-and-late-routes.test.ts b/dev-packages/e2e-tests/test-applications/hono-4-legacy/tests/basepath-and-late-routes.test.ts similarity index 100% rename from dev-packages/e2e-tests/test-applications/hono-4/tests/basepath-and-late-routes.test.ts rename to dev-packages/e2e-tests/test-applications/hono-4-legacy/tests/basepath-and-late-routes.test.ts diff --git a/dev-packages/e2e-tests/test-applications/hono-4/tests/constants.ts b/dev-packages/e2e-tests/test-applications/hono-4-legacy/tests/constants.ts similarity index 76% rename from dev-packages/e2e-tests/test-applications/hono-4/tests/constants.ts rename to dev-packages/e2e-tests/test-applications/hono-4-legacy/tests/constants.ts index 0bd38ea85aa3..2aee60fc6be8 100644 --- a/dev-packages/e2e-tests/test-applications/hono-4/tests/constants.ts +++ b/dev-packages/e2e-tests/test-applications/hono-4-legacy/tests/constants.ts @@ -2,4 +2,4 @@ export type Runtime = 'cloudflare' | 'node' | 'bun' | 'deno'; export const RUNTIME = (process.env.RUNTIME || 'node') as Runtime; -export const APP_NAME = 'hono-4'; +export const APP_NAME = 'hono-4-legacy'; diff --git a/dev-packages/e2e-tests/test-applications/hono-4/tests/errors.test.ts b/dev-packages/e2e-tests/test-applications/hono-4-legacy/tests/errors.test.ts similarity index 100% rename from dev-packages/e2e-tests/test-applications/hono-4/tests/errors.test.ts rename to dev-packages/e2e-tests/test-applications/hono-4-legacy/tests/errors.test.ts diff --git a/dev-packages/e2e-tests/test-applications/hono-4/tests/middleware.test.ts b/dev-packages/e2e-tests/test-applications/hono-4-legacy/tests/middleware.test.ts similarity index 100% rename from dev-packages/e2e-tests/test-applications/hono-4/tests/middleware.test.ts rename to dev-packages/e2e-tests/test-applications/hono-4-legacy/tests/middleware.test.ts diff --git a/dev-packages/e2e-tests/test-applications/hono-4/tests/multi-fetch.test.ts b/dev-packages/e2e-tests/test-applications/hono-4-legacy/tests/multi-fetch.test.ts similarity index 100% rename from dev-packages/e2e-tests/test-applications/hono-4/tests/multi-fetch.test.ts rename to dev-packages/e2e-tests/test-applications/hono-4-legacy/tests/multi-fetch.test.ts 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-legacy/tests/route-patterns.test.ts similarity index 100% rename from dev-packages/e2e-tests/test-applications/hono-4/tests/route-patterns.test.ts rename to dev-packages/e2e-tests/test-applications/hono-4-legacy/tests/route-patterns.test.ts diff --git a/dev-packages/e2e-tests/test-applications/hono-4/tests/tracing.test.ts b/dev-packages/e2e-tests/test-applications/hono-4-legacy/tests/tracing.test.ts similarity index 100% rename from dev-packages/e2e-tests/test-applications/hono-4/tests/tracing.test.ts rename to dev-packages/e2e-tests/test-applications/hono-4-legacy/tests/tracing.test.ts diff --git a/dev-packages/e2e-tests/test-applications/hono-4/tsconfig.json b/dev-packages/e2e-tests/test-applications/hono-4-legacy/tsconfig.json similarity index 100% rename from dev-packages/e2e-tests/test-applications/hono-4/tsconfig.json rename to dev-packages/e2e-tests/test-applications/hono-4-legacy/tsconfig.json diff --git a/dev-packages/e2e-tests/test-applications/hono-4/wrangler.jsonc b/dev-packages/e2e-tests/test-applications/hono-4-legacy/wrangler.jsonc similarity index 86% rename from dev-packages/e2e-tests/test-applications/hono-4/wrangler.jsonc rename to dev-packages/e2e-tests/test-applications/hono-4-legacy/wrangler.jsonc index d4344dfa198a..a3c646c03aa9 100644 --- a/dev-packages/e2e-tests/test-applications/hono-4/wrangler.jsonc +++ b/dev-packages/e2e-tests/test-applications/hono-4-legacy/wrangler.jsonc @@ -1,6 +1,6 @@ { "$schema": "node_modules/wrangler/config-schema.json", - "name": "hono-4", + "name": "hono-4-legacy", "main": "src/entry.cloudflare.ts", "compatibility_date": "2026-04-20", "compatibility_flags": ["nodejs_compat"], diff --git a/packages/hono/package.json b/packages/hono/package.json index bea6f10f2034..50337c1adbf6 100644 --- a/packages/hono/package.json +++ b/packages/hono/package.json @@ -74,7 +74,8 @@ "dependencies": { "@opentelemetry/api": "^1.9.1", "@sentry/core": "10.67.0", - "@sentry/conventions": "^0.23.0" + "@sentry/conventions": "^0.23.0", + "@sentry/server-utils": "10.67.0" }, "peerDependencies": { "@cloudflare/workers-types": "^4.x", diff --git a/packages/hono/src/bun/middleware.ts b/packages/hono/src/bun/middleware.ts index 6a1fbaf5a53e..20c922c32c0c 100644 --- a/packages/hono/src/bun/middleware.ts +++ b/packages/hono/src/bun/middleware.ts @@ -1,10 +1,8 @@ import { type BaseTransportOptions, debug, type Options } from '@sentry/core'; import { init } from './sdk'; import { getConnInfo } from 'hono/bun'; +import { applyHonoPatches, createHonoRequestMiddleware, type SentryHonoMiddlewareOptions } from '@sentry/server-utils'; import type { Env, Hono, MiddlewareHandler } from 'hono'; -import { requestHandler, responseHandler } from '../shared/middlewareHandlers'; -import { applyPatches } from '../shared/applyPatches'; -import type { SentryHonoMiddlewareOptions } from '../shared/types'; export interface HonoBunOptions extends Options, SentryHonoMiddlewareOptions {} @@ -16,13 +14,7 @@ export const sentry = (app: Hono, options: HonoBunOptions): Mi init(options); - applyPatches(app); + applyHonoPatches(app); - return async (context, next) => { - requestHandler(context, getConnInfo); - - await next(); // Handler runs in between Request above ⤴ and Response below ⤵ - - responseHandler(context, options.shouldHandleError); - }; + return createHonoRequestMiddleware({ getConnInfo, shouldHandleError: options.shouldHandleError }); }; diff --git a/packages/hono/src/cloudflare/middleware.ts b/packages/hono/src/cloudflare/middleware.ts index 205b7c28129a..e80289a5572d 100644 --- a/packages/hono/src/cloudflare/middleware.ts +++ b/packages/hono/src/cloudflare/middleware.ts @@ -1,12 +1,10 @@ import { withSentry } from '@sentry/cloudflare'; import { applySdkMetadata, type BaseTransportOptions, debug, type Options } from '@sentry/core'; import { getConnInfo } from 'hono/cloudflare-workers'; +import { applyHonoPatches, createHonoRequestMiddleware, type SentryHonoMiddlewareOptions } from '@sentry/server-utils'; import type { Env, Hono, MiddlewareHandler } from 'hono'; import { buildFilteredIntegrations } from '../shared/buildFilteredIntegrations'; import { LOW_QUALITY_TRANSACTION_PATTERNS } from '../shared/lowQualityTransactionPatterns'; -import { requestHandler, responseHandler } from '../shared/middlewareHandlers'; -import { applyPatches } from '../shared/applyPatches'; -import type { SentryHonoMiddlewareOptions } from '../shared/types'; export interface HonoCloudflareOptions extends Options, SentryHonoMiddlewareOptions {} @@ -41,18 +39,15 @@ export function sentry( app as unknown as ExportedHandler, ); - applyPatches(app); + applyHonoPatches(app); - return async (context, next) => { - const shouldHandleError = + return createHonoRequestMiddleware({ + getConnInfo, + // Cloudflare accepts middleware options as a function of `env`, so `shouldHandleError` is only + // known per request. + resolveShouldHandleError: context => typeof options === 'function' ? options(context.env as E['Bindings']).shouldHandleError - : options.shouldHandleError; - - requestHandler(context, getConnInfo); - - await next(); // Handler runs in between Request above ⤴ and Response below ⤵ - - responseHandler(context, shouldHandleError); - }; + : options.shouldHandleError, + }); } diff --git a/packages/hono/src/debug-build.ts b/packages/hono/src/debug-build.ts deleted file mode 100644 index 60aa50940582..000000000000 --- a/packages/hono/src/debug-build.ts +++ /dev/null @@ -1,8 +0,0 @@ -declare const __DEBUG_BUILD__: boolean; - -/** - * This serves as a build time flag that will be true by default, but false in non-debug builds or if users replace `__SENTRY_DEBUG__` in their generated code. - * - * ATTENTION: This constant must never cross package boundaries (i.e. be exported) to guarantee that it can be used for tree shaking. - */ -export const DEBUG_BUILD = __DEBUG_BUILD__; diff --git a/packages/hono/src/deno/middleware.ts b/packages/hono/src/deno/middleware.ts index 081589925ee1..f74af1379e85 100644 --- a/packages/hono/src/deno/middleware.ts +++ b/packages/hono/src/deno/middleware.ts @@ -1,10 +1,8 @@ import { type BaseTransportOptions, debug, type Options } from '@sentry/core'; import { init } from './sdk'; -import type { Env, Hono, MiddlewareHandler } from 'hono'; import { getConnInfo } from 'hono/deno'; -import { requestHandler, responseHandler } from '../shared/middlewareHandlers'; -import { applyPatches } from '../shared/applyPatches'; -import type { SentryHonoMiddlewareOptions } from '../shared/types'; +import { applyHonoPatches, createHonoRequestMiddleware, type SentryHonoMiddlewareOptions } from '@sentry/server-utils'; +import type { Env, Hono, MiddlewareHandler } from 'hono'; export interface HonoDenoOptions extends Options, SentryHonoMiddlewareOptions {} @@ -16,13 +14,7 @@ export const sentry = (app: Hono, options: HonoDenoOptions): M init(options); - applyPatches(app); - - return async (context, next) => { - requestHandler(context, getConnInfo); - - await next(); // Handler runs in between Request above ⤴ and Response below ⤵ + applyHonoPatches(app); - responseHandler(context, options.shouldHandleError); - }; + return createHonoRequestMiddleware({ getConnInfo, shouldHandleError: options.shouldHandleError }); }; diff --git a/packages/hono/src/index.bun.ts b/packages/hono/src/index.bun.ts index 7c456a21c52e..917a160284f6 100644 --- a/packages/hono/src/index.bun.ts +++ b/packages/hono/src/index.bun.ts @@ -1,6 +1,7 @@ -import { earlyPatchHono } from './shared/applyPatches'; +import { earlyPatchHono } from '@sentry/server-utils'; +import { Hono } from 'hono'; -earlyPatchHono(); +earlyPatchHono(Hono); export { sentry } from './bun/middleware'; diff --git a/packages/hono/src/index.cloudflare.ts b/packages/hono/src/index.cloudflare.ts index e46e119c206c..3daa130b409b 100644 --- a/packages/hono/src/index.cloudflare.ts +++ b/packages/hono/src/index.cloudflare.ts @@ -1,6 +1,7 @@ -import { earlyPatchHono } from './shared/applyPatches'; +import { earlyPatchHono } from '@sentry/server-utils'; +import { Hono } from 'hono'; -earlyPatchHono(); +earlyPatchHono(Hono); export { sentry } from './cloudflare/middleware'; diff --git a/packages/hono/src/index.deno.ts b/packages/hono/src/index.deno.ts index aa407b9192ca..2439139e91df 100644 --- a/packages/hono/src/index.deno.ts +++ b/packages/hono/src/index.deno.ts @@ -1,6 +1,7 @@ -import { earlyPatchHono } from './shared/applyPatches'; +import { earlyPatchHono } from '@sentry/server-utils'; +import { Hono } from 'hono'; -earlyPatchHono(); +earlyPatchHono(Hono); export { sentry } from './deno/middleware'; diff --git a/packages/hono/src/index.node.ts b/packages/hono/src/index.node.ts index 761cd27a44a6..99c03230df7c 100644 --- a/packages/hono/src/index.node.ts +++ b/packages/hono/src/index.node.ts @@ -1,6 +1,7 @@ -import { earlyPatchHono } from './shared/applyPatches'; +import { earlyPatchHono } from '@sentry/server-utils'; +import { Hono } from 'hono'; -earlyPatchHono(); +earlyPatchHono(Hono); export { sentry } from './node/middleware'; diff --git a/packages/hono/src/node/middleware.ts b/packages/hono/src/node/middleware.ts index b46dfbbac489..0b4688ef72f4 100644 --- a/packages/hono/src/node/middleware.ts +++ b/packages/hono/src/node/middleware.ts @@ -1,9 +1,7 @@ import { type BaseTransportOptions, consoleSandbox, debug, getClient, type Options } from '@sentry/core'; import { getConnInfo } from '@hono/node-server/conninfo'; +import { applyHonoPatches, createHonoRequestMiddleware, type SentryHonoMiddlewareOptions } from '@sentry/server-utils'; import type { Env, Hono, MiddlewareHandler } from 'hono'; -import { requestHandler, responseHandler } from '../shared/middlewareHandlers'; -import { applyPatches } from '../shared/applyPatches'; -import type { SentryHonoMiddlewareOptions } from '../shared/types'; export interface HonoNodeOptions extends Options {} @@ -40,13 +38,7 @@ export const sentry = (app: Hono, options?: SentryHonoMiddlewa } } - applyPatches(app); + applyHonoPatches(app); - return async (context, next) => { - requestHandler(context, getConnInfo); - - await next(); // Handler runs in between Request above ⤴ and Response below ⤵ - - responseHandler(context, options?.shouldHandleError); - }; + return createHonoRequestMiddleware({ getConnInfo, shouldHandleError: options?.shouldHandleError }); }; diff --git a/packages/hono/src/shared/patchAppRequest.ts b/packages/hono/src/shared/patchAppRequest.ts deleted file mode 100644 index a066d00a3c01..000000000000 --- a/packages/hono/src/shared/patchAppRequest.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { SENTRY_OP } from '@sentry/conventions/attributes'; -import { HTTP_SERVER } from '@sentry/conventions/op'; -import { - debug, - getActiveSpan, - getOriginalFunction, - SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, - startSpan, - type WrappedFunction, -} from '@sentry/core'; -import type { Env, Hono } from 'hono'; -import { DEBUG_BUILD } from '../debug-build'; - -const INTERNAL_REQUEST_OP = HTTP_SERVER; -const INTERNAL_REQUEST_ORIGIN = 'auto.http.hono.internal_request'; - -function extractPathname(input: string | Request | URL): string { - if (typeof input === 'string') { - return /^https?:\/\//.test(input) ? new URL(input).pathname : input; - } - - return input instanceof Request ? new URL(input.url).pathname : input.pathname; -} - -/** - * Patches `app.request()` on a Hono instance so that each internal dispatch - * is traced as an `http.server` span — child of whatever span is active at - * the call site. - * - * `.request()` is a class field (arrow function), so this must run per-instance. - * Idempotent: safe to call multiple times on the same instance. - */ -export function patchAppRequest(app: Hono): void { - if (getOriginalFunction(app.request as unknown as WrappedFunction)) { - DEBUG_BUILD && debug.log('[hono] app.request already patched — skipping.'); - return; - } - - const originalRequest = app.request; - - app.request = new Proxy(originalRequest, { - apply(_target, thisArg, args: [string | Request | URL, RequestInit?, ...unknown[]]) { - const [input, requestInit, ...rest] = args; - - if (!getActiveSpan()) { - return Reflect.apply(_target, thisArg, args); - } - - let method = requestInit?.method ?? (input instanceof Request ? input.method : 'GET'); - method = method.toUpperCase(); - - const path = extractPathname(input); - - return startSpan( - { - name: `${method} ${path}`, - onlyIfParent: true, - attributes: { - [SENTRY_OP]: INTERNAL_REQUEST_OP, - [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: INTERNAL_REQUEST_ORIGIN, - }, - }, - () => Reflect.apply(_target, thisArg, [input, requestInit, ...rest]), - ); - }, - get(target, prop, receiver) { - if (prop === '__sentry_original__') { - return originalRequest; - } - return Reflect.get(target, prop, receiver); - }, - }); - - DEBUG_BUILD && debug.log('[hono] Patched app.request for internal dispatch tracing.'); -} diff --git a/packages/hono/test/shared/applyPatches.test.ts b/packages/hono/test/shared/applyPatches.test.ts index 129e83d341d2..1347a72f1b0c 100644 --- a/packages/hono/test/shared/applyPatches.test.ts +++ b/packages/hono/test/shared/applyPatches.test.ts @@ -1,7 +1,7 @@ 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'; +import { applyHonoPatches as applyPatches } from '@sentry/server-utils'; vi.mock('@sentry/core', async () => { const actual = await vi.importActual('@sentry/core'); diff --git a/packages/hono/test/shared/earlyPatchRoute.test.ts b/packages/hono/test/shared/earlyPatchRoute.test.ts index 3e2eee6208a7..6cc210a7d6b7 100644 --- a/packages/hono/test/shared/earlyPatchRoute.test.ts +++ b/packages/hono/test/shared/earlyPatchRoute.test.ts @@ -1,8 +1,7 @@ import * as SentryCore from '@sentry/core'; +import { applyHonoPatches as applyPatches, earlyPatchHono } from '@sentry/server-utils'; import { Hono } from 'hono'; import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'; -import { applyPatches, earlyPatchHono } from '../../src/shared/applyPatches'; -import { installRouteHookOnPrototype } from '../../src/shared/patchRoute'; vi.mock('@sentry/core', async () => { const actual = await vi.importActual('@sentry/core'); @@ -19,10 +18,12 @@ vi.mock('@sentry/core', async () => { const startSpanMock = SentryCore.startSpan as ReturnType; -const honoBaseProto = Object.getPrototypeOf(Hono.prototype) as { route: Function }; +const honoBaseProto = Object.getPrototypeOf(Hono.prototype) as { route: (path: string, app: unknown) => unknown }; const originalRoute = honoBaseProto.route; -earlyPatchHono(); +// `earlyPatchHono` installs the `HonoBase.prototype.route` hook at import time, before any +// `sentry()`/`applyHonoPatches` runs, so sub-apps mounted early are still collected. +earlyPatchHono(Hono); describe('earlyPatchHono (two-phase prototype hook)', () => { beforeEach(() => { @@ -44,7 +45,7 @@ describe('earlyPatchHono (two-phase prototype hook)', () => { expect(startSpanMock).not.toHaveBeenCalled(); }); - it('patches collected sub-apps when applyPatches activates', async () => { + it('patches collected sub-apps when applyHonoPatches activates', async () => { const subApp = new Hono(); subApp.get('/hello', c => c.text('world')); @@ -59,29 +60,6 @@ describe('earlyPatchHono (two-phase prototype hook)', () => { expect(startSpanMock).toHaveBeenCalledWith(expect.objectContaining({ name: 'GET /hello' }), expect.any(Function)); }); - it('emits a debug log and applies patchAppRequest when sub-app was mounted before applyPatches', async () => { - const debugLogSpy = vi.spyOn(SentryCore.debug, 'log'); - - honoBaseProto.route = originalRoute; - installRouteHookOnPrototype(); - - const subApp = new Hono(); - subApp.get('/hello', c => c.text('world')); - - const parent = new Hono(); - parent.route('/api', subApp); - - applyPatches(parent); // retroactive instrumentation - - // The log warns the developer about the out-of-order setup. - expect(debugLogSpy).toHaveBeenCalledWith(expect.stringContaining('sub-app(s) were mounted before sentry()')); - - // patchAppRequest is applied retroactively - await subApp.request('/hello'); - - expect(startSpanMock).toHaveBeenCalledWith(expect.objectContaining({ name: 'GET /hello' }), expect.any(Function)); - }); - it('preserves correct route behavior', async () => { const subApp = new Hono(); subApp.get('/hello', c => c.text('world')); @@ -94,113 +72,3 @@ describe('earlyPatchHono (two-phase prototype hook)', () => { expect(await res.text()).toBe('world'); }); }); - -describe('installRouteHookOnPrototype idempotency', () => { - afterAll(() => { - honoBaseProto.route = originalRoute; - }); - - it('returns the same handle on repeated calls', () => { - const handle1 = installRouteHookOnPrototype(); - const handle2 = installRouteHookOnPrototype(); - - expect(handle1).toBe(handle2); - }); - - it('does not replace the patched route function on repeated calls', () => { - installRouteHookOnPrototype(); - const patchedRoute = honoBaseProto.route; - - installRouteHookOnPrototype(); - expect(honoBaseProto.route).toBe(patchedRoute); - }); - - it('calling activate() multiple times has no adverse effect', async () => { - const handle = installRouteHookOnPrototype(); - - handle.activate(); - handle.activate(); - handle.activate(); - - const app = new Hono(); - applyPatches(app); - - const subApp = new Hono(); - subApp.get('/hello', c => c.text('world')); - app.route('/api', subApp); - - await subApp.request('/hello'); - - expect(startSpanMock).toHaveBeenCalledTimes(1); - }); -}); - -describe('installRouteHookOnPrototype non-invasive patching', () => { - afterAll(() => { - honoBaseProto.route = originalRoute; - }); - - it('preserves function.name of the original route method', () => { - honoBaseProto.route = originalRoute; - const originalName = originalRoute.name; - - installRouteHookOnPrototype(); - - expect(honoBaseProto.route!.name).toBe(originalName); - }); - - it('preserves function.length of the original route method', () => { - honoBaseProto.route = originalRoute; - const originalLength = originalRoute.length; - - installRouteHookOnPrototype(); - - expect(honoBaseProto.route!.length).toBe(originalLength); - }); - - it('preserves symbol-keyed properties on the route method', () => { - honoBaseProto.route = originalRoute; - const ROUTER_META = Symbol('router-meta'); - (originalRoute as any)[ROUTER_META] = { version: 3 }; - - installRouteHookOnPrototype(); - - const symbols = Object.getOwnPropertySymbols(honoBaseProto.route!); - expect(symbols).toContain(ROUTER_META); - expect((honoBaseProto.route as any)[ROUTER_META]).toEqual({ version: 3 }); - }); - - it('preserves string-keyed custom properties on the route method', () => { - honoBaseProto.route = originalRoute; - (originalRoute as any).pluginId = 'openapi-router'; - (originalRoute as any).__patched_by_other_lib__ = true; - - installRouteHookOnPrototype(); - - expect((honoBaseProto.route as any).pluginId).toBe('openapi-router'); - expect((honoBaseProto.route as any).__patched_by_other_lib__).toBe(true); - }); - - it('preserves prototype chain of the original function', () => { - honoBaseProto.route = originalRoute; - const originalProto = Object.getPrototypeOf(originalRoute); - - installRouteHookOnPrototype(); - - expect(Object.getPrototypeOf(honoBaseProto.route!)).toBe(originalProto); - }); - - it('correctly calls the original route and preserves return value', () => { - honoBaseProto.route = originalRoute; - installRouteHookOnPrototype(); - - const app = new Hono(); - applyPatches(app); - - const subApp = new Hono(); - subApp.get('/test', c => c.text('ok')); - - const result = app.route('/api', subApp); - expect(result).toBe(app); - }); -}); diff --git a/packages/hono/test/shared/middlewareHandlers.test.ts b/packages/hono/test/shared/middlewareHandlers.test.ts index c52e4f62eb69..9253cf56cbd6 100644 --- a/packages/hono/test/shared/middlewareHandlers.test.ts +++ b/packages/hono/test/shared/middlewareHandlers.test.ts @@ -1,16 +1,10 @@ +import { HTTP_ROUTE, SENTRY_SEGMENT_NAME_SOURCE } from '@sentry/conventions/attributes'; import * as SentryCore from '@sentry/core'; -import { SENTRY_SEGMENT_NAME_SOURCE, HTTP_ROUTE } from '@sentry/conventions/attributes'; +import { createHonoRequestMiddleware } from '@sentry/server-utils'; import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { requestHandler, responseHandler } from '../../src/shared/middlewareHandlers'; -vi.mock('hono/route', () => ({ - routePath: () => '/test', - matchedRoutes: () => [{ basePath: '/', path: '/test', method: 'GET', handler: (_c: unknown) => undefined }], -})); - -vi.mock('../../src/utils/hono-context', () => ({ - hasFetchEvent: () => false, -})); +// These exercise the request/response handlers via the public `createHonoRequestMiddleware`, which +// runs `requestHandler` on the way in and `responseHandler` on the way out of `next()`. const mockSetTransactionName = vi.fn(); const mockSetSDKProcessingMetadata = vi.fn(); @@ -51,79 +45,92 @@ const getClientMock = SentryCore.getClient as ReturnType; const captureExceptionMock = SentryCore.captureException as ReturnType; 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 { return { - req: { method: 'GET', routeIndex: 0, raw: new Request('http://localhost/test') }, + req: { + method: 'GET', + routeIndex: 0, + // `resolveRouteName` reads the matched routes straight off the request. + matchedRoutes: [{ basePath: '/', path: '/test', method: 'GET', handler: (_c: unknown) => undefined }], + raw: new Request('http://localhost/test'), + }, + get event(): never { + throw new Error('no fetch event'); + }, res: { status }, error, }; } -describe('responseHandler', () => { +const noop = async (): Promise => {}; + +// oxlint-disable-next-line typescript/no-explicit-any +async function runMiddleware(context: unknown, options: Record = {}): Promise { + const middleware = createHonoRequestMiddleware(options); + // oxlint-disable-next-line typescript/no-explicit-any + await middleware(context as any, noop); +} + +describe('responseHandler (via createHonoRequestMiddleware)', () => { beforeEach(() => { vi.clearAllMocks(); }); describe('error capture — default behavior (no shouldHandleError)', () => { - it('captures error when context.error is set', () => { + it('captures error when context.error is set', async () => { const error = new Error('server error'); - // oxlint-disable-next-line typescript/no-explicit-any - responseHandler(createMockContext(500, error) as any); + await runMiddleware(createMockContext(500, error)); expect(captureExceptionMock).toHaveBeenCalledWith(error, { mechanism: { handled: false, type: 'auto.http.hono.context_error' }, }); }); - it('captures plain Error with no status (not an HTTPException) regardless of response status', () => { + it('captures plain Error with no status (not an HTTPException) regardless of response status', async () => { const error = new Error('plain error, no status property'); - // oxlint-disable-next-line typescript/no-explicit-any - responseHandler(createMockContext(404, error) as any); + await runMiddleware(createMockContext(404, error)); expect(captureExceptionMock).toHaveBeenCalledWith(error, { mechanism: { handled: false, type: 'auto.http.hono.context_error' }, }); }); - it('does not call captureException when there is no error', () => { - // oxlint-disable-next-line typescript/no-explicit-any - responseHandler(createMockContext(200) as any); + it('does not call captureException when there is no error', async () => { + await runMiddleware(createMockContext(200)); expect(captureExceptionMock).not.toHaveBeenCalled(); }); - it('delegates deduplication to the public capture API', () => { + it('delegates deduplication to the public capture API', async () => { const error = new Error('already captured'); Object.defineProperty(error, '__sentry_captured__', { value: true, writable: false }); - // oxlint-disable-next-line typescript/no-explicit-any - responseHandler(createMockContext(500, error) as any); + await runMiddleware(createMockContext(500, error)); expect(captureExceptionMock).toHaveBeenCalledWith(error, { mechanism: { handled: false, type: 'auto.http.hono.context_error' }, }); }); - it('does not capture 4xx HTTPException (status on error object)', () => { + it('does not capture 4xx HTTPException (status on error object)', async () => { const error = Object.assign(new Error('Not Found'), { status: 404 }); - // oxlint-disable-next-line typescript/no-explicit-any - responseHandler(createMockContext(404, error) as any); + await runMiddleware(createMockContext(404, error)); expect(captureExceptionMock).not.toHaveBeenCalled(); }); - it('does not capture 3xx HTTPException (status on error object)', () => { + it('does not capture 3xx HTTPException (status on error object)', async () => { const error = Object.assign(new Error('Redirect'), { status: 301 }); - // oxlint-disable-next-line typescript/no-explicit-any - responseHandler(createMockContext(301, error) as any); + await runMiddleware(createMockContext(301, error)); expect(captureExceptionMock).not.toHaveBeenCalled(); }); - it('captures 5xx HTTPException (status on error object)', () => { + it('captures 5xx HTTPException (status on error object)', async () => { const error = Object.assign(new Error('Service Unavailable'), { status: 503 }); - // oxlint-disable-next-line typescript/no-explicit-any - responseHandler(createMockContext(503, error) as any); + await runMiddleware(createMockContext(503, error)); expect(captureExceptionMock).toHaveBeenCalledWith(error, { mechanism: { handled: false, type: 'auto.http.hono.context_error' }, @@ -132,12 +139,11 @@ describe('responseHandler', () => { }); describe('error capture — custom shouldHandleError', () => { - it('calls shouldHandleError with the error and captures when it returns true', () => { + it('calls shouldHandleError with the error and captures when it returns true', async () => { const shouldHandleError = vi.fn().mockReturnValue(true); const error = Object.assign(new Error('Not Found'), { status: 404 }); - // oxlint-disable-next-line typescript/no-explicit-any - responseHandler(createMockContext(404, error) as any, shouldHandleError); + await runMiddleware(createMockContext(404, error), { shouldHandleError }); expect(shouldHandleError).toHaveBeenCalledWith(error); expect(captureExceptionMock).toHaveBeenCalledWith(error, { @@ -145,39 +151,35 @@ describe('responseHandler', () => { }); }); - it('does not capture when shouldHandleError returns false', () => { + it('does not capture when shouldHandleError returns false', async () => { const shouldHandleError = vi.fn().mockReturnValue(false); const error = new Error('suppressed 500 error'); - // oxlint-disable-next-line typescript/no-explicit-any - responseHandler(createMockContext(500, error) as any, shouldHandleError); + await runMiddleware(createMockContext(500, error), { shouldHandleError }); expect(shouldHandleError).toHaveBeenCalledWith(error); expect(captureExceptionMock).not.toHaveBeenCalled(); }); - it('captures 4xx error that would normally be skipped when shouldHandleError returns true', () => { + it('captures 4xx error that would normally be skipped when shouldHandleError returns true', async () => { const error = Object.assign(new Error('Unauthorized'), { status: 401 }); - // oxlint-disable-next-line typescript/no-explicit-any - responseHandler(createMockContext(401, error) as any, () => true); + await runMiddleware(createMockContext(401, error), { shouldHandleError: () => true }); expect(captureExceptionMock).toHaveBeenCalledWith(error, { mechanism: { handled: false, type: 'auto.http.hono.context_error' }, }); }); - it('suppresses 5xx error when shouldHandleError returns false', () => { + it('suppresses 5xx error when shouldHandleError returns false', async () => { const error = Object.assign(new Error('Internal Server Error'), { status: 500 }); - // oxlint-disable-next-line typescript/no-explicit-any - responseHandler(createMockContext(500, error) as any, () => false); + await runMiddleware(createMockContext(500, error), { shouldHandleError: () => false }); expect(captureExceptionMock).not.toHaveBeenCalled(); }); - it('does not invoke shouldHandleError when context.error is absent', () => { + it('does not invoke shouldHandleError when context.error is absent', async () => { const shouldHandleError = vi.fn().mockReturnValue(true); - // oxlint-disable-next-line typescript/no-explicit-any - responseHandler(createMockContext(200) as any, shouldHandleError); + await runMiddleware(createMockContext(200), { shouldHandleError }); expect(shouldHandleError).not.toHaveBeenCalled(); expect(captureExceptionMock).not.toHaveBeenCalled(); @@ -185,18 +187,16 @@ describe('responseHandler', () => { }); describe('transaction name', () => { - it('sets transaction name on isolation scope', () => { - // oxlint-disable-next-line typescript/no-explicit-any - requestHandler(createMockContext(200) as any); + it('sets transaction name on isolation scope', async () => { + await runMiddleware(createMockContext(200)); expect(mockSetTransactionName).toHaveBeenCalledWith('GET /test'); }); - it('sets http.route and segment name source on the root span', () => { + it('sets http.route and segment name source on the root span', async () => { getActiveSpanMock.mockReturnValue(mockRootSpan); - // oxlint-disable-next-line typescript/no-explicit-any - requestHandler(createMockContext(200) as any); + await runMiddleware(createMockContext(200)); expect(mockRootSpan.setAttribute).toHaveBeenCalledWith(HTTP_ROUTE, '/test'); expect(mockRootSpan.setAttribute).toHaveBeenCalledWith(SENTRY_SEGMENT_NAME_SOURCE, 'route'); @@ -204,7 +204,7 @@ describe('responseHandler', () => { }); }); -describe('requestHandler — connection info', () => { +describe('requestHandler — connection info (via createHonoRequestMiddleware)', () => { const activeSpan = { updateName: vi.fn(), setAttribute: vi.fn() }; beforeEach(() => { @@ -224,12 +224,11 @@ describe('requestHandler — connection info', () => { }); } - it('sets non-PII attributes (port, transport, type) regardless of userInfo', () => { + it('sets non-PII attributes (port, transport, type) regardless of userInfo', async () => { mockUserInfo(false); const getConnInfo = getConnInfoStub({ port: 54321, transport: 'tcp', addressType: 'IPv4' }); - // oxlint-disable-next-line typescript/no-explicit-any - requestHandler(createMockContext(200) as any, getConnInfo as any); + await runMiddleware(createMockContext(200), { getConnInfo }); expect(rootSpanAttributes['client.port']).toBe(54321); expect(rootSpanAttributes['network.peer.port']).toBe(54321); @@ -238,12 +237,11 @@ describe('requestHandler — connection info', () => { expect(rootSpanAttributes['client.address']).toBeUndefined(); }); - it('sets IP-bearing attributes and user.ip_address when userInfo is true', () => { + it('sets IP-bearing attributes and user.ip_address when userInfo is true', async () => { mockUserInfo(true); const getConnInfo = getConnInfoStub({ address: '203.0.113.5', port: 443, addressType: 'IPv6' }); - // oxlint-disable-next-line typescript/no-explicit-any - requestHandler(createMockContext(200) as any, getConnInfo as any); + await runMiddleware(createMockContext(200), { getConnInfo }); expect(rootSpanAttributes['client.address']).toBe('203.0.113.5'); expect(rootSpanAttributes['network.peer.address']).toBe('203.0.113.5'); @@ -251,13 +249,12 @@ describe('requestHandler — connection info', () => { expect(mockSetUser).toHaveBeenCalledWith({ ip_address: '203.0.113.5' }); }); - it('merges ip_address into the existing user without overwriting other fields', () => { + it('merges ip_address into the existing user without overwriting other fields', async () => { mockUserInfo(true); mockGetUser.mockReturnValue({ id: 'user-123', email: 'jane@example.com' }); const getConnInfo = getConnInfoStub({ address: '203.0.113.5', port: 443 }); - // oxlint-disable-next-line typescript/no-explicit-any - requestHandler(createMockContext(200) as any, getConnInfo as any); + await runMiddleware(createMockContext(200), { getConnInfo }); expect(mockSetUser).toHaveBeenCalledWith({ id: 'user-123', @@ -266,12 +263,11 @@ describe('requestHandler — connection info', () => { }); }); - it('omits IP-bearing attributes when userInfo is false', () => { + it('omits IP-bearing attributes when userInfo is false', async () => { mockUserInfo(false); const getConnInfo = getConnInfoStub({ address: '203.0.113.5', port: 8080 }); - // oxlint-disable-next-line typescript/no-explicit-any - requestHandler(createMockContext(200) as any, getConnInfo as any); + await runMiddleware(createMockContext(200), { getConnInfo }); expect(rootSpanAttributes['client.address']).toBeUndefined(); expect(rootSpanAttributes['network.peer.address']).toBeUndefined(); @@ -280,12 +276,11 @@ describe('requestHandler — connection info', () => { expect(rootSpanAttributes['client.port']).toBe(8080); }); - it('sets no connection attributes when remote info is empty', () => { + it('sets no connection attributes when remote info is empty', async () => { mockUserInfo(true); const getConnInfo = getConnInfoStub({}); - // oxlint-disable-next-line typescript/no-explicit-any - requestHandler(createMockContext(200) as any, getConnInfo as any); + await runMiddleware(createMockContext(200), { getConnInfo }); expect(rootSpanAttributes['client.port']).toBeUndefined(); expect(rootSpanAttributes['network.peer.port']).toBeUndefined(); @@ -295,38 +290,33 @@ describe('requestHandler — connection info', () => { expect(mockSetUser).not.toHaveBeenCalled(); }); - it('does not throw or set attributes when getConnInfo throws', () => { + it('does not throw or set attributes when getConnInfo throws', async () => { mockUserInfo(true); const getConnInfo = vi.fn(() => { throw new Error('socket unavailable'); }); - expect(() => - // oxlint-disable-next-line typescript/no-explicit-any - requestHandler(createMockContext(200) as any, getConnInfo as any), - ).not.toThrow(); + await expect(runMiddleware(createMockContext(200), { getConnInfo })).resolves.toBeUndefined(); expect(rootSpanAttributes['client.port']).toBeUndefined(); expect(rootSpanAttributes['client.address']).toBeUndefined(); expect(mockSetUser).not.toHaveBeenCalled(); }); - it('does not set connection attributes when there is no active span', () => { + it('does not set connection attributes when there is no active span', async () => { mockUserInfo(true); getActiveSpanMock.mockReturnValue(null); const getConnInfo = getConnInfoStub({ address: '203.0.113.5', port: 443 }); - // oxlint-disable-next-line typescript/no-explicit-any - requestHandler(createMockContext(200) as any, getConnInfo as any); + await runMiddleware(createMockContext(200), { getConnInfo }); expect(getConnInfo).not.toHaveBeenCalled(); expect(rootSpanAttributes).toEqual({}); }); - it('is a no-op when getConnInfo is not provided', () => { + it('is a no-op when getConnInfo is not provided', async () => { mockUserInfo(true); - // oxlint-disable-next-line typescript/no-explicit-any - requestHandler(createMockContext(200) as any); + await runMiddleware(createMockContext(200)); expect(rootSpanAttributes['client.port']).toBeUndefined(); expect(mockSetUser).not.toHaveBeenCalled(); diff --git a/packages/hono/test/shared/patchAppRequest.test.ts b/packages/hono/test/shared/patchAppRequest.test.ts index bcd5ccf3a863..245821091143 100644 --- a/packages/hono/test/shared/patchAppRequest.test.ts +++ b/packages/hono/test/shared/patchAppRequest.test.ts @@ -1,7 +1,7 @@ import * as SentryCore from '@sentry/core'; import { Hono } from 'hono'; import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { patchAppRequest } from '../../src/shared/patchAppRequest'; +import { applyHonoPatches } from '@sentry/server-utils'; vi.mock('@sentry/core', async () => { const actual = await vi.importActual('@sentry/core'); @@ -24,7 +24,7 @@ describe('patchAppRequest', () => { it('creates a hono.request span when .request() is called with an active parent span', async () => { const app = new Hono(); app.get('/hello', c => c.text('world')); - patchAppRequest(app); + applyHonoPatches(app); await app.request('/hello'); @@ -47,7 +47,7 @@ describe('patchAppRequest', () => { const app = new Hono(); app.get('/hello', c => c.text('world')); - patchAppRequest(app); + applyHonoPatches(app); const res = await app.request('/hello'); @@ -58,7 +58,7 @@ describe('patchAppRequest', () => { it('uses the method from requestInit when provided', async () => { const app = new Hono(); app.post('/submit', c => c.text('ok')); - patchAppRequest(app); + applyHonoPatches(app); await app.request('/submit', { method: 'POST' }); @@ -68,7 +68,7 @@ describe('patchAppRequest', () => { it('uses the method from a Request object when no requestInit is provided', async () => { const app = new Hono(); app.post('/submit', c => c.text('ok')); - patchAppRequest(app); + applyHonoPatches(app); await app.request(new Request('http://localhost/submit', { method: 'POST' })); @@ -78,7 +78,7 @@ describe('patchAppRequest', () => { it('defaults to GET when no method info is available', async () => { const app = new Hono(); app.get('/hello', c => c.text('world')); - patchAppRequest(app); + applyHonoPatches(app); await app.request('/hello'); @@ -89,17 +89,17 @@ describe('patchAppRequest', () => { const app = new Hono(); app.get('/hello', c => c.text('world')); - patchAppRequest(app); + applyHonoPatches(app); const firstPatched = app.request; - patchAppRequest(app); + applyHonoPatches(app); expect(app.request).toBe(firstPatched); }); it('preserves the original .request() return value', async () => { const app = new Hono(); app.get('/hello', c => c.json({ message: 'world' })); - patchAppRequest(app); + applyHonoPatches(app); const res = await app.request('/hello'); expect(res.status).toBe(200); @@ -111,7 +111,7 @@ describe('patchAppRequest', () => { it('stores the original request via __sentry_original__', () => { const app = new Hono(); const originalRequest = app.request; - patchAppRequest(app); + applyHonoPatches(app); // oxlint-disable-next-line typescript/no-explicit-any const sentryOriginal = (app.request as any).__sentry_original__; @@ -121,7 +121,7 @@ describe('patchAppRequest', () => { it('extracts pathname from a full URL string instead of using the raw string', async () => { const app = new Hono(); app.get('/api/hello', c => c.text('world')); - patchAppRequest(app); + applyHonoPatches(app); await app.request('http://localhost/api/hello'); @@ -134,7 +134,7 @@ describe('patchAppRequest', () => { it('extracts pathname from an https URL string', async () => { const app = new Hono(); app.get('/secure', c => c.text('ok')); - patchAppRequest(app); + applyHonoPatches(app); await app.request('https://example.com/secure'); @@ -144,7 +144,7 @@ describe('patchAppRequest', () => { it('extracts pathname from a Request object input', async () => { const app = new Hono(); app.get('/items/abc', c => c.text('found')); - patchAppRequest(app); + applyHonoPatches(app); await app.request(new Request('http://localhost/items/abc')); @@ -160,7 +160,7 @@ describe('patchAppRequest', () => { const CUSTOM_SYMBOL = Symbol('custom-meta'); (app.request as any)[CUSTOM_SYMBOL] = { version: 2 }; - patchAppRequest(app); + applyHonoPatches(app); const symbols = Object.getOwnPropertySymbols(app.request); expect(symbols).toContain(CUSTOM_SYMBOL); @@ -172,7 +172,7 @@ describe('patchAppRequest', () => { (app.request as any).customFlag = true; (app.request as any).metadata = { wrapped: false }; - patchAppRequest(app); + applyHonoPatches(app); expect((app.request as any).customFlag).toBe(true); expect((app.request as any).metadata).toEqual({ wrapped: false }); @@ -181,7 +181,7 @@ describe('patchAppRequest', () => { it('preserves function.name of the original request method', () => { const app = new Hono(); const originalName = app.request.name; - patchAppRequest(app); + applyHonoPatches(app); expect(app.request.name).toBe(originalName); }); @@ -189,14 +189,14 @@ describe('patchAppRequest', () => { it('preserves function.length of the original request method', () => { const app = new Hono(); const originalLength = app.request.length; - patchAppRequest(app); + applyHonoPatches(app); expect(app.request.length).toBe(originalLength); }); it('does not interfere with instanceof or typeof checks', () => { const app = new Hono(); - patchAppRequest(app); + applyHonoPatches(app); expect(typeof app.request).toBe('function'); }); @@ -204,7 +204,7 @@ describe('patchAppRequest', () => { it('preserves prototype chain of the original function', () => { const app = new Hono(); const originalProto = Object.getPrototypeOf(app.request); - patchAppRequest(app); + applyHonoPatches(app); expect(Object.getPrototypeOf(app.request)).toBe(originalProto); }); @@ -215,7 +215,7 @@ describe('patchAppRequest', () => { (app.request as any)[OPENAPI] = { paths: { '/hello': { get: {} } } }; (app.request as any).__middleware_chain__ = ['auth', 'cors']; - patchAppRequest(app); + applyHonoPatches(app); expect((app.request as any)[OPENAPI]).toEqual({ paths: { '/hello': { get: {} } } }); expect((app.request as any).__middleware_chain__).toEqual(['auth', 'cors']); diff --git a/packages/hono/test/shared/patchAppUse.test.ts b/packages/hono/test/shared/patchAppUse.test.ts index 8773bb6961c6..7fcb03774a29 100644 --- a/packages/hono/test/shared/patchAppUse.test.ts +++ b/packages/hono/test/shared/patchAppUse.test.ts @@ -1,7 +1,7 @@ import * as SentryCore from '@sentry/core'; import { Hono } from 'hono'; import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { patchAppUse, patchHttpMethodHandlers } from '../../src/shared/patchAppUse'; +import { applyHonoPatches } from '@sentry/server-utils'; vi.mock('@sentry/core', async () => { const actual = await vi.importActual('@sentry/core'); @@ -23,7 +23,7 @@ describe('patchAppUse (middleware spans)', () => { it('wraps handlers in app.use(handler) so startInactiveSpan is called when middleware runs', async () => { const app = new Hono(); - patchAppUse(app); + applyHonoPatches(app); const userHandler = vi.fn(async (_c: unknown, next: () => Promise) => { await next(); @@ -52,7 +52,7 @@ describe('patchAppUse (middleware spans)', () => { describe('span naming', () => { it('uses handler.name for span when handler has a name', async () => { const app = new Hono(); - patchAppUse(app); + applyHonoPatches(app); async function myNamedMiddleware(_c: unknown, next: () => Promise) { await next(); @@ -66,7 +66,7 @@ describe('patchAppUse (middleware spans)', () => { it('uses for span when handler is anonymous', async () => { const app = new Hono(); - patchAppUse(app); + applyHonoPatches(app); app.use(async (_c: unknown, next: () => Promise) => next()); @@ -80,7 +80,7 @@ describe('patchAppUse (middleware spans)', () => { it('wraps each handler in app.use(path, ...handlers) and passes path through', async () => { const app = new Hono(); - patchAppUse(app); + applyHonoPatches(app); const handler = async (_c: unknown, next: () => Promise) => next(); app.use('/api', handler); @@ -93,7 +93,7 @@ describe('patchAppUse (middleware spans)', () => { it('sets span error status when middleware throws a 5xx-like error', async () => { const app = new Hono(); - patchAppUse(app); + applyHonoPatches(app); const err = new Error('middleware error'); app.use(async () => { @@ -109,7 +109,7 @@ describe('patchAppUse (middleware spans)', () => { it('creates sibling spans for multiple middlewares (onion order, not parent-child)', async () => { const app = new Hono(); - patchAppUse(app); + applyHonoPatches(app); app.use( async (_c: unknown, next: () => Promise) => next(), @@ -133,10 +133,10 @@ describe('patchAppUse (middleware spans)', () => { it('does not stack proxies when called twice on the same instance', () => { const app = new Hono(); - patchAppUse(app); + applyHonoPatches(app); const firstUse = app.use; - patchAppUse(app); + applyHonoPatches(app); expect(app.use).toBe(firstUse); }); @@ -144,15 +144,15 @@ describe('patchAppUse (middleware spans)', () => { const app1 = new Hono(); const app2 = new Hono(); - patchAppUse(app1); - patchAppUse(app2); + applyHonoPatches(app1); + applyHonoPatches(app2); expect(app1.use).not.toBe(app2.use); }); it('preserves symbol-keyed and string-keyed properties on wrapped handlers', async () => { const app = new Hono(); - patchAppUse(app); + applyHonoPatches(app); const META = Symbol('test-meta'); const OPENAPI = Symbol('openapi'); @@ -176,27 +176,6 @@ describe('patchAppUse (middleware spans)', () => { expect((route!.handler as any)[OPENAPI]).toEqual({ responses: { 200: {} } }); expect((route!.handler as any).customProp).toBe('hello'); }); - - it('preserves this context when calling the original use (Proxy forwards thisArg)', () => { - type FakeApp = { - _capturedThis: unknown; - use: (...args: unknown[]) => FakeApp; - }; - const fakeApp: FakeApp = { - _capturedThis: null, - use(this: FakeApp, ..._args: unknown[]) { - this._capturedThis = this; - return this; - }, - }; - - patchAppUse(fakeApp as unknown as Parameters[0]); - - const noop = async (_c: unknown, next: () => Promise) => next(); - fakeApp.use(noop); - - expect(fakeApp._capturedThis).toBe(fakeApp); - }); }); describe('patchHttpMethodHandlers (inline middleware spans on main app)', () => { @@ -208,7 +187,7 @@ describe('patchHttpMethodHandlers (inline middleware spans on main app)', () => 'wraps inline middleware in app.%s(path, mw, handler)', async method => { const app = new Hono(); - patchHttpMethodHandlers(app); + applyHonoPatches(app); app[method]( '/test', @@ -236,7 +215,7 @@ describe('patchHttpMethodHandlers (inline middleware spans on main app)', () => it('does not wrap the sole handler when only one handler is passed', async () => { const app = new Hono(); - patchHttpMethodHandlers(app); + applyHonoPatches(app); app.get('/test', async function onlyHandler() { return new Response('ok'); @@ -249,7 +228,7 @@ describe('patchHttpMethodHandlers (inline middleware spans on main app)', () => it('wraps all handlers except the last when multiple handlers are passed', async () => { const app = new Hono(); - patchHttpMethodHandlers(app); + applyHonoPatches(app); app.get( '/test', @@ -275,7 +254,7 @@ describe('patchHttpMethodHandlers (inline middleware spans on main app)', () => it('wraps inline middleware in app.on(method, path, mw, handler)', async () => { const app = new Hono(); - patchHttpMethodHandlers(app); + applyHonoPatches(app); app.on( 'QUERY', @@ -296,64 +275,15 @@ describe('patchHttpMethodHandlers (inline middleware spans on main app)', () => expect(spanNames).not.toContain('onHandler'); }); - it('wraps app.query middleware when query is available (from 4.13.0)', async () => { - const context = { value: 'context' }; - const result = { value: 'result' }; - let registeredMiddleware: ((context: unknown, next: () => Promise) => Promise) | undefined; - let registeredHandler: ((context: unknown) => unknown) | undefined; - const query = vi.fn(function ( - this: unknown, - path: string, - middleware: (context: unknown, next: () => Promise) => Promise, - handler: (context: unknown) => unknown, - ) { - expect(this).toBe(fakeApp); - expect(path).toBe('/test'); - registeredMiddleware = middleware; - registeredHandler = handler; - return result; - }); - const fakeApp = Object.assign(new Hono(), { query }); - async function queryMiddleware(receivedContext: unknown, next: () => Promise) { - expect(receivedContext).toBe(context); - await next(); - } - const middleware = vi.fn(queryMiddleware); - const handler = vi.fn((receivedContext: unknown) => { - expect(receivedContext).toBe(context); - return 'handled'; - }); - - patchHttpMethodHandlers(fakeApp as unknown as Parameters[0]); - const registrationResult = fakeApp.query('/test', middleware, handler); - - expect(registrationResult).toBe(result); - if (!registeredMiddleware || !registeredHandler) { - throw new Error('query handlers were not registered'); - } - expect(registeredHandler).toBe(handler); - - const next = vi.fn(async () => undefined); - await registeredMiddleware(context, next); - const handlerResult = registeredHandler(context); - - expect(startInactiveSpanMock).toHaveBeenCalledTimes(1); - expect(startInactiveSpanMock).toHaveBeenCalledWith(expect.objectContaining({ name: 'queryMiddleware' })); - expect(middleware).toHaveBeenCalledWith(context, next); - expect(next).toHaveBeenCalledTimes(1); - expect(handler).toHaveBeenCalledWith(context); - expect(handlerResult).toBe('handled'); - }); - it('patches apps without app.query', () => { const app = new Hono(); - expect(() => patchHttpMethodHandlers(app)).not.toThrow(); + expect(() => applyHonoPatches(app)).not.toThrow(); }); it('does not wrap sole handler in app.on(method, path, handler)', async () => { const app = new Hono(); - patchHttpMethodHandlers(app); + applyHonoPatches(app); app.on('GET', '/test', async function soleHandler() { return new Response('ok'); @@ -366,8 +296,8 @@ describe('patchHttpMethodHandlers (inline middleware spans on main app)', () => it('does not double-wrap handlers already wrapped by patchAppUse', async () => { const app = new Hono(); - patchAppUse(app); - patchHttpMethodHandlers(app); + applyHonoPatches(app); + applyHonoPatches(app); app.use(async function useMw(_c: unknown, next: () => Promise) { await next(); @@ -382,15 +312,15 @@ describe('patchHttpMethodHandlers (inline middleware spans on main app)', () => it('produces exactly one span per middleware and does not stack Proxy layers when called multiple times on the same instance', async () => { const app = new Hono(); - patchHttpMethodHandlers(app); + applyHonoPatches(app); const firstGet = app.get; const firstOn = app.on; - patchHttpMethodHandlers(app); + applyHonoPatches(app); expect(app.get).toBe(firstGet); expect(app.on).toBe(firstOn); - patchHttpMethodHandlers(app); + applyHonoPatches(app); expect(app.get).toBe(firstGet); expect(app.on).toBe(firstOn); @@ -413,8 +343,8 @@ describe('patchHttpMethodHandlers (inline middleware spans on main app)', () => it('creates spans for both app.use middleware and inline middleware in app.get', async () => { const app = new Hono(); - patchAppUse(app); - patchHttpMethodHandlers(app); + applyHonoPatches(app); + applyHonoPatches(app); app.use('/test', async function globalMw(_c: unknown, next: () => Promise) { await next(); @@ -437,36 +367,10 @@ describe('patchHttpMethodHandlers (inline middleware spans on main app)', () => it('preserves return value and chaining', () => { const app = new Hono(); - patchHttpMethodHandlers(app); + applyHonoPatches(app); const result = app.get('/test', () => new Response('ok')); expect(result).toBe(app); }); - - it('forwards thisArg to the original method', () => { - let capturedThis: unknown = null; - const fakeMethod = function (this: unknown) { - // oxlint-disable-next-line @typescript-eslint/no-this-alias - capturedThis = this; - return this; - }; - const fakeApp = { - get: fakeMethod, - post: fakeMethod, - put: fakeMethod, - delete: fakeMethod, - options: fakeMethod, - patch: fakeMethod, - all: fakeMethod, - on: fakeMethod, - }; - - patchHttpMethodHandlers(fakeApp as unknown as Parameters[0]); - - // @ts-expect-error - we're only testing that thisArg is forwarded, so the args don't need to be correct - fakeApp.get('/test', () => new Response('ok')); - - expect(capturedThis).toBe(fakeApp); - }); }); diff --git a/packages/hono/test/shared/resolveRouteName.test.ts b/packages/hono/test/shared/resolveRouteName.test.ts deleted file mode 100644 index 4ca9f103db22..000000000000 --- a/packages/hono/test/shared/resolveRouteName.test.ts +++ /dev/null @@ -1,113 +0,0 @@ -import type { Context } from 'hono'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; - -const mockMatchedRoutes = vi.fn(); -const mockRoutePath = vi.fn(); - -vi.mock('hono/route', () => ({ - matchedRoutes: (c: unknown) => mockMatchedRoutes(c), - routePath: (c: unknown, index?: number) => mockRoutePath(c, index), -})); - -import { resolveRouteName } from '../../src/shared/resolveRouteName'; - -type Route = { - basePath: string; - path: string; - method: string; - handler: (...args: unknown[]) => unknown; -}; - -// Middleware has the signature `(context, next)` → arity 2 -// Route handlers are `(context)` → arity (no. of args) 1 -// `resolveRouteName` relies on this arity difference to tell them apart. -function mw(path: string, method = 'ALL'): Route { - return { basePath: '/', path, method, handler: (_c: unknown, _next: unknown) => undefined }; -} - -function handler(path: string, method = 'GET'): Route { - return { basePath: '/', path, method, handler: (_c: unknown) => undefined }; -} - -function ctx(routeIndex: number): Context { - return { req: { method: 'GET', routeIndex } } as unknown as Context; -} - -describe('resolveRouteName', () => { - beforeEach(() => { - vi.clearAllMocks(); - mockRoutePath.mockReturnValue('/fallback'); - }); - - it('returns the handler path when routeIndex points at a handler (normal flow)', () => { - mockMatchedRoutes.mockReturnValue([mw('/*'), handler('/users/:id')]); - - expect(resolveRouteName(ctx(1))).toBe('/users/:id'); - }); - - it('ignores a trailing catch-all middleware and uses the handler path', () => { - // app.use(fn) registered after the handlers → trailing `/*` is the last matched entry. - mockMatchedRoutes.mockReturnValue([mw('/*'), handler('/test-routes'), mw('/*')]); - - expect(resolveRouteName(ctx(1))).toBe('/test-routes'); - }); - - it('resolves the handler before dispatch when routeIndex still points at the sentry middleware', () => { - // Provisional pass: routeIndex is 0 (the sentry middleware) and `matchedRoutes` is already populated. - mockMatchedRoutes.mockReturnValue([mw('/*'), handler('/test-routes'), mw('/*')]); - - expect(resolveRouteName(ctx(0))).toBe('/test-routes'); - }); - - it('falls back to the matched handler when a middleware short-circuits (routeIndex on middleware)', () => { - // A scoped middleware throws before reaching the handler, so routeIndex stays on the middleware. - mockMatchedRoutes.mockReturnValue([mw('/*'), mw('/test/middleware/*'), handler('/test/middleware'), mw('/*')]); - - expect(resolveRouteName(ctx(1))).toBe('/test/middleware'); - }); - - it('prefers the responding handler over other matched handlers (overlap)', () => { - // Both `/users/:id` and a `/*` catch-all handler match; routeIndex disambiguates. - mockMatchedRoutes.mockReturnValue([mw('/*'), handler('/users/:id'), handler('/*')]); - - expect(resolveRouteName(ctx(1))).toBe('/users/:id'); - }); - - it('detects a sub-app handler wrapped by a custom onError (COMPOSED_HANDLER)', () => { - // Hono wraps the handler in an arity-2 closure but exposes the original via `__COMPOSED_HANDLER`. - const wrapped = ((_c: unknown, _next: unknown) => undefined) as Route['handler']; - (wrapped as unknown as Record).__COMPOSED_HANDLER = (_c: unknown) => undefined; - - mockMatchedRoutes.mockReturnValue([ - mw('/*'), - { basePath: '/', path: '/test/custom-on-error/fail', method: 'GET', handler: wrapped }, - mw('/*'), - ]); - - expect(resolveRouteName(ctx(1))).toBe('/test/custom-on-error/fail'); - }); - - it('falls back to routePath(c, -1) when only middleware matched', () => { - const context = ctx(1); - mockMatchedRoutes.mockReturnValue([mw('/*'), mw('/test-basepath/v1/*')]); - mockRoutePath.mockReturnValue('/test-basepath/v1/*'); - - expect(resolveRouteName(context)).toBe('/test-basepath/v1/*'); - expect(mockRoutePath).toHaveBeenCalledWith(context, -1); - }); - - it('falls back to routePath(c, -1) when no routes matched', () => { - const context = ctx(0); - mockMatchedRoutes.mockReturnValue([]); - mockRoutePath.mockReturnValue(''); - - expect(resolveRouteName(context)).toBe(''); - expect(mockRoutePath).toHaveBeenCalledWith(context, -1); - }); - - it('walks back to the last handler when routeIndex is out of range', () => { - mockMatchedRoutes.mockReturnValue([mw('/*'), handler('/test-late-get')]); - - expect(resolveRouteName(ctx(5))).toBe('/test-late-get'); - }); -}); diff --git a/packages/hono/test/shared/wrapMiddlewareSpan.test.ts b/packages/hono/test/shared/wrapMiddlewareSpan.test.ts deleted file mode 100644 index edf778188552..000000000000 --- a/packages/hono/test/shared/wrapMiddlewareSpan.test.ts +++ /dev/null @@ -1,127 +0,0 @@ -import * as SentryCore from '@sentry/core'; -import { SPAN_STATUS_ERROR } from '@sentry/core'; -import { type MiddlewareHandler } from 'hono'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { wrapMiddlewareWithSpan } from '../../src/shared/wrapMiddlewareSpan'; - -const mockSpan = { - setStatus: vi.fn(), - end: vi.fn(), -}; - -vi.mock('@sentry/core', async () => { - const actual = await vi.importActual('@sentry/core'); - return { - ...actual, - startInactiveSpan: vi.fn(() => mockSpan), - getActiveSpan: vi.fn(() => ({ spanId: 'root-span' })), - getRootSpan: vi.fn(span => span), - getOriginalFunction: vi.fn(() => undefined), - }; -}); - -const startInactiveSpanMock = SentryCore.startInactiveSpan as ReturnType; - -function makeContext(): unknown { - return { req: { method: 'GET' }, res: { status: 200 } }; -} - -const noop: () => Promise = async () => {}; - -describe('wrapMiddlewareWithSpan', () => { - beforeEach(() => { - vi.clearAllMocks(); - startInactiveSpanMock.mockReturnValue(mockSpan); - }); - - describe('span status', () => { - it('does not set span error status for a 4xx error', async () => { - const error = Object.assign(new Error('Not Found'), { status: 404 }); - const handler: MiddlewareHandler = async () => { - throw error; - }; - - const wrapped = wrapMiddlewareWithSpan(handler); - - await expect(wrapped(makeContext() as any, noop)).rejects.toThrow(error); - - expect(mockSpan.setStatus).not.toHaveBeenCalled(); - }); - - it('sets span status to error for a 5xx error', async () => { - const error = Object.assign(new Error('Server Error'), { status: 500 }); - const handler: MiddlewareHandler = async () => { - throw error; - }; - - const wrapped = wrapMiddlewareWithSpan(handler); - - await expect(wrapped(makeContext() as any, noop)).rejects.toThrow(error); - - expect(mockSpan.setStatus).toHaveBeenCalledWith({ code: SPAN_STATUS_ERROR, message: 'internal_error' }); - }); - - it('sets span status to error for a plain Error with no status', async () => { - const error = new Error('unexpected failure'); - const handler: MiddlewareHandler = async () => { - throw error; - }; - - const wrapped = wrapMiddlewareWithSpan(handler); - - await expect(wrapped(makeContext() as any, noop)).rejects.toThrow(error); - - expect(mockSpan.setStatus).toHaveBeenCalledWith({ code: SPAN_STATUS_ERROR, message: 'internal_error' }); - }); - - it('does not set span error status for a 3xx error', async () => { - const error = Object.assign(new Error('Redirect'), { status: 301 }); - const handler: MiddlewareHandler = async () => { - throw error; - }; - - const wrapped = wrapMiddlewareWithSpan(handler); - - await expect(wrapped(makeContext() as any, noop)).rejects.toThrow(error); - - expect(mockSpan.setStatus).not.toHaveBeenCalled(); - }); - }); - - describe('span lifecycle', () => { - it('always rethrows the error', async () => { - const error = new Error('must propagate'); - const handler: MiddlewareHandler = async () => { - throw error; - }; - - const wrapped = wrapMiddlewareWithSpan(handler); - - await expect(wrapped(makeContext() as any, noop)).rejects.toThrow('must propagate'); - }); - - it('ends the span even when the handler throws', async () => { - const handler: MiddlewareHandler = async () => { - throw new Error('boom'); - }; - - const wrapped = wrapMiddlewareWithSpan(handler); - - await expect(wrapped(makeContext() as any, noop)).rejects.toThrow(); - - expect(mockSpan.end).toHaveBeenCalledTimes(1); - }); - - it('ends the span when the handler succeeds', async () => { - const handler: MiddlewareHandler = async (_c, next) => { - await next(); - }; - - const wrapped = wrapMiddlewareWithSpan(handler); - - await wrapped(makeContext() as any, noop); - - expect(mockSpan.end).toHaveBeenCalledTimes(1); - }); - }); -}); diff --git a/packages/server-utils/src/index.ts b/packages/server-utils/src/index.ts index 57fa3f913cda..667c8efc12cd 100644 --- a/packages/server-utils/src/index.ts +++ b/packages/server-utils/src/index.ts @@ -36,6 +36,9 @@ export { genericPoolIntegration } from './integrations/generic-pool'; export { googleGenAIIntegration } from './integrations/google-genai'; export { graphqlIntegration } from './integrations/graphql'; export { hapiIntegration } from './integrations/hapi'; +// Shared, runtime-agnostic Hono instrumentation re-used by the `@sentry/hono` SDK. +export { applyHonoPatches, earlyPatchHono, createHonoRequestMiddleware } from './integrations/hono'; +export type { CreateHonoRequestMiddlewareOptions, SentryHonoMiddlewareOptions } from './integrations/hono'; export { koaIntegration } from './integrations/koa'; export { redisIntegration } from './integrations/redis'; export { kafkaIntegration } from './integrations/kafkajs'; diff --git a/packages/hono/src/shared/applyPatches.ts b/packages/server-utils/src/integrations/hono/applyPatches.ts similarity index 68% rename from packages/hono/src/shared/applyPatches.ts rename to packages/server-utils/src/integrations/hono/applyPatches.ts index e3189f2f63f2..c3ef1be088b9 100644 --- a/packages/hono/src/shared/applyPatches.ts +++ b/packages/server-utils/src/integrations/hono/applyPatches.ts @@ -1,6 +1,6 @@ import { debug } from '@sentry/core'; -import type { Env, Hono } from 'hono'; -import { DEBUG_BUILD } from '../debug-build'; +import { DEBUG_BUILD } from '../../debug-build'; +import type { Env, Hono } from './honoTypes'; import { patchAppRequest } from './patchAppRequest'; import { patchAppUse, patchHttpMethodHandlers } from './patchAppUse'; import { type RouteHookHandle, installRouteHookOnPrototype, wrapSubAppMiddleware } from './patchRoute'; @@ -11,10 +11,13 @@ let _routeHook: RouteHookHandle | undefined; /** * Hooks `HonoBase.prototype.route` at import time, before `sentry()` runs. * - * Collecting sub-app references early ensures nothing is missed if sub-apps are mounted synchronously before the `sentry()` middleware is registered. + * Collecting sub-app references early ensures nothing is missed if sub-apps are mounted synchronously + * before the `sentry()` middleware is registered. The `Hono` class is passed in by the caller (the + * `@sentry/hono` SDK, where `hono` is a peer dependency) so this module never imports `hono` itself; + * `HonoBase.prototype` is one level above the class prototype. */ -export function earlyPatchHono(): void { - _routeHook ??= installRouteHookOnPrototype(); +export function earlyPatchHono(honoClass: { prototype: object }): void { + _routeHook ??= installRouteHookOnPrototype(Object.getPrototypeOf(honoClass.prototype)); } /** @@ -25,8 +28,10 @@ export function earlyPatchHono(): void { * - Retroactively instruments sub-apps mounted before `sentry()` was called. */ export function applyPatches(app: Hono): void { + // `HonoBase.prototype` (where `route` lives) is two levels up from the app instance: + // app → Hono.prototype → HonoBase.prototype. Deriving it from the live app avoids importing `hono`. // Always call — installRouteHookOnPrototype is idempotent and returns existing handle when prototype already patched - _routeHook = installRouteHookOnPrototype(); + _routeHook = installRouteHookOnPrototype(Object.getPrototypeOf(Object.getPrototypeOf(app))); // `app.use` (instance own property) — wraps middleware at registration time on this instance. patchAppUse(app); diff --git a/packages/server-utils/src/integrations/hono/createHonoMiddleware.ts b/packages/server-utils/src/integrations/hono/createHonoMiddleware.ts new file mode 100644 index 000000000000..952b45743590 --- /dev/null +++ b/packages/server-utils/src/integrations/hono/createHonoMiddleware.ts @@ -0,0 +1,97 @@ +import { addNonEnumerableProperty, getDefaultIsolationScope, getIsolationScope } from '@sentry/core'; +import type { Context, GetConnInfo, MiddlewareHandler } from './honoTypes'; +import { requestHandler, responseHandler } from './middlewareHandlers'; +import type { SentryHonoMiddlewareOptions } from './types'; + +// Marks the Sentry request/response middleware so the span-wrapping patches never turn it into a +// middleware span — most importantly when an auto-instrumented sub-app carrying this middleware is +// mounted into a parent and `wrapSubAppMiddleware` wraps its handlers. Read by `wrapMiddlewareWithSpan`. +export const SENTRY_HONO_MIDDLEWARE = '__SENTRY_HONO_MIDDLEWARE__'; + +// `Symbol.for` (global registry) so the markers are shared even if this module is evaluated from more +// than one copy of `@sentry/server-utils`. +const HONO_REQUEST_HANDLED = Symbol.for('sentry.hono.requestHandled'); +// The effective `shouldHandleError` for the request, recorded even by a deduplicated middleware so a +// user-provided callback wins over the default (see below). +const HONO_SHOULD_HANDLE_ERROR = Symbol.for('sentry.hono.shouldHandleError'); + +export interface CreateHonoRequestMiddlewareOptions { + /** + * Runtime-specific `getConnInfo` helper (e.g. `@hono/node-server/conninfo`, `hono/bun`). + * Optional — connection-info attributes are simply skipped when it is not provided. + */ + getConnInfo?: GetConnInfo; + + /** Static `shouldHandleError` callback (Node/Bun/Deno). */ + shouldHandleError?: SentryHonoMiddlewareOptions['shouldHandleError']; + + /** + * Resolves `shouldHandleError` per request from the context. Cloudflare accepts + * middleware options as a function of `env`, so the callback is only known once a + * request comes in. When provided, this wins over the static `shouldHandleError`. + */ + resolveShouldHandleError?: (context: Context) => SentryHonoMiddlewareOptions['shouldHandleError']; +} + +/** + * The object that carries the per-request dedup/config markers. + * + * Prefer the per-request isolation scope over the Hono `Context`, so the request is treated as one + * even when several Sentry middlewares run for it: + * - a mounted sub-app that carries its own auto-registered middleware (same context, same scope), + * - an internal `app.request()` dispatch, which runs in a *new* Hono context but the *same* + * isolation scope (so it must not re-record the transaction name or overwrite the request data), + * - a manual `sentry()` middleware registered alongside the auto-instrumentation. + * + * When there is no per-request isolation scope (the default scope), fall back to the context so that + * at least same-context duplicates are still deduplicated. + */ +function getRequestScope(context: Context): Record { + const isolationScope = getIsolationScope(); + const target: object = isolationScope === getDefaultIsolationScope() ? context : isolationScope; + return target as Record; +} + +/** + * Builds the core Sentry request/response Hono middleware: it names the transaction, records the + * request, and captures unhandled context errors around `next()`. + * + * Idempotent per request (see {@link getRequestScope}), so duplicate registrations — a manual + * `sentry()` alongside the auto-instrumentation, mounted sub-apps, internal `.request()` dispatches — + * are all safe and run the handling exactly once. + * + * A user-provided `shouldHandleError` still takes effect even when the middleware carrying it is + * deduplicated behind the auto-instrumentation (which is prepended first, per request, from the + * `Context` constructor hook): the deduplicated middleware records its callback on the request scope, and the + * middleware that actually runs `responseHandler` uses it in preference to its own default. + */ +export function createHonoRequestMiddleware(options: CreateHonoRequestMiddlewareOptions = {}): MiddlewareHandler { + const middleware: MiddlewareHandler = async (context, next) => { + const scope = getRequestScope(context); + + const shouldHandleError = options.resolveShouldHandleError + ? options.resolveShouldHandleError(context) + : options.shouldHandleError; + // Record a user-provided callback so it wins even if this middleware is deduplicated. Runs before + // the dedup check so a later manual `sentry({ shouldHandleError })` overrides the auto default. + if (shouldHandleError) { + addNonEnumerableProperty(scope, HONO_SHOULD_HANDLE_ERROR, shouldHandleError); + } + + if (scope[HONO_REQUEST_HANDLED]) { + return next(); + } + addNonEnumerableProperty(scope, HONO_REQUEST_HANDLED, true); + + requestHandler(context, options.getConnInfo); + + await next(); // Handler runs in between Request above ⤴ and Response below ⤵ + + const effectiveShouldHandleError = + (scope[HONO_SHOULD_HANDLE_ERROR] as SentryHonoMiddlewareOptions['shouldHandleError']) ?? shouldHandleError; + responseHandler(context, effectiveShouldHandleError); + }; + + addNonEnumerableProperty(middleware, SENTRY_HONO_MIDDLEWARE, true); + return middleware; +} diff --git a/packages/hono/src/shared/defaultShouldHandleError.ts b/packages/server-utils/src/integrations/hono/defaultShouldHandleError.ts similarity index 100% rename from packages/hono/src/shared/defaultShouldHandleError.ts rename to packages/server-utils/src/integrations/hono/defaultShouldHandleError.ts diff --git a/packages/hono/src/utils/hono-context.ts b/packages/server-utils/src/integrations/hono/hono-context.ts similarity index 87% rename from packages/hono/src/utils/hono-context.ts rename to packages/server-utils/src/integrations/hono/hono-context.ts index 96df44ee655a..f1529c6eef10 100644 --- a/packages/hono/src/utils/hono-context.ts +++ b/packages/server-utils/src/integrations/hono/hono-context.ts @@ -1,4 +1,4 @@ -import type { Context } from 'hono'; +import type { Context } from './honoTypes'; /** * Checks whether the given Hono context has a fetch event. diff --git a/packages/server-utils/src/integrations/hono/honoTypes.ts b/packages/server-utils/src/integrations/hono/honoTypes.ts new file mode 100644 index 000000000000..a4dc01e14152 --- /dev/null +++ b/packages/server-utils/src/integrations/hono/honoTypes.ts @@ -0,0 +1,71 @@ +/** + * Vendored subset of `hono`'s public types used by the Sentry Hono instrumentation. + * + * The instrumentation lives in `@sentry/server-utils`, a dependency of every server SDK — including + * apps that do not use Hono. We therefore declare no dependency on `hono` at all: not at runtime + * (the instrumentation never statically imports it; the `Hono` prototype is derived from a live app + * instance and matched routes are read from the request's own getters), and not at build/type time + * (these minimal structural types stand in for `hono`'s). + * + * ATTENTION: keep these permissive. Values cross the boundary to the `@sentry/hono` SDK, which uses + * the real `hono` types, so these must stay assignable from them at those call sites. + */ + +/* oxlint-disable typescript/no-explicit-any -- vendored, deliberately permissive types */ + +export interface Env { + Bindings?: any; + Variables?: any; +} + +export type Next = () => Promise; + +export interface HonoRoute { + method: string; + path: string; + // Loose on purpose: Hono's own route handler union (`Handler | MiddlewareHandler`) is wider than a + // middleware handler, and this must stay assignable from it so the real `Context` flows into the + // vendored one at the `@sentry/hono` boundary. + handler: (...args: any[]) => any; +} + +export interface HonoRequest { + raw: Request; + method: string; + path: string; + routeIndex: number; + // These are public (though deprecated) getters on Hono's request. Reading them avoids a runtime + // import of the `hono/route` helpers, which are their non-deprecated replacements. + matchedRoutes: HonoRoute[]; + routePath: string; + [key: string]: any; +} + +export interface Context { + req: HonoRequest; + env: unknown; + error?: Error; + event: { request: Request }; + [key: string]: any; +} + +// Return type is `Promise` (not the wider sync union) to stay assignable to the real +// `hono` `MiddlewareHandler` at the `@sentry/hono` boundary; our handlers are always async. +export type MiddlewareHandler = (context: Context, next: Next) => Promise; + +export interface Hono { + // `use` is chainable (returns the app), which is also where the `E` type parameter is threaded. + use: (...args: any[]) => Hono; + request: (...args: any[]) => Response | Promise; + routes: HonoRoute[]; + [key: string]: any; +} + +export interface ConnInfoRemote { + address?: string; + port?: number; + transport?: string; + addressType?: string; +} + +export type GetConnInfo = (context: any) => { remote?: ConnInfoRemote }; diff --git a/packages/server-utils/src/integrations/hono/index.ts b/packages/server-utils/src/integrations/hono/index.ts new file mode 100644 index 000000000000..7f71e2bada2e --- /dev/null +++ b/packages/server-utils/src/integrations/hono/index.ts @@ -0,0 +1,20 @@ +import { applyPatches } from './applyPatches'; +import type { Hono } from './honoTypes'; + +// Shared, runtime-agnostic Hono instrumentation, re-used by the `@sentry/hono` SDK across all of its +// runtimes (Node, Bun, Cloudflare, Deno). None of these modules import `hono` (at runtime or type +// level), so they stay safe to load in every server SDK — including apps that do not use Hono. +export { earlyPatchHono } from './applyPatches'; +export { createHonoRequestMiddleware } from './createHonoMiddleware'; +export type { CreateHonoRequestMiddlewareOptions } from './createHonoMiddleware'; +export type { SentryHonoMiddlewareOptions } from './types'; + +/** + * Applies Sentry's Hono span patches to an app instance. + * + * Typed loosely (`object`) so the real `hono` `Hono` type used by the `@sentry/hono` SDK is + * accepted without a cast; internally it is treated as the vendored {@link Hono} shape. + */ +export function applyHonoPatches(app: object): void { + applyPatches(app as Hono); +} diff --git a/packages/hono/src/utils/isMiddleware.ts b/packages/server-utils/src/integrations/hono/isMiddleware.ts similarity index 100% rename from packages/hono/src/utils/isMiddleware.ts rename to packages/server-utils/src/integrations/hono/isMiddleware.ts diff --git a/packages/hono/src/shared/middlewareHandlers.ts b/packages/server-utils/src/integrations/hono/middlewareHandlers.ts similarity index 94% rename from packages/hono/src/shared/middlewareHandlers.ts rename to packages/server-utils/src/integrations/hono/middlewareHandlers.ts index ffba879603ea..ab1f3f000199 100644 --- a/packages/hono/src/shared/middlewareHandlers.ts +++ b/packages/server-utils/src/integrations/hono/middlewareHandlers.ts @@ -10,12 +10,11 @@ import { type Scope, winterCGRequestToRequestData, } from '@sentry/core'; -import type { Context } from 'hono'; -import { hasFetchEvent } from '../utils/hono-context'; +import type { Context, GetConnInfo } from './honoTypes'; +import { hasFetchEvent } from './hono-context'; import { defaultShouldHandleError } from './defaultShouldHandleError'; import { resolveRouteName } from './resolveRouteName'; -import { type SentryHonoMiddlewareOptions } from '../shared/types'; -import { type GetConnInfo } from 'hono/conninfo'; +import { type SentryHonoMiddlewareOptions } from './types'; import { HTTP_ROUTE } from '@sentry/conventions/attributes'; /** diff --git a/packages/server-utils/src/integrations/hono/patchAppRequest.ts b/packages/server-utils/src/integrations/hono/patchAppRequest.ts new file mode 100644 index 000000000000..69f073eb2185 --- /dev/null +++ b/packages/server-utils/src/integrations/hono/patchAppRequest.ts @@ -0,0 +1,132 @@ +import { SENTRY_OP } from '@sentry/conventions/attributes'; +import { HTTP_SERVER } from '@sentry/conventions/op'; +import { + debug, + getActiveSpan, + getOriginalFunction, + SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, + startSpan, + type WrappedFunction, +} from '@sentry/core'; +import type { Env, Hono } from './honoTypes'; +import { DEBUG_BUILD } from '../../debug-build'; + +const INTERNAL_REQUEST_OP = HTTP_SERVER; +const INTERNAL_REQUEST_ORIGIN = 'auto.http.hono.internal_request'; + +// Re-entrancy guard shared with the orchestrion channel subscriber +// (`instrumentInternalRequests` in `honoIntegration`). Both wrap the same `app.request`: this Proxy +// calls through to the original, which — when the default integration is active — is orchestrion- +// instrumented and publishes to the request channel. Without this guard the two stack a second, +// identical internal-request span inside the first. The Proxy owns the span; the channel subscriber +// reads this flag and opts out. `Symbol.for` on `globalThis` keeps it shared across copies of +// `@sentry/server-utils`, matching the request-handling dedup markers. +const INTERNAL_REQUEST_SPAN_ACTIVE = Symbol.for('sentry.hono.internalRequestSpanActive'); +type GuardCarrier = { [INTERNAL_REQUEST_SPAN_ACTIVE]?: boolean }; + +/** Whether the instance `app.request` Proxy is currently opening an internal-request span. */ +export function isInternalRequestSpanActive(): boolean { + return !!(globalThis as GuardCarrier)[INTERNAL_REQUEST_SPAN_ACTIVE]; +} + +function setInternalRequestSpanActive(active: boolean): void { + (globalThis as GuardCarrier)[INTERNAL_REQUEST_SPAN_ACTIVE] = active; +} + +function stripQueryAndHash(path: string): string { + const end = path.search(/[?#]/); + return end === -1 ? path : path.slice(0, end); +} + +/** + * Derive the span-name path from an `app.request()` argument, mirroring Hono's own handling so the + * name matches the path actually dispatched, with the query/hash stripped so they can't leak into + * span names or inflate cardinality. + * + * Hono treats an absolute `http(s)://` input as a full URL and everything else as a path under + * `http://localhost` (see `hono-base`'s `request`). We prepend that same fixed host rather than + * resolving the string as a URL reference: resolution rewrites protocol-relative inputs + * (`//example.com/foo` → host `example.com`, dropping the segment Hono keeps in the path) and throws + * on inputs Hono accepts (`//`, `http:`). This runs before the underlying dispatch, so it must never + * throw — the `catch` is a final guard against any remaining malformed input. + */ +export function extractPathname(input: unknown): string { + if (typeof input === 'string') { + try { + const url = /^https?:\/\//.test(input) + ? new URL(input) + : new URL(`http://localhost${input.startsWith('/') ? '' : '/'}${input}`); + return url.pathname; + } catch { + return stripQueryAndHash(input); + } + } + + if (input instanceof Request) { + return new URL(input.url).pathname; + } + + return input instanceof URL ? input.pathname : '/'; +} + +/** + * Patches `app.request()` on a Hono instance so that each internal dispatch + * is traced as an `http.server` span — child of whatever span is active at + * the call site. + * + * `.request()` is a class field (arrow function), so this must run per-instance. + * Idempotent: safe to call multiple times on the same instance. + */ +export function patchAppRequest(app: Hono): void { + if (getOriginalFunction(app.request as unknown as WrappedFunction)) { + DEBUG_BUILD && debug.log('[hono] app.request already patched — skipping.'); + return; + } + + const originalRequest = app.request; + + app.request = new Proxy(originalRequest, { + apply(_target, thisArg, args: [string | Request | URL, RequestInit?, ...unknown[]]) { + const [input, requestInit, ...rest] = args; + + if (!getActiveSpan()) { + return Reflect.apply(_target, thisArg, args); + } + + let method = requestInit?.method ?? (input instanceof Request ? input.method : 'GET'); + method = method.toUpperCase(); + + const path = extractPathname(input); + + return startSpan( + { + name: `${method} ${path}`, + onlyIfParent: true, + attributes: { + [SENTRY_OP]: INTERNAL_REQUEST_OP, + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: INTERNAL_REQUEST_ORIGIN, + }, + }, + () => { + // The flag only needs to cover the synchronous dispatch start, where the orchestrion + // channel would fire and open its duplicate span. `Reflect.apply` returns the pending + // promise synchronously, so `finally` clears it before any nested `.request()` runs. + setInternalRequestSpanActive(true); + try { + return Reflect.apply(_target, thisArg, [input, requestInit, ...rest]); + } finally { + setInternalRequestSpanActive(false); + } + }, + ); + }, + get(target, prop, receiver) { + if (prop === '__sentry_original__') { + return originalRequest; + } + return Reflect.get(target, prop, receiver); + }, + }); + + DEBUG_BUILD && debug.log('[hono] Patched app.request for internal dispatch tracing.'); +} diff --git a/packages/hono/src/shared/patchAppUse.ts b/packages/server-utils/src/integrations/hono/patchAppUse.ts similarity index 96% rename from packages/hono/src/shared/patchAppUse.ts rename to packages/server-utils/src/integrations/hono/patchAppUse.ts index 05bb058bf499..7268151072b5 100644 --- a/packages/hono/src/shared/patchAppUse.ts +++ b/packages/server-utils/src/integrations/hono/patchAppUse.ts @@ -1,6 +1,6 @@ import { debug } from '@sentry/core'; -import type { Env, Hono, MiddlewareHandler } from 'hono'; -import { DEBUG_BUILD } from '../debug-build'; +import type { Env, Hono, MiddlewareHandler } from './honoTypes'; +import { DEBUG_BUILD } from '../../debug-build'; import { wrapMiddlewareWithSpan } from './wrapMiddlewareSpan'; // oxlint-disable-next-line typescript/no-explicit-any diff --git a/packages/hono/src/shared/patchRoute.ts b/packages/server-utils/src/integrations/hono/patchRoute.ts similarity index 87% rename from packages/hono/src/shared/patchRoute.ts rename to packages/server-utils/src/integrations/hono/patchRoute.ts index 67e6d83d52f3..4f13b1b760ad 100644 --- a/packages/hono/src/shared/patchRoute.ts +++ b/packages/server-utils/src/integrations/hono/patchRoute.ts @@ -1,17 +1,12 @@ import { debug, getOriginalFunction } from '@sentry/core'; import type { WrappedFunction } from '@sentry/core'; -import type { Hono, MiddlewareHandler } from 'hono'; -import { Hono as HonoClass } from 'hono'; -import { DEBUG_BUILD } from '../debug-build'; -import { isMiddleware } from '../utils/isMiddleware'; +import { DEBUG_BUILD } from '../../debug-build'; +import type { Hono, HonoRoute } from './honoTypes'; +import { isMiddleware } from './isMiddleware'; import { patchAppRequest } from './patchAppRequest'; import { wrapMiddlewareWithSpan } from './wrapMiddlewareSpan'; -export type HonoRoute = { - method: string; - path: string; - handler: MiddlewareHandler; -}; +export type { HonoRoute }; // oxlint-disable-next-line typescript/no-explicit-any type HonoAny = Hono; @@ -60,15 +55,17 @@ function createRouteHook(): { handle: RouteHookHandle; onSubAppMounted: (subApp: /** * Installs a hook on `HonoBase.prototype.route` to intercept sub-app mounting. * + * `honoBaseProto` is `HonoBase.prototype`, where `route` is defined — one level above the concrete + * subclass. Callers derive it from a live app instance (`Object.getPrototypeOf(Object.getPrototypeOf(app))`) + * or from the `Hono` class (`Object.getPrototypeOf(Hono.prototype)`), so the instrumentation never + * imports `hono` itself. + * * Returns a handle with `activate()` and `getPendingSubApps()`. * Idempotent: subsequent calls return the same handle */ -export function installRouteHookOnPrototype(): RouteHookHandle { +export function installRouteHookOnPrototype(honoBaseProto: HonoBaseProto): RouteHookHandle { const noopHandle: RouteHookHandle = { activate: () => {}, getPendingSubApps: () => new Set() }; - // `route` is defined on HonoBase.prototype, one level above the concrete subclass - const honoBaseProto = Object.getPrototypeOf(HonoClass.prototype) as HonoBaseProto; - if (!honoBaseProto || typeof honoBaseProto.route !== 'function') { DEBUG_BUILD && debug.warn('[hono] Could not find HonoBase.prototype.route — sub-app instrumentation disabled.'); return noopHandle; diff --git a/packages/hono/src/shared/resolveRouteName.ts b/packages/server-utils/src/integrations/hono/resolveRouteName.ts similarity index 65% rename from packages/hono/src/shared/resolveRouteName.ts rename to packages/server-utils/src/integrations/hono/resolveRouteName.ts index 1fb720c22e5b..97b1283722ed 100644 --- a/packages/hono/src/shared/resolveRouteName.ts +++ b/packages/server-utils/src/integrations/hono/resolveRouteName.ts @@ -1,6 +1,5 @@ -import type { Context } from 'hono'; -import { matchedRoutes, routePath } from 'hono/route'; -import { isMiddleware } from '../utils/isMiddleware'; +import type { Context, HonoRoute } from './honoTypes'; +import { isMiddleware } from './isMiddleware'; // Arity alone is enough here (unlike `wrapSubAppMiddleware` in patchRoute.ts, which also needs position) // We only want the path, and inline middleware shares its handler's path. @@ -8,6 +7,19 @@ function isRouteHandler(handler: unknown): boolean { return typeof handler === 'function' && !isMiddleware(handler); } +// Read the request's own matched routes rather than importing the `hono/route` helpers, so the +// instrumentation needs no runtime import from `hono`. `c.req.matchedRoutes` and `c.req.routePath` +// are the (deprecated but public) getters those helpers wrap; `routePath(c, index)` is reimplemented +// here as `matchedRoutes(c).at(index)?.path` so an arbitrary index (e.g. -1) can be resolved. +function matchedRoutes(context: Context): HonoRoute[] { + return context.req.matchedRoutes ?? []; +} + +function routePath(context: Context, index?: number): string { + const routes = matchedRoutes(context); + return routes.at(index ?? context.req.routeIndex)?.path ?? ''; +} + /** * Resolves the route path of the matched handler for the transaction name. * diff --git a/packages/hono/src/shared/types.ts b/packages/server-utils/src/integrations/hono/types.ts similarity index 100% rename from packages/hono/src/shared/types.ts rename to packages/server-utils/src/integrations/hono/types.ts diff --git a/packages/hono/src/shared/wrapMiddlewareSpan.ts b/packages/server-utils/src/integrations/hono/wrapMiddlewareSpan.ts similarity index 83% rename from packages/hono/src/shared/wrapMiddlewareSpan.ts rename to packages/server-utils/src/integrations/hono/wrapMiddlewareSpan.ts index d1141000e137..e3e451247614 100644 --- a/packages/hono/src/shared/wrapMiddlewareSpan.ts +++ b/packages/server-utils/src/integrations/hono/wrapMiddlewareSpan.ts @@ -9,7 +9,8 @@ import { startInactiveSpan, type WrappedFunction, } from '@sentry/core'; -import { type MiddlewareHandler } from 'hono'; +import { type MiddlewareHandler } from './honoTypes'; +import { SENTRY_HONO_MIDDLEWARE } from './createHonoMiddleware'; import { defaultShouldHandleError } from './defaultShouldHandleError'; const MIDDLEWARE_ORIGIN = 'auto.middleware.hono'; @@ -21,6 +22,12 @@ const MIDDLEWARE_ORIGIN = 'auto.middleware.hono'; * (onion order: A → B → handler → B → A would otherwise nest B under A). */ export function wrapMiddlewareWithSpan(handler: MiddlewareHandler): MiddlewareHandler { + // Never turn Sentry's own request/response middleware into a middleware span — e.g. when an + // auto-instrumented sub-app carrying it is mounted into a parent and its handlers get wrapped. + if ((handler as unknown as Record)[SENTRY_HONO_MIDDLEWARE]) { + return handler; + } + if (getOriginalFunction(handler as unknown as WrappedFunction)) { return handler; } diff --git a/packages/server-utils/test/integrations/hono/createHonoMiddleware.test.ts b/packages/server-utils/test/integrations/hono/createHonoMiddleware.test.ts new file mode 100644 index 000000000000..cf2062390fe9 --- /dev/null +++ b/packages/server-utils/test/integrations/hono/createHonoMiddleware.test.ts @@ -0,0 +1,83 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +// Spy on the request/response handlers so we can assert exactly how many times they run. +const requestHandler = vi.fn(); +const responseHandler = vi.fn(); +vi.mock('../../../src/integrations/hono/middlewareHandlers', () => ({ + requestHandler: (...args: unknown[]) => requestHandler(...args), + responseHandler: (...args: unknown[]) => responseHandler(...args), +})); + +// eslint-disable-next-line import/first +import { createHonoRequestMiddleware } from '../../../src/integrations/hono/createHonoMiddleware'; + +// Minimal fake Hono context — dedup only needs a stable object to mark, not a real Hono app. +// oxlint-disable-next-line typescript/no-explicit-any +const fakeContext = (): any => ({ req: {} }); + +describe('createHonoRequestMiddleware — duplicate registration handling', () => { + beforeEach(() => { + requestHandler.mockClear(); + responseHandler.mockClear(); + }); + + it('runs request/response handling exactly once when two Sentry middlewares wrap the same request', async () => { + const outer = createHonoRequestMiddleware(); + const inner = createHonoRequestMiddleware(); + const context = fakeContext(); + + // Onion order: outer → inner → handler → inner → outer. + await outer(context, async () => { + await inner(context, async () => {}); + }); + + expect(requestHandler).toHaveBeenCalledTimes(1); + expect(responseHandler).toHaveBeenCalledTimes(1); + }); + + it('passes an already-handled context straight through to next()', async () => { + const context = fakeContext(); + + // First middleware handles the request and marks the context. + await createHonoRequestMiddleware()(context, async () => {}); + requestHandler.mockClear(); + responseHandler.mockClear(); + + // A second (duplicate) middleware on the same context must not re-run the handlers. + let nextCalled = false; + await createHonoRequestMiddleware()(context, async () => { + nextCalled = true; + }); + + expect(nextCalled).toBe(true); + expect(requestHandler).not.toHaveBeenCalled(); + expect(responseHandler).not.toHaveBeenCalled(); + }); + + it('handles independent requests independently (marker is per-context)', async () => { + const middleware = createHonoRequestMiddleware(); + + await middleware(fakeContext(), async () => {}); + await middleware(fakeContext(), async () => {}); + + expect(requestHandler).toHaveBeenCalledTimes(2); + expect(responseHandler).toHaveBeenCalledTimes(2); + }); + + it("uses a deduplicated middleware's shouldHandleError over the outer (auto) default", async () => { + const userShouldHandleError = (): boolean => true; + // Outer middleware mirrors the auto-instrumentation: registered first, no shouldHandleError. + const auto = createHonoRequestMiddleware(); + // Inner middleware mirrors a manual `sentry({ shouldHandleError })`: deduplicated behind `auto`. + const manual = createHonoRequestMiddleware({ shouldHandleError: userShouldHandleError }); + const context = fakeContext(); + + await auto(context, async () => { + await manual(context, async () => {}); + }); + + // The request is still handled exactly once, but with the user's callback, not the default. + expect(responseHandler).toHaveBeenCalledTimes(1); + expect(responseHandler).toHaveBeenCalledWith(context, userShouldHandleError); + }); +}); diff --git a/packages/hono/test/shared/defaultShouldHandleError.test.ts b/packages/server-utils/test/integrations/hono/defaultShouldHandleError.test.ts similarity index 88% rename from packages/hono/test/shared/defaultShouldHandleError.test.ts rename to packages/server-utils/test/integrations/hono/defaultShouldHandleError.test.ts index 85a29493c752..2b7705554f1e 100644 --- a/packages/hono/test/shared/defaultShouldHandleError.test.ts +++ b/packages/server-utils/test/integrations/hono/defaultShouldHandleError.test.ts @@ -1,6 +1,15 @@ -import { HTTPException } from 'hono/http-exception'; import { describe, expect, it } from 'vitest'; -import { defaultShouldHandleError } from '../../src/shared/defaultShouldHandleError'; +import { defaultShouldHandleError } from '../../../src/integrations/hono/defaultShouldHandleError'; + +// Minimal stand-in for hono's `HTTPException` (which carries a numeric `status`), so this unit test +// stays free of a `hono` dependency in `@sentry/server-utils`. +class HTTPException extends Error { + public status: number; + public constructor(status: number, options?: { message?: string }) { + super(options?.message); + this.status = status; + } +} describe('defaultShouldHandleError', () => { describe('HTTPException', () => { diff --git a/packages/hono/test/utils/isMiddleware.test.ts b/packages/server-utils/test/integrations/hono/isMiddleware.test.ts similarity index 95% rename from packages/hono/test/utils/isMiddleware.test.ts rename to packages/server-utils/test/integrations/hono/isMiddleware.test.ts index 6266d14e86db..ee193ac5ac8d 100644 --- a/packages/hono/test/utils/isMiddleware.test.ts +++ b/packages/server-utils/test/integrations/hono/isMiddleware.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { isMiddleware } from '../../src/utils/isMiddleware'; +import { isMiddleware } from '../../../src/integrations/hono/isMiddleware'; describe('isMiddleware', () => { it.each([