diff --git a/dev-packages/bun-integration-tests/expect.ts b/dev-packages/bun-integration-tests/expect.ts index 599caaa9e5be..6f1add5ffeae 100644 --- a/dev-packages/bun-integration-tests/expect.ts +++ b/dev-packages/bun-integration-tests/expect.ts @@ -68,7 +68,11 @@ export function expectedEvent(event: Event, { sdk }: { sdk: 'bun' | 'hono' }): E export function eventEnvelope( event: Event, - { includeSampleRand = false, sdk = 'bun' }: { includeSampleRand?: boolean; sdk?: 'bun' | 'hono' } = {}, + { + includeSampleRand = false, + includeTransaction = true, + sdk = 'bun', + }: { includeSampleRand?: boolean; includeTransaction?: boolean; sdk?: 'bun' | 'hono' } = {}, ): Envelope { return [ { @@ -79,11 +83,13 @@ export function eventEnvelope( environment: event.environment || 'production', public_key: 'public', trace_id: UUID_MATCHER, + sample_rate: expect.any(String), sampled: expect.any(String), // release is auto-detected from GitHub CI env vars, so only expect it if we know it will be there ...(process.env.GITHUB_SHA ? { release: expect.any(String) } : {}), ...(includeSampleRand && { sample_rand: expect.stringMatching(/^[01](\.\d+)?$/) }), + ...(includeTransaction && { transaction: expect.any(String) }), }, }, [[{ type: 'event' }, expectedEvent(event, { sdk })]], diff --git a/dev-packages/bun-integration-tests/suites/basic/test.ts b/dev-packages/bun-integration-tests/suites/basic/test.ts index 673464f0c81a..c03a09535702 100644 --- a/dev-packages/bun-integration-tests/suites/basic/test.ts +++ b/dev-packages/bun-integration-tests/suites/basic/test.ts @@ -25,7 +25,7 @@ it('captures an error thrown in Bun.serve fetch handler', async ({ signal }) => url: expect.stringContaining('/error'), }), }, - { includeSampleRand: true }, + { includeSampleRand: true, includeTransaction: false }, ), ) .ignore('transaction') diff --git a/dev-packages/bun-integration-tests/suites/hono-sdk/index.ts b/dev-packages/bun-integration-tests/suites/hono-sdk/index.ts new file mode 100644 index 000000000000..075fc896618b --- /dev/null +++ b/dev-packages/bun-integration-tests/suites/hono-sdk/index.ts @@ -0,0 +1,31 @@ +import { sentry } from '@sentry/hono/bun'; +import { Hono } from 'hono'; + +const app = new Hono(); + +app.use( + sentry(app, { + dsn: process.env.SENTRY_DSN, + tracesSampleRate: 1.0, + }), +); + +app.get('/', c => { + return c.text('Hello from Hono on Bun!'); +}); + +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'); +}); + +const server = Bun.serve({ + port: 0, + fetch: app.fetch, +}); + +process.send?.(JSON.stringify({ event: 'READY', port: server.port })); diff --git a/dev-packages/bun-integration-tests/suites/hono-sdk/test.ts b/dev-packages/bun-integration-tests/suites/hono-sdk/test.ts new file mode 100644 index 000000000000..62d5021fddb9 --- /dev/null +++ b/dev-packages/bun-integration-tests/suites/hono-sdk/test.ts @@ -0,0 +1,131 @@ +import { expect, it } from 'vitest'; +import { eventEnvelope, SHORT_UUID_MATCHER, UUID_MATCHER } from '../../expect'; +import { createRunner } from '../../runner'; + +it('Hono app captures parametrized errors (Hono SDK on Bun)', async ({ signal }) => { + const runner = createRunner(__dirname) + .expect(envelope => { + const [, envelopeItems] = envelope; + const [itemHeader, itemPayload] = envelopeItems[0]; + + expect(itemHeader.type).toBe('transaction'); + + expect(itemPayload).toMatchObject({ + type: 'transaction', + platform: 'node', + transaction: 'GET /error/:param', + transaction_info: { + source: 'route', + }, + contexts: { + trace: { + span_id: expect.any(String), + trace_id: expect.any(String), + op: 'http.server', + status: 'internal_error', + origin: 'auto.http.bun.serve', + }, + response: { + status_code: 500, + }, + }, + request: expect.objectContaining({ + method: 'GET', + url: expect.stringContaining('/error/param-123'), + }), + breadcrumbs: [ + { + timestamp: expect.any(Number), + category: 'console', + level: 'error', + message: 'Error: Test error from Hono app', + data: expect.objectContaining({ + logger: 'console', + arguments: [{ message: 'Test error from Hono app', name: 'Error', stack: expect.any(String) }], + }), + }, + ], + }); + }) + + .expect( + eventEnvelope( + { + level: 'error', + transaction: 'GET /error/:param', + exception: { + values: [ + { + type: 'Error', + value: 'Test error from Hono app', + stacktrace: { + frames: expect.any(Array), + }, + mechanism: { type: 'auto.http.hono.context_error', handled: false }, + }, + ], + }, + request: { + cookies: {}, + headers: expect.any(Object), + method: 'GET', + url: expect.stringContaining('/error/param-123'), + }, + breadcrumbs: [ + { + timestamp: expect.any(Number), + category: 'console', + level: 'error', + message: 'Error: Test error from Hono app', + data: expect.objectContaining({ + logger: 'console', + arguments: [{ message: 'Test error from Hono app', name: 'Error', stack: expect.any(String) }], + }), + }, + ], + }, + { sdk: 'hono', includeSampleRand: true, includeTransaction: true }, + ), + ) + .unordered() + .start(signal); + + await runner.makeRequest('get', '/error/param-123', { expectError: true }); + await runner.completed(); +}); + +it('Hono app captures parametrized route names on Bun', async ({ signal }) => { + const runner = createRunner(__dirname) + .expect(envelope => { + const [, envelopeItems] = envelope; + const [itemHeader, itemPayload] = envelopeItems[0]; + + expect(itemHeader.type).toBe('transaction'); + + expect(itemPayload).toMatchObject({ + type: 'transaction', + platform: 'node', + transaction: 'GET /hello/:name', + transaction_info: { + source: 'route', + }, + contexts: { + trace: { + span_id: SHORT_UUID_MATCHER, + trace_id: UUID_MATCHER, + op: 'http.server', + status: 'ok', + origin: 'auto.http.bun.serve', + }, + }, + request: expect.objectContaining({ + method: 'GET', + url: expect.stringContaining('/hello/world'), + }), + }); + }) + .start(signal); + + await runner.makeRequest('get', '/hello/world'); + await runner.completed(); +}); diff --git a/packages/hono/package.json b/packages/hono/package.json index c93820ebc20c..4c0057c74e02 100644 --- a/packages/hono/package.json +++ b/packages/hono/package.json @@ -46,6 +46,16 @@ "types": "./build/types/index.node.d.ts", "default": "./build/cjs/index.node.js" } + }, + "./bun": { + "import": { + "types": "./build/types/index.bun.d.ts", + "default": "./build/esm/index.bun.js" + }, + "require": { + "types": "./build/types/index.bun.d.ts", + "default": "./build/cjs/index.bun.js" + } } }, "typesVersions": { @@ -58,6 +68,9 @@ ], "build/types/index.node.d.ts": [ "build/types-ts3.8/index.node.d.ts" + ], + "build/types/index.bun.d.ts": [ + "build/types-ts3.8/index.bun.d.ts" ] } }, @@ -66,6 +79,7 @@ }, "dependencies": { "@opentelemetry/api": "^1.9.1", + "@sentry/bun": "10.49.0", "@sentry/cloudflare": "10.49.0", "@sentry/core": "10.49.0", "@sentry/node": "10.49.0" diff --git a/packages/hono/rollup.npm.config.mjs b/packages/hono/rollup.npm.config.mjs index a60ba1312cc9..2a03d7540bdc 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', 'src/index.node.ts'], + entrypoints: ['src/index.ts', 'src/index.cloudflare.ts', 'src/index.node.ts', 'src/index.bun.ts'], packageSpecificConfig: { output: { preserveModulesRoot: 'src', diff --git a/packages/hono/src/bun/middleware.ts b/packages/hono/src/bun/middleware.ts new file mode 100644 index 000000000000..cbca87ea6b9e --- /dev/null +++ b/packages/hono/src/bun/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 HonoBunOptions extends Options {} + +/** + * Sentry middleware for Hono running in a Bun runtime environment. + */ +export const sentry = (app: Hono, options: HonoBunOptions | undefined = {}): MiddlewareHandler => { + const isDebug = options.debug; + + isDebug && debug.log('Initialized Sentry Hono middleware (Bun)'); + + 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/bun/sdk.ts b/packages/hono/src/bun/sdk.ts new file mode 100644 index 000000000000..d30269058f4d --- /dev/null +++ b/packages/hono/src/bun/sdk.ts @@ -0,0 +1,24 @@ +import type { Client } from '@sentry/core'; +import { applySdkMetadata } from '@sentry/core'; +import { init as initBun } from '@sentry/bun'; +import type { HonoBunOptions } from './middleware'; +import { buildFilteredIntegrations } from '../shared/buildFilteredIntegrations'; + +/** + * Initializes Sentry for Hono running in a Bun 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: HonoBunOptions): Client | undefined { + applySdkMetadata(options, 'hono', ['hono', 'bun']); + + // Remove Hono from the SDK defaults to prevent double instrumentation: @sentry/bun + const filteredOptions: HonoBunOptions = { + ...options, + integrations: buildFilteredIntegrations(options.integrations, false), + }; + + return initBun(filteredOptions); +} diff --git a/packages/hono/src/cloudflare/middleware.ts b/packages/hono/src/cloudflare/middleware.ts index 1769bbd141a6..66151af2f87f 100644 --- a/packages/hono/src/cloudflare/middleware.ts +++ b/packages/hono/src/cloudflare/middleware.ts @@ -1,9 +1,9 @@ import { withSentry } from '@sentry/cloudflare'; -import { applySdkMetadata, type BaseTransportOptions, debug, getIntegrationsToSetup, type Options } from '@sentry/core'; +import { applySdkMetadata, type BaseTransportOptions, debug, type Options } from '@sentry/core'; import type { Env, Hono, MiddlewareHandler } from 'hono'; +import { buildFilteredIntegrations } from '../shared/buildFilteredIntegrations'; import { requestHandler, responseHandler } from '../shared/middlewareHandlers'; import { patchAppUse } from '../shared/patchAppUse'; -import { filterHonoIntegration } from '../shared/filterHonoIntegration'; export interface HonoCloudflareOptions extends Options {} @@ -22,20 +22,11 @@ export function sentry( honoOptions.debug && debug.log('Initialized Sentry Hono middleware (Cloudflare)'); - const { integrations: userIntegrations } = honoOptions; return { ...honoOptions, // Always filter out the Hono integration from defaults and user integrations. // The Hono integration is already set up by withSentry, so adding it again would cause capturing too early (in Cloudflare SDK) and non-parametrized URLs. - integrations: Array.isArray(userIntegrations) - ? defaults => - getIntegrationsToSetup({ - defaultIntegrations: defaults.filter(filterHonoIntegration), - integrations: userIntegrations.filter(filterHonoIntegration), - }) - : typeof userIntegrations === 'function' - ? defaults => userIntegrations(defaults).filter(filterHonoIntegration) - : defaults => defaults.filter(filterHonoIntegration), + integrations: buildFilteredIntegrations(honoOptions.integrations, true), }; }, // Cast needed because Hono exposes a narrower fetch signature than ExportedHandler diff --git a/packages/hono/src/index.bun.ts b/packages/hono/src/index.bun.ts new file mode 100644 index 000000000000..51fbac5fe01f --- /dev/null +++ b/packages/hono/src/index.bun.ts @@ -0,0 +1,5 @@ +export { sentry } from './bun/middleware'; + +export * from '@sentry/bun'; + +export { init } from './bun/sdk'; diff --git a/packages/hono/src/node/sdk.ts b/packages/hono/src/node/sdk.ts index ff71ffe55909..936cf612bb44 100644 --- a/packages/hono/src/node/sdk.ts +++ b/packages/hono/src/node/sdk.ts @@ -1,8 +1,8 @@ -import type { Client, Integration } from '@sentry/core'; -import { applySdkMetadata, getIntegrationsToSetup } from '@sentry/core'; +import type { Client } from '@sentry/core'; +import { applySdkMetadata } from '@sentry/core'; import { init as initNode } from '@sentry/node'; import type { HonoNodeOptions } from './middleware'; -import { filterHonoIntegration } from '../shared/filterHonoIntegration'; +import { buildFilteredIntegrations } from '../shared/buildFilteredIntegrations'; /** * Initializes Sentry for Hono running in a Node runtime environment. @@ -14,20 +14,10 @@ import { filterHonoIntegration } from '../shared/filterHonoIntegration'; 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), + integrations: buildFilteredIntegrations(options.integrations, false), }; return initNode(filteredOptions); diff --git a/packages/hono/src/shared/buildFilteredIntegrations.ts b/packages/hono/src/shared/buildFilteredIntegrations.ts new file mode 100644 index 000000000000..ccb0fd28029f --- /dev/null +++ b/packages/hono/src/shared/buildFilteredIntegrations.ts @@ -0,0 +1,29 @@ +import type { Integration } from '@sentry/core'; +import { getIntegrationsToSetup } from '@sentry/core'; +import { filterHonoIntegration } from './filterHonoIntegration'; + +/** + * Builds an `integrations` callback that removes the default Hono integration + * to prevent double instrumentation. + */ +export function buildFilteredIntegrations( + userIntegrations: Integration[] | ((defaults: Integration[]) => Integration[]) | undefined, + filterUserIntegrations: boolean, +): (defaults: Integration[]) => Integration[] { + if (Array.isArray(userIntegrations)) { + const integrations = filterUserIntegrations ? userIntegrations.filter(filterHonoIntegration) : userIntegrations; + return (defaults: Integration[]) => + getIntegrationsToSetup({ + defaultIntegrations: defaults.filter(filterHonoIntegration), + integrations, + }); + } + + if (typeof userIntegrations === 'function') { + return filterUserIntegrations + ? (defaults: Integration[]) => userIntegrations(defaults).filter(filterHonoIntegration) + : (defaults: Integration[]) => userIntegrations(defaults.filter(filterHonoIntegration)); + } + + return (defaults: Integration[]) => defaults.filter(filterHonoIntegration); +} diff --git a/packages/hono/test/bun/middleware.test.ts b/packages/hono/test/bun/middleware.test.ts new file mode 100644 index 000000000000..f3fc82d3696f --- /dev/null +++ b/packages/hono/test/bun/middleware.test.ts @@ -0,0 +1,149 @@ +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/bun/middleware'; + +vi.mock('@sentry/bun', () => ({ + init: vi.fn(), +})); + +// eslint-disable-next-line @typescript-eslint/consistent-type-imports +const { init: initBunMock } = await vi.importMock('@sentry/bun'); + +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 Bun Middleware', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('sentry middleware', () => { + it('calls applySdkMetadata with "hono" and "bun"', () => { + 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', 'bun']); + }); + + it('calls init from @sentry/bun', () => { + const app = new Hono(); + const options = { + dsn: 'https://public@dsn.ingest.sentry.io/1337', + }; + + sentry(app, options); + + expect(initBunMock).toHaveBeenCalledTimes(1); + expect(initBunMock).toHaveBeenCalledWith( + expect.objectContaining({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + }), + ); + }); + + it('sets SDK metadata before calling Bun 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 initBunCallOrder = (initBunMock as Mock).mock.invocationCallOrder[0]; + + expect(applySdkMetadataCallOrder).toBeLessThan(initBunCallOrder 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(initBunMock).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 initBun (never a raw array)', () => { + const app = new Hono(); + sentry(app, { dsn: 'https://public@dsn.ingest.sentry.io/1337' }); + + const callArgs = (initBunMock 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(initBunMock).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/bun', version: SDK_VERSION }, + ], + }), + }), + }), + ); + }); + }); +}); diff --git a/packages/hono/test/cloudflare/middleware.test.ts b/packages/hono/test/cloudflare/middleware.test.ts index ac512d41afee..46e13956ec4e 100644 --- a/packages/hono/test/cloudflare/middleware.test.ts +++ b/packages/hono/test/cloudflare/middleware.test.ts @@ -164,102 +164,4 @@ describe('Hono Cloudflare Middleware', () => { }); }); }); - - describe('filters Hono integration from user-provided integrations', () => { - const honoIntegration = { name: 'Hono' } as SentryCore.Integration; - const otherIntegration = { name: 'Other' } as SentryCore.Integration; - - const getIntegrationsResult = () => { - const optionsCallback = withSentryMock.mock.calls[0]?.[0]; - return optionsCallback().integrations; - }; - - it.each([ - ['filters Hono integration out', [honoIntegration, otherIntegration], [otherIntegration]], - ['keeps non-Hono integrations', [otherIntegration], [otherIntegration]], - ['returns empty array when only Hono integration provided', [honoIntegration], []], - ])('%s (array)', (_name, input, expected) => { - const app = new Hono(); - sentry(app, { integrations: input }); - - const integrationsFn = getIntegrationsResult() as ( - defaults: SentryCore.Integration[], - ) => SentryCore.Integration[]; - expect(integrationsFn([])).toEqual(expected); - }); - - it('filters Hono from defaults when user provides an array', () => { - const app = new Hono(); - sentry(app, { integrations: [otherIntegration] }); - - const integrationsFn = getIntegrationsResult() as ( - defaults: SentryCore.Integration[], - ) => SentryCore.Integration[]; - // Defaults (from Cloudflare) include Hono; result must exclude it and deduplicate (user + defaults overlap) - const defaultsWithHono = [honoIntegration, otherIntegration]; - expect(integrationsFn(defaultsWithHono)).toEqual([otherIntegration]); - }); - - it('deduplicates when user integrations overlap with defaults (by name)', () => { - const app = new Hono(); - const duplicateIntegration = { name: 'Other' } as SentryCore.Integration; - sentry(app, { integrations: [duplicateIntegration] }); - - const integrationsFn = getIntegrationsResult() as ( - defaults: SentryCore.Integration[], - ) => SentryCore.Integration[]; - const defaultsWithOverlap = [ - honoIntegration, - otherIntegration, // same name as duplicateIntegration - ]; - const result = integrationsFn(defaultsWithOverlap); - expect(result).toHaveLength(1); - expect(result[0]?.name).toBe('Other'); - }); - - it('filters Hono integration out of a function result', () => { - const app = new Hono(); - sentry(app, { integrations: () => [honoIntegration, otherIntegration] }); - - const integrationsFn = getIntegrationsResult() as unknown as ( - defaults: SentryCore.Integration[], - ) => SentryCore.Integration[]; - expect(integrationsFn([])).toEqual([otherIntegration]); - }); - - it('passes defaults through to the user-provided integrations function', () => { - const app = new Hono(); - const userFn = vi.fn((_defaults: SentryCore.Integration[]) => [otherIntegration]); - const defaults = [{ name: 'Default' } as SentryCore.Integration]; - - sentry(app, { integrations: userFn }); - - const integrationsFn = getIntegrationsResult() as unknown as ( - defaults: SentryCore.Integration[], - ) => SentryCore.Integration[]; - integrationsFn(defaults); - - expect(userFn).toHaveBeenCalledWith(defaults); - }); - - it('filters Hono integration returned by the user-provided integrations function', () => { - const app = new Hono(); - sentry(app, { integrations: (_defaults: SentryCore.Integration[]) => [honoIntegration] }); - - const integrationsFn = getIntegrationsResult() as unknown as ( - defaults: SentryCore.Integration[], - ) => SentryCore.Integration[]; - expect(integrationsFn([])).toEqual([]); - }); - - it('filters Hono integration from defaults when integrations is undefined', () => { - const app = new Hono(); - sentry(app, {}); - - const integrationsFn = getIntegrationsResult() as unknown as ( - defaults: SentryCore.Integration[], - ) => SentryCore.Integration[]; - expect(integrationsFn([honoIntegration, otherIntegration])).toEqual([otherIntegration]); - }); - }); }); diff --git a/packages/hono/test/node/middleware.test.ts b/packages/hono/test/node/middleware.test.ts index 1473daf98acc..b6561098ed8a 100644 --- a/packages/hono/test/node/middleware.test.ts +++ b/packages/hono/test/node/middleware.test.ts @@ -3,7 +3,6 @@ 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(), @@ -147,113 +146,4 @@ 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]); - }); - }); - }); }); diff --git a/packages/hono/test/shared/buildFilteredIntegrations.test.ts b/packages/hono/test/shared/buildFilteredIntegrations.test.ts new file mode 100644 index 000000000000..e2aec16d1119 --- /dev/null +++ b/packages/hono/test/shared/buildFilteredIntegrations.test.ts @@ -0,0 +1,149 @@ +import type { Integration } from '@sentry/core'; +import { describe, expect, it, vi } from 'vitest'; +import { buildFilteredIntegrations } from '../../src/shared/buildFilteredIntegrations'; + +const hono = { name: 'Hono' } as Integration; +const other = { name: 'Other' } as Integration; +const dflt = { name: 'Default' } as Integration; + +function names(integrations: Integration[]): string[] { + return integrations.map(i => i.name); +} + +describe('buildFilteredIntegrations', () => { + it.each([ + { label: 'array', input: [] as Integration[], filterUser: false }, + { label: 'function', input: () => [] as Integration[], filterUser: false }, + { label: 'undefined', input: undefined, filterUser: false }, + { label: 'array', input: [] as Integration[], filterUser: true }, + { label: 'function', input: () => [] as Integration[], filterUser: true }, + { label: 'undefined', input: undefined, filterUser: true }, + ])('returns a function when userIntegrations=$label, filterUserIntegrations=$filterUser', ({ input, filterUser }) => { + expect(typeof buildFilteredIntegrations(input, filterUser)).toBe('function'); + }); + + it.each([false, true])( + 'removes Hono from defaults when userIntegrations is undefined (filterUserIntegrations=%j)', + filterUser => { + const fn = buildFilteredIntegrations(undefined, filterUser); + expect(fn([hono, other])).toEqual([other]); + }, + ); + + it.each([false, true])( + 'deduplicates when user integrations overlap with defaults (filterUserIntegrations=%j)', + filterUser => { + const duplicate = { name: 'Other' } as Integration; + const fn = buildFilteredIntegrations([duplicate], filterUser); + const result = fn([hono, other]); + expect(result).toHaveLength(1); + expect(result[0]?.name).toBe('Other'); + }, + ); + + describe('filterUserIntegrations: false (Node / Bun)', () => { + describe('when userIntegrations is an array', () => { + it.each([ + { + scenario: 'removes Hono from defaults', + user: [other], + defaults: [hono, dflt], + includes: ['Other', 'Default'], + excludes: ['Hono'], + }, + { + scenario: 'preserves user-provided Hono', + user: [hono, other], + defaults: [], + includes: ['Hono', 'Other'], + excludes: [], + }, + ])('$scenario', ({ user, defaults, includes, excludes }) => { + const fn = buildFilteredIntegrations(user, false); + const result = names(fn(defaults)); + for (const name of includes) { + expect(result).toContain(name); + } + for (const name of excludes) { + expect(result).not.toContain(name); + } + }); + + it('preserves user-provided Hono even when defaults also include it', () => { + const fn = buildFilteredIntegrations([hono], false); + const result = fn([hono, other]); + expect(result.filter(i => i.name === 'Hono')).toHaveLength(1); + }); + }); + + describe('when userIntegrations is a function', () => { + it('filters Hono from defaults before passing to the user function', () => { + const userFn = vi.fn((_defaults: Integration[]) => [other]); + const fn = buildFilteredIntegrations(userFn, false); + fn([hono, dflt]); + + expect(userFn).toHaveBeenCalledWith([dflt]); + }); + + it('preserves Hono when explicitly returned by the user function', () => { + const fn = buildFilteredIntegrations(() => [hono, other], false); + expect(names(fn([]))).toEqual(['Hono', 'Other']); + }); + + it('excludes Hono when user function passes defaults through', () => { + const fn = buildFilteredIntegrations(defaults => defaults, false); + expect(names(fn([hono, other]))).toEqual(['Other']); + }); + }); + }); + + describe('filterUserIntegrations: true (Cloudflare)', () => { + describe('when userIntegrations is an array', () => { + it.each([ + { + scenario: 'removes Hono from both user array and defaults', + user: [hono, other], + defaults: [hono, dflt], + includes: ['Other', 'Default'], + excludes: ['Hono'], + }, + { + scenario: 'returns empty when only Hono is provided', + user: [hono], + defaults: [], + includes: [], + excludes: ['Hono'], + }, + { scenario: 'keeps non-Hono integrations', user: [other], defaults: [], includes: ['Other'], excludes: [] }, + ])('$scenario', ({ user, defaults, includes, excludes }) => { + const fn = buildFilteredIntegrations(user, true); + const result = names(fn(defaults)); + for (const name of includes) { + expect(result).toContain(name); + } + for (const name of excludes) { + expect(result).not.toContain(name); + } + }); + }); + + describe('when userIntegrations is a function', () => { + it('passes defaults through to the user function unfiltered', () => { + const userFn = vi.fn((_defaults: Integration[]) => [other]); + const defaults = [dflt]; + const fn = buildFilteredIntegrations(userFn, true); + fn(defaults); + + expect(userFn).toHaveBeenCalledWith(defaults); + }); + + it.each([ + { scenario: 'filters Hono from result', userFn: () => [hono, other], expected: [other] }, + { scenario: 'returns empty when user function only returns Hono', userFn: () => [hono], expected: [] }, + ])('$scenario', ({ userFn, expected }) => { + const fn = buildFilteredIntegrations(userFn, true); + expect(fn([])).toEqual(expected); + }); + }); + }); +});