diff --git a/packages/node/src/integrations/tracing/express.ts b/packages/node/src/integrations/tracing/express.ts index e13ce6212b79..f2f03cd53bfc 100644 --- a/packages/node/src/integrations/tracing/express.ts +++ b/packages/node/src/integrations/tracing/express.ts @@ -1,6 +1,6 @@ import type * as http from 'node:http'; import type { Span } from '@opentelemetry/api'; -import type { ExpressRequestInfo } from '@opentelemetry/instrumentation-express'; +import type { ExpressLayerType, ExpressRequestInfo } from '@opentelemetry/instrumentation-express'; import { ExpressInstrumentation } from '@opentelemetry/instrumentation-express'; import type { IntegrationFn } from '@sentry/core'; import { @@ -22,6 +22,41 @@ import { ExpressInstrumentationV5 } from './express-v5/instrumentation'; const INTEGRATION_NAME = 'Express'; const INTEGRATION_NAME_V5 = 'Express-V5'; +type IgnoreMatcher = string | RegExp | ((path: string) => boolean); + +interface ExpressOptions { + /** + * Ignore specific layers based on their path. + * + * Accepts an array of matchers that can be: + * - String: exact path match + * - RegExp: pattern matching + * - Function: custom logic that receives the path and returns boolean + */ + ignoreLayers?: IgnoreMatcher[]; + /** + * Ignore specific layers based on their type. + * + * Available layer types: + * - 'router': Express router layers + * - 'middleware': Express middleware layers + * - 'request_handler': Express request handler layers + * + * @example + * ```javascript + * // Ignore only middleware layers + * ignoreLayersType: ['middleware'] + * + * // Ignore multiple layer types + * ignoreLayersType: ['middleware', 'router'] + * + * // Ignore all layer types (effectively disables tracing) + * ignoreLayersType: ['middleware', 'router', 'request_handler'] + * ``` + */ + ignoreLayersType?: ('router' | 'middleware' | 'request_handler')[]; +} + function requestHook(span: Span): void { addOriginToSpan(span, 'auto.http.otel.express'); @@ -56,28 +91,32 @@ function spanNameHook(info: ExpressRequestInfo, defaultName: string): s export const instrumentExpress = generateInstrumentOnce( INTEGRATION_NAME, - () => + (options: ExpressOptions = {}) => new ExpressInstrumentation({ requestHook: span => requestHook(span), spanNameHook: (info, defaultName) => spanNameHook(info, defaultName), + ignoreLayers: options.ignoreLayers, + ignoreLayersType: options.ignoreLayersType as ExpressLayerType[], }), ); export const instrumentExpressV5 = generateInstrumentOnce( INTEGRATION_NAME_V5, - () => + (options: ExpressOptions = {}) => new ExpressInstrumentationV5({ requestHook: span => requestHook(span), spanNameHook: (info, defaultName) => spanNameHook(info, defaultName), + ignoreLayers: options.ignoreLayers, + ignoreLayersType: options.ignoreLayersType as ExpressLayerType[], }), ); -const _expressIntegration = (() => { +const _expressIntegration = ((options: ExpressOptions = {}) => { return { name: INTEGRATION_NAME, setupOnce() { - instrumentExpress(); - instrumentExpressV5(); + instrumentExpress(options); + instrumentExpressV5(options); }, }; }) satisfies IntegrationFn; @@ -89,6 +128,8 @@ const _expressIntegration = (() => { * * For more information, see the [express documentation](https://docs.sentry.io/platforms/javascript/guides/express/). * + * @param {ExpressOptions} options Configuration options for the Express integration. + * * @example * ```javascript * const Sentry = require('@sentry/node'); @@ -97,6 +138,34 @@ const _expressIntegration = (() => { * integrations: [Sentry.expressIntegration()], * }) * ``` + * + * @example + * ```javascript + * // To ignore specific middleware layers by path + * const Sentry = require('@sentry/node'); + * + * Sentry.init({ + * integrations: [ + * Sentry.expressIntegration({ + * ignoreLayers: ['/health', /^\/internal/] + * }) + * ], + * }) + * ``` + * + * @example + * ```javascript + * // To ignore specific middleware layers by type + * const Sentry = require('@sentry/node'); + * + * Sentry.init({ + * integrations: [ + * Sentry.expressIntegration({ + * ignoreLayersType: ['middleware'] + * }) + * ], + * }) + * ``` */ export const expressIntegration = defineIntegration(_expressIntegration); diff --git a/packages/node/test/integrations/tracing/express.test.ts b/packages/node/test/integrations/tracing/express.test.ts new file mode 100644 index 000000000000..6db44c58e365 --- /dev/null +++ b/packages/node/test/integrations/tracing/express.test.ts @@ -0,0 +1,197 @@ +import { ExpressInstrumentation } from '@opentelemetry/instrumentation-express'; +import { type MockInstance, beforeEach, describe, expect, it, vi } from 'vitest'; +import { expressIntegration, instrumentExpress, instrumentExpressV5 } from '../../../src/integrations/tracing/express'; +import { ExpressInstrumentationV5 } from '../../../src/integrations/tracing/express-v5/instrumentation'; +import { INSTRUMENTED } from '../../../src/otel/instrument'; + +vi.mock('@opentelemetry/instrumentation-express'); +vi.mock('../../../src/integrations/tracing/express-v5/instrumentation'); + +describe('Express', () => { + beforeEach(() => { + vi.clearAllMocks(); + delete INSTRUMENTED.Express; + delete INSTRUMENTED['Express-V5']; + + (ExpressInstrumentation as unknown as MockInstance).mockImplementation(() => { + return { + setTracerProvider: () => undefined, + setMeterProvider: () => undefined, + getConfig: () => ({}), + setConfig: () => ({}), + enable: () => undefined, + }; + }); + + (ExpressInstrumentationV5 as unknown as MockInstance).mockImplementation(() => { + return { + setTracerProvider: () => undefined, + setMeterProvider: () => undefined, + getConfig: () => ({}), + setConfig: () => ({}), + enable: () => undefined, + }; + }); + }); + + describe('instrumentExpress', () => { + it('defaults are correct for instrumentExpress', () => { + instrumentExpress({}); + + expect(ExpressInstrumentation).toHaveBeenCalledTimes(1); + expect(ExpressInstrumentation).toHaveBeenCalledWith({ + ignoreLayers: undefined, + ignoreLayersType: undefined, + requestHook: expect.any(Function), + spanNameHook: expect.any(Function), + }); + }); + + it('passes ignoreLayers option to instrumentation', () => { + instrumentExpress({ ignoreLayers: ['/health', /^\/internal/] }); + + expect(ExpressInstrumentation).toHaveBeenCalledTimes(1); + expect(ExpressInstrumentation).toHaveBeenCalledWith({ + ignoreLayers: ['/health', /^\/internal/], + ignoreLayersType: undefined, + requestHook: expect.any(Function), + spanNameHook: expect.any(Function), + }); + }); + + it('passes ignoreLayersType option to instrumentation', () => { + instrumentExpress({ ignoreLayersType: ['middleware'] }); + + expect(ExpressInstrumentation).toHaveBeenCalledTimes(1); + expect(ExpressInstrumentation).toHaveBeenCalledWith({ + ignoreLayers: undefined, + ignoreLayersType: ['middleware'], + requestHook: expect.any(Function), + spanNameHook: expect.any(Function), + }); + }); + + it('passes multiple ignoreLayersType values to instrumentation', () => { + instrumentExpress({ ignoreLayersType: ['middleware', 'router'] }); + + expect(ExpressInstrumentation).toHaveBeenCalledTimes(1); + expect(ExpressInstrumentation).toHaveBeenCalledWith({ + ignoreLayers: undefined, + ignoreLayersType: ['middleware', 'router'], + requestHook: expect.any(Function), + spanNameHook: expect.any(Function), + }); + }); + + it('passes both options to instrumentation', () => { + instrumentExpress({ + ignoreLayers: ['/health'], + ignoreLayersType: ['request_handler'], + }); + + expect(ExpressInstrumentation).toHaveBeenCalledTimes(1); + expect(ExpressInstrumentation).toHaveBeenCalledWith({ + ignoreLayers: ['/health'], + ignoreLayersType: ['request_handler'], + requestHook: expect.any(Function), + spanNameHook: expect.any(Function), + }); + }); + }); + + describe('instrumentExpressV5', () => { + it('defaults are correct for instrumentExpressV5', () => { + instrumentExpressV5({}); + + expect(ExpressInstrumentationV5).toHaveBeenCalledTimes(1); + expect(ExpressInstrumentationV5).toHaveBeenCalledWith({ + ignoreLayers: undefined, + ignoreLayersType: undefined, + requestHook: expect.any(Function), + spanNameHook: expect.any(Function), + }); + }); + + it('passes options to instrumentExpressV5', () => { + instrumentExpressV5({ + ignoreLayers: [(path: string) => path.startsWith('/admin')], + ignoreLayersType: ['middleware', 'router'], + }); + + expect(ExpressInstrumentationV5).toHaveBeenCalledTimes(1); + expect(ExpressInstrumentationV5).toHaveBeenCalledWith({ + ignoreLayers: [expect.any(Function)], + ignoreLayersType: ['middleware', 'router'], + requestHook: expect.any(Function), + spanNameHook: expect.any(Function), + }); + }); + }); + + describe('expressIntegration', () => { + it('defaults are correct for expressIntegration', () => { + expressIntegration().setupOnce!(); + + expect(ExpressInstrumentation).toHaveBeenCalledTimes(1); + expect(ExpressInstrumentation).toHaveBeenCalledWith({ + ignoreLayers: undefined, + ignoreLayersType: undefined, + requestHook: expect.any(Function), + spanNameHook: expect.any(Function), + }); + + expect(ExpressInstrumentationV5).toHaveBeenCalledTimes(1); + expect(ExpressInstrumentationV5).toHaveBeenCalledWith({ + ignoreLayers: undefined, + ignoreLayersType: undefined, + requestHook: expect.any(Function), + spanNameHook: expect.any(Function), + }); + }); + + it('passes options from expressIntegration to both instrumentations', () => { + expressIntegration({ + ignoreLayers: [/^\/api\/v1/], + ignoreLayersType: ['middleware'], + }).setupOnce!(); + + expect(ExpressInstrumentation).toHaveBeenCalledTimes(1); + expect(ExpressInstrumentation).toHaveBeenCalledWith({ + ignoreLayers: [/^\/api\/v1/], + ignoreLayersType: ['middleware'], + requestHook: expect.any(Function), + spanNameHook: expect.any(Function), + }); + + expect(ExpressInstrumentationV5).toHaveBeenCalledTimes(1); + expect(ExpressInstrumentationV5).toHaveBeenCalledWith({ + ignoreLayers: [/^\/api\/v1/], + ignoreLayersType: ['middleware'], + requestHook: expect.any(Function), + spanNameHook: expect.any(Function), + }); + }); + + it('passes all layer types from expressIntegration to instrumentation', () => { + expressIntegration({ + ignoreLayersType: ['router', 'middleware', 'request_handler'], + }).setupOnce!(); + + expect(ExpressInstrumentation).toHaveBeenCalledTimes(1); + expect(ExpressInstrumentation).toHaveBeenCalledWith({ + ignoreLayers: undefined, + ignoreLayersType: ['router', 'middleware', 'request_handler'], + requestHook: expect.any(Function), + spanNameHook: expect.any(Function), + }); + + expect(ExpressInstrumentationV5).toHaveBeenCalledTimes(1); + expect(ExpressInstrumentationV5).toHaveBeenCalledWith({ + ignoreLayers: undefined, + ignoreLayersType: ['router', 'middleware', 'request_handler'], + requestHook: expect.any(Function), + spanNameHook: expect.any(Function), + }); + }); + }); +});