From c0f12f78921b9ccf45ababd84222cd1f804eb879 Mon Sep 17 00:00:00 2001 From: isaacs Date: Tue, 24 Mar 2026 15:54:08 -0700 Subject: [PATCH 1/3] core: add getDefaultExport method This was implemented for the portable Express integration, but others will need the same functionality, so make it a reusable util. --- .../core/src/integrations/express/index.ts | 8 ++---- .../core/src/integrations/express/utils.ts | 9 ------- packages/core/src/utils/get-default-export.ts | 24 +++++++++++++++++ .../lib/integrations/express/utils.test.ts | 9 ------- .../test/lib/utils/get-default-export.test.ts | 27 +++++++++++++++++++ 5 files changed, 53 insertions(+), 24 deletions(-) create mode 100644 packages/core/src/utils/get-default-export.ts create mode 100644 packages/core/test/lib/utils/get-default-export.test.ts diff --git a/packages/core/src/integrations/express/index.ts b/packages/core/src/integrations/express/index.ts index bbb1f8fe8a28..df616e7b7f32 100644 --- a/packages/core/src/integrations/express/index.ts +++ b/packages/core/src/integrations/express/index.ts @@ -33,7 +33,6 @@ import { DEBUG_BUILD } from '../../debug-build'; import type { ExpressApplication, ExpressErrorMiddleware, - ExpressExport, ExpressHandlerOptions, ExpressIntegrationOptions, ExpressLayer, @@ -49,16 +48,13 @@ import type { import { defaultShouldHandleError, getLayerPath, - hasDefaultProp, isExpressWithoutRouterPrototype, isExpressWithRouterPrototype, } from './utils'; import { wrapMethod } from '../../utils/object'; import { patchLayer } from './patch-layer'; import { setSDKProcessingMetadata } from './set-sdk-processing-metadata'; - -const getExpressExport = (express: ExpressModuleExport): ExpressExport => - hasDefaultProp(express) ? express.default : (express as ExpressExport); +import { getDefaultExport } from '../../utils/get-default-export'; function isLegacyOptions( options: ExpressModuleExport | (ExpressIntegrationOptions & { express: ExpressModuleExport }), @@ -119,7 +115,7 @@ export function patchExpressModule( } // pass in the require() or import() result of express - const express = getExpressExport(moduleExports); + const express = getDefaultExport(moduleExports); const routerProto: ExpressRouterv4 | ExpressRouterv5 | undefined = isExpressWithRouterPrototype(express) ? express.Router.prototype // Express v5 : isExpressWithoutRouterPrototype(express) diff --git a/packages/core/src/integrations/express/utils.ts b/packages/core/src/integrations/express/utils.ts index c3473bbab18a..af22a6ea1d97 100644 --- a/packages/core/src/integrations/express/utils.ts +++ b/packages/core/src/integrations/express/utils.ts @@ -30,7 +30,6 @@ import type { SpanAttributes } from '../../types-hoist/span'; import { getStoredLayers } from './request-layer-store'; import type { - ExpressExport, ExpressIntegrationOptions, ExpressLayer, ExpressLayerType, @@ -254,14 +253,6 @@ const isExpressRouterPrototype = (routerProto?: unknown): routerProto is Express export const isExpressWithoutRouterPrototype = (express: unknown): express is ExpressExportv4 => isExpressRouterPrototype((express as ExpressExportv4).Router) && !isExpressWithRouterPrototype(express); -// dynamic puts the default on .default, require or normal import are fine -export const hasDefaultProp = ( - express: unknown, -): express is { - [k: string]: unknown; - default: ExpressExport; -} => !!express && typeof express === 'object' && 'default' in express && typeof express.default === 'function'; - function getStatusCodeFromResponse(error: MiddlewareError): number { const statusCode = error.status || error.statusCode || error.status_code || error.output?.statusCode; return statusCode ? parseInt(statusCode as string, 10) : 500; diff --git a/packages/core/src/utils/get-default-export.ts b/packages/core/src/utils/get-default-export.ts new file mode 100644 index 000000000000..ef37fea574f3 --- /dev/null +++ b/packages/core/src/utils/get-default-export.ts @@ -0,0 +1,24 @@ +/** + * Often we patch a module's default export, but we want to be able to do + * something like this: + * + * ```ts + * patchTheThing(await import('the-thing')); + * ``` + * + * Or like this: + * + * ```ts + * import theThing from 'the-thing'; + * patchTheThing(theThing); + * ``` + */ +export function getDefaultExport(moduleExport: T | { default: T }): T { + return ( + (!!moduleExport && + typeof moduleExport === 'object' && + 'default' in moduleExport && + (moduleExport as { default: T }).default) || + (moduleExport as T) + ); +} diff --git a/packages/core/test/lib/integrations/express/utils.test.ts b/packages/core/test/lib/integrations/express/utils.test.ts index b41d89076900..a7ec32d96e8d 100644 --- a/packages/core/test/lib/integrations/express/utils.test.ts +++ b/packages/core/test/lib/integrations/express/utils.test.ts @@ -15,7 +15,6 @@ import { getLayerMetadata, getLayerPath, getRouterPath, - hasDefaultProp, isExpressWithoutRouterPrototype, isExpressWithRouterPrototype, isLayerIgnored, @@ -368,14 +367,6 @@ describe('getConstructedRoute', () => { }); }); -describe('hasDefaultProp', () => { - it('returns detects the presence of a default function prop', () => { - expect(hasDefaultProp({ default: function express() {} })).toBe(true); - expect(hasDefaultProp({ default: 'other thing' })).toBe(false); - expect(hasDefaultProp({})).toBe(false); - }); -}); - describe('isExpressWith(out)RouterPrototype', () => { it('detects what kind of express this is', () => { expect(isExpressWithoutRouterPrototype({})).toBe(false); diff --git a/packages/core/test/lib/utils/get-default-export.test.ts b/packages/core/test/lib/utils/get-default-export.test.ts new file mode 100644 index 000000000000..2a12e8d1d1a9 --- /dev/null +++ b/packages/core/test/lib/utils/get-default-export.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from 'vitest'; +import { getDefaultExport } from '../../../src/utils/get-default-export'; + +describe('getDefaultExport', () => { + it('returns the default export if there is one', () => { + const mod = { + default: () => {}, + }; + expect(getDefaultExport(mod)).toBe(mod.default); + }); + it('returns the module export if no default', () => { + const mod = {}; + expect(getDefaultExport(mod)).toBe(mod); + }); + it('returns the module if a function and not plain object', () => { + const mod = Object.assign(function () {}, { + default: () => {}, + }); + expect(getDefaultExport(mod)).toBe(mod); + }); + it('returns the module if a default is falsey', () => { + const mod = Object.assign(function () {}, { + default: false, + }); + expect(getDefaultExport(mod)).toBe(mod); + }); +}); From bbb6e92b6f94a39157055acc78ef2979c3b1c44d Mon Sep 17 00:00:00 2001 From: isaacs Date: Mon, 20 Apr 2026 13:12:22 -0700 Subject: [PATCH 2/3] chore: remove unused imports from test files --- packages/core/test/lib/utils/weakRef.test.ts | 2 +- packages/opentelemetry/test/utils/contextData.test.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/core/test/lib/utils/weakRef.test.ts b/packages/core/test/lib/utils/weakRef.test.ts index cf050ccf3d6e..36e4fcb6b8f3 100644 --- a/packages/core/test/lib/utils/weakRef.test.ts +++ b/packages/core/test/lib/utils/weakRef.test.ts @@ -1,4 +1,4 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; import { derefWeakRef, makeWeakRef, type MaybeWeakRef } from '../../../src/utils/weakRef'; describe('Unit | util | weakRef', () => { diff --git a/packages/opentelemetry/test/utils/contextData.test.ts b/packages/opentelemetry/test/utils/contextData.test.ts index 597b9fa2b637..0d04dc6556a5 100644 --- a/packages/opentelemetry/test/utils/contextData.test.ts +++ b/packages/opentelemetry/test/utils/contextData.test.ts @@ -1,6 +1,6 @@ import { ROOT_CONTEXT } from '@opentelemetry/api'; import { Scope } from '@sentry/core'; -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { describe, expect, it } from 'vitest'; import { getContextFromScope, getScopesFromContext, From 8ad3dd05cc6a36e7e01ba4f00b73d4efe4707bed Mon Sep 17 00:00:00 2001 From: isaacs Date: Sun, 19 Apr 2026 18:05:29 -0700 Subject: [PATCH 3/3] feat(http): portable node:http client instrumentation (#20393) Refactor the `node:http` outgoing request instrumentation so that it can be applied to non-Node.js environments by patching the http module. Also, refactor so that the diagnostics_channel and monkeypatching paths can share code, and so that light and normal node-core instrumentations can share more of the functionality as well. To facilitate this, some portable minimal types are vendored in from the `node:http` module. --- .../aws-serverless/tests/layer.test.ts | 4 +- .../aws-serverless/tests/npm.test.ts | 4 +- .../tests/request-instrumentation.test.ts | 2 +- .../tests/request-instrumentation.test.ts | 2 +- .../tests/request-instrumentation.test.ts | 2 +- .../tests/server.test.ts | 6 +- .../requests/http-no-tracing-no-spans/test.ts | 261 +++------- .../http-client-spans/http-basic/test.ts | 4 +- .../http-strip-query/test.ts | 4 +- .../requests/http-no-tracing-no-spans/test.ts | 261 +++------- packages/core/src/index.ts | 16 +- .../http/add-outgoing-request-breadcrumb.ts | 39 ++ .../src/integrations/http/client-patch.ts | 127 +++++ .../integrations/http/client-subscriptions.ts | 165 +++++++ .../core/src/integrations/http/constants.ts | 5 + .../http/get-outgoing-span-data.ts | 85 ++++ .../src/integrations/http/get-request-url.ts | 12 + packages/core/src/integrations/http/index.ts | 3 + .../http/inject-trace-propagation-headers.ts | 66 +++ .../src/integrations/http/merge-baggage.ts | 28 ++ packages/core/src/integrations/http/types.ts | 247 ++++++++++ packages/core/src/tracing/index.ts | 1 + packages/core/src/tracing/trace.ts | 2 +- packages/core/src/utils/url.ts | 2 +- packages/node-core/src/index.ts | 3 + .../http/SentryHttpInstrumentation.ts | 457 ++++-------------- .../http/httpServerIntegration.ts | 4 +- .../http/httpServerSpansIntegration.ts | 45 +- .../node-core/src/integrations/http/index.ts | 5 +- .../integrations/http/outgoing-requests.ts | 6 - .../src/light/asyncLocalStorageStrategy.ts | 9 +- .../src/light/integrations/httpIntegration.ts | 169 +++---- .../src/utils/outgoingHttpRequest.ts | 144 +----- packages/node/src/integrations/http.ts | 201 ++------ .../node/src/integrations/tracing/index.ts | 3 +- packages/node/test/integrations/http.test.ts | 30 -- 36 files changed, 1214 insertions(+), 1210 deletions(-) create mode 100644 packages/core/src/integrations/http/add-outgoing-request-breadcrumb.ts create mode 100644 packages/core/src/integrations/http/client-patch.ts create mode 100644 packages/core/src/integrations/http/client-subscriptions.ts create mode 100644 packages/core/src/integrations/http/constants.ts create mode 100644 packages/core/src/integrations/http/get-outgoing-span-data.ts create mode 100644 packages/core/src/integrations/http/get-request-url.ts create mode 100644 packages/core/src/integrations/http/index.ts create mode 100644 packages/core/src/integrations/http/inject-trace-propagation-headers.ts create mode 100644 packages/core/src/integrations/http/merge-baggage.ts create mode 100644 packages/core/src/integrations/http/types.ts delete mode 100644 packages/node-core/src/integrations/http/outgoing-requests.ts delete mode 100644 packages/node/test/integrations/http.test.ts diff --git a/dev-packages/e2e-tests/test-applications/aws-serverless/tests/layer.test.ts b/dev-packages/e2e-tests/test-applications/aws-serverless/tests/layer.test.ts index 966ddf032218..456ad1ae2331 100644 --- a/dev-packages/e2e-tests/test-applications/aws-serverless/tests/layer.test.ts +++ b/dev-packages/e2e-tests/test-applications/aws-serverless/tests/layer.test.ts @@ -45,7 +45,7 @@ test.describe('Lambda layer', () => { expect.objectContaining({ data: expect.objectContaining({ 'sentry.op': 'http.client', - 'sentry.origin': 'auto.http.otel.http', + 'sentry.origin': 'auto.http.client', url: 'http://example.com/', }), description: 'GET http://example.com/', @@ -113,7 +113,7 @@ test.describe('Lambda layer', () => { expect.objectContaining({ data: expect.objectContaining({ 'sentry.op': 'http.client', - 'sentry.origin': 'auto.http.otel.http', + 'sentry.origin': 'auto.http.client', url: 'http://example.com/', }), description: 'GET http://example.com/', diff --git a/dev-packages/e2e-tests/test-applications/aws-serverless/tests/npm.test.ts b/dev-packages/e2e-tests/test-applications/aws-serverless/tests/npm.test.ts index e5b6ee1b9f32..c27b69b1a6f8 100644 --- a/dev-packages/e2e-tests/test-applications/aws-serverless/tests/npm.test.ts +++ b/dev-packages/e2e-tests/test-applications/aws-serverless/tests/npm.test.ts @@ -45,7 +45,7 @@ test.describe('NPM package', () => { expect.objectContaining({ data: expect.objectContaining({ 'sentry.op': 'http.client', - 'sentry.origin': 'auto.http.otel.http', + 'sentry.origin': 'auto.http.client', url: 'http://example.com/', }), description: 'GET http://example.com/', @@ -113,7 +113,7 @@ test.describe('NPM package', () => { expect.objectContaining({ data: expect.objectContaining({ 'sentry.op': 'http.client', - 'sentry.origin': 'auto.http.otel.http', + 'sentry.origin': 'auto.http.client', url: 'http://example.com/', }), description: 'GET http://example.com/', diff --git a/dev-packages/e2e-tests/test-applications/nextjs-14/tests/request-instrumentation.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-14/tests/request-instrumentation.test.ts index 2446ffa68659..66752e7c2e41 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-14/tests/request-instrumentation.test.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-14/tests/request-instrumentation.test.ts @@ -28,7 +28,7 @@ test('Should send a transaction with a fetch span', async ({ page }) => { data: expect.objectContaining({ 'http.method': 'GET', 'sentry.op': 'http.client', - 'sentry.origin': 'auto.http.otel.http', + 'sentry.origin': 'auto.http.client', }), description: 'GET https://github.com/', }), diff --git a/dev-packages/e2e-tests/test-applications/nextjs-app-dir/tests/request-instrumentation.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-app-dir/tests/request-instrumentation.test.ts index f392a63d4086..939347da2a09 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-app-dir/tests/request-instrumentation.test.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-app-dir/tests/request-instrumentation.test.ts @@ -16,7 +16,7 @@ test.skip('Should send a transaction with a http span', async ({ request }) => { data: expect.objectContaining({ 'http.method': 'GET', 'sentry.op': 'http.client', - 'sentry.origin': 'auto.http.otel.http', + 'sentry.origin': 'auto.http.client', }), description: 'GET https://example.com/', }), diff --git a/dev-packages/e2e-tests/test-applications/nextjs-pages-dir/tests/request-instrumentation.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-pages-dir/tests/request-instrumentation.test.ts index c65ba88c39c3..65a6820a83da 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-pages-dir/tests/request-instrumentation.test.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-pages-dir/tests/request-instrumentation.test.ts @@ -16,7 +16,7 @@ test.skip('Should send a transaction with a http span', async ({ request }) => { data: expect.objectContaining({ 'http.method': 'GET', 'sentry.op': 'http.client', - 'sentry.origin': 'auto.http.otel.http', + 'sentry.origin': 'auto.http.client', }), description: 'GET https://example.com/', }), diff --git a/dev-packages/e2e-tests/test-applications/node-express-esm-preload/tests/server.test.ts b/dev-packages/e2e-tests/test-applications/node-express-esm-preload/tests/server.test.ts index 937f2b7acc27..7e1b95e9e53f 100644 --- a/dev-packages/e2e-tests/test-applications/node-express-esm-preload/tests/server.test.ts +++ b/dev-packages/e2e-tests/test-applications/node-express-esm-preload/tests/server.test.ts @@ -133,7 +133,7 @@ test('Should record spans from http instrumentation', async ({ request }) => { trace_id: expect.stringMatching(/[a-f0-9]{32}/), data: expect.objectContaining({ 'http.flavor': '1.1', - 'http.host': 'example.com:80', + 'http.host': 'example.com', 'http.method': 'GET', 'http.response.status_code': 200, 'http.status_code': 200, @@ -146,7 +146,7 @@ test('Should record spans from http instrumentation', async ({ request }) => { 'net.transport': 'ip_tcp', 'otel.kind': 'CLIENT', 'sentry.op': 'http.client', - 'sentry.origin': 'auto.http.otel.http', + 'sentry.origin': 'auto.http.client', url: 'http://example.com/', }), description: 'GET http://example.com/', @@ -155,6 +155,6 @@ test('Should record spans from http instrumentation', async ({ request }) => { timestamp: expect.any(Number), status: 'ok', op: 'http.client', - origin: 'auto.http.otel.http', + origin: 'auto.http.client', }); }); diff --git a/dev-packages/node-core-integration-tests/suites/tracing/requests/http-no-tracing-no-spans/test.ts b/dev-packages/node-core-integration-tests/suites/tracing/requests/http-no-tracing-no-spans/test.ts index 17393f21a8a4..a1a9ce5d51dc 100644 --- a/dev-packages/node-core-integration-tests/suites/tracing/requests/http-no-tracing-no-spans/test.ts +++ b/dev-packages/node-core-integration-tests/suites/tracing/requests/http-no-tracing-no-spans/test.ts @@ -1,202 +1,101 @@ import { createTestServer } from '@sentry-internal/test-utils'; import { describe, expect } from 'vitest'; -import { conditionalTest } from '../../../../utils'; import { createEsmAndCjsTests } from '../../../../utils/runner'; describe('outgoing http requests with tracing & spans disabled', () => { createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument.mjs', (createRunner, test) => { - conditionalTest({ min: 22 })('node >=22', () => { - test('outgoing http requests are correctly instrumented with tracing & spans disabled', async () => { - expect.assertions(11); + test('outgoing http requests are correctly instrumented with tracing & spans disabled', async () => { + expect.assertions(11); - const [SERVER_URL, closeTestServer] = await createTestServer() - .get('/api/v0', headers => { - expect(headers['sentry-trace']).toEqual(expect.stringMatching(/^([a-f\d]{32})-([a-f\d]{16})$/)); - expect(headers['sentry-trace']).not.toEqual('00000000000000000000000000000000-0000000000000000'); - expect(headers['baggage']).toEqual(expect.any(String)); - }) - .get('/api/v1', headers => { - expect(headers['sentry-trace']).toEqual(expect.stringMatching(/^([a-f\d]{32})-([a-f\d]{16})$/)); - expect(headers['sentry-trace']).not.toEqual('00000000000000000000000000000000-0000000000000000'); - expect(headers['baggage']).toEqual(expect.any(String)); - }) - .get('/api/v2', headers => { - expect(headers['baggage']).toBeUndefined(); - expect(headers['sentry-trace']).toBeUndefined(); - }) - .get('/api/v3', headers => { - expect(headers['baggage']).toBeUndefined(); - expect(headers['sentry-trace']).toBeUndefined(); - }) - .start(); + const [SERVER_URL, closeTestServer] = await createTestServer() + .get('/api/v0', headers => { + expect(headers['sentry-trace']).toEqual(expect.stringMatching(/^([a-f\d]{32})-([a-f\d]{16})$/)); + expect(headers['sentry-trace']).not.toEqual('00000000000000000000000000000000-0000000000000000'); + expect(headers['baggage']).toEqual(expect.any(String)); + }) + .get('/api/v1', headers => { + expect(headers['sentry-trace']).toEqual(expect.stringMatching(/^([a-f\d]{32})-([a-f\d]{16})$/)); + expect(headers['sentry-trace']).not.toEqual('00000000000000000000000000000000-0000000000000000'); + expect(headers['baggage']).toEqual(expect.any(String)); + }) + .get('/api/v2', headers => { + expect(headers['baggage']).toBeUndefined(); + expect(headers['sentry-trace']).toBeUndefined(); + }) + .get('/api/v3', headers => { + expect(headers['baggage']).toBeUndefined(); + expect(headers['sentry-trace']).toBeUndefined(); + }) + .start(); - await createRunner() - .withEnv({ SERVER_URL }) - .expect({ - event: { - exception: { - values: [ - { - type: 'Error', - value: 'foo', - }, - ], - }, - breadcrumbs: [ - { - message: 'manual breadcrumb', - timestamp: expect.any(Number), - }, - { - category: 'http', - data: { - 'http.method': 'GET', - url: `${SERVER_URL}/api/v0`, - status_code: 200, - ADDED_PATH: '/api/v0', - }, - timestamp: expect.any(Number), - type: 'http', - }, - { - category: 'http', - data: { - 'http.method': 'GET', - url: `${SERVER_URL}/api/v1`, - status_code: 200, - ADDED_PATH: '/api/v1', - }, - timestamp: expect.any(Number), - type: 'http', - }, + await createRunner() + .withEnv({ SERVER_URL }) + .expect({ + event: { + exception: { + values: [ { - category: 'http', - data: { - 'http.method': 'GET', - url: `${SERVER_URL}/api/v2`, - status_code: 200, - ADDED_PATH: '/api/v2', - }, - timestamp: expect.any(Number), - type: 'http', - }, - { - category: 'http', - data: { - 'http.method': 'GET', - url: `${SERVER_URL}/api/v3`, - status_code: 200, - ADDED_PATH: '/api/v3', - }, - timestamp: expect.any(Number), - type: 'http', + type: 'Error', + value: 'foo', }, ], }, - }) - .start() - .completed(); - - closeTestServer(); - }); - }); - - // On older node versions, outgoing requests do not get trace-headers injected, sadly - // This is because the necessary diagnostics channel hook is not available yet - conditionalTest({ max: 21 })('node <22', () => { - test('outgoing http requests generate breadcrumbs correctly with tracing & spans disabled', async () => { - expect.assertions(9); - - const [SERVER_URL, closeTestServer] = await createTestServer() - .get('/api/v0', headers => { - // This is not instrumented, sadly - expect(headers['baggage']).toBeUndefined(); - expect(headers['sentry-trace']).toBeUndefined(); - }) - .get('/api/v1', headers => { - // This is not instrumented, sadly - expect(headers['baggage']).toBeUndefined(); - expect(headers['sentry-trace']).toBeUndefined(); - }) - .get('/api/v2', headers => { - expect(headers['baggage']).toBeUndefined(); - expect(headers['sentry-trace']).toBeUndefined(); - }) - .get('/api/v3', headers => { - expect(headers['baggage']).toBeUndefined(); - expect(headers['sentry-trace']).toBeUndefined(); - }) - .start(); - - await createRunner() - .withEnv({ SERVER_URL }) - .expect({ - event: { - exception: { - values: [ - { - type: 'Error', - value: 'foo', - }, - ], + breadcrumbs: [ + { + message: 'manual breadcrumb', + timestamp: expect.any(Number), }, - breadcrumbs: [ - { - message: 'manual breadcrumb', - timestamp: expect.any(Number), - }, - { - category: 'http', - data: { - 'http.method': 'GET', - url: `${SERVER_URL}/api/v0`, - status_code: 200, - ADDED_PATH: '/api/v0', - }, - timestamp: expect.any(Number), - type: 'http', + { + category: 'http', + data: { + 'http.method': 'GET', + url: `${SERVER_URL}/api/v0`, + status_code: 200, + ADDED_PATH: '/api/v0', }, - { - category: 'http', - data: { - 'http.method': 'GET', - url: `${SERVER_URL}/api/v1`, - status_code: 200, - ADDED_PATH: '/api/v1', - }, - timestamp: expect.any(Number), - type: 'http', + timestamp: expect.any(Number), + type: 'http', + }, + { + category: 'http', + data: { + 'http.method': 'GET', + url: `${SERVER_URL}/api/v1`, + status_code: 200, + ADDED_PATH: '/api/v1', }, - { - category: 'http', - data: { - 'http.method': 'GET', - url: `${SERVER_URL}/api/v2`, - status_code: 200, - ADDED_PATH: '/api/v2', - }, - timestamp: expect.any(Number), - type: 'http', + timestamp: expect.any(Number), + type: 'http', + }, + { + category: 'http', + data: { + 'http.method': 'GET', + url: `${SERVER_URL}/api/v2`, + status_code: 200, + ADDED_PATH: '/api/v2', }, - { - category: 'http', - data: { - 'http.method': 'GET', - url: `${SERVER_URL}/api/v3`, - status_code: 200, - ADDED_PATH: '/api/v3', - }, - timestamp: expect.any(Number), - type: 'http', + timestamp: expect.any(Number), + type: 'http', + }, + { + category: 'http', + data: { + 'http.method': 'GET', + url: `${SERVER_URL}/api/v3`, + status_code: 200, + ADDED_PATH: '/api/v3', }, - ], - }, - }) - .start() - .completed(); + timestamp: expect.any(Number), + type: 'http', + }, + ], + }, + }) + .start() + .completed(); - closeTestServer(); - }); + closeTestServer(); }); }); }); diff --git a/dev-packages/node-integration-tests/suites/tracing/http-client-spans/http-basic/test.ts b/dev-packages/node-integration-tests/suites/tracing/http-client-spans/http-basic/test.ts index 0549d7e914c0..1fea661d33e5 100644 --- a/dev-packages/node-integration-tests/suites/tracing/http-client-spans/http-basic/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/http-client-spans/http-basic/test.ts @@ -29,13 +29,13 @@ test('captures spans for outgoing http requests', async () => { expect.objectContaining({ description: expect.stringMatching(/GET .*\/api\/v0/), op: 'http.client', - origin: 'auto.http.otel.http', + origin: 'auto.http.client', status: 'ok', }), expect.objectContaining({ description: expect.stringMatching(/GET .*\/api\/v1/), op: 'http.client', - origin: 'auto.http.otel.http', + origin: 'auto.http.client', status: 'not_found', data: expect.objectContaining({ 'http.response.status_code': 404, diff --git a/dev-packages/node-integration-tests/suites/tracing/http-client-spans/http-strip-query/test.ts b/dev-packages/node-integration-tests/suites/tracing/http-client-spans/http-strip-query/test.ts index 94ccd6c9702a..60add149deab 100644 --- a/dev-packages/node-integration-tests/suites/tracing/http-client-spans/http-strip-query/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/http-client-spans/http-strip-query/test.ts @@ -37,11 +37,11 @@ test('strips and handles query params in spans of outgoing http requests', async 'net.transport': 'ip_tcp', 'otel.kind': 'CLIENT', 'sentry.op': 'http.client', - 'sentry.origin': 'auto.http.otel.http', + 'sentry.origin': 'auto.http.client', }, description: `GET ${SERVER_URL}/api/v0/users`, op: 'http.client', - origin: 'auto.http.otel.http', + origin: 'auto.http.client', status: 'ok', parent_span_id: txn.contexts?.trace?.span_id, span_id: expect.stringMatching(/[a-f\d]{16}/), diff --git a/dev-packages/node-integration-tests/suites/tracing/requests/http-no-tracing-no-spans/test.ts b/dev-packages/node-integration-tests/suites/tracing/requests/http-no-tracing-no-spans/test.ts index 17393f21a8a4..a1a9ce5d51dc 100644 --- a/dev-packages/node-integration-tests/suites/tracing/requests/http-no-tracing-no-spans/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/requests/http-no-tracing-no-spans/test.ts @@ -1,202 +1,101 @@ import { createTestServer } from '@sentry-internal/test-utils'; import { describe, expect } from 'vitest'; -import { conditionalTest } from '../../../../utils'; import { createEsmAndCjsTests } from '../../../../utils/runner'; describe('outgoing http requests with tracing & spans disabled', () => { createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument.mjs', (createRunner, test) => { - conditionalTest({ min: 22 })('node >=22', () => { - test('outgoing http requests are correctly instrumented with tracing & spans disabled', async () => { - expect.assertions(11); + test('outgoing http requests are correctly instrumented with tracing & spans disabled', async () => { + expect.assertions(11); - const [SERVER_URL, closeTestServer] = await createTestServer() - .get('/api/v0', headers => { - expect(headers['sentry-trace']).toEqual(expect.stringMatching(/^([a-f\d]{32})-([a-f\d]{16})$/)); - expect(headers['sentry-trace']).not.toEqual('00000000000000000000000000000000-0000000000000000'); - expect(headers['baggage']).toEqual(expect.any(String)); - }) - .get('/api/v1', headers => { - expect(headers['sentry-trace']).toEqual(expect.stringMatching(/^([a-f\d]{32})-([a-f\d]{16})$/)); - expect(headers['sentry-trace']).not.toEqual('00000000000000000000000000000000-0000000000000000'); - expect(headers['baggage']).toEqual(expect.any(String)); - }) - .get('/api/v2', headers => { - expect(headers['baggage']).toBeUndefined(); - expect(headers['sentry-trace']).toBeUndefined(); - }) - .get('/api/v3', headers => { - expect(headers['baggage']).toBeUndefined(); - expect(headers['sentry-trace']).toBeUndefined(); - }) - .start(); + const [SERVER_URL, closeTestServer] = await createTestServer() + .get('/api/v0', headers => { + expect(headers['sentry-trace']).toEqual(expect.stringMatching(/^([a-f\d]{32})-([a-f\d]{16})$/)); + expect(headers['sentry-trace']).not.toEqual('00000000000000000000000000000000-0000000000000000'); + expect(headers['baggage']).toEqual(expect.any(String)); + }) + .get('/api/v1', headers => { + expect(headers['sentry-trace']).toEqual(expect.stringMatching(/^([a-f\d]{32})-([a-f\d]{16})$/)); + expect(headers['sentry-trace']).not.toEqual('00000000000000000000000000000000-0000000000000000'); + expect(headers['baggage']).toEqual(expect.any(String)); + }) + .get('/api/v2', headers => { + expect(headers['baggage']).toBeUndefined(); + expect(headers['sentry-trace']).toBeUndefined(); + }) + .get('/api/v3', headers => { + expect(headers['baggage']).toBeUndefined(); + expect(headers['sentry-trace']).toBeUndefined(); + }) + .start(); - await createRunner() - .withEnv({ SERVER_URL }) - .expect({ - event: { - exception: { - values: [ - { - type: 'Error', - value: 'foo', - }, - ], - }, - breadcrumbs: [ - { - message: 'manual breadcrumb', - timestamp: expect.any(Number), - }, - { - category: 'http', - data: { - 'http.method': 'GET', - url: `${SERVER_URL}/api/v0`, - status_code: 200, - ADDED_PATH: '/api/v0', - }, - timestamp: expect.any(Number), - type: 'http', - }, - { - category: 'http', - data: { - 'http.method': 'GET', - url: `${SERVER_URL}/api/v1`, - status_code: 200, - ADDED_PATH: '/api/v1', - }, - timestamp: expect.any(Number), - type: 'http', - }, + await createRunner() + .withEnv({ SERVER_URL }) + .expect({ + event: { + exception: { + values: [ { - category: 'http', - data: { - 'http.method': 'GET', - url: `${SERVER_URL}/api/v2`, - status_code: 200, - ADDED_PATH: '/api/v2', - }, - timestamp: expect.any(Number), - type: 'http', - }, - { - category: 'http', - data: { - 'http.method': 'GET', - url: `${SERVER_URL}/api/v3`, - status_code: 200, - ADDED_PATH: '/api/v3', - }, - timestamp: expect.any(Number), - type: 'http', + type: 'Error', + value: 'foo', }, ], }, - }) - .start() - .completed(); - - closeTestServer(); - }); - }); - - // On older node versions, outgoing requests do not get trace-headers injected, sadly - // This is because the necessary diagnostics channel hook is not available yet - conditionalTest({ max: 21 })('node <22', () => { - test('outgoing http requests generate breadcrumbs correctly with tracing & spans disabled', async () => { - expect.assertions(9); - - const [SERVER_URL, closeTestServer] = await createTestServer() - .get('/api/v0', headers => { - // This is not instrumented, sadly - expect(headers['baggage']).toBeUndefined(); - expect(headers['sentry-trace']).toBeUndefined(); - }) - .get('/api/v1', headers => { - // This is not instrumented, sadly - expect(headers['baggage']).toBeUndefined(); - expect(headers['sentry-trace']).toBeUndefined(); - }) - .get('/api/v2', headers => { - expect(headers['baggage']).toBeUndefined(); - expect(headers['sentry-trace']).toBeUndefined(); - }) - .get('/api/v3', headers => { - expect(headers['baggage']).toBeUndefined(); - expect(headers['sentry-trace']).toBeUndefined(); - }) - .start(); - - await createRunner() - .withEnv({ SERVER_URL }) - .expect({ - event: { - exception: { - values: [ - { - type: 'Error', - value: 'foo', - }, - ], + breadcrumbs: [ + { + message: 'manual breadcrumb', + timestamp: expect.any(Number), }, - breadcrumbs: [ - { - message: 'manual breadcrumb', - timestamp: expect.any(Number), - }, - { - category: 'http', - data: { - 'http.method': 'GET', - url: `${SERVER_URL}/api/v0`, - status_code: 200, - ADDED_PATH: '/api/v0', - }, - timestamp: expect.any(Number), - type: 'http', + { + category: 'http', + data: { + 'http.method': 'GET', + url: `${SERVER_URL}/api/v0`, + status_code: 200, + ADDED_PATH: '/api/v0', }, - { - category: 'http', - data: { - 'http.method': 'GET', - url: `${SERVER_URL}/api/v1`, - status_code: 200, - ADDED_PATH: '/api/v1', - }, - timestamp: expect.any(Number), - type: 'http', + timestamp: expect.any(Number), + type: 'http', + }, + { + category: 'http', + data: { + 'http.method': 'GET', + url: `${SERVER_URL}/api/v1`, + status_code: 200, + ADDED_PATH: '/api/v1', }, - { - category: 'http', - data: { - 'http.method': 'GET', - url: `${SERVER_URL}/api/v2`, - status_code: 200, - ADDED_PATH: '/api/v2', - }, - timestamp: expect.any(Number), - type: 'http', + timestamp: expect.any(Number), + type: 'http', + }, + { + category: 'http', + data: { + 'http.method': 'GET', + url: `${SERVER_URL}/api/v2`, + status_code: 200, + ADDED_PATH: '/api/v2', }, - { - category: 'http', - data: { - 'http.method': 'GET', - url: `${SERVER_URL}/api/v3`, - status_code: 200, - ADDED_PATH: '/api/v3', - }, - timestamp: expect.any(Number), - type: 'http', + timestamp: expect.any(Number), + type: 'http', + }, + { + category: 'http', + data: { + 'http.method': 'GET', + url: `${SERVER_URL}/api/v3`, + status_code: 200, + ADDED_PATH: '/api/v3', }, - ], - }, - }) - .start() - .completed(); + timestamp: expect.any(Number), + type: 'http', + }, + ], + }, + }) + .start() + .completed(); - closeTestServer(); - }); + closeTestServer(); }); }); }); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index c3f8c454e997..e7e6365fc9fa 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -141,9 +141,21 @@ export { instrumentPostgresJsSql } from './integrations/postgresjs'; export { zodErrorsIntegration } from './integrations/zoderrors'; export { thirdPartyErrorFilterIntegration } from './integrations/third-party-errors-filter'; export { consoleIntegration } from './integrations/console'; -export { featureFlagsIntegration, type FeatureFlagsIntegration } from './integrations/featureFlags'; +export type { FeatureFlagsIntegration } from './integrations/featureFlags'; +export { featureFlagsIntegration } from './integrations/featureFlags'; export { growthbookIntegration } from './integrations/featureFlags'; export { conversationIdIntegration } from './integrations/conversationId'; +export { patchHttpModuleClient, patchHttpsModuleClient } from './integrations/http/client-patch'; +export { getHttpClientSubscriptions } from './integrations/http/client-subscriptions'; +export { addOutgoingRequestBreadcrumb } from './integrations/http/add-outgoing-request-breadcrumb'; +export { HTTP_ON_CLIENT_REQUEST, HTTP_ON_SERVER_REQUEST } from './integrations/http/constants'; +export type { + HttpInstrumentationOptions, + HttpClientRequest, + HttpIncomingMessage, + HttpServerResponse, + HttpModuleExport, +} from './integrations/http/types'; export { profiler } from './profiling'; // eslint thinks the entire function is deprecated (while only one overload is actually deprecated) @@ -567,9 +579,9 @@ export type { UnstableRollupPluginOptions, UnstableWebpackPluginOptions, } from './build-time-plugins/buildTimeOptionsBase'; +export type { RandomSafeContextRunner as _INTERNAL_RandomSafeContextRunner } from './utils/randomSafeContext'; export { withRandomSafeContext as _INTERNAL_withRandomSafeContext, - type RandomSafeContextRunner as _INTERNAL_RandomSafeContextRunner, safeMathRandom as _INTERNAL_safeMathRandom, safeDateNow as _INTERNAL_safeDateNow, } from './utils/randomSafeContext'; diff --git a/packages/core/src/integrations/http/add-outgoing-request-breadcrumb.ts b/packages/core/src/integrations/http/add-outgoing-request-breadcrumb.ts new file mode 100644 index 000000000000..7ccf029021c0 --- /dev/null +++ b/packages/core/src/integrations/http/add-outgoing-request-breadcrumb.ts @@ -0,0 +1,39 @@ +import { addBreadcrumb } from '../../breadcrumbs'; +import { getBreadcrumbLogLevelFromHttpStatusCode } from '../../utils/breadcrumb-log-level'; +import { getSanitizedUrlString, parseUrl } from '../../utils/url'; +import { getRequestUrl } from './get-request-url'; +import type { HttpClientRequest, HttpIncomingMessage } from './types'; + +/** + * Create a breadcrumb for a finished outgoing HTTP request. + */ +export function addOutgoingRequestBreadcrumb( + request: HttpClientRequest, + response: HttpIncomingMessage | undefined, +): void { + const url = getRequestUrl(request); + const parsedUrl = parseUrl(url); + + const statusCode = response?.statusCode; + const level = getBreadcrumbLogLevelFromHttpStatusCode(statusCode); + + addBreadcrumb( + { + category: 'http', + data: { + status_code: statusCode, + url: getSanitizedUrlString(parsedUrl), + 'http.method': request.method || 'GET', + ...(parsedUrl.search ? { 'http.query': parsedUrl.search } : {}), + ...(parsedUrl.hash ? { 'http.fragment': parsedUrl.hash } : {}), + }, + type: 'http', + level, + }, + { + event: 'response', + request, + response, + }, + ); +} diff --git a/packages/core/src/integrations/http/client-patch.ts b/packages/core/src/integrations/http/client-patch.ts new file mode 100644 index 000000000000..9a93b3585883 --- /dev/null +++ b/packages/core/src/integrations/http/client-patch.ts @@ -0,0 +1,127 @@ +/** + * Platform-portable HTTP(S) outgoing-request patching integration + * + * Patches the `http` and `https` Node.js built-in module exports to create + * Sentry spans for outgoing requests and optionally inject distributed trace + * propagation headers. + * + * @module + * + * This Sentry integration is a derivative work based on the OpenTelemetry + * HTTP instrumentation. + * + * + * + * Extended under the terms of the Apache 2.0 license linked below: + * + * ---- + * + * Copyright The OpenTelemetry Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { getDefaultExport } from '../../utils/get-default-export'; +import { HTTP_ON_CLIENT_REQUEST } from './constants'; +import type { HttpExport, HttpModuleExport, HttpInstrumentationOptions, HttpClientRequest } from './types'; +import { getOriginalFunction, wrapMethod } from '../../utils/object'; +import type { WrappedFunction } from '../../types-hoist/wrappedfunction'; +import { getHttpClientSubscriptions } from './client-subscriptions'; + +function patchHttpRequest( + httpModule: HttpExport, + options: HttpInstrumentationOptions, + protocol: 'http:' | 'https:', + port: 80 | 443, +): WrappedFunction { + // avoid double-wrap + if (!getOriginalFunction(httpModule.request)) { + const { [HTTP_ON_CLIENT_REQUEST]: onHttpClientRequestCreated } = getHttpClientSubscriptions(options); + + const originalRequest = httpModule.request; + wrapMethod(httpModule, 'request', function patchedRequest(this: HttpExport, ...args: unknown[]) { + // Apply protocol defaults when options are passed as a plain object + if (args.length > 0 && typeof args[0] === 'object' && args[0] !== null) { + const opts = args[0] as Record; + if ((opts.constructor as { name?: string } | undefined)?.name !== 'URL') { + args[0] = { protocol, port, ...opts }; + } + } + const request = originalRequest.apply(this, args) as HttpClientRequest; + onHttpClientRequestCreated({ request }, HTTP_ON_CLIENT_REQUEST); + return request; + }); + } + + return httpModule.request; +} + +function patchHttpGet(httpModule: HttpExport, patchedRequest: WrappedFunction) { + if (!getOriginalFunction(httpModule.get)) { + wrapMethod(httpModule, 'get', function patchedGet(this: HttpExport, ...args: unknown[]) { + // http.get is like http.request but automatically calls .end() + const request = patchedRequest.apply(this, args) as HttpClientRequest; + request.end(); + return request; + }); + } +} + +function patchModule( + httpModuleExport: HttpModuleExport, + options: HttpInstrumentationOptions = {}, + protocol: 'http:' | 'https:', + port: 80 | 443, +): HttpModuleExport { + const httpDefault = getDefaultExport(httpModuleExport); + const httpModule = httpModuleExport as HttpExport; + // if we have a default, patch that, and copy to the import container + if (httpDefault !== httpModuleExport) { + patchModule(httpDefault, options, protocol, port); + // copy with defineProperty because these might be configured oddly + for (const method of ['get', 'request']) { + const desc = Object.getOwnPropertyDescriptor(httpDefault, method); + /* v8 ignore next - will always be set at this point */ + Object.defineProperty(httpModule, method, desc ?? {}); + } + return httpModule; + } + + patchHttpGet(httpModule, patchHttpRequest(httpModule, options, protocol, port)); + return httpModuleExport; +} + +/** + * Patch an `http`-module-shaped export so that every outgoing request is + * tracked as a Sentry span. + * + * @example + * ```javascript + * import http from 'http'; + * import { patchHttpModule } from '@sentry/core'; + * patchHttpModule(http, { propagateTrace: true }); + * ``` + */ +export const patchHttpModuleClient = ( + httpModuleExport: HttpModuleExport, + options: HttpInstrumentationOptions = {}, +): HttpModuleExport => patchModule(httpModuleExport, options, 'http:', 80); + +/** + * Patch an `https`-module-shaped export. Equivalent to `patchHttpModule` but + * sets default `protocol` / `port` for HTTPS when option objects are passed. + */ +export const patchHttpsModuleClient = ( + httpModuleExport: HttpModuleExport, + options: HttpInstrumentationOptions = {}, +): HttpModuleExport => patchModule(httpModuleExport, options, 'https:', 443); diff --git a/packages/core/src/integrations/http/client-subscriptions.ts b/packages/core/src/integrations/http/client-subscriptions.ts new file mode 100644 index 000000000000..6417e8364905 --- /dev/null +++ b/packages/core/src/integrations/http/client-subscriptions.ts @@ -0,0 +1,165 @@ +/** + * Define the channels and subscription methods to subscribe to in order to + * instrument the `node:http` module. Note that this does *not* actually + * register the subscriptions, it simply returns a data object with the + * channel names and the subscription handlers. Attach these to diagnostic + * channels on Node versions where they are supported (ie, >=22.12.0). + * + * If any other platforms that do support diagnostic channels eventually add + * channel coverage for the `node:http` client, then these methods can be + * used on those platforms as well. + * + * This implementation is used in the client-patch strategy, by simply + * calling the handlers with the relevant data at the appropriate time. + */ + +import type { SpanStatus } from '../../types-hoist/spanStatus'; +import { addOutgoingRequestBreadcrumb } from './add-outgoing-request-breadcrumb'; +import { + getSpanStatusFromHttpCode, + SPAN_STATUS_ERROR, + SPAN_STATUS_UNSET, + startInactiveSpan, + SUPPRESS_TRACING_KEY, + withActiveSpan, +} from '../../tracing'; +import { debug } from '../../utils/debug-logger'; +import { LRUMap } from '../../utils/lru'; +import { getOutgoingRequestSpanData, setIncomingResponseSpanData } from './get-outgoing-span-data'; +import { getRequestUrl } from './get-request-url'; +import { injectTracePropagationHeaders } from './inject-trace-propagation-headers'; +import type { HttpInstrumentationOptions, HttpClientRequest } from './types'; +import { DEBUG_BUILD } from '../../debug-build'; +import { LOG_PREFIX, HTTP_ON_CLIENT_REQUEST } from './constants'; +import type { ClientSubscriptionName } from './constants'; +import { getClient, getCurrentScope } from '../../currentScopes'; +import { hasSpansEnabled } from '../../utils/hasSpansEnabled'; + +type ChannelListener = (message: unknown, name: string | symbol) => void; + +export type HttpClientSubscriptions = Record; + +export function getHttpClientSubscriptions(options: HttpInstrumentationOptions): HttpClientSubscriptions { + const propagationDecisionMap = new LRUMap(100); + const getConfig = () => getClient()?.getOptions(); + + const onHttpClientRequestCreated: ChannelListener = (data: unknown): void => { + const clientOptions = getConfig(); + const { + errorMonitor = 'error', + spans: createSpans = clientOptions ? hasSpansEnabled(clientOptions) : true, + propagateTrace = false, + breadcrumbs = true, + } = options; + + const { request } = data as { request: HttpClientRequest }; + + // Skip if tracing is suppressed (e.g., inside Sentry.suppressTracing()) + if (getCurrentScope().getScopeData().sdkProcessingMetadata[SUPPRESS_TRACING_KEY] === true) { + return; + } + + // check if request is ignored anyway + if (options.ignoreOutgoingRequests?.(getRequestUrl(request), request)) { + return; + } + + if (!createSpans) { + // Even without spans, set up a response listener for breadcrumbs. + if (breadcrumbs) { + const onRequestError = () => { + addOutgoingRequestBreadcrumb(request, undefined); + }; + request.on(errorMonitor, onRequestError); + request.prependListener('response', response => { + // no longer need this, got a response. + request.removeListener(errorMonitor, onRequestError); + if (request.listenerCount('response') <= 1) { + response.resume(); + } + response.on('end', () => addOutgoingRequestBreadcrumb(request, response)); + response.on(errorMonitor, () => addOutgoingRequestBreadcrumb(request, undefined)); + }); + } + + if (propagateTrace) { + injectTracePropagationHeaders(request, propagationDecisionMap); + } + return; + } + + const span = startInactiveSpan(getOutgoingRequestSpanData(request)); + options.outgoingRequestHook?.(span, request); + + // Inject trace headers after span creation so sentry-trace contains the outgoing + // span's ID (not the parent's), enabling downstream services to link to this span. + if (propagateTrace) { + if (span.isRecording()) { + withActiveSpan(span, () => { + injectTracePropagationHeaders(request, propagationDecisionMap); + }); + } else { + injectTracePropagationHeaders(request, propagationDecisionMap); + } + } + + let spanEnded = false; + function endSpan(status: SpanStatus): void { + if (!spanEnded) { + spanEnded = true; + span.setStatus(status); + span.end(); + } + } + + // Fallback: end span if the connection closes before any response. + const requestOnClose = () => endSpan({ code: SPAN_STATUS_UNSET }); + request.on('close', requestOnClose); + + request.on(errorMonitor, error => { + DEBUG_BUILD && debug.log(LOG_PREFIX, 'outgoingRequest on request error()', error); + if (breadcrumbs) { + addOutgoingRequestBreadcrumb(request, undefined); + } + endSpan({ code: SPAN_STATUS_ERROR }); + }); + + request.prependListener('response', response => { + // no longer need this, listen on response now + request.removeListener('close', requestOnClose); + if (request.listenerCount('response') <= 1) { + response.resume(); + } + setIncomingResponseSpanData(response, span); + options.outgoingResponseHook?.(span, response); + + let finished = false; + function finishWithResponse(error?: unknown): void { + if (finished) { + return; + } + finished = true; + if (error) { + DEBUG_BUILD && debug.log(LOG_PREFIX, 'outgoingRequest on response error()', error); + } + if (breadcrumbs) { + addOutgoingRequestBreadcrumb(request, response); + } + const aborted = response.aborted && !response.complete; + const status: SpanStatus = + error || typeof response.statusCode !== 'number' || aborted + ? { code: SPAN_STATUS_ERROR } + : getSpanStatusFromHttpCode(response.statusCode); + options.applyCustomAttributesOnSpan?.(span, request, response); + endSpan(status); + } + + response.on('end', finishWithResponse); + response.on(errorMonitor, finishWithResponse); + }); + }; + + return { + [HTTP_ON_CLIENT_REQUEST]: onHttpClientRequestCreated, + }; +} diff --git a/packages/core/src/integrations/http/constants.ts b/packages/core/src/integrations/http/constants.ts new file mode 100644 index 000000000000..f2af12b00b62 --- /dev/null +++ b/packages/core/src/integrations/http/constants.ts @@ -0,0 +1,5 @@ +export const LOG_PREFIX = '@sentry/instrumentation-http'; +export const HTTP_ON_CLIENT_REQUEST = 'http.client.request.created'; +export const HTTP_ON_SERVER_REQUEST = 'http.server.request.start'; +export type ClientSubscriptionName = typeof HTTP_ON_CLIENT_REQUEST; +export type ServerSubscriptionName = typeof HTTP_ON_SERVER_REQUEST; diff --git a/packages/core/src/integrations/http/get-outgoing-span-data.ts b/packages/core/src/integrations/http/get-outgoing-span-data.ts new file mode 100644 index 000000000000..e8334a0981b3 --- /dev/null +++ b/packages/core/src/integrations/http/get-outgoing-span-data.ts @@ -0,0 +1,85 @@ +import type { Span, SpanAttributes } from '../../types-hoist/span'; +import { SEMANTIC_ATTRIBUTE_SENTRY_OP } from '../../semanticAttributes'; +import { getHttpSpanDetailsFromUrlObject, parseStringToURLObject } from '../../utils/url'; +import type { HttpClientRequest, HttpIncomingMessage } from './types'; +import { getRequestUrl } from './get-request-url'; +import type { StartSpanOptions } from '../../types-hoist/startSpanOptions'; + +/** + * Build the initial span name and attributes for an outgoing HTTP request. + * This is called before the span is created, to get the initial details. + */ +export function getOutgoingRequestSpanData(request: HttpClientRequest): StartSpanOptions { + const url = getRequestUrl(request); + const [name, attributes] = getHttpSpanDetailsFromUrlObject( + parseStringToURLObject(url), + 'client', + 'auto.http.client', + request, + ); + + const userAgent = request.getHeader('user-agent'); + + return { + name, + attributes: { + // TODO(v11): Update these to the Sentry semantic attributes for urls. + // https://getsentry.github.io/sentry-conventions/attributes/ + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'http.client', + 'otel.kind': 'CLIENT', + 'http.url': url, + 'http.method': request.method, + 'http.target': request.path || '/', + 'net.peer.name': request.host, + 'http.host': request.getHeader('host') as string | undefined, + ...(userAgent ? { 'user_agent.original': userAgent as string } : {}), + ...attributes, + }, + onlyIfParent: true, + }; +} + +/** + * Add span attributes once the response is received. + */ +export function setIncomingResponseSpanData(response: HttpIncomingMessage, span: Span): void { + const { statusCode, statusMessage, httpVersion, socket } = response; + const transport = httpVersion?.toUpperCase() !== 'QUIC' ? 'ip_tcp' : 'ip_udp'; + + span.setAttributes({ + 'http.response.status_code': statusCode, + 'network.protocol.version': httpVersion, + // TODO(v11): Update these to the Sentry semantic attributes for urls. + // https://getsentry.github.io/sentry-conventions/attributes/ + 'http.flavor': httpVersion, + 'network.transport': transport, + 'net.transport': transport, + 'http.status_text': statusMessage?.toUpperCase(), + 'http.status_code': statusCode, + ...getResponseContentLengthAttributes(response), + ...getSocketAttrs(socket), + }); +} + +function getSocketAttrs(socket: HttpIncomingMessage['socket']): SpanAttributes { + if (!socket) return {}; + const { remoteAddress, remotePort } = socket; + return { + 'network.peer.address': remoteAddress, + 'network.peer.port': remotePort, + 'net.peer.ip': remoteAddress, + 'net.peer.port': remotePort, + }; +} + +function getResponseContentLengthAttributes(response: HttpIncomingMessage): SpanAttributes { + const { headers } = response; + const contentLengthHeader = headers['content-length']; + const length = contentLengthHeader ? parseInt(String(contentLengthHeader), 10) : -1; + const encoding = headers['content-encoding']; + return length >= 0 + ? encoding && encoding !== 'identity' + ? { 'http.response_content_length': length } + : { 'http.response_content_length_uncompressed': length } + : {}; +} diff --git a/packages/core/src/integrations/http/get-request-url.ts b/packages/core/src/integrations/http/get-request-url.ts new file mode 100644 index 000000000000..773c4303b202 --- /dev/null +++ b/packages/core/src/integrations/http/get-request-url.ts @@ -0,0 +1,12 @@ +import type { HttpClientRequest } from './types'; + +/** + * Build the full URL string from a Node.js ClientRequest. + * Mirrors the `getClientRequestUrl` helper in node-core. + */ +export function getRequestUrl(request: HttpClientRequest): string { + const hostname = request.getHeader('host') || request.host; + const protocol = request.protocol ?? 'http:'; + const path = request.path ?? '/'; + return `${protocol}//${hostname}${path}`; +} diff --git a/packages/core/src/integrations/http/index.ts b/packages/core/src/integrations/http/index.ts new file mode 100644 index 000000000000..bbcaf3400c07 --- /dev/null +++ b/packages/core/src/integrations/http/index.ts @@ -0,0 +1,3 @@ +export type { HttpInstrumentationOptions } from './types'; +export * from './client-patch'; +export * from './client-subscriptions'; diff --git a/packages/core/src/integrations/http/inject-trace-propagation-headers.ts b/packages/core/src/integrations/http/inject-trace-propagation-headers.ts new file mode 100644 index 000000000000..656ffa0d3250 --- /dev/null +++ b/packages/core/src/integrations/http/inject-trace-propagation-headers.ts @@ -0,0 +1,66 @@ +import type { LRUMap } from '../../utils/lru'; +import { getClient } from '../../currentScopes'; +import { DEBUG_BUILD } from '../../debug-build'; +import { debug } from '../../utils/debug-logger'; +import { isError } from '../../utils/is'; +import { getTraceData } from '../../utils/traceData'; +import { shouldPropagateTraceForUrl } from '../../utils/tracePropagationTargets'; +import { LOG_PREFIX } from './constants'; +import { getRequestUrl } from './get-request-url'; +import { mergeBaggage } from './merge-baggage'; +import type { HttpClientRequest } from './types'; + +/** + * Inject Sentry trace-propagation headers into an outgoing request if the + * target URL matches the configured `tracePropagationTargets`. + */ +export function injectTracePropagationHeaders( + request: HttpClientRequest, + propagationDecisionMap: LRUMap, +): void { + const url = getRequestUrl(request); + const clientOptions = getClient()?.getOptions(); + const { tracePropagationTargets, propagateTraceparent } = clientOptions ?? {}; + + if (!shouldPropagateTraceForUrl(url, tracePropagationTargets, propagationDecisionMap)) { + return; + } + + const traceData = getTraceData({ propagateTraceparent }); + if (!traceData) return; + + const { 'sentry-trace': sentryTrace, baggage, traceparent } = traceData; + + if (sentryTrace && !request.getHeader('sentry-trace')) { + try { + request.setHeader('sentry-trace', sentryTrace); + DEBUG_BUILD && debug.log(LOG_PREFIX, 'Added sentry-trace header'); + } catch (e) { + DEBUG_BUILD && + debug.error(LOG_PREFIX, 'Failed to set sentry-trace header:', isError(e) ? e.message : 'Unknown error'); + } + } + + if (traceparent && !request.getHeader('traceparent')) { + try { + request.setHeader('traceparent', traceparent); + DEBUG_BUILD && debug.log(LOG_PREFIX, 'Added traceparent header'); + } catch (e) { + DEBUG_BUILD && + debug.error(LOG_PREFIX, 'Failed to set traceparent header:', isError(e) ? e.message : 'Unknown error'); + } + } + + if (baggage) { + const merged = mergeBaggage(request.getHeader('baggage'), baggage); + if (merged) { + try { + request.setHeader('baggage', merged); + DEBUG_BUILD && debug.log(LOG_PREFIX, 'Added baggage header'); + } catch (e) { + DEBUG_BUILD && + debug.error(LOG_PREFIX, 'Failed to set baggage header:', isError(e) ? e.message : 'Unknown error'); + } + } + } +} diff --git a/packages/core/src/integrations/http/merge-baggage.ts b/packages/core/src/integrations/http/merge-baggage.ts new file mode 100644 index 000000000000..46eaf50f01c1 --- /dev/null +++ b/packages/core/src/integrations/http/merge-baggage.ts @@ -0,0 +1,28 @@ +import { objectToBaggageHeader, parseBaggageHeader } from '../../utils/baggage'; + +// TODO: should this be in utils/baggage? + +/** + * Merge two baggage header values, preserving non-Sentry entries from the + * existing header and overwriting Sentry entries with new ones. + */ +export function mergeBaggage(existing: string | string[] | number | undefined, incoming: string): string | undefined { + if (!existing) return incoming; + + const existingEntries = parseBaggageHeader(existing) ?? {}; + const incomingEntries = parseBaggageHeader(incoming) ?? {}; + + // Start with non-sentry entries from existing (sentry-* entries will be replaced by incoming) + const merged: Record = {}; + for (const [key, value] of Object.entries(existingEntries)) { + if (!key.startsWith('sentry-')) { + merged[key] = value; + } + } + // Add all incoming entries (the new Sentry DSC) + for (const [key, value] of Object.entries(incomingEntries)) { + merged[key] = value; + } + + return objectToBaggageHeader(merged); +} diff --git a/packages/core/src/integrations/http/types.ts b/packages/core/src/integrations/http/types.ts new file mode 100644 index 000000000000..0347b056268b --- /dev/null +++ b/packages/core/src/integrations/http/types.ts @@ -0,0 +1,247 @@ +/** + * Platform-portable HTTP(S) outgoing-request integration – type definitions. + * + * @module + * + * This Sentry integration is a derivative work based on the OpenTelemetry + * HTTP instrumentation. + * + * + * + * Extended under the terms of the Apache 2.0 license linked below: + * + * ---- + * + * Copyright The OpenTelemetry Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import type { RequestEventData } from '../../types-hoist/request'; +import type { Span } from '../../types-hoist/span'; + +/** Minimal interface for a Node.js http.ClientRequest */ +export interface HttpClientRequest { + method?: string; + path?: string; + host?: string; + protocol?: string; + end(): void; + getHeader(name: string): string | string[] | number | undefined; + setHeader(name: string, value: string | string[] | number): void; + removeHeader(name: string): void; + prependListener(event: 'response', listener: (res: HttpIncomingMessage) => void): this; + prependListener(event: string | symbol, listener: (...args: unknown[]) => void): this; + on(event: string | symbol, listener: (...args: unknown[]) => void): this; + once(event: string | symbol, listener: (...args: unknown[]) => void): this; + listenerCount(event: string | symbol): number; + removeListener(event: string | symbol, listener: (...args: unknown[]) => void): this; +} + +/** Minimal interface for a Node.js http.ServerResponse */ +export interface HttpServerResponse { + statusCode: number; + statusMessage?: string; + headers: Record; + once(ev: string, ...data: unknown[]): this; + once(ev: 'close'): this; + on(ev: string | symbol, handler: (...data: unknown[]) => void): this; +} + +export interface HttpServer { + emit(ev: string, ...data: unknown[]): this; + emit(ev: 'request', request: HttpIncomingMessage, response: HttpServerResponse): this; +} + +export interface HttpSocket { + remoteAddress?: string; + remotePort?: number; + localAddress?: string; + localPort?: number; +} + +/** Minimal interface for a Node.js http.IncomingMessage */ +export interface HttpIncomingMessage { + statusCode?: number; + statusMessage?: string; + httpVersion?: string; + url?: string; + method?: string; + headers: Record; + socket?: HttpSocket; + aborted?: boolean; + complete?: boolean; + resume(): void; + on(event: 'end', listener: () => void): this; + on(event: string | symbol, listener: (...args: unknown[]) => void): this; + addListener(event: 'end', listener: () => void): this; + addListener(event: string | symbol, listener: (...args: unknown[]) => void): this; + off(event: string | symbol, listener: (...args: unknown[]) => void): this; + removeListener(event: string | symbol, listener: (...args: unknown[]) => void): this; +} + +/** Minimal interface for a Node.js http / https module export */ +export interface HttpExport { + request: (...args: unknown[]) => HttpClientRequest; + get: (...args: unknown[]) => HttpClientRequest; + [key: string]: unknown; +} + +export type HttpModuleExport = HttpExport | (HttpExport & { default: HttpExport }); + +export interface HttpInstrumentationOptions { + /** + * Whether to create spans for outgoing HTTP requests. + * @default true + */ + spans?: boolean; + + /** + * Whether to inject distributed trace propagation headers + * (`sentry-trace`, `baggage`, `traceparent`) into outgoing requests. + * @default false + */ + propagateTrace?: boolean; + + /** + * Skip span / breadcrumb creation for requests to matching URLs. + * Receives the full URL string and the outgoing request object. + */ + ignoreOutgoingRequests?: (url: string, request: HttpClientRequest) => boolean; + + /** + * Whether breadcrumbs should be recorded for outgoing requests. + * @default true + */ + breadcrumbs?: boolean; + + /** + * Called after the outgoing-request span is created by the client. + * Use this to add custom attributes to the span. + */ + outgoingRequestHook?: (span: Span, request: HttpClientRequest) => void; + + /** + * Called when the response is received by the client. + */ + outgoingResponseHook?: (span: Span, response: HttpIncomingMessage) => void; + + /** + * Called when both the request and response are available (after the + * response ends). Useful for adding attributes based on both objects. + */ + applyCustomAttributesOnSpan?: (span: Span, request: HttpClientRequest, response: HttpIncomingMessage) => void; + + /** + * Symbol to use for observing errors on EventEmitters without consuming + * them. Pass `EventEmitter.errorMonitor` from Node.js `events` module. + * Falls back to the plain `'error'` event string when not provided. + * + * Using the real `errorMonitor` symbol ensures that Sentry does not + * swallow errors before they reach user-supplied `'error'` handlers. + */ + errorMonitor?: symbol | string; + + /** + * Controls the maximum size of incoming HTTP request bodies attached to events. + * + * Available options: + * - 'none': No request bodies will be attached + * - 'small': Request bodies up to 1,000 bytes will be attached + * - 'medium': Request bodies up to 10,000 bytes will be attached (default) + * - 'always': Request bodies will always be attached + * + * Note that even with 'always' setting, bodies exceeding 1MB will never be attached + * for performance and security reasons. + * + * @default 'medium' + */ + maxRequestBodySize?: 'none' | 'small' | 'medium' | 'always'; + + /** + * Do not capture the request body for incoming HTTP requests to URLs where the given callback returns `true`. + * This can be useful for long running requests where the body is not needed and we want to avoid capturing it. + * + * @param url Contains the entire URL, including query string (if any), protocol, host, etc. of the incoming request. + * @param request Contains the {@type RequestOptions} object used to make the incoming request. + */ + ignoreRequestBody?: (url: string, request: HttpIncomingMessage) => boolean; + + /** + * Whether the integration should create [Sessions](https://docs.sentry.io/product/releases/health/#sessions) for incoming requests to track the health and crash-free rate of your releases in Sentry. + * Read more about Release Health: https://docs.sentry.io/product/releases/health/ + * + * Defaults to `true`. + */ + sessions?: boolean; + + /** + * Number of milliseconds until sessions tracked with `trackIncomingRequestsAsSessions` will be flushed as a session aggregate. + * + * Defaults to `60000` (60s). + */ + sessionFlushingDelayMS?: number; + + /** + * Optional callback that can be used by integrations to emit the 'request' + * event within a given Sentry or OTEL context, possibly after creating a + * span, as in the HttpServerSpansIntegration. + */ + wrapServerEmitRequest?: ( + request: HttpIncomingMessage, + response: HttpServerResponse, + normalizedRequest: RequestEventData, + next: () => void, + ) => void; + + /** + * Do not capture spans for incoming HTTP requests to URLs where the given callback returns `true`. + * Spans will be non recording if tracing is disabled. + * + * The `urlPath` param consists of the URL path and query string (if any) of the incoming request. + * For example: `'/users/details?id=123'` + * + * The `request` param contains the original {@type IncomingMessage} object of the incoming request. + * You can use it to filter on additional properties like method, headers, etc. + */ + ignoreIncomingRequests?: (urlPath: string, request: HttpIncomingMessage) => boolean; + + /** + * Whether to automatically ignore common static asset requests like favicon.ico, robots.txt, etc. + * This helps reduce noise in your transactions. + * + * @default `true` + */ + ignoreStaticAssets?: boolean; + + /** + * Do not capture spans for incoming HTTP requests with the given status codes. + * By default, spans with some 3xx and 4xx status codes are ignored (see @default). + * Expects an array of status codes or a range of status codes, e.g. [[300,399], 404] would ignore 3xx and 404 status codes. + * + * @default `[[401, 404], [301, 303], [305, 399]]` + */ + ignoreStatusCodes?: (number | [number, number])[]; + + /** + * A hook that can be used to mutate the span for incoming requests. + * This is triggered after the span is created, but before it is recorded. + */ + onSpanCreated?: (span: Span, request: HttpIncomingMessage, response: HttpServerResponse) => void; + + /** + * A hook that can be used to mutate the span one last time when the + * response is finished, eg to update the transaction name based on + * the RPC metadata. + */ + onSpanEnd?: (span: Span, request: HttpIncomingMessage, response: HttpServerResponse) => void; +} diff --git a/packages/core/src/tracing/index.ts b/packages/core/src/tracing/index.ts index 3d3736876015..9b56045b37f3 100644 --- a/packages/core/src/tracing/index.ts +++ b/packages/core/src/tracing/index.ts @@ -13,6 +13,7 @@ export { withActiveSpan, suppressTracing, startNewTrace, + SUPPRESS_TRACING_KEY, } from './trace'; export { getDynamicSamplingContextFromClient, diff --git a/packages/core/src/tracing/trace.ts b/packages/core/src/tracing/trace.ts index 08411722cedf..ebf35e360af4 100644 --- a/packages/core/src/tracing/trace.ts +++ b/packages/core/src/tracing/trace.ts @@ -36,7 +36,7 @@ import { SPAN_STATUS_ERROR } from './spanstatus'; import { setCapturedScopesOnSpan } from './utils'; import type { Client } from '../client'; -const SUPPRESS_TRACING_KEY = '__SENTRY_SUPPRESS_TRACING__'; +export const SUPPRESS_TRACING_KEY = '__SENTRY_SUPPRESS_TRACING__'; /** * Wraps a function with a transaction/span and finishes the span after the function is done. diff --git a/packages/core/src/utils/url.ts b/packages/core/src/utils/url.ts index bf0c17dbc278..9365dc8ae496 100644 --- a/packages/core/src/utils/url.ts +++ b/packages/core/src/utils/url.ts @@ -188,7 +188,7 @@ export function getHttpSpanDetailsFromUrlObject( } if (!isURLObjectRelative(urlObject)) { - attributes[SEMANTIC_ATTRIBUTE_URL_FULL] = urlObject.href; + attributes[SEMANTIC_ATTRIBUTE_URL_FULL] = stripDataUrlContent(urlObject.href); if (urlObject.port) { attributes['url.port'] = urlObject.port; } diff --git a/packages/node-core/src/index.ts b/packages/node-core/src/index.ts index a9633b94c25d..8ce1cade960d 100644 --- a/packages/node-core/src/index.ts +++ b/packages/node-core/src/index.ts @@ -3,6 +3,9 @@ export { httpIntegration } from './integrations/http'; export { httpServerSpansIntegration } from './integrations/http/httpServerSpansIntegration'; export { httpServerIntegration } from './integrations/http/httpServerIntegration'; +export type { HttpServerIntegrationOptions } from './integrations/http/httpServerIntegration'; +export type { HttpServerSpansIntegrationOptions } from './integrations/http/httpServerSpansIntegration'; + export { SentryHttpInstrumentation, type SentryHttpInstrumentationOptions, diff --git a/packages/node-core/src/integrations/http/SentryHttpInstrumentation.ts b/packages/node-core/src/integrations/http/SentryHttpInstrumentation.ts index b25d32138aa9..3e59f6de6041 100644 --- a/packages/node-core/src/integrations/http/SentryHttpInstrumentation.ts +++ b/packages/node-core/src/integrations/http/SentryHttpInstrumentation.ts @@ -1,48 +1,28 @@ -/* eslint-disable max-lines */ -import type { ChannelListener } from 'node:diagnostics_channel'; -import { subscribe, unsubscribe } from 'node:diagnostics_channel'; -import { errorMonitor } from 'node:events'; +import { subscribe } from 'node:diagnostics_channel'; import type * as http from 'node:http'; -import type * as https from 'node:https'; -import { context, SpanStatusCode, trace } from '@opentelemetry/api'; +import { context, trace } from '@opentelemetry/api'; import { isTracingSuppressed } from '@opentelemetry/core'; import type { InstrumentationConfig } from '@opentelemetry/instrumentation'; import { InstrumentationBase, InstrumentationNodeModuleDefinition } from '@opentelemetry/instrumentation'; -import { - ATTR_HTTP_RESPONSE_STATUS_CODE, - ATTR_NETWORK_PEER_ADDRESS, - ATTR_NETWORK_PEER_PORT, - ATTR_NETWORK_PROTOCOL_VERSION, - ATTR_NETWORK_TRANSPORT, - ATTR_URL_FULL, - ATTR_USER_AGENT_ORIGINAL, - SEMATTRS_HTTP_RESPONSE_CONTENT_LENGTH, - SEMATTRS_HTTP_RESPONSE_CONTENT_LENGTH_UNCOMPRESSED, -} from '@opentelemetry/semantic-conventions'; -import type { Span, SpanAttributes, SpanStatus } from '@sentry/core'; -import { - debug, - getHttpSpanDetailsFromUrlObject, - getSpanStatusFromHttpCode, - LRUMap, - parseStringToURLObject, - SDK_VERSION, - SEMANTIC_ATTRIBUTE_SENTRY_OP, - startInactiveSpan, +import type { ClientRequest, IncomingMessage, ServerResponse } from 'node:http'; +import type { + HttpClientRequest, + HttpIncomingMessage, + HttpInstrumentationOptions, + HttpModuleExport, + Span, } from '@sentry/core'; -import { DEBUG_BUILD } from '../../debug-build'; +import { getHttpClientSubscriptions, patchHttpModuleClient, patchHttpsModuleClient, SDK_VERSION } from '@sentry/core'; import { INSTRUMENTATION_NAME } from './constants'; -import { - addRequestBreadcrumb, - addTracePropagationHeadersToOutgoingRequest, - getClientRequestUrl, - getRequestOptions, -} from './outgoing-requests'; +import { HTTP_ON_CLIENT_REQUEST } from '@sentry/core'; +import { NODE_VERSION } from '../../nodeVersion'; +import { errorMonitor } from 'node:events'; +import { getRequestOptions } from '../../utils/outgoingHttpRequest'; -type Http = typeof http; -type Https = typeof https; -type IncomingHttpHeaders = http.IncomingHttpHeaders; -type OutgoingHttpHeaders = http.OutgoingHttpHeaders; +const FULLY_SUPPORTS_HTTP_DIAGNOSTICS_CHANNEL = + (NODE_VERSION.major === 22 && NODE_VERSION.minor >= 12) || + (NODE_VERSION.major === 23 && NODE_VERSION.minor >= 2) || + NODE_VERSION.major >= 24; export type SentryHttpInstrumentationOptions = InstrumentationConfig & { /** @@ -96,19 +76,19 @@ export type SentryHttpInstrumentationOptions = InstrumentationConfig & { * Hooks for outgoing request spans, called when `createSpansForOutgoingRequests` is enabled. * These mirror the OTEL HttpInstrumentation hooks for backwards compatibility. */ - outgoingRequestHook?: (span: Span, request: http.ClientRequest) => void; - outgoingResponseHook?: (span: Span, response: http.IncomingMessage) => void; + outgoingRequestHook?: (span: Span, request: ClientRequest | HttpClientRequest) => void; + outgoingResponseHook?: (span: Span, response: IncomingMessage | HttpIncomingMessage) => void; outgoingRequestApplyCustomAttributes?: ( span: Span, - request: http.ClientRequest, - response: http.IncomingMessage, + request: HttpClientRequest, + response: HttpIncomingMessage, ) => void; // All options below do not do anything anymore in this instrumentation, and will be removed in the future. // They are only kept here for backwards compatibility - the respective functionality is now handled by the httpServerIntegration/httpServerSpansIntegration. /** - * @depreacted This no longer does anything. + * @deprecated This no longer does anything. */ extractIncomingTraceFromHeader?: boolean; @@ -125,7 +105,7 @@ export type SentryHttpInstrumentationOptions = InstrumentationConfig & { /** * @deprecated This no longer does anything. */ - ignoreSpansForIncomingRequests?: (urlPath: string, request: http.IncomingMessage) => boolean; + ignoreSpansForIncomingRequests?: (urlPath: string, request: IncomingMessage) => boolean; /** * @deprecated This no longer does anything. @@ -146,12 +126,12 @@ export type SentryHttpInstrumentationOptions = InstrumentationConfig & { * @deprecated This no longer does anything. */ instrumentation?: { - requestHook?: (span: Span, req: http.ClientRequest | http.IncomingMessage) => void; - responseHook?: (span: Span, response: http.IncomingMessage | http.ServerResponse) => void; + requestHook?: (span: Span, req: ClientRequest | IncomingMessage) => void; + responseHook?: (span: Span, response: IncomingMessage | ServerResponse) => void; applyCustomAttributesOnSpan?: ( span: Span, - request: http.ClientRequest | http.IncomingMessage, - response: http.IncomingMessage | http.ServerResponse, + request: ClientRequest | IncomingMessage, + response: IncomingMessage | ServerResponse, ) => void; }; @@ -178,64 +158,77 @@ export type SentryHttpInstrumentationOptions = InstrumentationConfig & { * https://github.com/open-telemetry/opentelemetry-js/blob/f8ab5592ddea5cba0a3b33bf8d74f27872c0367f/experimental/packages/opentelemetry-instrumentation-http/src/http.ts */ export class SentryHttpInstrumentation extends InstrumentationBase { - private _propagationDecisionMap: LRUMap; - private _ignoreOutgoingRequestsMap: WeakMap; - public constructor(config: SentryHttpInstrumentationOptions = {}) { super(INSTRUMENTATION_NAME, SDK_VERSION, config); - - this._propagationDecisionMap = new LRUMap(100); - this._ignoreOutgoingRequestsMap = new WeakMap(); } /** @inheritdoc */ public init(): [InstrumentationNodeModuleDefinition, InstrumentationNodeModuleDefinition] { - // We register handlers when either http or https is instrumented - // but we only want to register them once, whichever is loaded first - let hasRegisteredHandlers = false; - - const onHttpClientResponseFinish = ((_data: unknown) => { - const data = _data as { request: http.ClientRequest; response: http.IncomingMessage }; - this._onOutgoingRequestFinish(data.request, data.response); - }) satisfies ChannelListener; - - const onHttpClientRequestError = ((_data: unknown) => { - const data = _data as { request: http.ClientRequest }; - this._onOutgoingRequestFinish(data.request, undefined); - }) satisfies ChannelListener; - - const onHttpClientRequestCreated = ((_data: unknown) => { - const data = _data as { request: http.ClientRequest }; - this._onOutgoingRequestCreated(data.request); - }) satisfies ChannelListener; - - const wrap = (moduleExports: T): T => { - if (hasRegisteredHandlers) { - return moduleExports; - } + const { outgoingRequestApplyCustomAttributes: applyCustomAttributesOnSpan, ...options } = this.getConfig(); + const patchOptions: HttpInstrumentationOptions = { + propagateTrace: options.propagateTraceInOutgoingRequests, + applyCustomAttributesOnSpan, + ...options, + spans: options.createSpansForOutgoingRequests ?? options.spans, + ignoreOutgoingRequests(url, request) { + return ( + isTracingSuppressed(context.active()) || + !!options.ignoreOutgoingRequests?.(url, getRequestOptions(request as ClientRequest)) + ); + }, + outgoingRequestHook(span, request) { + options.outgoingRequestHook?.(span, request); + // We monkey-patch `req.once('response'), which is used to trigger + // the callback of the request, so that it runs in the active context + // eslint-disable-next-line @typescript-eslint/unbound-method, deprecation/deprecation + const originalOnce = request.once; + + const newOnce = new Proxy(originalOnce, { + apply(target, thisArg, args: Parameters) { + const [event] = args; + if (event !== 'response') { + return target.apply(thisArg, args); + } + + const parentContext = context.active(); + const requestContext = trace.setSpan(parentContext, span); + + return context.with(requestContext, () => { + return target.apply(thisArg, args); + }); + }, + }); - hasRegisteredHandlers = true; + // eslint-disable-next-line deprecation/deprecation + request.once = newOnce; + }, + outgoingResponseHook(span, response) { + options.outgoingResponseHook?.(span, response); + context.bind(context.active(), response); + }, + errorMonitor, + }; - subscribe('http.client.response.finish', onHttpClientResponseFinish); + // only generate the subscriber function if we'll actually use it + const { [HTTP_ON_CLIENT_REQUEST]: onHttpClientRequestCreated } = FULLY_SUPPORTS_HTTP_DIAGNOSTICS_CHANNEL + ? getHttpClientSubscriptions(patchOptions) + : {}; - // When an error happens, we still want to have a breadcrumb - // In this case, `http.client.response.finish` is not triggered - subscribe('http.client.request.error', onHttpClientRequestError); + // guard because we cover both http and https with the same subscribers + let hasRegisteredHandlers = false; + const sub = onHttpClientRequestCreated + ? (moduleExports: T): T => { + if (!hasRegisteredHandlers && onHttpClientRequestCreated) { + hasRegisteredHandlers = true; + subscribe(HTTP_ON_CLIENT_REQUEST, onHttpClientRequestCreated); + } + return moduleExports; + } + : undefined; - // NOTE: This channel only exists since Node 22.12+ - // Before that, outgoing requests are not patched - // and trace headers are not propagated, sadly. - if (this.getConfig().propagateTraceInOutgoingRequests || this.getConfig().createSpansForOutgoingRequests) { - subscribe('http.client.request.created', onHttpClientRequestCreated); - } - return moduleExports; - }; + const wrapHttp = sub ?? ((moduleExports: HttpModuleExport) => patchHttpModuleClient(moduleExports, patchOptions)); - const unwrap = (): void => { - unsubscribe('http.client.response.finish', onHttpClientResponseFinish); - unsubscribe('http.client.request.error', onHttpClientRequestError); - unsubscribe('http.client.request.created', onHttpClientRequestCreated); - }; + const wrapHttps = sub ?? ((moduleExports: HttpModuleExport) => patchHttpsModuleClient(moduleExports, patchOptions)); /** * You may be wondering why we register these diagnostics-channel listeners @@ -246,278 +239,8 @@ export class SentryHttpInstrumentation extends InstrumentationBase) { - const [event] = args; - if (event !== 'response') { - return target.apply(thisArg, args); - } - - const parentContext = context.active(); - const requestContext = trace.setSpan(parentContext, span); - - return context.with(requestContext, () => { - return target.apply(thisArg, args); - }); - }, - }); - - // eslint-disable-next-line deprecation/deprecation - request.once = newOnce; - - /** - * Determines if the request has errored or the response has ended/errored. - */ - let responseFinished = false; - - const endSpan = (status: SpanStatus): void => { - if (responseFinished) { - return; - } - responseFinished = true; - - span.setStatus(status); - span.end(); - }; - - request.prependListener('response', response => { - if (request.listenerCount('response') <= 1) { - response.resume(); - } - - context.bind(context.active(), response); - - const additionalAttributes = _getOutgoingRequestEndedSpanData(response); - span.setAttributes(additionalAttributes); - - this.getConfig().outgoingResponseHook?.(span, response); - this.getConfig().outgoingRequestApplyCustomAttributes?.(span, request, response); - - const endHandler = (forceError: boolean = false): void => { - this._diag.debug('outgoingRequest on end()'); - - const status = - // eslint-disable-next-line deprecation/deprecation - forceError || typeof response.statusCode !== 'number' || (response.aborted && !response.complete) - ? { code: SpanStatusCode.ERROR } - : getSpanStatusFromHttpCode(response.statusCode); - - endSpan(status); - }; - - response.on('end', () => { - endHandler(); - }); - response.on(errorMonitor, error => { - this._diag.debug('outgoingRequest on response error()', error); - endHandler(true); - }); - }); - - // Fallback if proper response end handling above fails - request.on('close', () => { - endSpan({ code: SpanStatusCode.UNSET }); - }); - request.on(errorMonitor, error => { - this._diag.debug('outgoingRequest on request error()', error); - endSpan({ code: SpanStatusCode.ERROR }); - }); - - return span; - } - - /** - * This is triggered when an outgoing request finishes. - * It has access to the final request and response objects. - */ - private _onOutgoingRequestFinish(request: http.ClientRequest, response?: http.IncomingMessage): void { - DEBUG_BUILD && debug.log(INSTRUMENTATION_NAME, 'Handling finished outgoing request'); - - const _breadcrumbs = this.getConfig().breadcrumbs; - const breadCrumbsEnabled = typeof _breadcrumbs === 'undefined' ? true : _breadcrumbs; - - // Note: We cannot rely on the map being set by `_onOutgoingRequestCreated`, because that is not run in Node <22 - const shouldIgnore = this._ignoreOutgoingRequestsMap.get(request) ?? this._shouldIgnoreOutgoingRequest(request); - this._ignoreOutgoingRequestsMap.set(request, shouldIgnore); - - if (breadCrumbsEnabled && !shouldIgnore) { - addRequestBreadcrumb(request, response); - } - } - - /** - * This is triggered when an outgoing request is created. - * It creates a span (if enabled) and propagates trace headers within the span's context, - * so downstream services link to the outgoing HTTP span rather than its parent. - */ - private _onOutgoingRequestCreated(request: http.ClientRequest): void { - DEBUG_BUILD && debug.log(INSTRUMENTATION_NAME, 'Handling outgoing request created'); - - const shouldIgnore = this._ignoreOutgoingRequestsMap.get(request) ?? this._shouldIgnoreOutgoingRequest(request); - this._ignoreOutgoingRequestsMap.set(request, shouldIgnore); - - if (shouldIgnore) { - return; - } - - const shouldCreateSpan = this.getConfig().createSpansForOutgoingRequests && (this.getConfig().spans ?? true); - const shouldPropagate = this.getConfig().propagateTraceInOutgoingRequests; - - if (shouldCreateSpan) { - const span = this._startSpanForOutgoingRequest(request); - - // Propagate headers within the span's context so the sentry-trace header - // contains the outgoing span's ID, not the parent span's ID. - // Only do this if the span is recording (has a parent) - otherwise the non-recording - // span would produce all-zero trace IDs instead of using the scope's propagation context. - if (shouldPropagate && span.isRecording()) { - const requestContext = trace.setSpan(context.active(), span); - context.with(requestContext, () => { - addTracePropagationHeadersToOutgoingRequest(request, this._propagationDecisionMap); - }); - } else if (shouldPropagate) { - addTracePropagationHeadersToOutgoingRequest(request, this._propagationDecisionMap); - } - } else if (shouldPropagate) { - addTracePropagationHeadersToOutgoingRequest(request, this._propagationDecisionMap); - } - } - - /** - * Check if the given outgoing request should be ignored. - */ - private _shouldIgnoreOutgoingRequest(request: http.ClientRequest): boolean { - if (isTracingSuppressed(context.active())) { - return true; - } - - const ignoreOutgoingRequests = this.getConfig().ignoreOutgoingRequests; - - if (!ignoreOutgoingRequests) { - return false; - } - - const options = getRequestOptions(request); - const url = getClientRequestUrl(request); - return ignoreOutgoingRequests(url, options); - } -} - -function _getOutgoingRequestSpanData(request: http.ClientRequest): [string, SpanAttributes] { - const url = getClientRequestUrl(request); - - const [name, attributes] = getHttpSpanDetailsFromUrlObject( - parseStringToURLObject(url), - 'client', - 'auto.http.otel.http', - request, - ); - - const userAgent = request.getHeader('user-agent'); - - return [ - name, - { - [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'http.client', - 'otel.kind': 'CLIENT', - [ATTR_USER_AGENT_ORIGINAL]: userAgent, - [ATTR_URL_FULL]: url, - 'http.url': url, - 'http.method': request.method, - 'http.target': request.path || '/', - 'net.peer.name': request.host, - 'http.host': request.getHeader('host'), - ...attributes, - }, - ]; -} - -function _getOutgoingRequestEndedSpanData(response: http.IncomingMessage): SpanAttributes { - const { statusCode, statusMessage, httpVersion, socket } = response; - - const transport = httpVersion.toUpperCase() !== 'QUIC' ? 'ip_tcp' : 'ip_udp'; - - const additionalAttributes: SpanAttributes = { - [ATTR_HTTP_RESPONSE_STATUS_CODE]: statusCode, - [ATTR_NETWORK_PROTOCOL_VERSION]: httpVersion, - 'http.flavor': httpVersion, - [ATTR_NETWORK_TRANSPORT]: transport, - 'net.transport': transport, - ['http.status_text']: statusMessage?.toUpperCase(), - 'http.status_code': statusCode, - ...getResponseContentLengthAttributes(response), - }; - - if (socket) { - const { remoteAddress, remotePort } = socket; - - additionalAttributes[ATTR_NETWORK_PEER_ADDRESS] = remoteAddress; - additionalAttributes[ATTR_NETWORK_PEER_PORT] = remotePort; - additionalAttributes['net.peer.ip'] = remoteAddress; - additionalAttributes['net.peer.port'] = remotePort; - } - - return additionalAttributes; -} - -function getResponseContentLengthAttributes(response: http.IncomingMessage): SpanAttributes { - const length = getContentLength(response.headers); - if (length == null) { - return {}; - } - - if (isCompressed(response.headers)) { - // eslint-disable-next-line deprecation/deprecation - return { [SEMATTRS_HTTP_RESPONSE_CONTENT_LENGTH]: length }; - } else { - // eslint-disable-next-line deprecation/deprecation - return { [SEMATTRS_HTTP_RESPONSE_CONTENT_LENGTH_UNCOMPRESSED]: length }; - } -} - -function getContentLength(headers: http.OutgoingHttpHeaders | http.IncomingHttpHeaders): number | undefined { - const contentLengthHeader = headers['content-length']; - if (typeof contentLengthHeader === 'number') { - return contentLengthHeader; - } - if (typeof contentLengthHeader !== 'string') { - return undefined; - } - - const contentLength = parseInt(contentLengthHeader, 10); - if (isNaN(contentLength)) { - return undefined; - } - - return contentLength; -} - -function isCompressed(headers: OutgoingHttpHeaders | IncomingHttpHeaders): boolean { - const encoding = headers['content-encoding']; - - return !!encoding && encoding !== 'identity'; } diff --git a/packages/node-core/src/integrations/http/httpServerIntegration.ts b/packages/node-core/src/integrations/http/httpServerIntegration.ts index 986be8d4c8ff..1c08e9f4f16e 100644 --- a/packages/node-core/src/integrations/http/httpServerIntegration.ts +++ b/packages/node-core/src/integrations/http/httpServerIntegration.ts @@ -4,7 +4,7 @@ import type { EventEmitter } from 'node:events'; import type { IncomingMessage, RequestOptions, Server, ServerResponse } from 'node:http'; import type { Socket } from 'node:net'; import { context, createContextKey, propagation } from '@opentelemetry/api'; -import type { AggregationCounts, Client, Integration, IntegrationFn, Scope } from '@sentry/core'; +import type { AggregationCounts, Client, HttpIncomingMessage, Integration, IntegrationFn, Scope } from '@sentry/core'; import { _INTERNAL_safeMathRandom, addNonEnumerableProperty, @@ -30,7 +30,7 @@ interface WeakRefImpl { } type StartSpanCallback = (next: () => boolean) => boolean; -type RequestWithOptionalStartSpanCallback = IncomingMessage & { +type RequestWithOptionalStartSpanCallback = HttpIncomingMessage & { _startSpanCallback?: WeakRefImpl; }; diff --git a/packages/node-core/src/integrations/http/httpServerSpansIntegration.ts b/packages/node-core/src/integrations/http/httpServerSpansIntegration.ts index 7909482a5923..3d70387df415 100644 --- a/packages/node-core/src/integrations/http/httpServerSpansIntegration.ts +++ b/packages/node-core/src/integrations/http/httpServerSpansIntegration.ts @@ -1,5 +1,5 @@ import { errorMonitor } from 'node:events'; -import type { ClientRequest, IncomingHttpHeaders, IncomingMessage, ServerResponse } from 'node:http'; +import type { IncomingHttpHeaders } from 'node:http'; import { context, SpanKind, trace } from '@opentelemetry/api'; import type { RPCMetadata } from '@opentelemetry/core'; import { getRPCMetadata, isTracingSuppressed, RPCType, setRPCMetadata } from '@opentelemetry/core'; @@ -11,7 +11,17 @@ import { SEMATTRS_NET_HOST_PORT, SEMATTRS_NET_PEER_IP, } from '@opentelemetry/semantic-conventions'; -import type { Event, Integration, IntegrationFn, Span, SpanAttributes, SpanStatus } from '@sentry/core'; +import type { + Event, + HttpClientRequest, + HttpIncomingMessage, + HttpServerResponse, + Integration, + IntegrationFn, + Span, + SpanAttributes, + SpanStatus, +} from '@sentry/core'; import { debug, getIsolationScope, @@ -43,7 +53,7 @@ export interface HttpServerSpansIntegrationOptions { * The `request` param contains the original {@type IncomingMessage} object of the incoming request. * You can use it to filter on additional properties like method, headers, etc. */ - ignoreIncomingRequests?: (urlPath: string, request: IncomingMessage) => boolean; + ignoreIncomingRequests?: (urlPath: string, request: HttpIncomingMessage) => boolean; /** * Whether to automatically ignore common static asset requests like favicon.ico, robots.txt, etc. @@ -66,12 +76,12 @@ export interface HttpServerSpansIntegrationOptions { * @deprecated This is deprecated in favor of `incomingRequestSpanHook`. */ instrumentation?: { - requestHook?: (span: Span, req: ClientRequest | IncomingMessage) => void; - responseHook?: (span: Span, response: IncomingMessage | ServerResponse) => void; + requestHook?: (span: Span, req: HttpClientRequest | HttpIncomingMessage) => void; + responseHook?: (span: Span, response: HttpIncomingMessage | HttpServerResponse) => void; applyCustomAttributesOnSpan?: ( span: Span, - request: ClientRequest | IncomingMessage, - response: IncomingMessage | ServerResponse, + request: HttpClientRequest | HttpIncomingMessage, + response: HttpIncomingMessage | HttpServerResponse, ) => void; }; @@ -79,7 +89,7 @@ export interface HttpServerSpansIntegrationOptions { * A hook that can be used to mutate the span for incoming requests. * This is triggered after the span is created, but before it is recorded. */ - onSpanCreated?: (span: Span, request: IncomingMessage, response: ServerResponse) => void; + onSpanCreated?: (span: Span, request: HttpIncomingMessage, response: HttpServerResponse) => void; } const _httpServerSpansIntegration = ((options: HttpServerSpansIntegrationOptions = {}) => { @@ -106,8 +116,8 @@ const _httpServerSpansIntegration = ((options: HttpServerSpansIntegrationOptions client.on('httpServerRequest', (_request, _response, normalizedRequest) => { // Type-casting this here because we do not want to put the node types into core - const request = _request as IncomingMessage; - const response = _response as ServerResponse; + const request = _request as HttpIncomingMessage; + const response = _response as HttpServerResponse; const startSpan = (next: () => boolean): boolean => { if ( @@ -127,7 +137,7 @@ const _httpServerSpansIntegration = ((options: HttpServerSpansIntegrationOptions const userAgent = headers['user-agent']; const ips = headers['x-forwarded-for']; const httpVersion = request.httpVersion; - const host = headers.host; + const host = headers.host as string | undefined; const hostname = host?.replace(/^(.*)(:[0-9]{1,5})/, '$1') || 'localhost'; const tracer = client.tracer; @@ -264,7 +274,7 @@ export const httpServerSpansIntegration = _httpServerSpansIntegration as ( processEvent: (event: Event) => Event | null; }; -function isKnownPrefetchRequest(req: IncomingMessage): boolean { +function isKnownPrefetchRequest(req: HttpIncomingMessage): boolean { // Currently only handles Next.js prefetch requests but may check other frameworks in the future. return req.headers['next-router-prefetch'] === '1'; } @@ -290,13 +300,13 @@ export function isStaticAssetRequest(urlPath: string): boolean { } function shouldIgnoreSpansForIncomingRequest( - request: IncomingMessage, + request: HttpIncomingMessage, { ignoreStaticAssets, ignoreIncomingRequests, }: { ignoreStaticAssets?: boolean; - ignoreIncomingRequests?: (urlPath: string, request: IncomingMessage) => boolean; + ignoreIncomingRequests?: (urlPath: string, request: HttpIncomingMessage) => boolean; }, ): boolean { if (isTracingSuppressed(context.active())) { @@ -325,7 +335,7 @@ function shouldIgnoreSpansForIncomingRequest( return false; } -function getRequestContentLengthAttribute(request: IncomingMessage): SpanAttributes { +function getRequestContentLengthAttribute(request: HttpIncomingMessage): SpanAttributes { const length = getContentLength(request.headers); if (length == null) { return {}; @@ -358,7 +368,10 @@ function isCompressed(headers: IncomingHttpHeaders): boolean { return !!encoding && encoding !== 'identity'; } -function getIncomingRequestAttributesOnResponse(request: IncomingMessage, response: ServerResponse): SpanAttributes { +function getIncomingRequestAttributesOnResponse( + request: HttpIncomingMessage, + response: HttpServerResponse, +): SpanAttributes { // take socket from the request, // since it may be detached from the response object in keep-alive mode const { socket } = request; diff --git a/packages/node-core/src/integrations/http/index.ts b/packages/node-core/src/integrations/http/index.ts index 34cb86704415..a59186875c76 100644 --- a/packages/node-core/src/integrations/http/index.ts +++ b/packages/node-core/src/integrations/http/index.ts @@ -1,4 +1,5 @@ -import type { IncomingMessage, RequestOptions } from 'node:http'; +import type { RequestOptions } from 'node:http'; +import type { HttpIncomingMessage } from '@sentry/core'; import { defineIntegration } from '@sentry/core'; import { generateInstrumentOnce } from '../../otel/instrument'; import type { NodeClient } from '../../sdk/client'; @@ -73,7 +74,7 @@ interface HttpOptions { * The `request` param contains the original {@type IncomingMessage} object of the incoming request. * You can use it to filter on additional properties like method, headers, etc. */ - ignoreIncomingRequests?: (urlPath: string, request: IncomingMessage) => boolean; + ignoreIncomingRequests?: (urlPath: string, request: HttpIncomingMessage) => boolean; /** * Do not capture spans for incoming HTTP requests with the given status codes. diff --git a/packages/node-core/src/integrations/http/outgoing-requests.ts b/packages/node-core/src/integrations/http/outgoing-requests.ts deleted file mode 100644 index b505904906fc..000000000000 --- a/packages/node-core/src/integrations/http/outgoing-requests.ts +++ /dev/null @@ -1,6 +0,0 @@ -export { - addRequestBreadcrumb, - addTracePropagationHeadersToOutgoingRequest, - getClientRequestUrl, - getRequestOptions, -} from '../../utils/outgoingHttpRequest'; diff --git a/packages/node-core/src/light/asyncLocalStorageStrategy.ts b/packages/node-core/src/light/asyncLocalStorageStrategy.ts index 1d6f3f413e59..00a7939d664f 100644 --- a/packages/node-core/src/light/asyncLocalStorageStrategy.ts +++ b/packages/node-core/src/light/asyncLocalStorageStrategy.ts @@ -1,6 +1,11 @@ import { AsyncLocalStorage } from 'node:async_hooks'; import type { Scope } from '@sentry/core'; -import { getDefaultCurrentScope, getDefaultIsolationScope, setAsyncContextStrategy } from '@sentry/core'; +import { + getDefaultCurrentScope, + getDefaultIsolationScope, + setAsyncContextStrategy, + SUPPRESS_TRACING_KEY, +} from '@sentry/core'; /** * Sets the async context strategy to use AsyncLocalStorage. @@ -62,7 +67,7 @@ export function setAsyncLocalStorageAsyncContextStrategy(): void { // In contrast to the browser, we can rely on async context isolation here function suppressTracing(callback: () => T): T { return withScope(scope => { - scope.setSDKProcessingMetadata({ __SENTRY_SUPPRESS_TRACING__: true }); + scope.setSDKProcessingMetadata({ [SUPPRESS_TRACING_KEY]: true }); return callback(); }); } diff --git a/packages/node-core/src/light/integrations/httpIntegration.ts b/packages/node-core/src/light/integrations/httpIntegration.ts index 0f3d6f5c5cc4..d13f908e1b1f 100644 --- a/packages/node-core/src/light/integrations/httpIntegration.ts +++ b/packages/node-core/src/light/integrations/httpIntegration.ts @@ -1,30 +1,35 @@ -import type { ChannelListener } from 'node:diagnostics_channel'; import { subscribe } from 'node:diagnostics_channel'; -import type { ClientRequest, IncomingMessage, RequestOptions, Server } from 'node:http'; -import type { Integration, IntegrationFn } from '@sentry/core'; +import type { RequestOptions } from 'node:http'; +import type { HttpClientRequest, HttpIncomingMessage, Integration, IntegrationFn } from '@sentry/core'; import { + addOutgoingRequestBreadcrumb, continueTrace, debug, generateSpanId, getCurrentScope, + getHttpClientSubscriptions, getIsolationScope, + HTTP_ON_CLIENT_REQUEST, httpRequestToRequestData, - LRUMap, stripUrlQueryAndFragment, + SUPPRESS_TRACING_KEY, withIsolationScope, } from '@sentry/core'; +import type { ClientRequest, IncomingMessage, Server } from 'node:http'; import { DEBUG_BUILD } from '../../debug-build'; import { patchRequestToCaptureBody } from '../../utils/captureRequestBody'; -import { - addRequestBreadcrumb, - addTracePropagationHeadersToOutgoingRequest, - getClientRequestUrl, - getRequestOptions, -} from '../../utils/outgoingHttpRequest'; +import { getClientRequestUrl, getRequestOptions } from '../../utils/outgoingHttpRequest'; import type { LightNodeClient } from '../client'; +import { errorMonitor } from 'node:events'; +import { NODE_VERSION } from '../../nodeVersion'; const INTEGRATION_NAME = 'Http'; +const FULLY_SUPPORTS_HTTP_DIAGNOSTICS_CHANNEL = + (NODE_VERSION.major === 22 && NODE_VERSION.minor >= 12) || + (NODE_VERSION.major === 23 && NODE_VERSION.minor >= 2) || + NODE_VERSION.major >= 24; + // We keep track of emit functions we wrapped, to avoid double wrapping const wrappedEmitFns = new WeakSet(); @@ -83,6 +88,8 @@ export interface HttpIntegrationOptions { const _httpIntegration = ((options: HttpIntegrationOptions = {}) => { const _options = { + ...options, + sessions: false, maxRequestBodySize: options.maxRequestBodySize ?? 'medium', ignoreRequestBody: options.ignoreRequestBody, breadcrumbs: options.breadcrumbs ?? true, @@ -90,40 +97,74 @@ const _httpIntegration = ((options: HttpIntegrationOptions = {}) => { ignoreOutgoingRequests: options.ignoreOutgoingRequests, }; - const propagationDecisionMap = new LRUMap(100); - const ignoreOutgoingRequestsMap = new WeakMap(); - return { name: INTEGRATION_NAME, setupOnce() { - const onHttpServerRequestStart = ((_data: unknown) => { + const onHttpServerRequestStart = (_data: unknown) => { const data = _data as { server: Server }; instrumentServer(data.server, _options); - }) satisfies ChannelListener; - - const onHttpClientRequestCreated = ((_data: unknown) => { - const data = _data as { request: ClientRequest }; - onOutgoingRequestCreated(data.request, _options, propagationDecisionMap, ignoreOutgoingRequestsMap); - }) satisfies ChannelListener; - - const onHttpClientResponseFinish = ((_data: unknown) => { - const data = _data as { request: ClientRequest; response: IncomingMessage }; - onOutgoingRequestFinish(data.request, data.response, _options, ignoreOutgoingRequestsMap); - }) satisfies ChannelListener; - - const onHttpClientRequestError = ((_data: unknown) => { - const data = _data as { request: ClientRequest }; - onOutgoingRequestFinish(data.request, undefined, _options, ignoreOutgoingRequestsMap); - }) satisfies ChannelListener; + }; + + const { ignoreOutgoingRequests } = _options; + + const { [HTTP_ON_CLIENT_REQUEST]: onHttpClientRequestCreated } = getHttpClientSubscriptions({ + breadcrumbs: _options.breadcrumbs, + propagateTrace: _options.tracePropagation, + ignoreOutgoingRequests: ignoreOutgoingRequests + ? (url, request) => ignoreOutgoingRequests(url, getRequestOptions(request as ClientRequest)) + : undefined, + // No spans in light mode + spans: false, + errorMonitor, + }); subscribe('http.server.request.start', onHttpServerRequestStart); - subscribe('http.client.request.created', onHttpClientRequestCreated); - subscribe('http.client.response.finish', onHttpClientResponseFinish); - subscribe('http.client.request.error', onHttpClientRequestError); + + // Subscribe on the request creation in node versions that support it + subscribe(HTTP_ON_CLIENT_REQUEST, onHttpClientRequestCreated); + + // fall back to just doing breadcrumbs on the request.end() channel + // if we do not have earlier access to the request object at creation + // time. The http.client.request.error channel is only available on + // the same node versions as client.request.created, so no help. + if (_options.breadcrumbs && !FULLY_SUPPORTS_HTTP_DIAGNOSTICS_CHANNEL) { + subscribe('http.client.response.finish', (data: unknown) => { + const { request, response } = data as { + request: HttpClientRequest; + response: HttpIncomingMessage; + }; + onOutgoingResponseFinish(request, response, _options); + }); + } }, }; }) satisfies IntegrationFn; +function onOutgoingResponseFinish( + request: HttpClientRequest, + response: HttpIncomingMessage | undefined, + options: { + breadcrumbs: boolean; + ignoreOutgoingRequests?: (url: string, request: RequestOptions) => boolean; + }, +): void { + if (!options.breadcrumbs) { + return; + } + // Check if tracing is suppressed (e.g. for Sentry's own transport requests) + if (getCurrentScope().getScopeData().sdkProcessingMetadata[SUPPRESS_TRACING_KEY]) { + return; + } + const { ignoreOutgoingRequests } = options; + if (ignoreOutgoingRequests) { + const url = getClientRequestUrl(request as ClientRequest); + if (ignoreOutgoingRequests(url, getRequestOptions(request as ClientRequest))) { + return; + } + } + addOutgoingRequestBreadcrumb(request, response); +} + /** * This integration handles incoming and outgoing HTTP requests in light mode (without OpenTelemetry). * @@ -221,65 +262,3 @@ function instrumentServer( wrappedEmitFns.add(newEmit); server.emit = newEmit; } - -function onOutgoingRequestCreated( - request: ClientRequest, - options: { tracePropagation: boolean; ignoreOutgoingRequests?: (url: string, request: RequestOptions) => boolean }, - propagationDecisionMap: LRUMap, - ignoreOutgoingRequestsMap: WeakMap, -): void { - const shouldIgnore = shouldIgnoreOutgoingRequest(request, options); - ignoreOutgoingRequestsMap.set(request, shouldIgnore); - - if (shouldIgnore) { - return; - } - - if (options.tracePropagation) { - addTracePropagationHeadersToOutgoingRequest(request, propagationDecisionMap); - } -} - -function onOutgoingRequestFinish( - request: ClientRequest, - response: IncomingMessage | undefined, - options: { - breadcrumbs: boolean; - ignoreOutgoingRequests?: (url: string, request: RequestOptions) => boolean; - }, - ignoreOutgoingRequestsMap: WeakMap, -): void { - if (!options.breadcrumbs) { - return; - } - - // Note: We cannot rely on the map being set by `onOutgoingRequestCreated`, because that channel - // only exists since Node 22 - const shouldIgnore = ignoreOutgoingRequestsMap.get(request) ?? shouldIgnoreOutgoingRequest(request, options); - - if (shouldIgnore) { - return; - } - - addRequestBreadcrumb(request, response); -} - -/** Check if the given outgoing request should be ignored. */ -function shouldIgnoreOutgoingRequest( - request: ClientRequest, - options: { ignoreOutgoingRequests?: (url: string, request: RequestOptions) => boolean }, -): boolean { - // Check if tracing is suppressed (e.g. for Sentry's own transport requests) - if (getCurrentScope().getScopeData().sdkProcessingMetadata.__SENTRY_SUPPRESS_TRACING__) { - return true; - } - - const { ignoreOutgoingRequests } = options; - - if (!ignoreOutgoingRequests) { - return false; - } - - const url = getClientRequestUrl(request); - return ignoreOutgoingRequests(url, getRequestOptions(request)); -} diff --git a/packages/node-core/src/utils/outgoingHttpRequest.ts b/packages/node-core/src/utils/outgoingHttpRequest.ts index 34624900b472..e25b4ad570df 100644 --- a/packages/node-core/src/utils/outgoingHttpRequest.ts +++ b/packages/node-core/src/utils/outgoingHttpRequest.ts @@ -1,145 +1,5 @@ -import type { LRUMap, SanitizedRequestData } from '@sentry/core'; -import { - addBreadcrumb, - debug, - getBreadcrumbLogLevelFromHttpStatusCode, - getClient, - getSanitizedUrlString, - getTraceData, - isError, - parseUrl, - shouldPropagateTraceForUrl, -} from '@sentry/core'; -import type { ClientRequest, IncomingMessage, RequestOptions } from 'http'; -import { DEBUG_BUILD } from '../debug-build'; -import { mergeBaggageHeaders } from './baggage'; - -const LOG_PREFIX = '@sentry/instrumentation-http'; - -/** Add a breadcrumb for outgoing requests. */ -export function addRequestBreadcrumb(request: ClientRequest, response: IncomingMessage | undefined): void { - const data = getBreadcrumbData(request); - - const statusCode = response?.statusCode; - const level = getBreadcrumbLogLevelFromHttpStatusCode(statusCode); - - addBreadcrumb( - { - category: 'http', - data: { - status_code: statusCode, - ...data, - }, - type: 'http', - level, - }, - { - event: 'response', - request, - response, - }, - ); -} - -/** - * Add trace propagation headers to an outgoing request. - * This must be called _before_ the request is sent! - */ -// eslint-disable-next-line complexity -export function addTracePropagationHeadersToOutgoingRequest( - request: ClientRequest, - propagationDecisionMap: LRUMap, -): void { - const url = getClientRequestUrl(request); - - const { tracePropagationTargets, propagateTraceparent } = getClient()?.getOptions() || {}; - const headersToAdd = shouldPropagateTraceForUrl(url, tracePropagationTargets, propagationDecisionMap) - ? getTraceData({ propagateTraceparent }) - : undefined; - - if (!headersToAdd) { - return; - } - - const { 'sentry-trace': sentryTrace, baggage, traceparent } = headersToAdd; - - const hasExistingSentryTraceHeader = !!request.getHeader('sentry-trace'); - - if (hasExistingSentryTraceHeader) { - return; - } - - if (sentryTrace) { - try { - request.setHeader('sentry-trace', sentryTrace); - DEBUG_BUILD && debug.log(LOG_PREFIX, 'Added sentry-trace header to outgoing request'); - } catch (error) { - DEBUG_BUILD && - debug.error( - LOG_PREFIX, - 'Failed to add sentry-trace header to outgoing request:', - isError(error) ? error.message : 'Unknown error', - ); - } - } - - if (traceparent && !request.getHeader('traceparent')) { - try { - request.setHeader('traceparent', traceparent); - DEBUG_BUILD && debug.log(LOG_PREFIX, 'Added traceparent header to outgoing request'); - } catch (error) { - DEBUG_BUILD && - debug.error( - LOG_PREFIX, - 'Failed to add traceparent header to outgoing request:', - isError(error) ? error.message : 'Unknown error', - ); - } - } - - if (baggage) { - const existingBaggage = request.getHeader('baggage'); - const newBaggage = mergeBaggageHeaders(existingBaggage, baggage); - if (newBaggage) { - try { - request.setHeader('baggage', newBaggage); - DEBUG_BUILD && debug.log(LOG_PREFIX, 'Added baggage header to outgoing request'); - } catch (error) { - DEBUG_BUILD && - debug.error( - LOG_PREFIX, - 'Failed to add baggage header to outgoing request:', - isError(error) ? error.message : 'Unknown error', - ); - } - } - } -} - -function getBreadcrumbData(request: ClientRequest): Partial { - try { - // `request.host` does not contain the port, but the host header does - const host = request.getHeader('host') || request.host; - const url = new URL(request.path, `${request.protocol}//${host}`); - const parsedUrl = parseUrl(url.toString()); - - const data: Partial = { - url: getSanitizedUrlString(parsedUrl), - 'http.method': request.method || 'GET', - }; - - if (parsedUrl.search) { - data['http.query'] = parsedUrl.search; - } - if (parsedUrl.hash) { - data['http.fragment'] = parsedUrl.hash; - } - - return data; - } catch { - return {}; - } -} +import type { SanitizedRequestData } from '@sentry/core'; +import type { ClientRequest, RequestOptions } from 'http'; /** Convert an outgoing request to request options. */ export function getRequestOptions(request: ClientRequest): RequestOptions { diff --git a/packages/node/src/integrations/http.ts b/packages/node/src/integrations/http.ts index 3e38c12f0c4b..29d59dff96a6 100644 --- a/packages/node/src/integrations/http.ts +++ b/packages/node/src/integrations/http.ts @@ -1,38 +1,24 @@ -import type { ClientRequest, IncomingMessage, RequestOptions, ServerResponse } from 'node:http'; -import { diag } from '@opentelemetry/api'; -import type { HttpInstrumentationConfig } from '@opentelemetry/instrumentation-http'; -import { HttpInstrumentation } from '@opentelemetry/instrumentation-http'; -import type { Span } from '@sentry/core'; -import { - defineIntegration, - getClient, - hasSpansEnabled, - SEMANTIC_ATTRIBUTE_URL_FULL, - stripDataUrlContent, -} from '@sentry/core'; -import type { HTTPModuleRequestIncomingMessage, NodeClient, SentryHttpInstrumentationOptions } from '@sentry/node-core'; +import type { RequestOptions } from 'node:http'; +import type { HttpClientRequest, HttpIncomingMessage, HttpServerResponse, Span } from '@sentry/core'; +import { defineIntegration, hasSpansEnabled, SEMANTIC_ATTRIBUTE_URL_FULL, stripDataUrlContent } from '@sentry/core'; +import type { + NodeClient, + SentryHttpInstrumentationOptions, + HttpServerIntegrationOptions, + HttpServerSpansIntegrationOptions, +} from '@sentry/node-core'; import { - addOriginToSpan, generateInstrumentOnce, getRequestUrl, httpServerIntegration, httpServerSpansIntegration, - NODE_VERSION, SentryHttpInstrumentation, } from '@sentry/node-core'; -import type { NodeClientOptions } from '../types'; const INTEGRATION_NAME = 'Http'; -const INSTRUMENTATION_NAME = '@opentelemetry_sentry-patched/instrumentation-http'; - -// The `http.client.request.created` diagnostics channel, needed for trace propagation, -// was added in Node 22.12.0 (backported from 23.2.0). Earlier 22.x versions don't have it. -const FULLY_SUPPORTS_HTTP_DIAGNOSTICS_CHANNEL = - (NODE_VERSION.major === 22 && NODE_VERSION.minor >= 12) || - (NODE_VERSION.major === 23 && NODE_VERSION.minor >= 2) || - NODE_VERSION.major >= 24; - +// TODO(v11): Consolidate all the various HTTP integration options into one, +// and deprecate the duplicated and aliased options. interface HttpOptions { /** * Whether breadcrumbs should be recorded for outgoing requests. @@ -97,13 +83,13 @@ interface HttpOptions { * The `request` param contains the original {@type IncomingMessage} object of the incoming request. * You can use it to filter on additional properties like method, headers, etc. */ - ignoreIncomingRequests?: (urlPath: string, request: IncomingMessage) => boolean; + ignoreIncomingRequests?: (urlPath: string, request: HttpIncomingMessage) => boolean; /** * A hook that can be used to mutate the span for incoming requests. * This is triggered after the span is created, but before it is recorded. */ - incomingRequestSpanHook?: (span: Span, request: IncomingMessage, response: ServerResponse) => void; + incomingRequestSpanHook?: (span: Span, request: HttpIncomingMessage, response: HttpServerResponse) => void; /** * Whether to automatically ignore common static asset requests like favicon.ico, robots.txt, etc. @@ -157,12 +143,12 @@ interface HttpOptions { * Additional instrumentation options that are passed to the underlying HttpInstrumentation. */ instrumentation?: { - requestHook?: (span: Span, req: ClientRequest | HTTPModuleRequestIncomingMessage) => void; - responseHook?: (span: Span, response: HTTPModuleRequestIncomingMessage | ServerResponse) => void; + requestHook?: (span: Span, req: HttpIncomingMessage | HttpClientRequest) => void; + responseHook?: (span: Span, response: HttpIncomingMessage | HttpServerResponse) => void; applyCustomAttributesOnSpan?: ( span: Span, - request: ClientRequest | HTTPModuleRequestIncomingMessage, - response: HTTPModuleRequestIncomingMessage | ServerResponse, + request: HttpIncomingMessage | HttpClientRequest, + response: HttpIncomingMessage | HttpServerResponse, ) => void; }; } @@ -174,65 +160,6 @@ export const instrumentSentryHttp = generateInstrumentOnce(INTEGRATION_NAME, config => { - const instrumentation = new HttpInstrumentation({ - ...config, - // This is hard-coded and can never be overridden by the user - disableIncomingRequestInstrumentation: true, - }); - - // We want to update the logger namespace so we can better identify what is happening here - try { - instrumentation['_diag'] = diag.createComponentLogger({ - namespace: INSTRUMENTATION_NAME, - }); - // @ts-expect-error We are writing a read-only property here... - instrumentation.instrumentationName = INSTRUMENTATION_NAME; - } catch { - // ignore errors here... - } - - // The OTel HttpInstrumentation (>=0.213.0) has a guard (`_httpPatched`/`_httpsPatched`) - // that prevents patching `http`/`https` when loaded by both CJS `require()` and ESM `import`. - // In environments like AWS Lambda, the runtime loads `http` via CJS first (for the Runtime API), - // and then the user's ESM handler imports `node:http`. The guard blocks ESM patching after CJS, - // which breaks HTTP spans for ESM handlers. We disable this guard to allow both to be patched. - // TODO(andrei): Remove once https://github.com/open-telemetry/opentelemetry-js/issues/6489 is fixed. - try { - const noopDescriptor = { get: () => false, set: () => {} }; - Object.defineProperty(instrumentation, '_httpPatched', noopDescriptor); - Object.defineProperty(instrumentation, '_httpsPatched', noopDescriptor); - } catch { - // ignore errors here... - } - - return instrumentation; -}); - -/** Exported only for tests. */ -export function _shouldUseOtelHttpInstrumentation( - options: HttpOptions, - clientOptions: Partial = {}, -): boolean { - // If `spans` is passed in, it takes precedence - // Else, we by default emit spans, unless `skipOpenTelemetrySetup` is set to `true` or spans are not enabled - if (typeof options.spans === 'boolean') { - return options.spans; - } - - if (clientOptions.skipOpenTelemetrySetup) { - return false; - } - - // IMPORTANT: We only disable span instrumentation when spans are not enabled _and_ we are on a Node version - // that fully supports the necessary diagnostics channels for trace propagation - if (!hasSpansEnabled(clientOptions) && FULLY_SUPPORTS_HTTP_DIAGNOSTICS_CHANNEL) { - return false; - } - - return true; -} - /** * The http integration instruments Node's internal http and https modules. * It creates breadcrumbs and spans for outgoing HTTP requests which will be attached to the currently active span. @@ -240,27 +167,26 @@ export function _shouldUseOtelHttpInstrumentation( export const httpIntegration = defineIntegration((options: HttpOptions = {}) => { const spans = options.spans ?? true; const disableIncomingRequestSpans = options.disableIncomingRequestSpans; + const enableServerSpans = spans && !disableIncomingRequestSpans; const serverOptions = { sessions: options.trackIncomingRequestsAsSessions, sessionFlushingDelayMS: options.sessionFlushingDelayMS, ignoreRequestBody: options.ignoreIncomingRequestBody, maxRequestBodySize: options.maxIncomingRequestBodySize, - } satisfies Parameters[0]; + } satisfies HttpServerIntegrationOptions; - const serverSpansOptions = { + const serverSpansOptions: HttpServerSpansIntegrationOptions = { ignoreIncomingRequests: options.ignoreIncomingRequests, ignoreStaticAssets: options.ignoreStaticAssets, ignoreStatusCodes: options.dropSpansForIncomingRequestStatusCodes, instrumentation: options.instrumentation, onSpanCreated: options.incomingRequestSpanHook, - } satisfies Parameters[0]; + }; const server = httpServerIntegration(serverOptions); const serverSpans = httpServerSpansIntegration(serverSpansOptions); - const enableServerSpans = spans && !disableIncomingRequestSpans; - return { name: INTEGRATION_NAME, setup(client: NodeClient) { @@ -271,99 +197,42 @@ export const httpIntegration = defineIntegration((options: HttpOptions = {}) => } }, setupOnce() { - const clientOptions = (getClient()?.getOptions() || {}) satisfies Partial; - const useOtelHttpInstrumentation = _shouldUseOtelHttpInstrumentation(options, clientOptions); - server.setupOnce(); - const sentryHttpInstrumentationOptions = { + const sentryHttpInstrumentationOptions: SentryHttpInstrumentationOptions = { breadcrumbs: options.breadcrumbs, - propagateTraceInOutgoingRequests: - typeof options.tracePropagation === 'boolean' - ? options.tracePropagation - : FULLY_SUPPORTS_HTTP_DIAGNOSTICS_CHANNEL || !useOtelHttpInstrumentation, - createSpansForOutgoingRequests: FULLY_SUPPORTS_HTTP_DIAGNOSTICS_CHANNEL, spans: options.spans, + propagateTraceInOutgoingRequests: options.tracePropagation ?? true, + createSpansForOutgoingRequests: options.spans, ignoreOutgoingRequests: options.ignoreOutgoingRequests, - outgoingRequestHook: (span: Span, request: ClientRequest) => { + outgoingRequestHook: (span: Span, request: HttpClientRequest) => { // Sanitize data URLs to prevent long base64 strings in span attributes const url = getRequestUrl(request); if (url.startsWith('data:')) { const sanitizedUrl = stripDataUrlContent(url); + // TODO(v11): Update these to the Sentry semantic attributes. + // https://getsentry.github.io/sentry-conventions/attributes/ span.setAttribute('http.url', sanitizedUrl); span.setAttribute(SEMANTIC_ATTRIBUTE_URL_FULL, sanitizedUrl); span.updateName(`${request.method || 'GET'} ${sanitizedUrl}`); } - options.instrumentation?.requestHook?.(span, request); }, outgoingResponseHook: options.instrumentation?.responseHook, outgoingRequestApplyCustomAttributes: options.instrumentation?.applyCustomAttributesOnSpan, - } satisfies SentryHttpInstrumentationOptions; + }; - // This is Sentry-specific instrumentation for outgoing request breadcrumbs & trace propagation + // This is Sentry-specific instrumentation for outgoing request + // breadcrumbs & trace propagation. It uses the diagnostic channels on + // node versions that support it, falling back to monkey-patching when + // needed. instrumentSentryHttp(sentryHttpInstrumentationOptions); - - // This is the "regular" OTEL instrumentation that emits outgoing request spans - if (useOtelHttpInstrumentation) { - const instrumentationConfig = getConfigWithDefaults(options); - instrumentOtelHttp(instrumentationConfig); - } }, processEvent(event) { - // Note: We always run this, even if spans are disabled - // The reason being that e.g. the remix integration disables span creation here but still wants to use the ignore status codes option + // Always run this, even if spans are disabled + // The reason being that e.g. the remix integration disables span + // creation here but still wants to use the ignore status codes option return serverSpans.processEvent(event); }, }; }); - -function getConfigWithDefaults(options: Partial = {}): HttpInstrumentationConfig { - const instrumentationConfig = { - // This is handled by the SentryHttpInstrumentation on Node 22+ - disableOutgoingRequestInstrumentation: FULLY_SUPPORTS_HTTP_DIAGNOSTICS_CHANNEL, - - ignoreOutgoingRequestHook: request => { - const url = getRequestUrl(request); - - if (!url) { - return false; - } - - const _ignoreOutgoingRequests = options.ignoreOutgoingRequests; - if (_ignoreOutgoingRequests?.(url, request)) { - return true; - } - - return false; - }, - - requireParentforOutgoingSpans: false, - requestHook: (span, req) => { - addOriginToSpan(span, 'auto.http.otel.http'); - - // Sanitize data URLs to prevent long base64 strings in span attributes - const url = getRequestUrl(req as ClientRequest); - if (url.startsWith('data:')) { - const sanitizedUrl = stripDataUrlContent(url); - span.setAttribute('http.url', sanitizedUrl); - span.setAttribute(SEMANTIC_ATTRIBUTE_URL_FULL, sanitizedUrl); - span.updateName(`${(req as ClientRequest).method || 'GET'} ${sanitizedUrl}`); - } - - options.instrumentation?.requestHook?.(span, req); - }, - responseHook: (span, res) => { - options.instrumentation?.responseHook?.(span, res); - }, - applyCustomAttributesOnSpan: ( - span: Span, - request: ClientRequest | HTTPModuleRequestIncomingMessage, - response: HTTPModuleRequestIncomingMessage | ServerResponse, - ) => { - options.instrumentation?.applyCustomAttributesOnSpan?.(span, request, response); - }, - } satisfies HttpInstrumentationConfig; - - return instrumentationConfig; -} diff --git a/packages/node/src/integrations/tracing/index.ts b/packages/node/src/integrations/tracing/index.ts index dcd2efa5595c..944d762f26b4 100644 --- a/packages/node/src/integrations/tracing/index.ts +++ b/packages/node/src/integrations/tracing/index.ts @@ -1,5 +1,5 @@ import type { Integration } from '@sentry/core'; -import { instrumentOtelHttp, instrumentSentryHttp } from '../http'; +import { instrumentSentryHttp } from '../http'; import { amqplibIntegration, instrumentAmqplib } from './amqplib'; import { anthropicAIIntegration, instrumentAnthropicAi } from './anthropic-ai'; import { connectIntegration, instrumentConnect } from './connect'; @@ -72,7 +72,6 @@ export function getAutoPerformanceIntegrations(): Integration[] { export function getOpenTelemetryInstrumentationToPreload(): (((options?: any) => void) & { id: string })[] { return [ instrumentSentryHttp, - instrumentOtelHttp, instrumentExpress, instrumentConnect, instrumentFastify, diff --git a/packages/node/test/integrations/http.test.ts b/packages/node/test/integrations/http.test.ts deleted file mode 100644 index d02bc12393c6..000000000000 --- a/packages/node/test/integrations/http.test.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { _shouldUseOtelHttpInstrumentation } from '../../src/integrations/http'; -import { conditionalTest } from '../helpers/conditional'; - -describe('httpIntegration', () => { - describe('_shouldInstrumentSpans', () => { - it.each([ - [{ spans: true }, {}, true], - [{ spans: false }, {}, false], - [{ spans: true }, { skipOpenTelemetrySetup: true }, true], - [{ spans: false }, { skipOpenTelemetrySetup: true }, false], - [{}, { skipOpenTelemetrySetup: true }, false], - [{}, { tracesSampleRate: 0, skipOpenTelemetrySetup: true }, false], - [{}, { tracesSampleRate: 0 }, true], - ])('returns the correct value for options=%j and clientOptions=%j', (options, clientOptions, expected) => { - const actual = _shouldUseOtelHttpInstrumentation(options, clientOptions); - expect(actual).toBe(expected); - }); - - conditionalTest({ min: 22 })('returns false without tracesSampleRate on Node >=22.12', () => { - const actual = _shouldUseOtelHttpInstrumentation({}, {}); - expect(actual).toBe(false); - }); - - conditionalTest({ max: 21 })('returns true without tracesSampleRate on Node <22', () => { - const actual = _shouldUseOtelHttpInstrumentation({}, {}); - expect(actual).toBe(true); - }); - }); -});