From 730a0417578c434d1f5b1ac8468f91bd8b32f328 Mon Sep 17 00:00:00 2001 From: RulaKhaled Date: Thu, 13 Aug 2026 11:41:38 +0200 Subject: [PATCH 1/5] ref(node)!: Remove legacy incoming HTTP span hooks and default keepAlive to true Incoming request spans now only go through `onSpanCreated` / `incomingRequestSpanHook`. The HTTP transport reuses sockets by default now that Node 8 keepAlive leaks are out of support. Fixes #22260 Co-Authored-By: Cursor Grok 4.6 --- CHANGELOG.md | 1 + MIGRATION.md | 42 +++++++++++++++++++ .../httpIntegration/instrument-options.mjs | 25 ----------- .../suites/tracing/httpIntegration/test.ts | 42 ------------------- docs/migration/v11-end-state.md | 42 +++++++++++++++++++ .../http/SentryHttpInstrumentation.ts | 3 +- .../http/httpServerSpansIntegration.ts | 20 --------- packages/node/src/integrations/http/index.ts | 5 +-- packages/node/src/transports/http.ts | 7 +--- packages/node/test/transports/http.test.ts | 19 ++++++++- packages/nuxt/src/server/sdk.ts | 14 +++---- 11 files changed, 113 insertions(+), 107 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 94ce3adf042a..65ce5437ebc9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ Work in this release was contributed by @psh4607, @thijsw, @trinitiwowka, @nehap - `DenoMongoose` => `Mongoose` - `DenoMysql` => `Mysql` - `DenoPostgres` => `Postgres` +- ref(node)!: Remove legacy incoming HTTP span hooks and default the HTTP transport `keepAlive` to `true` ## 10.67.0 diff --git a/MIGRATION.md b/MIGRATION.md index ab1b120a4bbc..226c956f0ebb 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -518,6 +518,47 @@ Two consequences to be aware of when upgrading: - **Issue grouping:** Grouping in Sentry differs for events with and without stack traces, so you may see new issue groups after upgrading. - **Release health:** Events with a stack trace are counted as errors, so a `captureMessage` call (including messages emitted by `captureConsoleIntegration`) now marks the current session as _errored_. This affects errored-session counts but does **not** mark sessions as crashed, so crash-free session rate is unaffected. If you use `captureMessage` for purely informational output, consider using Sentry Logs instead, which is better suited and does not affect release health. +### Incoming HTTP span hooks moved to `incomingRequestSpanHook` + +Affected SDKs: `@sentry/node` and dependents. + +The deprecated `httpIntegration` / `httpServerSpansIntegration` hooks `instrumentation.requestHook`, `instrumentation.responseHook`, and `instrumentation.applyCustomAttributesOnSpan` no longer run for incoming request spans. Use `incomingRequestSpanHook` (on `httpIntegration`) or `onSpanCreated` (on `httpServerSpansIntegration`) instead: + +```js +// before +Sentry.httpIntegration({ + instrumentation: { + requestHook: (span, req) => { + span.setAttribute('custom', true); + }, + }, +}); + +// after +Sentry.httpIntegration({ + incomingRequestSpanHook: (span, req, res) => { + span.setAttribute('custom', true); + }, +}); +``` + +`httpIntegration`'s `instrumentation` option is still honored for **outgoing** request spans. + +### Node HTTP transport `keepAlive` defaults to `true` + +Affected SDKs: `@sentry/node` and dependents. + +The Node HTTP transport now reuses sockets by default (`keepAlive: true`). The previous default of `false` existed because of a memory leak in Node 8, which is no longer relevant (minimum Node is 20.19.0). Idle sockets are still closed after 2 seconds. Pass `keepAlive: false` in transport options to restore the previous behavior: + +```js +Sentry.init({ + dsn: '__DSN__', + transportOptions: { + keepAlive: false, + }, +}); +``` + ### Span attribute changes Affected SDKs: All SDKs. @@ -808,6 +849,7 @@ Sentry.init({ - (Express) The deprecated `patchExpressModule(options)` signature was removed. Use `patchExpressModule(moduleExports, getOptions)` instead. - The `@sentry/node-core/light/otlp` entry point was removed, along with its optional `@opentelemetry/exporter-trace-otlp-http` peer dependency. `otlpIntegration` is now exported directly from every server-side SDK, so `Sentry.otlpIntegration()` needs no extra import or install. - The `otlpIntegration` options `setupOtlpTracesExporter` and `collectorUrl` were removed, and the integration no longer sets up a span exporter, span processor, or tracer provider. Configure your own exporter and point it at `Sentry.getOtlpTracesEndpoint(dsn)`, or at your collector's URL if you route through one. See [Connecting Sentry to your OpenTelemetry traces](#connecting-sentry-to-your-opentelemetry-traces). +- The deprecated `httpServerSpansIntegration` `instrumentation.{requestHook,responseHook,applyCustomAttributesOnSpan}` option was removed. Use `onSpanCreated`, or `httpIntegration({ incomingRequestSpanHook })`, to mutate incoming request spans. ### `@sentry/cloudflare` diff --git a/dev-packages/node-integration-tests/suites/tracing/httpIntegration/instrument-options.mjs b/dev-packages/node-integration-tests/suites/tracing/httpIntegration/instrument-options.mjs index 3f052dd70a9d..96aea9ff619e 100644 --- a/dev-packages/node-integration-tests/suites/tracing/httpIntegration/instrument-options.mjs +++ b/dev-packages/node-integration-tests/suites/tracing/httpIntegration/instrument-options.mjs @@ -19,31 +19,6 @@ Sentry.init({ resMethod: res.req.method, }); }, - instrumentation: { - requestHook: (span, req) => { - span.setAttribute('attr1', 'yes'); - Sentry.setExtra('requestHookCalled', { - url: req.url, - method: req.method, - }); - }, - responseHook: (span, res) => { - span.setAttribute('attr2', 'yes'); - Sentry.setExtra('responseHookCalled', { - url: res.req.url, - method: res.req.method, - }); - }, - applyCustomAttributesOnSpan: (span, req, res) => { - span.setAttribute('attr3', 'yes'); - Sentry.setExtra('applyCustomAttributesOnSpanCalled', { - reqUrl: req.url, - reqMethod: req.method, - resUrl: res.req.url, - resMethod: res.req.method, - }); - }, - }, }), ], }); diff --git a/dev-packages/node-integration-tests/suites/tracing/httpIntegration/test.ts b/dev-packages/node-integration-tests/suites/tracing/httpIntegration/test.ts index c3b6cd63ec9f..e1053a706d16 100644 --- a/dev-packages/node-integration-tests/suites/tracing/httpIntegration/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/httpIntegration/test.ts @@ -22,48 +22,6 @@ describe('httpIntegration', () => { describe('instrumentation options', () => { createEsmAndCjsTests(__dirname, 'server.mjs', 'instrument-options.mjs', (createRunner, test) => { - test('allows to pass instrumentation options to integration', async () => { - const runner = createRunner() - .expect({ - transaction: { - contexts: { - trace: { - span_id: expect.stringMatching(/[a-f\d]{16}/), - trace_id: expect.stringMatching(/[a-f\d]{32}/), - data: { - 'url.full': expect.stringMatching(/\/test$/), - 'http.response.status_code': 200, - attr1: 'yes', - attr2: 'yes', - attr3: 'yes', - }, - op: 'http.server', - status: 'ok', - }, - }, - extra: { - requestHookCalled: { - url: expect.stringMatching(/\/test$/), - method: 'GET', - }, - responseHookCalled: { - url: expect.stringMatching(/\/test$/), - method: 'GET', - }, - applyCustomAttributesOnSpanCalled: { - reqUrl: expect.stringMatching(/\/test$/), - reqMethod: 'GET', - resUrl: expect.stringMatching(/\/test$/), - resMethod: 'GET', - }, - }, - }, - }) - .start(); - runner.makeRequest('get', '/test'); - await runner.completed(); - }); - test('allows to configure incomingRequestSpanHook', async () => { const runner = createRunner() .expect({ diff --git a/docs/migration/v11-end-state.md b/docs/migration/v11-end-state.md index a4f852421600..0551dd08af05 100644 --- a/docs/migration/v11-end-state.md +++ b/docs/migration/v11-end-state.md @@ -516,6 +516,47 @@ Two consequences to be aware of when upgrading: - **Issue grouping:** Grouping in Sentry differs for events with and without stack traces, so you may see new issue groups after upgrading. - **Release health:** Events with a stack trace are counted as errors, so a `captureMessage` call (including messages emitted by `captureConsoleIntegration`) now marks the current session as _errored_. This affects errored-session counts but does **not** mark sessions as crashed, so crash-free session rate is unaffected. If you use `captureMessage` for purely informational output, consider using Sentry Logs instead, which is better suited and does not affect release health. +### Incoming HTTP span hooks moved to `incomingRequestSpanHook` + +Affected SDKs: `@sentry/node` and dependents. + +The deprecated `httpIntegration` / `httpServerSpansIntegration` hooks `instrumentation.requestHook`, `instrumentation.responseHook`, and `instrumentation.applyCustomAttributesOnSpan` no longer run for incoming request spans. Use `incomingRequestSpanHook` (on `httpIntegration`) or `onSpanCreated` (on `httpServerSpansIntegration`) instead: + +```js +// before +Sentry.httpIntegration({ + instrumentation: { + requestHook: (span, req) => { + span.setAttribute('custom', true); + }, + }, +}); + +// after +Sentry.httpIntegration({ + incomingRequestSpanHook: (span, req, res) => { + span.setAttribute('custom', true); + }, +}); +``` + +`httpIntegration`'s `instrumentation` option is still honored for **outgoing** request spans. + +### Node HTTP transport `keepAlive` defaults to `true` + +Affected SDKs: `@sentry/node` and dependents. + +The Node HTTP transport now reuses sockets by default (`keepAlive: true`). The previous default of `false` existed because of a memory leak in Node 8, which is no longer relevant (minimum Node is 20.19.0). Idle sockets are still closed after 2 seconds. Pass `keepAlive: false` in transport options to restore the previous behavior: + +```js +Sentry.init({ + dsn: '__DSN__', + transportOptions: { + keepAlive: false, + }, +}); +``` + ### `tracePropagationTargets` matching is now case-insensitive Affected SDKs: All SDKs. @@ -796,6 +837,7 @@ Sentry.init({ - (Express) The deprecated `patchExpressModule(options)` signature was removed. Use `patchExpressModule(moduleExports, getOptions)` instead. - The `@sentry/node-core/light/otlp` entry point was removed, along with its optional `@opentelemetry/exporter-trace-otlp-http` peer dependency. `otlpIntegration` is now exported directly from every server-side SDK, so `Sentry.otlpIntegration()` needs no extra import or install. - The `otlpIntegration` options `setupOtlpTracesExporter` and `collectorUrl` were removed, and the integration no longer sets up a span exporter, span processor, or tracer provider. Configure your own exporter and point it at `Sentry.getOtlpTracesEndpoint(dsn)`, or at your collector's URL if you route through one. See [Connecting Sentry to your OpenTelemetry traces](#connecting-sentry-to-your-opentelemetry-traces). +- The deprecated `httpServerSpansIntegration` `instrumentation.{requestHook,responseHook,applyCustomAttributesOnSpan}` option was removed. Use `onSpanCreated`, or `httpIntegration({ incomingRequestSpanHook })`, to mutate incoming request spans. ### `@sentry/cloudflare` diff --git a/packages/node/src/integrations/http/SentryHttpInstrumentation.ts b/packages/node/src/integrations/http/SentryHttpInstrumentation.ts index e3d3b2ea6768..cbf60b720abd 100644 --- a/packages/node/src/integrations/http/SentryHttpInstrumentation.ts +++ b/packages/node/src/integrations/http/SentryHttpInstrumentation.ts @@ -198,8 +198,7 @@ export function instrumentHttpOutgoingRequests( let _currentListener: ChannelListener | undefined; function instrumentHttpOutgoingRequestsViaChannel(options: HttpInstrumentationOptions): void { const { [HTTP_ON_CLIENT_REQUEST]: onHttpClientRequestCreated } = getHttpClientSubscriptions(options); - // If it was previously subscribed, first unsubscribe it - // TODO(v11): We can likely remove this when we drop preload support + // Replace a previous subscription so a later call does not stack duplicate listeners. if (_currentListener) { unsubscribe(HTTP_ON_CLIENT_REQUEST, _currentListener); } diff --git a/packages/node/src/integrations/http/httpServerSpansIntegration.ts b/packages/node/src/integrations/http/httpServerSpansIntegration.ts index cf0bd2846420..dad376ce65d9 100644 --- a/packages/node/src/integrations/http/httpServerSpansIntegration.ts +++ b/packages/node/src/integrations/http/httpServerSpansIntegration.ts @@ -26,7 +26,6 @@ import { } from '@sentry/conventions/attributes'; import type { Event, - HttpClientRequest, HttpIncomingMessage, HttpServerResponse, Integration, @@ -93,19 +92,6 @@ export interface HttpServerSpansIntegrationOptions { */ ignoreStatusCodes?: (number | [number, number])[]; - /** - * @deprecated This is deprecated in favor of `incomingRequestSpanHook`. - */ - instrumentation?: { - requestHook?: (span: Span, req: HttpClientRequest | HttpIncomingMessage) => void; - responseHook?: (span: Span, response: HttpIncomingMessage | HttpServerResponse) => void; - applyCustomAttributesOnSpan?: ( - span: Span, - request: HttpClientRequest | HttpIncomingMessage, - response: HttpIncomingMessage | HttpServerResponse, - ) => void; - }; - /** * 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. @@ -124,8 +110,6 @@ const _httpServerSpansIntegration = ((options: HttpServerSpansIntegrationOptions ]; const { onSpanCreated } = options; - // eslint-disable-next-line typescript/no-deprecated - const { requestHook, responseHook, applyCustomAttributesOnSpan } = options.instrumentation ?? {}; return { name: INTEGRATION_NAME, @@ -203,10 +187,6 @@ const _httpServerSpansIntegration = ((options: HttpServerSpansIntegrationOptions }, }); - // TODO v11: Remove the following three hooks, only onSpanCreated should remain - requestHook?.(span, request); - responseHook?.(span, response); - applyCustomAttributesOnSpan?.(span, request, response); onSpanCreated?.(span, request, response); return withActiveSpan(span, () => { diff --git a/packages/node/src/integrations/http/index.ts b/packages/node/src/integrations/http/index.ts index 6f5bd9094181..36b71950c721 100644 --- a/packages/node/src/integrations/http/index.ts +++ b/packages/node/src/integrations/http/index.ts @@ -141,7 +141,8 @@ interface HttpOptions { disableIncomingRequestSpans?: boolean; /** - * Additional instrumentation options that are passed to the underlying HttpInstrumentation. + * Hooks for outgoing HTTP request spans. + * These no longer run for incoming request spans; use `incomingRequestSpanHook` for those. */ instrumentation?: { requestHook?: (span: Span, req: HttpIncomingMessage | HttpClientRequest) => void; @@ -174,8 +175,6 @@ export const httpIntegration = defineIntegration((options: HttpOptions = {}) => ignoreIncomingRequests: options.ignoreIncomingRequests, ignoreStaticAssets: options.ignoreStaticAssets, ignoreStatusCodes: options.dropSpansForIncomingRequestStatusCodes, - // oxlint-disable-next-line typescript/no-deprecated -- pass through the deprecated option for back-compat - instrumentation: options.instrumentation, onSpanCreated: options.incomingRequestSpanHook, }; diff --git a/packages/node/src/transports/http.ts b/packages/node/src/transports/http.ts index f4fae1be057d..43990b9597a3 100644 --- a/packages/node/src/transports/http.ts +++ b/packages/node/src/transports/http.ts @@ -20,7 +20,7 @@ export interface NodeTransportOptions extends BaseTransportOptions { caCerts?: string | Buffer | Array; /** Custom HTTP module. Defaults to the native 'http' and 'https' modules. */ httpModule?: HTTPModule; - /** Allow overriding connection keepAlive, defaults to false */ + /** Allow overriding connection keepAlive, defaults to true */ keepAlive?: boolean; } @@ -68,10 +68,7 @@ export function makeNodeTransport(options: NodeTransportOptions): Transport { ); const nativeHttpModule = isHttps ? https : http; - const keepAlive = options.keepAlive === undefined ? false : options.keepAlive; - - // TODO(v11): Evaluate if we can set keepAlive to true. This would involve testing for memory leaks in older node - // versions(>= 8) as they had memory leaks when using it: #2555 + const keepAlive = options.keepAlive ?? true; const agent = proxy ? (new HttpsProxyAgent(proxy) as http.Agent) : new nativeHttpModule.Agent({ keepAlive, maxSockets: 30, timeout: 2000 }); diff --git a/packages/node/test/transports/http.test.ts b/packages/node/test/transports/http.test.ts index 5b7a93db24d5..c058d4e807bb 100644 --- a/packages/node/test/transports/http.test.ts +++ b/packages/node/test/transports/http.test.ts @@ -7,6 +7,7 @@ import { serializeEnvelope, } from '@sentry/core'; import * as http from 'http'; +import * as nodeHttp from 'node:http'; import { afterEach, describe, expect, it, type Mock, vi } from 'vitest'; import { createGunzip } from 'zlib'; import * as httpProxyAgent from '../../src/proxy'; @@ -117,7 +118,7 @@ describe('makeNewHttpTransport()', () => { await transport.send(EVENT_ENVELOPE); }); - it('allows overriding keepAlive', async () => { + it('uses keepAlive by default', async () => { await setupTestServer({ statusCode: SUCCESS }, req => { expect(req.headers).toEqual( expect.objectContaining({ @@ -127,10 +128,24 @@ describe('makeNewHttpTransport()', () => { ); }); - const transport = makeNodeTransport({ keepAlive: true, ...defaultOptions }); + const transport = makeNodeTransport(defaultOptions); await transport.send(EVENT_ENVELOPE); }); + it('allows disabling keepAlive', () => { + const AgentSpy = vi.spyOn(nodeHttp, 'Agent'); + + try { + makeNodeTransport({ keepAlive: false, ...defaultOptions }); + + expect(AgentSpy).toHaveBeenCalledWith( + expect.objectContaining({ keepAlive: false, maxSockets: 30, timeout: 2000 }), + ); + } finally { + AgentSpy.mockRestore(); + } + }); + it('should correctly send user-provided headers to server', async () => { await setupTestServer({ statusCode: SUCCESS }, req => { expect(req.headers).toEqual( diff --git a/packages/nuxt/src/server/sdk.ts b/packages/nuxt/src/server/sdk.ts index 25d5f4772772..7ff099f291e9 100644 --- a/packages/nuxt/src/server/sdk.ts +++ b/packages/nuxt/src/server/sdk.ts @@ -106,14 +106,12 @@ function getNuxtDefaultIntegrations(options: NodeOptions): Integration[] { ...getDefaultNodeIntegrations(options).filter(integration => integration.name !== 'Http'), // The httpIntegration is added as defaultIntegration, so users can still overwrite it httpIntegration({ - instrumentation: { - responseHook: () => { - // Flush eagerly on serverless platforms, where the function may be frozen before the transport - // sends, handing the flush to a platform `waitUntil` where one exists so it doesn't block. On a - // long-running server this is a no-op, so pending outcomes keep aggregating on the flush interval - // instead of shipping one client_report envelope per response. - void flushIfServerless(); - }, + incomingRequestSpanHook: () => { + // Flush eagerly on serverless platforms, where the function may be frozen before the transport + // sends, handing the flush to a platform `waitUntil` where one exists so it doesn't block. On a + // long-running server this is a no-op, so pending outcomes keep aggregating on the flush interval + // instead of shipping one client_report envelope per response. + void flushIfServerless(); }, }), ]; From bcbc98423480a090bf5f18a4e9f9cef93425ee4b Mon Sep 17 00:00:00 2001 From: RulaKhaled Date: Thu, 13 Aug 2026 11:42:23 +0200 Subject: [PATCH 2/5] chore: Link changelog entry to #23396 --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 65ce5437ebc9..4dde4bed83ad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,7 +13,7 @@ Work in this release was contributed by @psh4607, @thijsw, @trinitiwowka, @nehap - `DenoMongoose` => `Mongoose` - `DenoMysql` => `Mysql` - `DenoPostgres` => `Postgres` -- ref(node)!: Remove legacy incoming HTTP span hooks and default the HTTP transport `keepAlive` to `true` +- ref(node)!: Remove legacy incoming HTTP span hooks and default the HTTP transport `keepAlive` to `true` ([#23396](https://github.com/getsentry/sentry-javascript/pull/23396)) ## 10.67.0 From 044bf40dcba687f05ea9c27ff9a7ae0c7522d236 Mon Sep 17 00:00:00 2001 From: RulaKhaled Date: Fri, 14 Aug 2026 13:21:36 +0200 Subject: [PATCH 3/5] chore: Keep HTTP v11 migration notes only in v11-end-state.md CHANGELOG and root MIGRATION.md are maintained separately; the breaking-change write-up for this PR lives in docs/migration/v11-end-state.md. --- CHANGELOG.md | 1 - MIGRATION.md | 42 ------------------------------------------ 2 files changed, 43 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4dde4bed83ad..94ce3adf042a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,7 +13,6 @@ Work in this release was contributed by @psh4607, @thijsw, @trinitiwowka, @nehap - `DenoMongoose` => `Mongoose` - `DenoMysql` => `Mysql` - `DenoPostgres` => `Postgres` -- ref(node)!: Remove legacy incoming HTTP span hooks and default the HTTP transport `keepAlive` to `true` ([#23396](https://github.com/getsentry/sentry-javascript/pull/23396)) ## 10.67.0 diff --git a/MIGRATION.md b/MIGRATION.md index 226c956f0ebb..ab1b120a4bbc 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -518,47 +518,6 @@ Two consequences to be aware of when upgrading: - **Issue grouping:** Grouping in Sentry differs for events with and without stack traces, so you may see new issue groups after upgrading. - **Release health:** Events with a stack trace are counted as errors, so a `captureMessage` call (including messages emitted by `captureConsoleIntegration`) now marks the current session as _errored_. This affects errored-session counts but does **not** mark sessions as crashed, so crash-free session rate is unaffected. If you use `captureMessage` for purely informational output, consider using Sentry Logs instead, which is better suited and does not affect release health. -### Incoming HTTP span hooks moved to `incomingRequestSpanHook` - -Affected SDKs: `@sentry/node` and dependents. - -The deprecated `httpIntegration` / `httpServerSpansIntegration` hooks `instrumentation.requestHook`, `instrumentation.responseHook`, and `instrumentation.applyCustomAttributesOnSpan` no longer run for incoming request spans. Use `incomingRequestSpanHook` (on `httpIntegration`) or `onSpanCreated` (on `httpServerSpansIntegration`) instead: - -```js -// before -Sentry.httpIntegration({ - instrumentation: { - requestHook: (span, req) => { - span.setAttribute('custom', true); - }, - }, -}); - -// after -Sentry.httpIntegration({ - incomingRequestSpanHook: (span, req, res) => { - span.setAttribute('custom', true); - }, -}); -``` - -`httpIntegration`'s `instrumentation` option is still honored for **outgoing** request spans. - -### Node HTTP transport `keepAlive` defaults to `true` - -Affected SDKs: `@sentry/node` and dependents. - -The Node HTTP transport now reuses sockets by default (`keepAlive: true`). The previous default of `false` existed because of a memory leak in Node 8, which is no longer relevant (minimum Node is 20.19.0). Idle sockets are still closed after 2 seconds. Pass `keepAlive: false` in transport options to restore the previous behavior: - -```js -Sentry.init({ - dsn: '__DSN__', - transportOptions: { - keepAlive: false, - }, -}); -``` - ### Span attribute changes Affected SDKs: All SDKs. @@ -849,7 +808,6 @@ Sentry.init({ - (Express) The deprecated `patchExpressModule(options)` signature was removed. Use `patchExpressModule(moduleExports, getOptions)` instead. - The `@sentry/node-core/light/otlp` entry point was removed, along with its optional `@opentelemetry/exporter-trace-otlp-http` peer dependency. `otlpIntegration` is now exported directly from every server-side SDK, so `Sentry.otlpIntegration()` needs no extra import or install. - The `otlpIntegration` options `setupOtlpTracesExporter` and `collectorUrl` were removed, and the integration no longer sets up a span exporter, span processor, or tracer provider. Configure your own exporter and point it at `Sentry.getOtlpTracesEndpoint(dsn)`, or at your collector's URL if you route through one. See [Connecting Sentry to your OpenTelemetry traces](#connecting-sentry-to-your-opentelemetry-traces). -- The deprecated `httpServerSpansIntegration` `instrumentation.{requestHook,responseHook,applyCustomAttributesOnSpan}` option was removed. Use `onSpanCreated`, or `httpIntegration({ incomingRequestSpanHook })`, to mutate incoming request spans. ### `@sentry/cloudflare` From 00722fefe649f7b770ffdbeb0e4236d00546c889 Mon Sep 17 00:00:00 2001 From: RulaKhaled Date: Wed, 19 Aug 2026 10:32:41 +0200 Subject: [PATCH 4/5] fix(node): Retry keepAlive sends on ECONNRESET and drop unused HTTP unsubscribe Keep-alive sockets can die while a serverless isolate is frozen; retry once when Node reports a reused socket reset. setupOnce is unique by name, so the diagnostics-channel unsubscribe is unused. Co-Authored-By: Cursor Grok 4.6 --- .../http/SentryHttpInstrumentation.ts | 11 +- packages/node/src/transports/http.ts | 126 ++++++++++-------- packages/node/test/transports/http.test.ts | 102 ++++++++++++++ 3 files changed, 172 insertions(+), 67 deletions(-) diff --git a/packages/node/src/integrations/http/SentryHttpInstrumentation.ts b/packages/node/src/integrations/http/SentryHttpInstrumentation.ts index cbf60b720abd..773b87cc33f7 100644 --- a/packages/node/src/integrations/http/SentryHttpInstrumentation.ts +++ b/packages/node/src/integrations/http/SentryHttpInstrumentation.ts @@ -1,5 +1,4 @@ -import type { ChannelListener } from 'node:diagnostics_channel'; -import { subscribe, unsubscribe } from 'node:diagnostics_channel'; +import { subscribe } from 'node:diagnostics_channel'; import { context, trace } from '@opentelemetry/api'; import type { ClientRequest, IncomingMessage, ServerResponse } from 'node:http'; import type { HttpClientRequest, HttpIncomingMessage, HttpInstrumentationOptions, Span } from '@sentry/core'; @@ -137,7 +136,6 @@ export type SentryHttpInstrumentationOptions = OutgoingHttpRequestInstrumentatio * It uses the diagnostics channel if available, otherwise it falls back to monkey-patching. * * The instrumentation will start spans, create breadcrumbs, and propagate trace headers in outgoing requests (depending on the settings). - * This can be called multiple times, where the last invocation will have the current options. * * @TODO Cleanup options in v11 */ @@ -195,16 +193,9 @@ export function instrumentHttpOutgoingRequests( } } -let _currentListener: ChannelListener | undefined; function instrumentHttpOutgoingRequestsViaChannel(options: HttpInstrumentationOptions): void { const { [HTTP_ON_CLIENT_REQUEST]: onHttpClientRequestCreated } = getHttpClientSubscriptions(options); - // Replace a previous subscription so a later call does not stack duplicate listeners. - if (_currentListener) { - unsubscribe(HTTP_ON_CLIENT_REQUEST, _currentListener); - } - subscribe(HTTP_ON_CLIENT_REQUEST, onHttpClientRequestCreated); - _currentListener = onHttpClientRequestCreated; } /** diff --git a/packages/node/src/transports/http.ts b/packages/node/src/transports/http.ts index 43990b9597a3..a6055fb1167e 100644 --- a/packages/node/src/transports/http.ts +++ b/packages/node/src/transports/http.ts @@ -110,63 +110,75 @@ function createRequestExecutor( ): TransportRequestExecutor { const { hostname, pathname, port, protocol, search } = new URL(options.url); return function makeRequest(request: TransportRequest): Promise { - return new Promise((resolve, reject) => { - // This ensures we do not generate any spans in OpenTelemetry for the transport - suppressTracing(() => { - let body = streamFromBody(request.body); - - const headers: Record = { ...options.headers }; - - if (request.body.length > GZIP_THRESHOLD) { - headers['content-encoding'] = 'gzip'; - body = body.pipe(createGzip()); - } - - const hostnameIsIPv6 = hostname.startsWith('['); - - const req = httpModule.request( - { - method: 'POST', - agent, - headers, - // Remove "[" and "]" from IPv6 hostnames - hostname: hostnameIsIPv6 ? hostname.slice(1, -1) : hostname, - path: `${pathname}${search}`, - port, - protocol, - ca: options.caCerts, - }, - res => { - res.on('data', () => { - // Drain socket - }); - - res.on('end', () => { - // Drain socket - }); - - res.setEncoding('utf8'); - - // "Key-value pairs of header names and values. Header names are lower-cased." - // https://nodejs.org/api/http.html#http_message_headers - const retryAfterHeader = res.headers['retry-after'] ?? null; - const rateLimitsHeader = res.headers['x-sentry-rate-limits'] ?? null; - - resolve({ - statusCode: res.statusCode, - headers: { - 'retry-after': retryAfterHeader, - 'x-sentry-rate-limits': Array.isArray(rateLimitsHeader) - ? rateLimitsHeader[0] || null - : rateLimitsHeader, - }, - }); - }, - ); - - req.on('error', reject); - body.pipe(req); + const sendRequest = (canRetry: boolean): Promise => + new Promise((resolve, reject) => { + // This ensures we do not generate any spans in OpenTelemetry for the transport + suppressTracing(() => { + // Recreate the body on each attempt so a retry is not piping a consumed stream. + let body = streamFromBody(request.body); + + const headers: Record = { ...options.headers }; + + if (request.body.length > GZIP_THRESHOLD) { + headers['content-encoding'] = 'gzip'; + body = body.pipe(createGzip()); + } + + const hostnameIsIPv6 = hostname.startsWith('['); + + const req = httpModule.request( + { + method: 'POST', + agent, + headers, + // Remove "[" and "]" from IPv6 hostnames + hostname: hostnameIsIPv6 ? hostname.slice(1, -1) : hostname, + path: `${pathname}${search}`, + port, + protocol, + ca: options.caCerts, + }, + res => { + res.on('data', () => { + // Drain socket + }); + + res.on('end', () => { + // Drain socket + }); + + res.setEncoding('utf8'); + + // "Key-value pairs of header names and values. Header names are lower-cased." + // https://nodejs.org/api/http.html#http_message_headers + const retryAfterHeader = res.headers['retry-after'] ?? null; + const rateLimitsHeader = res.headers['x-sentry-rate-limits'] ?? null; + + resolve({ + statusCode: res.statusCode, + headers: { + 'retry-after': retryAfterHeader, + 'x-sentry-rate-limits': Array.isArray(rateLimitsHeader) + ? rateLimitsHeader[0] || null + : rateLimitsHeader, + }, + }); + }, + ); + + req.on('error', error => { + // Keep-alive sockets can go stale while a serverless isolate is frozen. + // Node recommends a single retry when the reused socket resets. + if (canRetry && req.reusedSocket && (error as NodeJS.ErrnoException).code === 'ECONNRESET') { + resolve(sendRequest(false)); + } else { + reject(error); + } + }); + body.pipe(req); + }); }); - }); + + return sendRequest(true); }; } diff --git a/packages/node/test/transports/http.test.ts b/packages/node/test/transports/http.test.ts index c058d4e807bb..081d1a1d8e07 100644 --- a/packages/node/test/transports/http.test.ts +++ b/packages/node/test/transports/http.test.ts @@ -6,12 +6,16 @@ import { createTransport, serializeEnvelope, } from '@sentry/core'; +import { EventEmitter } from 'node:events'; import * as http from 'http'; +import type { ClientRequest } from 'node:http'; import * as nodeHttp from 'node:http'; +import { Writable } from 'node:stream'; import { afterEach, describe, expect, it, type Mock, vi } from 'vitest'; import { createGunzip } from 'zlib'; import * as httpProxyAgent from '../../src/proxy'; import { makeNodeTransport } from '../../src/transports'; +import type { HTTPModule, HTTPModuleRequestIncomingMessage } from '../../src/transports/http-module'; vi.mock('@sentry/core', async () => { const actualCore = await vi.importActual('@sentry/core'); @@ -90,6 +94,52 @@ const defaultOptions = { recordDroppedEvent: () => undefined, }; +interface MockHttpRequestBehavior { + reusedSocket?: boolean; + errorCode?: string; + statusCode?: number; +} + +function createMockHttpModule(behaviors: MockHttpRequestBehavior[]): { + httpModule: HTTPModule; + getRequestCount: () => number; +} { + let requestCount = 0; + + return { + getRequestCount: () => requestCount, + httpModule: { + request(_options, callback) { + const behavior = behaviors[requestCount] ?? {}; + requestCount += 1; + + const req = new Writable({ + write(_chunk, _encoding, cb) { + cb(); + }, + }); + Object.defineProperty(req, 'reusedSocket', { value: behavior.reusedSocket ?? false }); + + queueMicrotask(() => { + if (behavior.errorCode) { + req.emit('error', Object.assign(new Error(behavior.errorCode), { code: behavior.errorCode })); + return; + } + + const res = new EventEmitter() as EventEmitter & HTTPModuleRequestIncomingMessage; + res.headers = {}; + res.statusCode = behavior.statusCode ?? SUCCESS; + res.setEncoding = () => undefined; + callback?.(res); + res.emit('end'); + }); + + return req as unknown as ClientRequest; + }, + }, + }; +} + // empty function to keep test output clean const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); @@ -146,6 +196,58 @@ describe('makeNewHttpTransport()', () => { } }); + it('retries once when a reused keepAlive socket resets with ECONNRESET', async () => { + const { httpModule, getRequestCount } = createMockHttpModule([ + { reusedSocket: true, errorCode: 'ECONNRESET' }, + { statusCode: SUCCESS }, + ]); + + const transport = makeNodeTransport({ ...defaultOptions, httpModule }); + + await expect(transport.send(EVENT_ENVELOPE)).resolves.toEqual({ + statusCode: SUCCESS, + headers: { + 'retry-after': null, + 'x-sentry-rate-limits': null, + }, + }); + expect(getRequestCount()).toBe(2); + }); + + it('rejects if the retry also fails with ECONNRESET', async () => { + const { httpModule, getRequestCount } = createMockHttpModule([ + { reusedSocket: true, errorCode: 'ECONNRESET' }, + { reusedSocket: true, errorCode: 'ECONNRESET' }, + ]); + + const transport = makeNodeTransport({ ...defaultOptions, httpModule }); + + await expect(transport.send(EVENT_ENVELOPE)).rejects.toHaveProperty('code', 'ECONNRESET'); + expect(getRequestCount()).toBe(2); + }); + + it('does not retry ECONNRESET when the socket was not reused', async () => { + const { httpModule, getRequestCount } = createMockHttpModule([ + { reusedSocket: false, errorCode: 'ECONNRESET' }, + ]); + + const transport = makeNodeTransport({ ...defaultOptions, httpModule }); + + await expect(transport.send(EVENT_ENVELOPE)).rejects.toHaveProperty('code', 'ECONNRESET'); + expect(getRequestCount()).toBe(1); + }); + + it('does not retry a reused socket on a non-ECONNRESET error', async () => { + const { httpModule, getRequestCount } = createMockHttpModule([ + { reusedSocket: true, errorCode: 'ECONNREFUSED' }, + ]); + + const transport = makeNodeTransport({ ...defaultOptions, httpModule }); + + await expect(transport.send(EVENT_ENVELOPE)).rejects.toHaveProperty('code', 'ECONNREFUSED'); + expect(getRequestCount()).toBe(1); + }); + it('should correctly send user-provided headers to server', async () => { await setupTestServer({ statusCode: SUCCESS }, req => { expect(req.headers).toEqual( From 8e48326ce6a9a9db4650b06c4ec66bf53771f219 Mon Sep 17 00:00:00 2001 From: RulaKhaled Date: Wed, 19 Aug 2026 11:04:07 +0200 Subject: [PATCH 5/5] chore: Format HTTP transport retry tests --- packages/node/test/transports/http.test.ts | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/packages/node/test/transports/http.test.ts b/packages/node/test/transports/http.test.ts index 081d1a1d8e07..4c2db2b796ba 100644 --- a/packages/node/test/transports/http.test.ts +++ b/packages/node/test/transports/http.test.ts @@ -227,9 +227,7 @@ describe('makeNewHttpTransport()', () => { }); it('does not retry ECONNRESET when the socket was not reused', async () => { - const { httpModule, getRequestCount } = createMockHttpModule([ - { reusedSocket: false, errorCode: 'ECONNRESET' }, - ]); + const { httpModule, getRequestCount } = createMockHttpModule([{ reusedSocket: false, errorCode: 'ECONNRESET' }]); const transport = makeNodeTransport({ ...defaultOptions, httpModule }); @@ -238,9 +236,7 @@ describe('makeNewHttpTransport()', () => { }); it('does not retry a reused socket on a non-ECONNRESET error', async () => { - const { httpModule, getRequestCount } = createMockHttpModule([ - { reusedSocket: true, errorCode: 'ECONNREFUSED' }, - ]); + const { httpModule, getRequestCount } = createMockHttpModule([{ reusedSocket: true, errorCode: 'ECONNREFUSED' }]); const transport = makeNodeTransport({ ...defaultOptions, httpModule });