Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
- Notifications
You must be signed in to change notification settings - Fork 1.8k
ref(node): Refactor http instrumentation away from OTEL#21974
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Uh oh!
There was an error while loading. Please reload this page.
Changes from all commits
1d0540061d5d684deef679059e69afff6ab899aea6037d2ead8278e828a1f769b55092b29e837d0781b5708fa18214dec2File filter
Filter by extension
Conversations
Uh oh!
There was an error while loading. Please reload this page.
Jump to
Uh oh!
There was an error while loading. Please reload this page.
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,9 @@ | ||
| import * as Sentry from '@sentry/node'; | ||
| import { loggingTransport } from '@sentry-internal/node-integration-tests'; | ||
| Sentry.init({ | ||
| dsn: 'https://public@dsn.ingest.sentry.io/1337', | ||
| release: '1.0', | ||
| tracesSampleRate: 1.0, | ||
| transport: loggingTransport, | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,30 @@ | ||
| import * as Sentry from '@sentry/node'; | ||
| import * as http from 'http'; | ||
| // An agent that only allows a single concurrent socket. The second request is | ||
| // queued behind the first, so its socket is not assigned until after the first | ||
| // request finishes and its headers have already been serialized via | ||
| // `_storeHeader`. Trace-propagation headers must still be injected in this case. | ||
| const agent = new http.Agent({ maxSockets: 1, keepAlive: false }); | ||
| Sentry.startSpan({ name: 'test_span' }, async () => { | ||
| await Promise.all([ | ||
| makeHttpRequest(`${process.env.SERVER_URL}/api/request-1`), | ||
| makeHttpRequest(`${process.env.SERVER_URL}/api/request-2`), | ||
| ]); | ||
| }); | ||
| function makeHttpRequest(url) { | ||
| return new Promise(resolve => { | ||
| http | ||
| .request(url, { agent, headers: { connection: 'close' } }, httpRes => { | ||
| httpRes.on('data', () => { | ||
| // we don't care about data | ||
| }); | ||
| httpRes.on('end', () => { | ||
| resolve(); | ||
| }); | ||
| }) | ||
| .end(); | ||
| }); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,33 @@ | ||
| import { createTestServer } from '@sentry-internal/test-utils'; | ||
| import { describe, expect } from 'vitest'; | ||
| import { createEsmAndCjsTests } from '../../../../utils/runner'; | ||
| describe('outgoing http with maxed-out agent sockets', () => { | ||
| createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument.mjs', (createRunner, test) => { | ||
| test('injects trace headers into requests queued behind a busy socket', async () => { | ||
| expect.assertions(5); | ||
| const [SERVER_URL, closeTestServer] = await createTestServer() | ||
| .get('/api/request-1', headers => { | ||
| expect(headers['sentry-trace']).toEqual(expect.stringMatching(/^([a-f\d]{32})-([a-f\d]{16})-1$/)); | ||
| expect(headers['baggage']).toEqual(expect.any(String)); | ||
| }) | ||
| .get('/api/request-2', headers => { | ||
| expect(headers['sentry-trace']).toEqual(expect.stringMatching(/^([a-f\d]{32})-([a-f\d]{16})-1$/)); | ||
| expect(headers['baggage']).toEqual(expect.any(String)); | ||
| }) | ||
| .start(); | ||
| await createRunner() | ||
| .withEnv({ SERVER_URL }) | ||
| .expect({ | ||
| transaction: { | ||
| // we're not too concerned with the actual transaction here since this is tested elsewhere | ||
| }, | ||
| }) | ||
| .start() | ||
| .completed(); | ||
| closeTestServer(); | ||
| }); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -37,63 +37,84 @@ import type { HttpExport, HttpModuleExport, HttpInstrumentationOptions, HttpClie | ||
| import { getOriginalFunction, wrapMethod } from '../../utils/object'; | ||
| import { getHttpClientSubscriptions } from './client-subscriptions'; | ||
| function patchHttpRequest(httpModule: HttpExport, options: HttpInstrumentationOptions): void { | ||
| // avoid double-wrap | ||
| if (!getOriginalFunction(httpModule.request)) { | ||
| const { [HTTP_ON_CLIENT_REQUEST]: onHttpClientRequestCreated } = getHttpClientSubscriptions({ | ||
| ...options, | ||
| http: httpModule, | ||
| }); | ||
| let onHttpClientRequestCreated: ReturnType<typeof getHttpClientSubscriptions>[typeof HTTP_ON_CLIENT_REQUEST]; | ||
| const originalRequest = httpModule.request; | ||
| wrapMethod(httpModule, 'request', function patchedRequest(this: HttpExport, ...args: unknown[]) { | ||
| const request = originalRequest.apply(this, args) as HttpClientRequest; | ||
| onHttpClientRequestCreated({ request }, HTTP_ON_CLIENT_REQUEST); | ||
| return request; | ||
| }); | ||
| /** | ||
| * Patch `ClientRequest.prototype._storeHeader` so that every outgoing request | ||
| * is routed through our instrumentation. | ||
| * | ||
| * We deliberately patch the shared `ClientRequest` prototype rather than the | ||
| * module's `request`/`get` exports. Every outgoing request — no matter how the | ||
| * module was imported (`require('node:http')`, `import http from 'node:http'`, | ||
| * or `import * as http from 'node:http'`) — ultimately constructs a | ||
| * `ClientRequest` and serializes its headers through `_storeHeader` on this one | ||
| * prototype. ES module namespace bindings (`import * as http` / `import | ||
| * { request }`) are immutable snapshots that cannot be monkey-patched at all, | ||
| * but the prototype is a shared, mutable object, so patching it reaches those | ||
| * consumers too. | ||
| * | ||
| * `_storeHeader` is the method that renders the outgoing header block into | ||
| * `request._header`. We run *before* the original, which is the last moment a | ||
| * header can still be added via `setHeader` (afterwards `request._header` is | ||
| * set and any `setHeader` call throws `ERR_HTTP_HEADERS_SENT`). It runs | ||
| * synchronously in the caller's async context — during `request.end()` / | ||
| * `request.write()` — so spans are parented correctly, and is invoked exactly | ||
| * once per request whether the headers are being sent immediately or buffered | ||
| * while a socket is assigned. | ||
| * | ||
| * We intentionally do *not* hook `onSocket`: it does not fire until a socket is | ||
| * assigned to the request, which for an `Agent` with all sockets busy (e.g. | ||
| * `maxSockets: 1`) happens only *after* the request's headers have already been | ||
| * serialized — far too late to inject trace-propagation headers. | ||
| * | ||
| * `https` requests reuse `http`'s `ClientRequest`, so patching `http` covers | ||
| ||
| * both; the `https` module does not expose its own `ClientRequest` and is a | ||
| * no-op here. | ||
| */ | ||
| function patchClientRequest(httpModule: HttpExport, options: HttpInstrumentationOptions): void { | ||
| const proto = httpModule.ClientRequest?.prototype; | ||
| // Nothing to patch if the module doesn't expose `ClientRequest` (e.g. `https`) | ||
| if (typeof proto?._storeHeader !== 'function') { | ||
| return; | ||
| } | ||
| } | ||
| // This simply ensures that http.get calls http.request, which we patched. | ||
| // Call it from the object each time, to ensure that any subsequent patches | ||
| // or other mutations are also respected. | ||
| function patchHttpGet(httpModule: HttpExport) { | ||
| if (!getOriginalFunction(httpModule.get)) { | ||
| // match node's normalization to exactly 3 arguments. | ||
| wrapMethod(httpModule, 'get', function patchedGet(this: HttpExport, input: unknown, options: unknown, cb: unknown) { | ||
| // http.get is like http.request but automatically calls .end() | ||
| const request = httpModule.request.call(this, input, options, cb) as HttpClientRequest; | ||
| request.end(); | ||
| return request; | ||
| }); | ||
| const subscriptions = getHttpClientSubscriptions({ | ||
| ...options, | ||
| http: httpModule, | ||
| }); | ||
| onHttpClientRequestCreated = subscriptions[HTTP_ON_CLIENT_REQUEST]; | ||
| // This means it was already wrapped, we just update onHttpClientRequestCreated and then stop | ||
| // future calls will pick up the new function | ||
| if (getOriginalFunction(proto._storeHeader)) { | ||
| return; | ||
| } | ||
| const originalStoreHeader = proto._storeHeader; | ||
| wrapMethod(proto, '_storeHeader', function patchedStoreHeader(this: HttpClientRequest, ...args: unknown[]) { | ||
| // Never let instrumentation errors break the underlying request. | ||
| try { | ||
| onHttpClientRequestCreated({ request: this }, HTTP_ON_CLIENT_REQUEST); | ||
| } catch { | ||
| // ignore | ||
| } | ||
| return originalStoreHeader.apply(this, args); | ||
sentry[bot] marked this conversation as resolved.
Uh oh!There was an error while loading. Please reload this page. | ||
| }); | ||
| } | ||
| function patchModule(httpModuleExport: HttpModuleExport, options: HttpInstrumentationOptions = {}): 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); | ||
| // copy with defineProperty because these might be configured oddly | ||
| for (const method of ['get', 'request']) { | ||
| const desc = Object.getOwnPropertyDescriptor(httpDefault, method); | ||
| /* v8 ignore start - will always be set at this point */ | ||
| if (desc) { | ||
| Object.defineProperty(httpModule, method, desc); | ||
| } | ||
| /* v8 ignore stop */ | ||
| } | ||
| return httpModule; | ||
| } | ||
| patchHttpRequest(httpModule, options); | ||
| patchHttpGet(httpModule); | ||
| // Resolve to the underlying module in case we were handed an interop | ||
| // container (e.g. `{ default: http }`). Either the container or its default | ||
| // export carries the same `ClientRequest` class. | ||
| const httpModule = getDefaultExport(httpModuleExport); | ||
| patchClientRequest(httpModule, options); | ||
| return httpModuleExport; | ||
| } | ||
| /** | ||
| * Patch an `node:http` or `node:https` module-shaped export so that every | ||
| * outgoing request is tracked by Sentry. | ||
| * Patch `node:http`. This also covers `node:https` as it reuses the same `ClientRequest` class. | ||
| * | ||
| * @example | ||
| * ```javascript | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -102,12 +102,33 @@ export interface HttpIncomingMessage { | ||
| removeListener(event: string | symbol, listener: (...args: unknown[]) => void): this; | ||
| } | ||
| /** Minimal interface for a Node.js http / https module export */ | ||
| /** Minimal interface for the Node.js `http.ClientRequest` constructor. */ | ||
| export interface HttpClientRequestConstructor { | ||
| prototype: { | ||
| // The method we actually patch: it renders the outgoing header block into | ||
| // `request._header`, is called exactly once per request right before the | ||
| // headers are serialized, and is the last point at which a header can still | ||
| // be added. `https` requests go through `http`'s `ClientRequest`, so this | ||
| // is the shared choke point for all outgoing requests. `_storeHeader` is | ||
| // not part of the public `@types/node` surface, so we also reference the | ||
| // (publicly typed) `onSocket` to keep the real `node:http` `ClientRequest` | ||
| // structurally assignable to this type. | ||
| //oxlint-disable-next-line typescript/no-explicit-any | ||
| _storeHeader?: (this: HttpClientRequest, ...args: any[]) => unknown; | ||
| //oxlint-disable-next-line typescript/no-explicit-any | ||
| onSocket?: (this: HttpClientRequest, ...args: any[]) => unknown; | ||
| }; | ||
| } | ||
| /** Minimal interface for a Node.js http module export */ | ||
| export interface HttpExport { | ||
| //oxlint-disable typescript/no-explicit-any | ||
| request: (...args: any[]) => HttpClientRequest; | ||
| //oxlint-disable typescript/no-explicit-any | ||
| get: (...args: any[]) => HttpClientRequest; | ||
mydea marked this conversation as resolved.
Uh oh!There was an error while loading. Please reload this page. | ||
| // Only `http` exports this; `https` reuses `http`'s `ClientRequest`, so this | ||
| // is `undefined` on the `https` module. | ||
| ClientRequest?: HttpClientRequestConstructor; | ||
| [key: string]: unknown; | ||
| } | ||
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
q: Is this part of the PR? Pretty sure it is, just wondering why we didn't need it before. And if we need it we can remove the
disable-next-lineabove, unfortunately oxlint, doesn't complain if the rule doesn't apply 😢There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
only tangentially, noticed that this lead to squiggly lines in tests that I touched here - imho something seems not properly set up for node integration tests, need to look at this in a follow up :) and will remove the ignore line!