From 16fa689956b63466a208be077c4e2147c286cc83 Mon Sep 17 00:00:00 2001 From: s1gr1d <32902192+s1gr1d@users.noreply.github.com> Date: Mon, 16 Mar 2026 11:39:28 +0100 Subject: [PATCH 1/4] feat(hono): Add basic instrumentation for Node runtime --- packages/hono/README.md | 47 +++++- packages/hono/package.json | 13 ++ packages/hono/rollup.npm.config.mjs | 2 +- packages/hono/src/index.node.ts | 3 + packages/hono/src/node/middleware.ts | 32 ++++ .../hono/src/shared/middlewareHandlers.ts | 9 +- packages/hono/test/vercel/middleware.test.ts | 138 ++++++++++++++++++ 7 files changed, 239 insertions(+), 5 deletions(-) create mode 100644 packages/hono/src/index.node.ts create mode 100644 packages/hono/src/node/middleware.ts create mode 100644 packages/hono/test/vercel/middleware.test.ts diff --git a/packages/hono/README.md b/packages/hono/README.md index 23d9487a0295..ba20e96a31d0 100644 --- a/packages/hono/README.md +++ b/packages/hono/README.md @@ -54,9 +54,8 @@ const app = new Hono(); // Initialize Sentry middleware right after creating the app app.use( - '*', sentry(app, { - dsn: 'your-sentry-dsn', + dsn: '__DSN__', // ...other Sentry options }), ); @@ -65,3 +64,47 @@ app.use( export default app; ``` + +## Setup (Node) + +### 1. Initialize Sentry in your Hono app + +Initialize the Sentry Hono middleware as early as possible in your app: + +```ts +import { Hono } from 'hono'; +import { serve } from '@hono/node-server'; +import { sentry } from '@sentry/hono/node'; + +const app = new Hono(); + +// Initialize Sentry middleware right after creating the app +app.use( + sentry(app, { + dsn: '__DSN__', + tracesSampleRate: 1.0, + }), +); + +// ... your routes and other middleware + +serve(app); +``` + +### 2. Add `preload` script to start command + +To ensure that Sentry can capture spans from third-party libraries (e.g. database clients) used in your Hono app, Sentry needs to wrap these libraries as early as possible. + +When starting the Hono Node application, use the `@sentry/node/preload` hook with the `--import` CLI option to ensure modules are wrapped before the application code runs: + +```bash +node --import @sentry/node/preload index.js +``` + +This can also be added to the `NODE_OPTIONS` environment variable: + +```bash +NODE_OPTIONS="--import @sentry/node/preload" +``` + +Read more about this preload script in the docs: https://docs.sentry.io/platforms/javascript/guides/hono/install/late-initialization/#late-initialization-with-esm diff --git a/packages/hono/package.json b/packages/hono/package.json index c371aad129db..5735101cd5f4 100644 --- a/packages/hono/package.json +++ b/packages/hono/package.json @@ -36,6 +36,16 @@ "types": "./build/types/index.cloudflare.d.ts", "default": "./build/cjs/index.cloudflare.js" } + }, + "./node": { + "import": { + "types": "./build/types/index.node.d.ts", + "default": "./build/esm/index.node.js" + }, + "require": { + "types": "./build/types/index.node.d.ts", + "default": "./build/cjs/index.node.js" + } } }, "typesVersions": { @@ -45,6 +55,9 @@ ], "build/types/index.cloudflare.d.ts": [ "build/types-ts3.8/index.cloudflare.d.ts" + ], + "build/types/index.node.d.ts": [ + "build/types-ts3.8/index.node.d.ts" ] } }, diff --git a/packages/hono/rollup.npm.config.mjs b/packages/hono/rollup.npm.config.mjs index 6f491584a9d0..a60ba1312cc9 100644 --- a/packages/hono/rollup.npm.config.mjs +++ b/packages/hono/rollup.npm.config.mjs @@ -1,7 +1,7 @@ import { makeBaseNPMConfig, makeNPMConfigVariants } from '@sentry-internal/rollup-utils'; const baseConfig = makeBaseNPMConfig({ - entrypoints: ['src/index.ts', 'src/index.cloudflare.ts'], + entrypoints: ['src/index.ts', 'src/index.cloudflare.ts', 'src/index.node.ts'], packageSpecificConfig: { output: { preserveModulesRoot: 'src', diff --git a/packages/hono/src/index.node.ts b/packages/hono/src/index.node.ts new file mode 100644 index 000000000000..39a5d13f20dc --- /dev/null +++ b/packages/hono/src/index.node.ts @@ -0,0 +1,3 @@ +export { sentry } from './node/middleware'; + +export * from '@sentry/node'; diff --git a/packages/hono/src/node/middleware.ts b/packages/hono/src/node/middleware.ts new file mode 100644 index 000000000000..4db34c0a79a9 --- /dev/null +++ b/packages/hono/src/node/middleware.ts @@ -0,0 +1,32 @@ +import { applySdkMetadata, type BaseTransportOptions, debug, type Options } from '@sentry/core'; +import { init as initNode } from '@sentry/node'; +import type { Context, Hono, MiddlewareHandler } from 'hono'; +import { patchAppUse } from '../shared/patchAppUse'; +import { requestHandler, responseHandler } from '../shared/middlewareHandlers'; + +export interface HonoOptions extends Options { + context?: Context; +} + +/** + * Sentry middleware for Hono running in a Node runtime environment. + */ +export const sentry = (app: Hono, options: HonoOptions | undefined = {}): MiddlewareHandler => { + const isDebug = options.debug; + + isDebug && debug.log('Initialized Sentry Hono middleware (Node)'); + + applySdkMetadata(options, 'hono'); + + initNode(options); + + patchAppUse(app); + + return async (context, next) => { + requestHandler(context); + + await next(); // Handler runs in between Request above ⤴ and Response below ⤵ + + responseHandler(context); + }; +}; diff --git a/packages/hono/src/shared/middlewareHandlers.ts b/packages/hono/src/shared/middlewareHandlers.ts index 9745bcfa3988..d5c13b22bcec 100644 --- a/packages/hono/src/shared/middlewareHandlers.ts +++ b/packages/hono/src/shared/middlewareHandlers.ts @@ -1,9 +1,10 @@ -import { getIsolationScope } from '@sentry/cloudflare'; import { getActiveSpan, getClient, getDefaultIsolationScope, + getIsolationScope, getRootSpan, + SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, updateSpanName, winterCGRequestToRequestData, } from '@sentry/core'; @@ -32,7 +33,11 @@ export function responseHandler(context: Context): void { const activeSpan = getActiveSpan(); if (activeSpan) { activeSpan.updateName(`${context.req.method} ${routePath(context)}`); - updateSpanName(getRootSpan(activeSpan), `${context.req.method} ${routePath(context)}`); + activeSpan.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, 'route'); + + const rootSpan = getRootSpan(activeSpan); + updateSpanName(rootSpan, `${context.req.method} ${routePath(context)}`); + rootSpan.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, 'route'); } getIsolationScope().setTransactionName(`${context.req.method} ${routePath(context)}`); diff --git a/packages/hono/test/vercel/middleware.test.ts b/packages/hono/test/vercel/middleware.test.ts new file mode 100644 index 000000000000..243104f9dcdf --- /dev/null +++ b/packages/hono/test/vercel/middleware.test.ts @@ -0,0 +1,138 @@ +import * as SentryCore from '@sentry/core'; +import { SDK_VERSION } from '@sentry/core'; +import { Hono } from 'hono'; +import { beforeEach, describe, expect, it, type Mock, vi } from 'vitest'; +import { sentry } from '../../src/node/middleware'; + +vi.mock('@sentry/node', () => ({ + init: vi.fn(), +})); + +// eslint-disable-next-line @typescript-eslint/consistent-type-imports +const { init: initNodeMock } = await vi.importMock('@sentry/node'); + +vi.mock('@sentry/core', async () => { + const actual = await vi.importActual('@sentry/core'); + return { + ...actual, + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + applySdkMetadata: vi.fn(actual.applySdkMetadata), + }; +}); + +const applySdkMetadataMock = SentryCore.applySdkMetadata as Mock; + +describe('Hono Vercel Middleware', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('sentry middleware', () => { + it('calls applySdkMetadata with "hono"', () => { + const app = new Hono(); + const options = { + dsn: 'https://public@dsn.ingest.sentry.io/1337', + }; + + sentry(app, options); + + expect(applySdkMetadataMock).toHaveBeenCalledTimes(1); + expect(applySdkMetadataMock).toHaveBeenCalledWith(options, 'hono'); + }); + + it('calls init from @sentry/node', () => { + const app = new Hono(); + const options = { + dsn: 'https://public@dsn.ingest.sentry.io/1337', + }; + + sentry(app, options); + + expect(initNodeMock).toHaveBeenCalledTimes(1); + expect(initNodeMock).toHaveBeenCalledWith( + expect.objectContaining({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + }), + ); + }); + + it('sets SDK metadata before calling Node init', () => { + const app = new Hono(); + const options = { + dsn: 'https://public@dsn.ingest.sentry.io/1337', + }; + + sentry(app, options); + + const applySdkMetadataCallOrder = applySdkMetadataMock.mock.invocationCallOrder[0]; + const initNodeCallOrder = (initNodeMock as Mock).mock.invocationCallOrder[0]; + + expect(applySdkMetadataCallOrder).toBeLessThan(initNodeCallOrder as number); + }); + + it('preserves all user options', () => { + const app = new Hono(); + const options = { + dsn: 'https://public@dsn.ingest.sentry.io/1337', + environment: 'production', + sampleRate: 0.5, + tracesSampleRate: 1.0, + debug: true, + }; + + sentry(app, options); + + expect(initNodeMock).toHaveBeenCalledWith( + expect.objectContaining({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + environment: 'production', + sampleRate: 0.5, + tracesSampleRate: 1.0, + debug: true, + }), + ); + }); + + it('returns a middleware handler function', () => { + const app = new Hono(); + const options = { + dsn: 'https://public@dsn.ingest.sentry.io/1337', + }; + + const middleware = sentry(app, options); + + expect(middleware).toBeDefined(); + expect(typeof middleware).toBe('function'); + expect(middleware).toHaveLength(2); // Hono middleware takes (context, next) + }); + + it('returns an async middleware handler', () => { + const app = new Hono(); + const middleware = sentry(app, {}); + + expect(middleware.constructor.name).toBe('AsyncFunction'); + }); + + it('includes hono SDK metadata', () => { + const app = new Hono(); + const options = { + dsn: 'https://public@dsn.ingest.sentry.io/1337', + }; + + sentry(app, options); + + expect(initNodeMock).toHaveBeenCalledWith( + expect.objectContaining({ + _metadata: expect.objectContaining({ + sdk: expect.objectContaining({ + name: 'sentry.javascript.hono', + version: SDK_VERSION, + packages: [{ name: 'npm:@sentry/hono', version: SDK_VERSION }], + }), + }), + }), + ); + }); + }); +}); From b09ca39742835392baa9727657db8bf3f36814c8 Mon Sep 17 00:00:00 2001 From: s1gr1d <32902192+s1gr1d@users.noreply.github.com> Date: Mon, 16 Mar 2026 13:05:37 +0100 Subject: [PATCH 2/4] add integration test --- .../node-integration-tests/package.json | 1 + .../suites/hono-sdk/instrument.mjs | 1 + .../suites/hono-sdk/scenario.mjs | 31 ++++++ .../suites/hono-sdk/test.ts | 96 +++++++++++++++++++ 4 files changed, 129 insertions(+) create mode 100644 dev-packages/node-integration-tests/suites/hono-sdk/instrument.mjs create mode 100644 dev-packages/node-integration-tests/suites/hono-sdk/scenario.mjs create mode 100644 dev-packages/node-integration-tests/suites/hono-sdk/test.ts diff --git a/dev-packages/node-integration-tests/package.json b/dev-packages/node-integration-tests/package.json index f6ab451153aa..cb4c2d6ca6be 100644 --- a/dev-packages/node-integration-tests/package.json +++ b/dev-packages/node-integration-tests/package.json @@ -40,6 +40,7 @@ "@prisma/client": "6.15.0", "@sentry/aws-serverless": "10.43.0", "@sentry/core": "10.43.0", + "@sentry/hono": "10.43.0", "@sentry/node": "10.43.0", "@types/mongodb": "^3.6.20", "@types/mysql": "^2.15.21", diff --git a/dev-packages/node-integration-tests/suites/hono-sdk/instrument.mjs b/dev-packages/node-integration-tests/suites/hono-sdk/instrument.mjs new file mode 100644 index 000000000000..508cbe487e91 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/hono-sdk/instrument.mjs @@ -0,0 +1 @@ +// Sentry is initialized by the @sentry/hono/node middleware in scenario.mjs diff --git a/dev-packages/node-integration-tests/suites/hono-sdk/scenario.mjs b/dev-packages/node-integration-tests/suites/hono-sdk/scenario.mjs new file mode 100644 index 000000000000..92a08fcb5bb5 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/hono-sdk/scenario.mjs @@ -0,0 +1,31 @@ +import { serve } from '@hono/node-server'; +import { sentry } from '@sentry/hono/node'; +import { loggingTransport, sendPortToRunner } from '@sentry-internal/node-integration-tests'; +import { Hono } from 'hono'; + +const app = new Hono(); + +app.use( + sentry(app, { + dsn: 'https://public@dsn.ingest.sentry.io/1337', + tracesSampleRate: 1.0, + transport: loggingTransport, + }), +); + +app.get('/', c => { + return c.text('Hello from Hono on Node!'); +}); + +app.get('/hello/:name', c => { + const name = c.req.param('name'); + return c.text(`Hello, ${name}!`); +}); + +app.get('/error/:param', () => { + throw new Error('Test error from Hono app'); +}); + +serve({ fetch: app.fetch, port: 0 }, info => { + sendPortToRunner(info.port); +}); diff --git a/dev-packages/node-integration-tests/suites/hono-sdk/test.ts b/dev-packages/node-integration-tests/suites/hono-sdk/test.ts new file mode 100644 index 000000000000..df17e3af48aa --- /dev/null +++ b/dev-packages/node-integration-tests/suites/hono-sdk/test.ts @@ -0,0 +1,96 @@ +import { afterAll, describe, expect } from 'vitest'; +import { cleanupChildProcesses, createEsmAndCjsTests } from '../../utils/runner'; + +describe('hono-sdk (Node)', () => { + afterAll(() => { + cleanupChildProcesses(); + }); + + createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument.mjs', (createRunner, test) => { + test('creates a transaction for a basic GET request', async () => { + const runner = createRunner() + .expect({ + transaction: { + transaction: 'GET /', + contexts: { + trace: { + op: 'http.server', + status: 'ok', + }, + }, + }, + }) + .start(); + runner.makeRequest('get', '/'); + await runner.completed(); + }); + + test('creates a transaction with a parametrized route name', async () => { + const runner = createRunner() + .expect({ + transaction: { + transaction: 'GET /hello/:name', + transaction_info: { + source: 'route', + }, + contexts: { + trace: { + op: 'http.server', + status: 'ok', + }, + }, + }, + }) + .start(); + runner.makeRequest('get', '/hello/world'); + await runner.completed(); + }); + + test('captures an error with the correct mechanism', async () => { + const runner = createRunner() + .ignore('transaction') + .expect({ + event: { + exception: { + values: [ + { + type: 'Error', + value: 'Test error from Hono app', + mechanism: { + type: 'auto.faas.hono.error_handler', + handled: false, + }, + }, + ], + }, + transaction: 'GET /error/:param', + }, + }) + .start(); + runner.makeRequest('get', '/error/param-123', { expectError: true }); + await runner.completed(); + }); + + test('creates a transaction with internal_error status when an error occurs', async () => { + const runner = createRunner() + .ignore('event') + .expect({ + transaction: { + transaction: 'GET /error/:param', + contexts: { + trace: { + op: 'http.server', + status: 'internal_error', + data: expect.objectContaining({ + 'http.response.status_code': 500, + }), + }, + }, + }, + }) + .start(); + runner.makeRequest('get', '/error/param-456', { expectError: true }); + await runner.completed(); + }); + }); +}); From e0d80d4e43a7df3b69ff83e3883e69b41e3abda5 Mon Sep 17 00:00:00 2001 From: s1gr1d <32902192+s1gr1d@users.noreply.github.com> Date: Tue, 17 Mar 2026 10:54:18 +0100 Subject: [PATCH 3/4] review comments etc --- packages/hono/README.md | 4 ++-- packages/hono/src/cloudflare/middleware.ts | 4 ++-- packages/hono/src/index.node.ts | 2 ++ packages/hono/src/node/middleware.ts | 16 ++++++---------- packages/hono/src/node/sdk.ts | 17 +++++++++++++++++ .../test/{vercel => node}/middleware.test.ts | 9 ++++++--- 6 files changed, 35 insertions(+), 17 deletions(-) create mode 100644 packages/hono/src/node/sdk.ts rename packages/hono/test/{vercel => node}/middleware.test.ts (93%) diff --git a/packages/hono/README.md b/packages/hono/README.md index 3783d82fd595..c359536c656e 100644 --- a/packages/hono/README.md +++ b/packages/hono/README.md @@ -99,7 +99,7 @@ const app = new Hono(); // Initialize Sentry middleware right after creating the app app.use( sentry(app, { - dsn: '__DSN__', + dsn: '__DSN__', // or process.env.SENTRY_DSN tracesSampleRate: 1.0, }), ); @@ -119,7 +119,7 @@ When starting the Hono Node application, use the `@sentry/node/preload` hook wit node --import @sentry/node/preload index.js ``` -This can also be added to the `NODE_OPTIONS` environment variable: +This option can also be added to the `NODE_OPTIONS` environment variable: ```bash NODE_OPTIONS="--import @sentry/node/preload" diff --git a/packages/hono/src/cloudflare/middleware.ts b/packages/hono/src/cloudflare/middleware.ts index 76d571d2cda7..2f9052aebed3 100644 --- a/packages/hono/src/cloudflare/middleware.ts +++ b/packages/hono/src/cloudflare/middleware.ts @@ -11,7 +11,7 @@ import type { Env, Hono, MiddlewareHandler } from 'hono'; import { requestHandler, responseHandler } from '../shared/middlewareHandlers'; import { patchAppUse } from '../shared/patchAppUse'; -export interface HonoOptions extends Options {} +export interface HonoCloudflareOptions extends Options {} const filterHonoIntegration = (integration: Integration): boolean => integration.name !== 'Hono'; @@ -20,7 +20,7 @@ const filterHonoIntegration = (integration: Integration): boolean => integration */ export function sentry( app: Hono, - options: HonoOptions | ((env: E['Bindings']) => HonoOptions), + options: HonoCloudflareOptions | ((env: E['Bindings']) => HonoCloudflareOptions), ): MiddlewareHandler { withSentry( env => { diff --git a/packages/hono/src/index.node.ts b/packages/hono/src/index.node.ts index 39a5d13f20dc..02e94b67be89 100644 --- a/packages/hono/src/index.node.ts +++ b/packages/hono/src/index.node.ts @@ -1,3 +1,5 @@ export { sentry } from './node/middleware'; export * from '@sentry/node'; + +export { init } from './node/sdk'; diff --git a/packages/hono/src/node/middleware.ts b/packages/hono/src/node/middleware.ts index 4db34c0a79a9..1dbca92d02e5 100644 --- a/packages/hono/src/node/middleware.ts +++ b/packages/hono/src/node/middleware.ts @@ -1,24 +1,20 @@ -import { applySdkMetadata, type BaseTransportOptions, debug, type Options } from '@sentry/core'; -import { init as initNode } from '@sentry/node'; -import type { Context, Hono, MiddlewareHandler } from 'hono'; +import { type BaseTransportOptions, debug, type Options } from '@sentry/core'; +import { init } from './sdk'; +import type { Hono, MiddlewareHandler } from 'hono'; import { patchAppUse } from '../shared/patchAppUse'; import { requestHandler, responseHandler } from '../shared/middlewareHandlers'; -export interface HonoOptions extends Options { - context?: Context; -} +export interface HonoNodeOptions extends Options {} /** * Sentry middleware for Hono running in a Node runtime environment. */ -export const sentry = (app: Hono, options: HonoOptions | undefined = {}): MiddlewareHandler => { +export const sentry = (app: Hono, options: HonoNodeOptions | undefined = {}): MiddlewareHandler => { const isDebug = options.debug; isDebug && debug.log('Initialized Sentry Hono middleware (Node)'); - applySdkMetadata(options, 'hono'); - - initNode(options); + init(options); patchAppUse(app); diff --git a/packages/hono/src/node/sdk.ts b/packages/hono/src/node/sdk.ts new file mode 100644 index 000000000000..f2174dda4179 --- /dev/null +++ b/packages/hono/src/node/sdk.ts @@ -0,0 +1,17 @@ +import type { Client } from '@sentry/core'; +import { applySdkMetadata } from '@sentry/core'; +import { init as initNode } from '@sentry/node'; +import { HonoNodeOptions } from './middleware'; + +/** + * Initializes Sentry for Hono running in a Node runtime environment. + * + * In general, it is recommended to initialize Sentry via the `sentry()` middleware, as it sets up everything by default and calls `init` internally. + * + * When manually calling `init`, add the `honoIntegration` to the `integrations` array to set up the Hono integration. + */ +export function init(options: HonoNodeOptions): Client | undefined { + applySdkMetadata(options, 'hono', ['hono', 'node']); + + return initNode(options); +} diff --git a/packages/hono/test/vercel/middleware.test.ts b/packages/hono/test/node/middleware.test.ts similarity index 93% rename from packages/hono/test/vercel/middleware.test.ts rename to packages/hono/test/node/middleware.test.ts index 243104f9dcdf..019c5a61300f 100644 --- a/packages/hono/test/vercel/middleware.test.ts +++ b/packages/hono/test/node/middleware.test.ts @@ -23,7 +23,7 @@ vi.mock('@sentry/core', async () => { const applySdkMetadataMock = SentryCore.applySdkMetadata as Mock; -describe('Hono Vercel Middleware', () => { +describe('Hono Node Middleware', () => { beforeEach(() => { vi.clearAllMocks(); }); @@ -38,7 +38,7 @@ describe('Hono Vercel Middleware', () => { sentry(app, options); expect(applySdkMetadataMock).toHaveBeenCalledTimes(1); - expect(applySdkMetadataMock).toHaveBeenCalledWith(options, 'hono'); + expect(applySdkMetadataMock).toHaveBeenCalledWith(options, 'hono', ['hono', 'node']); }); it('calls init from @sentry/node', () => { @@ -128,7 +128,10 @@ describe('Hono Vercel Middleware', () => { sdk: expect.objectContaining({ name: 'sentry.javascript.hono', version: SDK_VERSION, - packages: [{ name: 'npm:@sentry/hono', version: SDK_VERSION }], + packages: [ + { name: 'npm:@sentry/hono', version: SDK_VERSION }, + { name: 'npm:@sentry/node', version: SDK_VERSION }, + ], }), }), }), From 4b860e504e76ad5ffc5a26b02e3bc32774cd26e5 Mon Sep 17 00:00:00 2001 From: s1gr1d <32902192+s1gr1d@users.noreply.github.com> Date: Tue, 17 Mar 2026 11:39:10 +0100 Subject: [PATCH 4/4] filter integration (prevent double-instrumentation) --- packages/hono/src/cloudflare/middleware.ts | 12 +- packages/hono/src/node/sdk.ts | 25 +++- .../hono/src/shared/filterHonoIntegration.ts | 3 + packages/hono/test/node/middleware.test.ts | 118 ++++++++++++++++++ 4 files changed, 144 insertions(+), 14 deletions(-) create mode 100644 packages/hono/src/shared/filterHonoIntegration.ts diff --git a/packages/hono/src/cloudflare/middleware.ts b/packages/hono/src/cloudflare/middleware.ts index 2f9052aebed3..1769bbd141a6 100644 --- a/packages/hono/src/cloudflare/middleware.ts +++ b/packages/hono/src/cloudflare/middleware.ts @@ -1,20 +1,12 @@ import { withSentry } from '@sentry/cloudflare'; -import { - applySdkMetadata, - type BaseTransportOptions, - debug, - getIntegrationsToSetup, - type Integration, - type Options, -} from '@sentry/core'; +import { applySdkMetadata, type BaseTransportOptions, debug, getIntegrationsToSetup, type Options } from '@sentry/core'; import type { Env, Hono, MiddlewareHandler } from 'hono'; import { requestHandler, responseHandler } from '../shared/middlewareHandlers'; import { patchAppUse } from '../shared/patchAppUse'; +import { filterHonoIntegration } from '../shared/filterHonoIntegration'; export interface HonoCloudflareOptions extends Options {} -const filterHonoIntegration = (integration: Integration): boolean => integration.name !== 'Hono'; - /** * Sentry middleware for Hono on Cloudflare Workers. */ diff --git a/packages/hono/src/node/sdk.ts b/packages/hono/src/node/sdk.ts index f2174dda4179..ff71ffe55909 100644 --- a/packages/hono/src/node/sdk.ts +++ b/packages/hono/src/node/sdk.ts @@ -1,7 +1,8 @@ -import type { Client } from '@sentry/core'; -import { applySdkMetadata } from '@sentry/core'; +import type { Client, Integration } from '@sentry/core'; +import { applySdkMetadata, getIntegrationsToSetup } from '@sentry/core'; import { init as initNode } from '@sentry/node'; -import { HonoNodeOptions } from './middleware'; +import type { HonoNodeOptions } from './middleware'; +import { filterHonoIntegration } from '../shared/filterHonoIntegration'; /** * Initializes Sentry for Hono running in a Node runtime environment. @@ -13,5 +14,21 @@ import { HonoNodeOptions } from './middleware'; export function init(options: HonoNodeOptions): Client | undefined { applySdkMetadata(options, 'hono', ['hono', 'node']); - return initNode(options); + const { integrations: userIntegrations } = options; + + // Remove Hono from the SDK defaults to prevent double instrumentation: @sentry/node + const filteredOptions: HonoNodeOptions = { + ...options, + integrations: Array.isArray(userIntegrations) + ? (defaults: Integration[]) => + getIntegrationsToSetup({ + defaultIntegrations: defaults.filter(filterHonoIntegration), + integrations: userIntegrations, // user's explicit Hono integration is preserved + }) + : typeof userIntegrations === 'function' + ? (defaults: Integration[]) => userIntegrations(defaults.filter(filterHonoIntegration)) + : (defaults: Integration[]) => defaults.filter(filterHonoIntegration), + }; + + return initNode(filteredOptions); } diff --git a/packages/hono/src/shared/filterHonoIntegration.ts b/packages/hono/src/shared/filterHonoIntegration.ts new file mode 100644 index 000000000000..743dac8997d5 --- /dev/null +++ b/packages/hono/src/shared/filterHonoIntegration.ts @@ -0,0 +1,3 @@ +import type { Integration } from '@sentry/core'; + +export const filterHonoIntegration = (integration: Integration): boolean => integration.name !== 'Hono'; diff --git a/packages/hono/test/node/middleware.test.ts b/packages/hono/test/node/middleware.test.ts index 019c5a61300f..1473daf98acc 100644 --- a/packages/hono/test/node/middleware.test.ts +++ b/packages/hono/test/node/middleware.test.ts @@ -3,6 +3,7 @@ import { SDK_VERSION } from '@sentry/core'; import { Hono } from 'hono'; import { beforeEach, describe, expect, it, type Mock, vi } from 'vitest'; import { sentry } from '../../src/node/middleware'; +import type { Integration } from '@sentry/core'; vi.mock('@sentry/node', () => ({ init: vi.fn(), @@ -114,6 +115,14 @@ describe('Hono Node Middleware', () => { expect(middleware.constructor.name).toBe('AsyncFunction'); }); + it('passes an integrations function to initNode (never a raw array)', () => { + const app = new Hono(); + sentry(app, { dsn: 'https://public@dsn.ingest.sentry.io/1337' }); + + const callArgs = (initNodeMock as Mock).mock.calls[0]?.[0]; + expect(typeof callArgs.integrations).toBe('function'); + }); + it('includes hono SDK metadata', () => { const app = new Hono(); const options = { @@ -138,4 +147,113 @@ describe('Hono Node Middleware', () => { ); }); }); + + describe('Hono integration filtering', () => { + const honoIntegration = { name: 'Hono' } as Integration; + const otherIntegration = { name: 'Other' } as Integration; + + const getIntegrationsFn = (): ((defaults: Integration[]) => Integration[]) => { + const callArgs = (initNodeMock as Mock).mock.calls[0]?.[0]; + return callArgs.integrations as (defaults: Integration[]) => Integration[]; + }; + + describe('when integrations is an array', () => { + it('keeps a user-explicitly-provided Hono integration', () => { + const app = new Hono(); + sentry(app, { integrations: [honoIntegration, otherIntegration] }); + + const integrationsFn = getIntegrationsFn(); + const result = integrationsFn([]); + expect(result.map(i => i.name)).toContain('Hono'); + expect(result.map(i => i.name)).toContain('Other'); + }); + + it('keeps non-Hono user integrations', () => { + const app = new Hono(); + sentry(app, { integrations: [otherIntegration] }); + + const integrationsFn = getIntegrationsFn(); + expect(integrationsFn([])).toEqual([otherIntegration]); + }); + + it('preserves user-provided Hono even when defaults would also provide it', () => { + const app = new Hono(); + sentry(app, { integrations: [honoIntegration] }); + + const integrationsFn = getIntegrationsFn(); + // Defaults include Hono, but it should be filtered from defaults; user's copy is kept + const result = integrationsFn([honoIntegration, otherIntegration]); + expect(result.filter(i => i.name === 'Hono')).toHaveLength(1); + }); + + it('removes Hono from defaults when user does not explicitly provide it', () => { + const app = new Hono(); + sentry(app, { integrations: [otherIntegration] }); + + const integrationsFn = getIntegrationsFn(); + const defaultsWithHono = [honoIntegration, otherIntegration]; + const result = integrationsFn(defaultsWithHono); + expect(result.map(i => i.name)).not.toContain('Hono'); + }); + + it('deduplicates non-Hono integrations when user integrations overlap with defaults', () => { + const app = new Hono(); + const duplicateIntegration = { name: 'Other' } as Integration; + sentry(app, { integrations: [duplicateIntegration] }); + + const integrationsFn = getIntegrationsFn(); + const defaultsWithOverlap = [honoIntegration, otherIntegration]; + const result = integrationsFn(defaultsWithOverlap); + expect(result).toHaveLength(1); + expect(result[0]?.name).toBe('Other'); + }); + }); + + describe('when integrations is a function', () => { + it('passes defaults without Hono to the user function', () => { + const app = new Hono(); + const userFn = vi.fn((_defaults: Integration[]) => [otherIntegration]); + const defaultIntegration = { name: 'Default' } as Integration; + + sentry(app, { integrations: userFn }); + + const integrationsFn = getIntegrationsFn(); + integrationsFn([honoIntegration, defaultIntegration]); + + const receivedDefaults = userFn.mock.calls[0]?.[0] as Integration[]; + expect(receivedDefaults.map(i => i.name)).not.toContain('Hono'); + expect(receivedDefaults.map(i => i.name)).toContain('Default'); + }); + + it('preserves a Hono integration explicitly returned by the user function', () => { + const app = new Hono(); + sentry(app, { integrations: () => [honoIntegration, otherIntegration] }); + + const integrationsFn = getIntegrationsFn(); + const result = integrationsFn([]); + expect(result.map(i => i.name)).toContain('Hono'); + expect(result.map(i => i.name)).toContain('Other'); + }); + + it('does not include Hono when user function just returns defaults', () => { + const app = new Hono(); + sentry(app, { integrations: (defaults: Integration[]) => defaults }); + + const integrationsFn = getIntegrationsFn(); + const result = integrationsFn([honoIntegration, otherIntegration]); + expect(result.map(i => i.name)).not.toContain('Hono'); + expect(result.map(i => i.name)).toContain('Other'); + }); + }); + + describe('when integrations is undefined', () => { + it('removes Hono from defaults', () => { + const app = new Hono(); + sentry(app, {}); + + const integrationsFn = getIntegrationsFn(); + expect(integrationsFn([honoIntegration, otherIntegration])).toEqual([otherIntegration]); + }); + }); + }); });