diff --git a/dev-packages/node-integration-tests/package.json b/dev-packages/node-integration-tests/package.json index ce4df19b71c7..c31e4d2ce5be 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(); + }); + }); +}); diff --git a/packages/hono/README.md b/packages/hono/README.md index b1b9e07760f9..c359536c656e 100644 --- a/packages/hono/README.md +++ b/packages/hono/README.md @@ -56,7 +56,7 @@ 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 }), ); @@ -82,3 +82,47 @@ app.use(sentry(app, env => ({ dsn: env.SENTRY_DSN }))); 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__', // or process.env.SENTRY_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 option 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 4dfd1532077c..0ccea9cb15bb 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/cloudflare/middleware.ts b/packages/hono/src/cloudflare/middleware.ts index 76d571d2cda7..1769bbd141a6 100644 --- a/packages/hono/src/cloudflare/middleware.ts +++ b/packages/hono/src/cloudflare/middleware.ts @@ -1,26 +1,18 @@ 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 HonoOptions extends Options {} - -const filterHonoIntegration = (integration: Integration): boolean => integration.name !== 'Hono'; +export interface HonoCloudflareOptions extends Options {} /** * Sentry middleware for Hono on Cloudflare Workers. */ 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 new file mode 100644 index 000000000000..02e94b67be89 --- /dev/null +++ b/packages/hono/src/index.node.ts @@ -0,0 +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 new file mode 100644 index 000000000000..1dbca92d02e5 --- /dev/null +++ b/packages/hono/src/node/middleware.ts @@ -0,0 +1,28 @@ +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 HonoNodeOptions extends Options {} + +/** + * Sentry middleware for Hono running in a Node runtime environment. + */ +export const sentry = (app: Hono, options: HonoNodeOptions | undefined = {}): MiddlewareHandler => { + const isDebug = options.debug; + + isDebug && debug.log('Initialized Sentry Hono middleware (Node)'); + + init(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/node/sdk.ts b/packages/hono/src/node/sdk.ts new file mode 100644 index 000000000000..ff71ffe55909 --- /dev/null +++ b/packages/hono/src/node/sdk.ts @@ -0,0 +1,34 @@ +import type { Client, Integration } from '@sentry/core'; +import { applySdkMetadata, getIntegrationsToSetup } from '@sentry/core'; +import { init as initNode } from '@sentry/node'; +import type { HonoNodeOptions } from './middleware'; +import { filterHonoIntegration } from '../shared/filterHonoIntegration'; + +/** + * 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']); + + 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/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/node/middleware.test.ts b/packages/hono/test/node/middleware.test.ts new file mode 100644 index 000000000000..1473daf98acc --- /dev/null +++ b/packages/hono/test/node/middleware.test.ts @@ -0,0 +1,259 @@ +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'; +import type { Integration } from '@sentry/core'; + +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 Node 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', ['hono', 'node']); + }); + + 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('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 = { + 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 }, + { name: 'npm:@sentry/node', version: SDK_VERSION }, + ], + }), + }), + }), + ); + }); + }); + + 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]); + }); + }); + }); +});