diff --git a/dev-packages/node-integration-tests/suites/express/tracing/instrument-filterStatusCode.mjs b/dev-packages/node-integration-tests/suites/express/tracing/instrument-filterStatusCode.mjs index 31473a90df73..5f9d055276cd 100644 --- a/dev-packages/node-integration-tests/suites/express/tracing/instrument-filterStatusCode.mjs +++ b/dev-packages/node-integration-tests/suites/express/tracing/instrument-filterStatusCode.mjs @@ -1,6 +1,9 @@ import * as Sentry from '@sentry/node'; import { loggingTransport } from '@sentry-internal/node-integration-tests'; +if (process.env.USE_ORCHESTRION === 'true') { + Sentry.experimentalUseDiagnosticsChannelInjection(); +} Sentry.init({ dsn: 'https://public@dsn.ingest.sentry.io/1337', release: '1.0', diff --git a/dev-packages/node-integration-tests/suites/express/tracing/instrument.mjs b/dev-packages/node-integration-tests/suites/express/tracing/instrument.mjs index 56c180aa1978..dc56a017df2a 100644 --- a/dev-packages/node-integration-tests/suites/express/tracing/instrument.mjs +++ b/dev-packages/node-integration-tests/suites/express/tracing/instrument.mjs @@ -1,6 +1,9 @@ import * as Sentry from '@sentry/node'; import { loggingTransport } from '@sentry-internal/node-integration-tests'; +if (process.env.USE_ORCHESTRION === 'true') { + Sentry.experimentalUseDiagnosticsChannelInjection(); +} Sentry.init({ dsn: 'https://public@dsn.ingest.sentry.io/1337', release: '1.0', diff --git a/dev-packages/node-integration-tests/suites/express/tracing/scenario.mjs b/dev-packages/node-integration-tests/suites/express/tracing/scenario.mjs index e307319a1fd0..c6467cb0672e 100644 --- a/dev-packages/node-integration-tests/suites/express/tracing/scenario.mjs +++ b/dev-packages/node-integration-tests/suites/express/tracing/scenario.mjs @@ -51,6 +51,28 @@ app.post('/test-post-ignore-body', function (req, res) { res.send({ status: 'ok', body: req.body }); }); +// A mounted sub-router, so we get a `router`-type layer wrapping a +// `request_handler` layer — used to assert the router span encloses the route +// handler span it dispatches. +const userRouter = express.Router(); +userRouter.get('/:id', (_req, res) => { + // Delay the response so the difference between the two instrumentations' + // router-span durations is unambiguous: the orchestrion router span stays + // open until the response finishes (~this delay), while the OTel one ends + // immediately (~0ms). + setTimeout(() => res.send({ response: 'response user' }), 100); +}); +app.use('/test/router/user', userRouter); + +// A sub-router mounted under a *parameterized* path. The transaction name / +// `http.route` should keep the parameter (`:version`) rather than the concrete +// value, otherwise route cardinality explodes. +const versionedRouter = express.Router({ mergeParams: true }); +versionedRouter.get('/user', (_req, res) => { + res.send({ response: 'response versioned' }); +}); +app.use('/test/version/:version', versionedRouter); + Sentry.setupExpressErrorHandler(app); startExpressServerAndSendPortToRunner(app); diff --git a/dev-packages/node-integration-tests/suites/express/tracing/test.ts b/dev-packages/node-integration-tests/suites/express/tracing/test.ts index 62f827440522..c0d1cdc3b4b9 100644 --- a/dev-packages/node-integration-tests/suites/express/tracing/test.ts +++ b/dev-packages/node-integration-tests/suites/express/tracing/test.ts @@ -7,126 +7,61 @@ describe('express tracing', () => { cleanupChildProcesses(); }); - createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument.mjs', (createRunner, test) => { - test('should create and send transactions for Express routes and spans for middlewares.', async () => { - const runner = createRunner() - .expect({ - transaction: { - contexts: { - trace: { - span_id: expect.stringMatching(/[a-f\d]{16}/), - trace_id: expect.stringMatching(/[a-f\d]{32}/), - data: { - url: expect.stringMatching(/\/test\/express$/), - 'http.response.status_code': 200, + describe.each([ + ['otel', {}], + ['orchestrion', { USE_ORCHESTRION: 'true' }], + ])('%s', (name, env: Record) => { + createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument.mjs', (createRunner, test) => { + test('should create and send transactions for Express routes and spans for middlewares.', async () => { + const runner = createRunner() + .withEnv(env) + .expect({ + transaction: { + contexts: { + trace: { + span_id: expect.stringMatching(/[a-f\d]{16}/), + trace_id: expect.stringMatching(/[a-f\d]{32}/), + data: { + url: expect.stringMatching(/\/test\/express$/), + 'http.response.status_code': 200, + }, + op: 'http.server', + status: 'ok', }, - op: 'http.server', - status: 'ok', }, - }, - spans: expect.arrayContaining([ - expect.objectContaining({ - data: expect.objectContaining({ - 'express.name': 'corsMiddleware', - 'express.type': 'middleware', + spans: expect.arrayContaining([ + expect.objectContaining({ + data: expect.objectContaining({ + 'express.name': 'corsMiddleware', + 'express.type': 'middleware', + }), + description: 'corsMiddleware', + op: 'middleware.express', + origin: 'auto.http.express', }), - description: 'corsMiddleware', - op: 'middleware.express', - origin: 'auto.http.express', - }), - expect.objectContaining({ - data: expect.objectContaining({ - 'express.name': '/test/express', - 'express.type': 'request_handler', + expect.objectContaining({ + data: expect.objectContaining({ + 'express.name': '/test/express', + 'express.type': 'request_handler', + }), + description: '/test/express', + op: 'request_handler.express', + origin: 'auto.http.express', }), - description: '/test/express', - op: 'request_handler.express', - origin: 'auto.http.express', - }), - ]), - }, - }) - .start(); - runner.makeRequest('get', '/test/express'); - await runner.completed(); - }); - - test('should set a correct transaction name for routes specified in RegEx', async () => { - const runner = createRunner() - .expect({ - transaction: { - transaction: 'GET /\\/test\\/regex/', - transaction_info: { - source: 'route', - }, - contexts: { - trace: { - trace_id: expect.stringMatching(/[a-f\d]{32}/), - span_id: expect.stringMatching(/[a-f\d]{16}/), - data: { - url: expect.stringMatching(/\/test\/regex$/), - 'http.response.status_code': 200, - }, - op: 'http.server', - status: 'ok', - }, + ]), }, - }, - }) - .start(); - runner.makeRequest('get', '/test/regex'); - await runner.completed(); - }); - - test('handles root page correctly', async () => { - const runner = createRunner() - .expect({ - transaction: { - transaction: 'GET /', - contexts: { - trace: { - span_id: expect.stringMatching(/[a-f\d]{16}/), - trace_id: expect.stringMatching(/[a-f\d]{32}/), - data: { - 'http.response.status_code': 200, - url: expect.stringMatching(/\/$/), - 'http.method': 'GET', - 'http.url': expect.stringMatching(/\/$/), - 'http.route': '/', - 'http.target': '/', - }, - op: 'http.server', - status: 'ok', - }, - }, - }, - }) - .start(); - runner.makeRequest('get', '/'); - await runner.completed(); - }); - - test.each(['/401', '/402', '/403', '/does-not-exist'])('ignores %s route by default', async (url: string) => { - const runner = createRunner() - .expect({ - // No transaction is sent for the 401, 402, 403, 404 routes - transaction: { - transaction: 'GET /', - }, - }) - .start(); - runner.makeRequest('get', url, { expectError: true }); - runner.makeRequest('get', '/'); - await runner.completed(); - }); + }) + .start(); + runner.makeRequest('get', '/test/express'); + await runner.completed(); + }); - test.each([['array1'], ['array5']])( - 'should set a correct transaction name for routes consisting of arrays of routes for %p', - async (segment: string) => { - const runner = await createRunner() + test('should set a correct transaction name for routes specified in RegEx', async () => { + const runner = createRunner() + .withEnv(env) .expect({ transaction: { - transaction: 'GET /test/array1,/\\/test\\/array[2-9]/', + transaction: 'GET /\\/test\\/regex/', transaction_info: { source: 'route', }, @@ -135,7 +70,7 @@ describe('express tracing', () => { trace_id: expect.stringMatching(/[a-f\d]{32}/), span_id: expect.stringMatching(/[a-f\d]{16}/), data: { - url: expect.stringMatching(`/test/${segment}$`), + url: expect.stringMatching(/\/test\/regex$/), 'http.response.status_code': 200, }, op: 'http.server', @@ -145,243 +80,380 @@ describe('express tracing', () => { }, }) .start(); - await runner.makeRequest('get', `/test/${segment}`); + runner.makeRequest('get', '/test/regex'); await runner.completed(); - }, - ); + }); - test.each([ - ['arr/545'], - ['arr/required'], - ['arr/required'], - ['arr/requiredPath'], - ['arr/required/lastParam'], - ['arr55/required/lastParam'], - ['arr/requiredPath/optionalPath/'], - ['arr/requiredPath/optionalPath/lastParam'], - ])('should handle more complex regexes in route arrays correctly for %p', async (segment: string) => { - const runner = await createRunner() - .expect({ - transaction: { - transaction: 'GET /test/arr/:id,/\\/test\\/arr\\d*\\/required(path)?(\\/optionalPath)?\\/(lastParam)?/', - transaction_info: { - source: 'route', - }, - contexts: { - trace: { - trace_id: expect.stringMatching(/[a-f\d]{32}/), - span_id: expect.stringMatching(/[a-f\d]{16}/), - data: { - url: expect.stringMatching(`/test/${segment}$`), - 'http.response.status_code': 200, - }, - op: 'http.server', - status: 'ok', - }, + test('nests a sub-router route handler span under the router span', async () => { + const runner = createRunner() + .withEnv(env) + .expect({ + transaction: transaction => { + expect(transaction.transaction).toBe('GET /test/router/user/:id'); + + const spans = transaction.spans || []; + const routerSpan = spans.find(span => span.data?.['express.type'] === 'router'); + const handlerSpan = spans.find(span => span.data?.['express.type'] === 'request_handler'); + + expect(routerSpan).toBeDefined(); + expect(handlerSpan).toBeDefined(); + + // The route handler nests under the router span in both instrumentations. + expect(handlerSpan?.parent_span_id).toBe(routerSpan?.span_id); + + // The handler delays its response by ~100ms (see scenario). + const routerDurationMs = ((routerSpan?.timestamp ?? 0) - (routerSpan?.start_timestamp ?? 0)) * 1000; + + if (name === 'orchestrion') { + // The orchestrion router span stays open until the response finishes, so + // it spans the whole sub-stack it dispatched (~the 100ms handler delay). + expect(routerDurationMs).toBeGreaterThan(50); + } else { + // The OTel integration ends router spans immediately, so the router span + // is a ~0ms marker regardless of how long its sub-stack runs. + expect(routerDurationMs).toBeLessThan(50); + } }, - }, - }) - .start(); - await runner.makeRequest('get', `/test/${segment}`); - await runner.completed(); - }); + }) + .start(); + runner.makeRequest('get', '/test/router/user/42'); + await runner.completed(); + }); - describe('request data', () => { - test('correctly captures JSON request data', async () => { + test('keeps the parameter in a route mounted under a parameterized sub-router path', async () => { const runner = createRunner() + .withEnv(env) .expect({ transaction: { - transaction: 'POST /test-post', - request: { - url: expect.stringMatching(/^http:\/\/localhost:(\d+)\/test-post$/), - method: 'POST', - headers: { - 'user-agent': expect.stringContaining(''), - 'content-type': 'application/json', - }, - data: JSON.stringify({ - foo: 'bar', - other: 1, - }), + // The `:version` parameter must be preserved — using the concrete value + // (`/test/version/v1/user`) would explode route cardinality. + transaction: 'GET /test/version/:version/user', + transaction_info: { + source: 'route', }, }, }) .start(); - - runner.makeRequest('post', '/test-post', { - headers: { - 'Content-Type': 'application/json', - }, - data: JSON.stringify({ foo: 'bar', other: 1 }), - }); + runner.makeRequest('get', '/test/version/v1/user'); await runner.completed(); }); - test('correctly captures plain text request data', async () => { + test('handles root page correctly', async () => { const runner = createRunner() + .withEnv(env) .expect({ transaction: { - transaction: 'POST /test-post', - request: { - url: expect.stringMatching(/^http:\/\/localhost:(\d+)\/test-post$/), - method: 'POST', - headers: { - 'user-agent': expect.stringContaining(''), - 'content-type': 'text/plain', + transaction: 'GET /', + contexts: { + trace: { + span_id: expect.stringMatching(/[a-f\d]{16}/), + trace_id: expect.stringMatching(/[a-f\d]{32}/), + data: { + 'http.response.status_code': 200, + url: expect.stringMatching(/\/$/), + 'http.method': 'GET', + 'http.url': expect.stringMatching(/\/$/), + 'http.route': '/', + 'http.target': '/', + }, + op: 'http.server', + status: 'ok', }, - data: 'some plain text', }, }, }) .start(); - - runner.makeRequest('post', '/test-post', { - headers: { 'Content-Type': 'text/plain' }, - data: 'some plain text', - }); + runner.makeRequest('get', '/'); await runner.completed(); }); - test('correctly captures text buffer request data', async () => { + test.each(['/401', '/402', '/403', '/does-not-exist'])('ignores %s route by default', async (url: string) => { const runner = createRunner() + .withEnv(env) .expect({ + // No transaction is sent for the 401, 402, 403, 404 routes transaction: { - transaction: 'POST /test-post', - request: { - url: expect.stringMatching(/^http:\/\/localhost:(\d+)\/test-post$/), - method: 'POST', - headers: { - 'user-agent': expect.stringContaining(''), - 'content-type': 'application/octet-stream', - }, - data: 'some plain text in buffer', - }, + transaction: 'GET /', }, }) .start(); - - runner.makeRequest('post', '/test-post', { - headers: { 'Content-Type': 'application/octet-stream' }, - data: Buffer.from('some plain text in buffer'), - }); + runner.makeRequest('get', url, { expectError: true }); + runner.makeRequest('get', '/'); await runner.completed(); }); - test('correctly captures non-text buffer request data', async () => { - const runner = createRunner() + test.each([['array1'], ['array5']])( + 'should set a correct transaction name for routes consisting of arrays of routes for %p', + async (segment: string) => { + const runner = await createRunner() + .withEnv(env) + .expect({ + transaction: { + transaction: 'GET /test/array1,/\\/test\\/array[2-9]/', + transaction_info: { + source: 'route', + }, + contexts: { + trace: { + trace_id: expect.stringMatching(/[a-f\d]{32}/), + span_id: expect.stringMatching(/[a-f\d]{16}/), + data: { + url: expect.stringMatching(`/test/${segment}$`), + 'http.response.status_code': 200, + }, + op: 'http.server', + status: 'ok', + }, + }, + }, + }) + .start(); + await runner.makeRequest('get', `/test/${segment}`); + await runner.completed(); + }, + ); + + test.each([ + ['arr/545'], + ['arr/required'], + ['arr/required'], + ['arr/requiredPath'], + ['arr/required/lastParam'], + ['arr55/required/lastParam'], + ['arr/requiredPath/optionalPath/'], + ['arr/requiredPath/optionalPath/lastParam'], + ])('should handle more complex regexes in route arrays correctly for %p', async (segment: string) => { + const runner = await createRunner() + .withEnv(env) .expect({ transaction: { - transaction: 'POST /test-post', - request: { - url: expect.stringMatching(/^http:\/\/localhost:(\d+)\/test-post$/), - method: 'POST', - headers: { - 'user-agent': expect.stringContaining(''), - 'content-type': 'application/octet-stream', + transaction: 'GET /test/arr/:id,/\\/test\\/arr\\d*\\/required(path)?(\\/optionalPath)?\\/(lastParam)?/', + transaction_info: { + source: 'route', + }, + contexts: { + trace: { + trace_id: expect.stringMatching(/[a-f\d]{32}/), + span_id: expect.stringMatching(/[a-f\d]{16}/), + data: { + url: expect.stringMatching(`/test/${segment}$`), + 'http.response.status_code': 200, + }, + op: 'http.server', + status: 'ok', }, - // This is some non-ascii string representation - data: expect.any(String), }, }, }) .start(); - - const body = new Uint8Array([1, 2, 3, 4, 5]).buffer; - - runner.makeRequest('post', '/test-post', { - headers: { 'Content-Type': 'application/octet-stream' }, - data: body, - }); + await runner.makeRequest('get', `/test/${segment}`); await runner.completed(); }); - test('correctly ignores request data', async () => { - const runner = createRunner() - .expect({ - transaction: e => { - assertSentryTransaction(e, { - transaction: 'POST /test-post-ignore-body', + describe('request data', () => { + test('correctly captures JSON request data', async () => { + const runner = createRunner() + .withEnv(env) + .expect({ + transaction: { + transaction: 'POST /test-post', request: { - url: expect.stringMatching(/^http:\/\/localhost:(\d+)\/test-post-ignore-body$/), + url: expect.stringMatching(/^http:\/\/localhost:(\d+)\/test-post$/), method: 'POST', headers: { 'user-agent': expect.stringContaining(''), - 'content-type': 'application/octet-stream', + 'content-type': 'application/json', }, + data: JSON.stringify({ + foo: 'bar', + other: 1, + }), }, - }); - // Ensure the request body has been ignored - expect(e).have.property('request').that.does.not.have.property('data'); + }, + }) + .start(); + + runner.makeRequest('post', '/test-post', { + headers: { + 'Content-Type': 'application/json', }, - }) - .start(); + data: JSON.stringify({ foo: 'bar', other: 1 }), + }); + await runner.completed(); + }); + + test('correctly captures plain text request data', async () => { + const runner = createRunner() + .withEnv(env) + .expect({ + transaction: { + transaction: 'POST /test-post', + request: { + url: expect.stringMatching(/^http:\/\/localhost:(\d+)\/test-post$/), + method: 'POST', + headers: { + 'user-agent': expect.stringContaining(''), + 'content-type': 'text/plain', + }, + data: 'some plain text', + }, + }, + }) + .start(); - runner.makeRequest('post', '/test-post-ignore-body', { - headers: { 'Content-Type': 'application/octet-stream' }, - data: Buffer.from('some plain text in buffer'), + runner.makeRequest('post', '/test-post', { + headers: { 'Content-Type': 'text/plain' }, + data: 'some plain text', + }); + await runner.completed(); }); - await runner.completed(); - }); - }); - }); - describe('filter status codes', () => { - createEsmAndCjsTests( - __dirname, - 'scenario-filterStatusCode.mjs', - 'instrument-filterStatusCode.mjs', - (createRunner, test) => { - // We opt-out of the default [401, 404] filtering in order to test how these spans are handled - test.each([ - { status_code: 401, url: '/401', status: 'unauthenticated' }, - { status_code: 402, url: '/402', status: 'invalid_argument' }, - { status_code: 403, url: '/403', status: 'permission_denied' }, - { status_code: 404, url: '/does-not-exist', status: 'not_found' }, - ])( - 'handles %s route correctly', - async ({ status_code, url, status }: { status_code: number; url: string; status: string }) => { - const runner = createRunner() - .expect({ - transaction: { - transaction: `GET ${url}`, - contexts: { - trace: { - span_id: expect.stringMatching(/[a-f\d]{16}/), - trace_id: expect.stringMatching(/[a-f\d]{32}/), - data: { - 'http.response.status_code': status_code, - url: expect.stringMatching(url), - 'http.method': 'GET', - 'http.url': expect.stringMatching(url), - 'http.target': url, - }, - op: 'http.server', - status, - }, + test('correctly captures text buffer request data', async () => { + const runner = createRunner() + .withEnv(env) + .expect({ + transaction: { + transaction: 'POST /test-post', + request: { + url: expect.stringMatching(/^http:\/\/localhost:(\d+)\/test-post$/), + method: 'POST', + headers: { + 'user-agent': expect.stringContaining(''), + 'content-type': 'application/octet-stream', }, + data: 'some plain text in buffer', }, - }) - .start(); - runner.makeRequest('get', url, { expectError: true }); - await runner.completed(); - }, - ); + }, + }) + .start(); + + runner.makeRequest('post', '/test-post', { + headers: { 'Content-Type': 'application/octet-stream' }, + data: Buffer.from('some plain text in buffer'), + }); + await runner.completed(); + }); - test('filters defined status codes', async () => { + test('correctly captures non-text buffer request data', async () => { const runner = createRunner() + .withEnv(env) .expect({ transaction: { - transaction: 'GET /', + transaction: 'POST /test-post', + request: { + url: expect.stringMatching(/^http:\/\/localhost:(\d+)\/test-post$/), + method: 'POST', + headers: { + 'user-agent': expect.stringContaining(''), + 'content-type': 'application/octet-stream', + }, + // This is some non-ascii string representation + data: expect.any(String), + }, }, }) .start(); - await runner.makeRequest('get', '/499', { expectError: true }); - await runner.makeRequest('get', '/300', { expectError: true }); - await runner.makeRequest('get', '/399', { expectError: true }); - await runner.makeRequest('get', '/'); + + const body = new Uint8Array([1, 2, 3, 4, 5]).buffer; + + runner.makeRequest('post', '/test-post', { + headers: { 'Content-Type': 'application/octet-stream' }, + data: body, + }); await runner.completed(); }); - }, - ); + + test('correctly ignores request data', async () => { + const runner = createRunner() + .withEnv(env) + .expect({ + transaction: e => { + assertSentryTransaction(e, { + transaction: 'POST /test-post-ignore-body', + request: { + url: expect.stringMatching(/^http:\/\/localhost:(\d+)\/test-post-ignore-body$/), + method: 'POST', + headers: { + 'user-agent': expect.stringContaining(''), + 'content-type': 'application/octet-stream', + }, + }, + }); + // Ensure the request body has been ignored + expect(e).have.property('request').that.does.not.have.property('data'); + }, + }) + .start(); + + runner.makeRequest('post', '/test-post-ignore-body', { + headers: { 'Content-Type': 'application/octet-stream' }, + data: Buffer.from('some plain text in buffer'), + }); + await runner.completed(); + }); + }); + }); + + describe('filter status codes', () => { + createEsmAndCjsTests( + __dirname, + 'scenario-filterStatusCode.mjs', + 'instrument-filterStatusCode.mjs', + (createRunner, test) => { + // We opt-out of the default [401, 404] filtering in order to test how these spans are handled + test.each([ + { status_code: 401, url: '/401', status: 'unauthenticated' }, + { status_code: 402, url: '/402', status: 'invalid_argument' }, + { status_code: 403, url: '/403', status: 'permission_denied' }, + { status_code: 404, url: '/does-not-exist', status: 'not_found' }, + ])( + 'handles %s route correctly', + async ({ status_code, url, status }: { status_code: number; url: string; status: string }) => { + const runner = createRunner() + .withEnv(env) + .expect({ + transaction: { + transaction: `GET ${url}`, + contexts: { + trace: { + span_id: expect.stringMatching(/[a-f\d]{16}/), + trace_id: expect.stringMatching(/[a-f\d]{32}/), + data: { + 'http.response.status_code': status_code, + url: expect.stringMatching(url), + 'http.method': 'GET', + 'http.url': expect.stringMatching(url), + 'http.target': url, + }, + op: 'http.server', + status, + }, + }, + }, + }) + .start(); + runner.makeRequest('get', url, { expectError: true }); + await runner.completed(); + }, + ); + + test('filters defined status codes', async () => { + const runner = createRunner() + .withEnv(env) + .expect({ + transaction: { + transaction: 'GET /', + }, + }) + .start(); + await runner.makeRequest('get', '/499', { expectError: true }); + await runner.makeRequest('get', '/300', { expectError: true }); + await runner.makeRequest('get', '/399', { expectError: true }); + await runner.makeRequest('get', '/'); + await runner.completed(); + }); + }, + ); + }); }); }); diff --git a/packages/node/src/sdk/experimentalUseDiagnosticsChannelInjection.ts b/packages/node/src/sdk/experimentalUseDiagnosticsChannelInjection.ts index 6bba862cdfc0..44ee01586a9a 100644 --- a/packages/node/src/sdk/experimentalUseDiagnosticsChannelInjection.ts +++ b/packages/node/src/sdk/experimentalUseDiagnosticsChannelInjection.ts @@ -1,6 +1,7 @@ import { mysqlChannelIntegration, lruMemoizerChannelIntegration, + expressChannelIntegration, detectOrchestrionSetup, } from '@sentry/server-utils/orchestrion'; import { registerDiagnosticsChannelInjection } from '@sentry/server-utils/orchestrion/register'; @@ -41,7 +42,11 @@ import { setDiagnosticsChannelInjectionLoader } from './diagnosticsChannelInject */ export function experimentalUseDiagnosticsChannelInjection(): void { setDiagnosticsChannelInjectionLoader((): DiagnosticsChannelInjection => { - const integrations = [mysqlChannelIntegration(), lruMemoizerChannelIntegration()] as const; + const integrations = [ + mysqlChannelIntegration(), + lruMemoizerChannelIntegration(), + expressChannelIntegration(), + ] as const; const replacedOtelIntegrationNames = integrations.map(i => i.name); return { diff --git a/packages/server-utils/src/integrations/tracing-channel/express/index.ts b/packages/server-utils/src/integrations/tracing-channel/express/index.ts new file mode 100644 index 000000000000..2c7f19b4206a --- /dev/null +++ b/packages/server-utils/src/integrations/tracing-channel/express/index.ts @@ -0,0 +1,40 @@ +import * as diagnosticsChannel from 'node:diagnostics_channel'; +import type { IntegrationFn } from '@sentry/core'; +import { defineIntegration, waitForTracingChannelBinding } from '@sentry/core'; +import type { ExpressIntegrationOptions } from './types'; +import { instrumentExpress } from './instrumentation'; + +// NOTE: this uses the same name as the OTel integration by design. +// When enabled, the OTel 'Express' integration is omitted from the default set. +const INTEGRATION_NAME = 'Express' as const; + +const _expressChannelIntegration = ((options: ExpressIntegrationOptions = {}) => { + return { + name: INTEGRATION_NAME, + setupOnce() { + // `tracingChannel` is unavailable before Node 18.19 so do nothing in that case. + if (!diagnosticsChannel.tracingChannel) { + return; + } + + waitForTracingChannelBinding(() => { + instrumentExpress(options, diagnosticsChannel.tracingChannel); + }); + }, + }; +}) satisfies IntegrationFn; + +/** + * EXPERIMENTAL — orchestrion-driven Express integration. + * + * Subscribes to the `orchestrion:express:handle` (Express v4) and + * `orchestrion:router:handle` (Express v5, via the `router` package) + * diagnostics_channels that the orchestrion code transform injects into the + * routing layer's request handler (`Layer.prototype.handle_request` / + * `handleRequest`). One span is opened per layer invocation — producing the + * same spans as the OTel Express instrumentation. + * + * Requires the orchestrion runtime hook or bundler plugin to be active — wire + * that up via `experimentalUseDiagnosticsChannelInjection()`. + */ +export const expressChannelIntegration = defineIntegration(_expressChannelIntegration); diff --git a/packages/server-utils/src/integrations/tracing-channel/express/instrumentation.ts b/packages/server-utils/src/integrations/tracing-channel/express/instrumentation.ts new file mode 100644 index 000000000000..d3cfe0dc13ec --- /dev/null +++ b/packages/server-utils/src/integrations/tracing-channel/express/instrumentation.ts @@ -0,0 +1,301 @@ +import type * as diagnosticsChannel from 'node:diagnostics_channel'; +import { HTTP_ROUTE } from '@sentry/conventions/attributes'; +import type { Span } from '@sentry/core'; +import { + debug, + getActiveSpan, + getDefaultIsolationScope, + getIsolationScope, + getRootSpan, + SEMANTIC_ATTRIBUTE_SENTRY_OP, + SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, + spanToJSON, + startInactiveSpan, + stringMatchesSomePattern, +} from '@sentry/core'; +import { DEBUG_BUILD } from '../../../debug-build'; +import { CHANNELS } from '../../../orchestrion/channels'; +import { bindTracingChannelToSpan } from '../../../tracing-channel'; +import { + getActualMatchedRoute, + getConstructedRoute, + getLayerPath, + getLayerRegisteredPath, + popLayerPath, + pushLayerPath, + setLayerRegisteredPath, +} from './route'; +import type { + ExpressIntegrationOptions, + ExpressLayer, + ExpressLayerType, + ExpressRequest, + ExpressResponse, + HandleChannelContext, + RegistrationChannelContext, +} from './types'; + +const ORIGIN = 'auto.http.express'; + +// `express.name`/`express.type` are Sentry-internal Express attributes (not part +// of `@sentry/conventions`); kept in sync with `@sentry/core`'s OTel-derived +// Express integration so the emitted spans are identical across both code paths. +const ATTR_EXPRESS_NAME = 'express.name'; +const ATTR_EXPRESS_TYPE = 'express.type'; + +const NOOP = (): void => {}; + +let _isInstrumented = false; + +export function instrumentExpress( + options: ExpressIntegrationOptions, + tracingChannel: typeof diagnosticsChannel.tracingChannel, +): void { + if (_isInstrumented) { + return; + } + _isInstrumented = true; + + // Record each layer's registered path *pattern* as it is registered, so the + // matched route can be reconstructed with its parameters intact at request + // time. Only the `end` event matters (the layer is on the router's stack by + // then); the others are required by the subscriber type, so no-op them. + for (const channelName of [ + CHANNELS.EXPRESS_ROUTE, + CHANNELS.EXPRESS_USE, + CHANNELS.ROUTER_ROUTE, + CHANNELS.ROUTER_USE, + ]) { + tracingChannel(channelName).subscribe({ + start: NOOP, + asyncStart: NOOP, + asyncEnd: NOOP, + error: NOOP, + end: captureRegisteredLayerPath, + }); + } + + for (const channelName of [CHANNELS.EXPRESS_HANDLE, CHANNELS.ROUTER_HANDLE]) { + DEBUG_BUILD && debug.log(`[orchestrion:express] subscribing to channel "${channelName}"`); + + const channel = tracingChannel(channelName); + + bindTracingChannelToSpan(channel, data => getSpanForLayer(data, options), { + beforeSpanEnd(_span, data) { + data._sentryCleanup?.(); + }, + }); + + // Pop the layer path when the layer hands off via `next`. `asyncStart` fires + // when `next` is called and *before* the downstream layer runs, so the + // per-request path chain reflects only the current chain when each layer + // reconstructs its route. Only `asyncStart` is relevant here. + channel.subscribe({ + start: NOOP, + asyncEnd: NOOP, + end: NOOP, + error: NOOP, + asyncStart: popLayerPathForLayer, + }); + } +} + +/** Record the freshly-registered layer's path pattern from a `route`/`use` call. */ +function captureRegisteredLayerPath(data: RegistrationChannelContext): void { + const stack = data.self?.stack; + if (!Array.isArray(stack)) { + return; + } + const layer = stack[stack.length - 1]; + if (layer) { + setLayerRegisteredPath(layer, getLayerPath(data.arguments ?? [])); + } +} + +/** Pop the path a layer pushed once it hands control onward via `next`. */ +function popLayerPathForLayer(data: HandleChannelContext): void { + if (!data._sentryStoredLayer) { + return; + } + // Clear the marker first so a layer that (incorrectly) calls `next` more than + // once can't pop again and take a parent's entry off the stack with it. + data._sentryStoredLayer = false; + const req = data.arguments?.[0] as ExpressRequest | undefined; + if (req) { + popLayerPath(req); + } +} + +/** + * Open a span for one layer invocation. Returns `undefined` to opt the layer + * out (error handlers, or a layer with no active parent trace) — the helper + * then leaves the active context untouched. + */ +function getSpanForLayer(data: HandleChannelContext, options: ExpressIntegrationOptions): Span | undefined { + const layer = data.self; + const args = data.arguments; + if (!layer || !Array.isArray(args)) { + return undefined; + } + + // Express only treats a 4-arg handler as an error handler and skips it in + // the normal request pipeline; match the OTel integration and don't trace it. + if (layer.handle?.length === 4) { + return undefined; + } + + // A Route dispatches to its handlers via the same `handle_request` method, + // but those inner layers already run inside the route-dispatch layer's + // `request_handler` span. They carry `.method` (and no `.route`); skip them + // so we emit one span per route, matching the OTel Express integration. + if (layer.method && !layer.route) { + return undefined; + } + + const req = args[0] as ExpressRequest | undefined; + const res = args[1] as ExpressResponse | undefined; + if (!req) { + return undefined; + } + + // No active parent span means this request is being ignored (unsampled / + // filtered), so don't open a span + if (!getActiveSpan()) { + return undefined; + } + + const type = getLayerType(layer); + + // Push this layer's registered path onto the request's chain so a + // `request_handler` can reconstruct the full route with parameters intact + // (`req.baseUrl` only exposes the *resolved* mount prefix). The matching pop + // happens on `asyncStart` when the layer hands off via `next`. + const registeredPath = getLayerRegisteredPath(layer); + if (registeredPath != null) { + pushLayerPath(req, registeredPath); + data._sentryStoredLayer = true; + } + + // `constructedRoute` (the full registered pattern) names the span/transaction; + // `matchedRoute` (validated against the request URL) is the `http.route`. + const constructedRoute = type === 'request_handler' ? getConstructedRoute(req) : undefined; + const matchedRoute = + type === 'request_handler' && constructedRoute != null ? getActualMatchedRoute(req, constructedRoute) : undefined; + + const name = + type === 'request_handler' + ? constructedRoute || 'request handler' + : type === 'router' + ? (layer.path ?? '/') + : (layer.name ?? ''); + + // Propagate the route to the root `http.server` span *before* the ignore + // check, so the transaction is still named even when the layer's own span is + // ignored — matches the OTel Express integration's `onRouteResolved` timing. + if (matchedRoute) { + setHttpServerSpanRoute(matchedRoute); + } + + if (type === 'request_handler' && constructedRoute) { + const isolationScope = getIsolationScope(); + if (isolationScope !== getDefaultIsolationScope()) { + const method = typeof req.method === 'string' ? req.method.toUpperCase() : 'GET'; + isolationScope.setTransactionName(`${method} ${constructedRoute}`); + } else { + DEBUG_BUILD && + debug.warn( + '[orchestrion:express] Isolation scope is still default isolation scope - skipping transaction name', + ); + } + } + + // Honor `ignoreLayers`/`ignoreLayersType`: skip the span for matching layers. + // We intentionally do NOT pop the pushed path here (unlike OTel Express, which + // pops on ignore): the path is still popped on `asyncStart` when the layer + // calls `next`, so a following sibling isn't polluted, while an ignored + // *router* keeps its mount prefix on the stack for the sub-stack it dispatches + // — so routes under an ignored router stay correct. The only entries that + // never pop come from layers that end the response without `next()`, and those + // sit on a per-request store that is discarded when the request ends. + if (isLayerIgnored(name, type, options)) { + return undefined; + } + + const span = startInactiveSpan({ + name, + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: ORIGIN, + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: `${type}.express`, + [ATTR_EXPRESS_NAME]: name, + [ATTR_EXPRESS_TYPE]: type, + ...(matchedRoute ? { [HTTP_ROUTE]: matchedRoute } : {}), + }, + }); + + // A layer that sends the response (route handlers, typically) never calls + // `next`, so the channel's `asyncEnd` never fires. End on the response's + // `finish` in that case. When `next` *is* called, the helper ends the span + // (via `asyncEnd`) and `beforeSpanEnd` removes this now-redundant listener. + if (res && typeof res.once === 'function') { + const onFinish = (): void => { + span.end(); + }; + res.once('finish', onFinish); + data._sentryCleanup = () => res.removeListener('finish', onFinish); + } + + return span; +} + +function getLayerType(layer: ExpressLayer): ExpressLayerType { + if (layer.name === 'router') { + return 'router'; + } + // `bound dispatch` (v4) / `handle` — the route-dispatch layer created by `router.route()`. + if (layer.name === 'bound dispatch' || layer.name === 'handle') { + return 'request_handler'; + } + return 'middleware'; +} + +/** + * Propagate the resolved route to the root `http.server` span so the + * transaction gets a parameterized `http.route`. Mirrors `@sentry/node`'s + * `setHttpServerSpanRouteAttribute`; inlined to keep this package free of + * `@sentry/node` deps. No-op unless the root span is an `http.server` span. + */ +function setHttpServerSpanRoute(route: string): void { + const activeSpan = getActiveSpan(); + const rootSpan = activeSpan && getRootSpan(activeSpan); + if (!rootSpan) { + return; + } + if (spanToJSON(rootSpan).data[SEMANTIC_ATTRIBUTE_SENTRY_OP] !== 'http.server') { + return; + } + rootSpan.setAttribute(HTTP_ROUTE, route); +} + +/** + * Whether a layer should be skipped per the `ignoreLayers`/`ignoreLayersType` + * options. Matches `@sentry/core`'s Express `isLayerIgnored`: `ignoreLayersType` + * filters by layer type, `ignoreLayers` matches the layer's name against + * string/RegExp/predicate patterns (exact string match). + */ +function isLayerIgnored(name: string, type: ExpressLayerType, options: ExpressIntegrationOptions): boolean { + const { ignoreLayers, ignoreLayersType } = options; + + if (Array.isArray(ignoreLayersType) && ignoreLayersType.includes(type)) { + return true; + } + + if (!Array.isArray(ignoreLayers)) { + return false; + } + + try { + return stringMatchesSomePattern(name, ignoreLayers, true); + } catch { + return false; + } +} diff --git a/packages/server-utils/src/integrations/tracing-channel/express/route.ts b/packages/server-utils/src/integrations/tracing-channel/express/route.ts new file mode 100644 index 000000000000..96635da1e232 --- /dev/null +++ b/packages/server-utils/src/integrations/tracing-channel/express/route.ts @@ -0,0 +1,139 @@ +import type { ExpressLayer, ExpressRequest } from './types'; + +/** + * Registered path *pattern* per routing `Layer`, captured when the layer is + * registered via `Router.prototype.route`/`.use`. `undefined` means the layer + * was registered without an explicit path (e.g. `app.use(mw)`), so it does not + * contribute to the reconstructed route. + * + * This is the piece `req.baseUrl` can't provide: at request time `req.baseUrl` + * holds the *resolved* mount prefix (`/api/v1`), whereas we want the registered + * pattern (`/api/:version`). + */ +const layerRegisteredPaths = new WeakMap(); + +/** Record the path pattern a layer was registered with. */ +export function setLayerRegisteredPath(layer: ExpressLayer, path: string | undefined): void { + layerRegisteredPaths.set(layer, path); +} + +/** Read the path pattern a layer was registered with, if any. */ +export function getLayerRegisteredPath(layer: ExpressLayer): string | undefined { + return layerRegisteredPaths.get(layer); +} + +/** + * Per-request ordered stack of the registered path patterns of the layers + * currently on the matched chain. Layers push on entry and pop when they hand + * off via `next`, so at any point it reflects the path from the app root down + * to the currently-executing layer. `WeakMap` so entries are released with the + * request. + */ +const requestLayerPaths = new WeakMap(); + +function getStore(req: ExpressRequest): string[] { + let store = requestLayerPaths.get(req); + if (!store) { + store = []; + requestLayerPaths.set(req, store); + } + return store; +} + +/** Push a layer's registered path onto the request's chain. */ +export function pushLayerPath(req: ExpressRequest, path: string): void { + getStore(req).push(path); +} + +/** Pop the most recently pushed layer path off the request's chain. */ +export function popLayerPath(req: ExpressRequest): void { + getStore(req).pop(); +} + +/** + * The path pattern a `route`/`use` call registered, derived from its arguments. + * A leading string/RegExp/number path becomes the pattern (arrays are joined + * with `,`); a bare handler function yields `undefined`. Kept in sync with + * `@sentry/core`'s Express `getLayerPath`. + */ +export function getLayerPath(args: unknown[]): string | undefined { + const firstArg = args[0]; + if (Array.isArray(firstArg)) { + return firstArg.map(segment => extractLayerPathSegment(segment) ?? '').join(','); + } + return extractLayerPathSegment(firstArg); +} + +function extractLayerPathSegment(segment: unknown): string | undefined { + return typeof segment === 'string' + ? segment + : segment instanceof RegExp || typeof segment === 'number' + ? String(segment) + : undefined; +} + +/** + * Concatenate the stored layer paths into the full route pattern (parameters + * preserved), e.g. `/api/:version/user`. Mirrors `@sentry/core`. + */ +export function getConstructedRoute(req: ExpressRequest): string { + const layersStore = getStore(req); + + let constructedRoute = ''; + for (const path of layersStore) { + if (path === '/' || path === '/*') { + continue; + } + constructedRoute += !constructedRoute || constructedRoute.endsWith('/') ? path : `/${path}`; + } + + return constructedRoute.replace(/\/{2,}/g, '/'); +} + +/** + * Validate the constructed route against the request URL, returning it only + * when it plausibly corresponds to a real match (otherwise `undefined`). Mirrors + * `@sentry/core`'s `getActualMatchedRoute` — used for the `http.route` attribute. + */ +export function getActualMatchedRoute(req: ExpressRequest, constructedRoute: string): string | undefined { + const layersStore = getStore(req); + + if (layersStore.length === 0) { + return undefined; + } + + const originalUrl = typeof req.originalUrl === 'string' ? req.originalUrl : ''; + + // The layer store also includes root paths in case a non-existing url was requested. + if (layersStore.every(path => path === '/')) { + return originalUrl === '/' ? '/' : undefined; + } + + if (constructedRoute === '*') { + return constructedRoute; + } + + // For RegExp routes or route arrays, return the constructed route as-is. + if ( + constructedRoute.includes('/') && + (constructedRoute.includes(',') || + constructedRoute.includes('\\') || + constructedRoute.includes('*') || + constructedRoute.includes('[')) + ) { + return constructedRoute; + } + + const normalizedRoute = constructedRoute.startsWith('/') ? constructedRoute : `/${constructedRoute}`; + + const isValidRoute = + normalizedRoute.length > 0 && + (originalUrl === normalizedRoute || originalUrl.startsWith(normalizedRoute) || isRoutePattern(normalizedRoute)); + + return isValidRoute ? normalizedRoute : undefined; +} + +/** Whether a route contains parameter/wildcard patterns (`:id`, `*`). */ +function isRoutePattern(route: string): boolean { + return route.includes(':') || route.includes('*'); +} diff --git a/packages/server-utils/src/integrations/tracing-channel/express/types.ts b/packages/server-utils/src/integrations/tracing-channel/express/types.ts new file mode 100644 index 000000000000..1cd56b104083 --- /dev/null +++ b/packages/server-utils/src/integrations/tracing-channel/express/types.ts @@ -0,0 +1,63 @@ +export type ExpressLayerType = 'router' | 'middleware' | 'request_handler'; + +/** + * The subset of an Express routing `Layer` we read at handle time. `handle` is + * the user's middleware/handler; `route` is only set on route-dispatch layers + * (and carries the parameterized path). + */ +export interface ExpressLayer { + handle?: { length?: number }; + name?: string; + path?: string; + route?: { path?: unknown }; + // Only set on a Route's *inner* method-handler layers (e.g. `'get'`), which + // run inside the route-dispatch layer we already span. + method?: string; +} + +/** Minimal Express request/response shapes — avoids a hard dep on `node:http`. */ +export interface ExpressRequest { + method?: string; + baseUrl?: string; + originalUrl?: string; +} +export interface ExpressResponse { + once(event: string, listener: () => void): unknown; + removeListener(event: string, listener: () => void): unknown; +} + +/** + * The shape orchestrion's transform attaches to the tracing-channel `context` + * object for `Layer.prototype.handle_request`/`handleRequest`: `self` is the + * Layer the method was invoked on and `arguments` are `[req, res, next]`. + * + * `_sentryCleanup` is ours: a teardown for the `res.on('finish')` listener we + * register, invoked from `beforeSpanEnd` when the span ends via `next()`. + * `_sentryStoredLayer` marks that this invocation pushed a layer path (so the + * matching pop on `asyncStart` stays symmetric). + */ +export interface HandleChannelContext { + self?: ExpressLayer; + arguments?: unknown[]; + _sentryCleanup?: () => void; + _sentryStoredLayer?: boolean; +} + +/** + * The context orchestrion attaches to the `route`/`use` registration channels: + * `self` is the router the method was invoked on (its freshly-pushed layer is + * the last entry in `stack`) and `arguments` are the registration args (the + * first of which is the path pattern). + */ +export interface RegistrationChannelContext { + self?: { stack?: ExpressLayer[] }; + arguments?: unknown[]; +} + +type IgnoreMatcher = string | RegExp | ((name: string) => boolean); +export interface ExpressIntegrationOptions { + /** Ignore specific based on their name */ + ignoreLayers?: IgnoreMatcher[]; + /** Ignore specific layers based on their type */ + ignoreLayersType?: ExpressLayerType[]; +} diff --git a/packages/server-utils/src/orchestrion/channels.ts b/packages/server-utils/src/orchestrion/channels.ts index ad2d8ccdd4dd..fad5dd963dc4 100644 --- a/packages/server-utils/src/orchestrion/channels.ts +++ b/packages/server-utils/src/orchestrion/channels.ts @@ -14,6 +14,19 @@ export const CHANNELS = { MYSQL_QUERY: 'orchestrion:mysql:query', LRU_MEMOIZER_LOAD: 'orchestrion:lru-memoizer:load', + // Express v4 runs each layer's handler through `Layer.prototype.handle_request` + // in the `express` module. + EXPRESS_HANDLE: 'orchestrion:express:handle', + // Express v5 delegates routing to the standalone `router` package, where the + // equivalent method is `Layer.prototype.handleRequest`. + ROUTER_HANDLE: 'orchestrion:router:handle', + // Layer *registration* (`Router.prototype.route`/`.use`), used to capture each + // layer's registered path pattern so the matched route can be reconstructed + // with its parameters intact (`req.baseUrl` only exposes the resolved prefix). + EXPRESS_ROUTE: 'orchestrion:express:route', + EXPRESS_USE: 'orchestrion:express:use', + ROUTER_ROUTE: 'orchestrion:router:route', + ROUTER_USE: 'orchestrion:router:use', } as const; export type ChannelName = (typeof CHANNELS)[keyof typeof CHANNELS]; diff --git a/packages/server-utils/src/orchestrion/config.ts b/packages/server-utils/src/orchestrion/config.ts index 104df2185386..ca33613d3d98 100644 --- a/packages/server-utils/src/orchestrion/config.ts +++ b/packages/server-utils/src/orchestrion/config.ts @@ -38,6 +38,59 @@ export const SENTRY_INSTRUMENTATIONS: InstrumentationConfig[] = [ module: { name: 'lru-memoizer', versionRange: '>=2.1.0 <4', filePath: 'lib/async.js' }, functionQuery: { functionName: 'memoizedFunction', kind: 'Callback' }, }, + // Express funnels every middleware/route handler through a single method on + // its routing `Layer`, so instrumenting that one method covers the whole + // request pipeline. The `expressChannelIntegration` opens one span per layer + // invocation. Both are `Layer.prototype. = function (req, res, next)` + // prototype assignments (not `class` methods), so `expressionName` (matching + // the assignment's `left.property.name`) is used. `Callback`: the handler's + // last argument is `next`, so the transform ends the traced operation when + // `next` is invoked (and publishes `error` when it's called with an error). + // + // Express v4 ships its own router in `express/lib/router/layer.js`. + { + channelName: 'handle', + module: { name: 'express', versionRange: '>=4.0.0 <5', filePath: 'lib/router/layer.js' }, + // v4's method is `Layer.prototype.handle_request = function handle(...)` — + // match the assigned property name, not the function name. + functionQuery: { expressionName: 'handle_request', kind: 'Callback' }, + }, + // Express v5 delegates routing to the standalone `router` package. + { + channelName: 'handle', + module: { name: 'router', versionRange: '>=2.0.0 <3', filePath: 'lib/layer.js' }, + functionQuery: { expressionName: 'handleRequest', kind: 'Callback' }, + }, + // Layer *registration* methods. `Router.prototype.route`/`.use` are called + // once per registered route/middleware (including internally by `app.get`/ + // `app.use`), so subscribing here lets us record each layer's registered path + // *pattern* — which the handler path (`req.baseUrl`) can't recover for + // parameterized mounts. `Sync`: these return synchronously and, unlike a + // handler, `use`'s trailing function argument is a registration payload, not a + // callback — so `Callback` would misclassify it and never fire `end`. + // + // Express v4 ships its own router in `express/lib/router/index.js`. + { + channelName: 'route', + module: { name: 'express', versionRange: '>=4.0.0 <5', filePath: 'lib/router/index.js' }, + functionQuery: { expressionName: 'route', kind: 'Sync' }, + }, + { + channelName: 'use', + module: { name: 'express', versionRange: '>=4.0.0 <5', filePath: 'lib/router/index.js' }, + functionQuery: { expressionName: 'use', kind: 'Sync' }, + }, + // Express v5 delegates routing to the standalone `router` package. + { + channelName: 'route', + module: { name: 'router', versionRange: '>=2.0.0 <3', filePath: 'index.js' }, + functionQuery: { expressionName: 'route', kind: 'Sync' }, + }, + { + channelName: 'use', + module: { name: 'router', versionRange: '>=2.0.0 <3', filePath: 'index.js' }, + functionQuery: { expressionName: 'use', kind: 'Sync' }, + }, ]; /** diff --git a/packages/server-utils/src/orchestrion/index.ts b/packages/server-utils/src/orchestrion/index.ts index 4b182e51ec13..bd4a112d9acf 100644 --- a/packages/server-utils/src/orchestrion/index.ts +++ b/packages/server-utils/src/orchestrion/index.ts @@ -1,3 +1,4 @@ export { detectOrchestrionSetup } from './detect'; export { mysqlChannelIntegration } from '../integrations/tracing-channel/mysql'; export { lruMemoizerChannelIntegration } from '../integrations/tracing-channel/lru-memoizer'; +export { expressChannelIntegration } from '../integrations/tracing-channel/express';