From 97c18d31b8f74bd2b41ee6806182cf57f310186d Mon Sep 17 00:00:00 2001 From: Francesco Novy Date: Tue, 24 Sep 2024 10:41:49 +0200 Subject: [PATCH] feat(node): Implement Sentry-specific http instrumentation --- .../node-otel-without-tracing/package.json | 1 + .../node-otel-without-tracing/src/app.ts | 9 +- .../src/instrument.ts | 7 +- .../tests/errors.test.ts | 21 ++ .../tests/transactions.test.ts | 9 +- packages/node/src/integrations/http.ts | 312 ------------------ .../http/SentryHttpInstrumentation.ts | 285 ++++++++++++++++ packages/node/src/integrations/http/index.ts | 222 +++++++++++++ packages/opentelemetry/src/index.ts | 2 +- 9 files changed, 543 insertions(+), 325 deletions(-) delete mode 100644 packages/node/src/integrations/http.ts create mode 100644 packages/node/src/integrations/http/SentryHttpInstrumentation.ts create mode 100644 packages/node/src/integrations/http/index.ts diff --git a/dev-packages/e2e-tests/test-applications/node-otel-without-tracing/package.json b/dev-packages/e2e-tests/test-applications/node-otel-without-tracing/package.json index afe666c2a8f1..ed01eff7dce2 100644 --- a/dev-packages/e2e-tests/test-applications/node-otel-without-tracing/package.json +++ b/dev-packages/e2e-tests/test-applications/node-otel-without-tracing/package.json @@ -14,6 +14,7 @@ "@opentelemetry/sdk-trace-node": "1.26.0", "@opentelemetry/exporter-trace-otlp-http": "0.53.0", "@opentelemetry/instrumentation-undici": "0.6.0", + "@opentelemetry/instrumentation-http": "0.53.0", "@opentelemetry/instrumentation": "0.53.0", "@sentry/core": "latest || *", "@sentry/node": "latest || *", diff --git a/dev-packages/e2e-tests/test-applications/node-otel-without-tracing/src/app.ts b/dev-packages/e2e-tests/test-applications/node-otel-without-tracing/src/app.ts index c3fdfb9134a5..383eaf1b4484 100644 --- a/dev-packages/e2e-tests/test-applications/node-otel-without-tracing/src/app.ts +++ b/dev-packages/e2e-tests/test-applications/node-otel-without-tracing/src/app.ts @@ -7,6 +7,8 @@ import express from 'express'; const app = express(); const port = 3030; +Sentry.setTag('root-level-tag', 'yes'); + app.get('/test-success', function (req, res) { res.send({ version: 'v1' }); }); @@ -23,8 +25,6 @@ app.get('/test-transaction', function (req, res) { await fetch('http://localhost:3030/test-success'); - await Sentry.flush(); - res.send({}); }); }); @@ -38,7 +38,10 @@ app.get('/test-error', async function (req, res) { }); app.get('/test-exception/:id', function (req, _res) { - throw new Error(`This is an exception with id ${req.params.id}`); + const id = req.params.id; + Sentry.setTag(`param-${id}`, id); + + throw new Error(`This is an exception with id ${id}`); }); Sentry.setupExpressErrorHandler(app); diff --git a/dev-packages/e2e-tests/test-applications/node-otel-without-tracing/src/instrument.ts b/dev-packages/e2e-tests/test-applications/node-otel-without-tracing/src/instrument.ts index d887696b1e73..52c4403b2164 100644 --- a/dev-packages/e2e-tests/test-applications/node-otel-without-tracing/src/instrument.ts +++ b/dev-packages/e2e-tests/test-applications/node-otel-without-tracing/src/instrument.ts @@ -1,7 +1,8 @@ +const { HttpInstrumentation } = require('@opentelemetry/instrumentation-http'); const { NodeTracerProvider, BatchSpanProcessor } = require('@opentelemetry/sdk-trace-node'); const { OTLPTraceExporter } = require('@opentelemetry/exporter-trace-otlp-http'); const Sentry = require('@sentry/node'); -const { SentrySpanProcessor, SentryPropagator } = require('@sentry/opentelemetry'); +const { SentryPropagator } = require('@sentry/opentelemetry'); const { UndiciInstrumentation } = require('@opentelemetry/instrumentation-undici'); const { registerInstrumentations } = require('@opentelemetry/instrumentation'); @@ -15,6 +16,8 @@ Sentry.init({ tunnel: `http://localhost:3031/`, // proxy server // Tracing is completely disabled + integrations: [Sentry.httpIntegration({ spans: true })], + // Custom OTEL setup skipOpenTelemetrySetup: true, }); @@ -37,5 +40,5 @@ provider.register({ }); registerInstrumentations({ - instrumentations: [new UndiciInstrumentation()], + instrumentations: [new UndiciInstrumentation(), new HttpInstrumentation()], }); diff --git a/dev-packages/e2e-tests/test-applications/node-otel-without-tracing/tests/errors.test.ts b/dev-packages/e2e-tests/test-applications/node-otel-without-tracing/tests/errors.test.ts index 28e63f02090c..1e4526a891a3 100644 --- a/dev-packages/e2e-tests/test-applications/node-otel-without-tracing/tests/errors.test.ts +++ b/dev-packages/e2e-tests/test-applications/node-otel-without-tracing/tests/errors.test.ts @@ -28,3 +28,24 @@ test('Sends correct error event', async ({ baseURL }) => { span_id: expect.any(String), }); }); + +test('Isolates requests correctly', async ({ baseURL }) => { + const errorEventPromise1 = waitForError('node-otel-without-tracing', event => { + return !event.type && event.exception?.values?.[0]?.value === 'This is an exception with id 555-a'; + }); + const errorEventPromise2 = waitForError('node-otel-without-tracing', event => { + return !event.type && event.exception?.values?.[0]?.value === 'This is an exception with id 555-b'; + }); + + fetch(`${baseURL}/test-exception/555-a`); + fetch(`${baseURL}/test-exception/555-b`); + + const errorEvent1 = await errorEventPromise1; + const errorEvent2 = await errorEventPromise2; + + expect(errorEvent1.transaction).toEqual('GET /test-exception/555-a'); + expect(errorEvent1.tags).toEqual({ 'root-level-tag': 'yes', 'param-555-a': '555-a' }); + + expect(errorEvent2.transaction).toEqual('GET /test-exception/555-b'); + expect(errorEvent2.tags).toEqual({ 'root-level-tag': 'yes', 'param-555-b': '555-b' }); +}); diff --git a/dev-packages/e2e-tests/test-applications/node-otel-without-tracing/tests/transactions.test.ts b/dev-packages/e2e-tests/test-applications/node-otel-without-tracing/tests/transactions.test.ts index 9c91a0ed9531..bb069b7e3e11 100644 --- a/dev-packages/e2e-tests/test-applications/node-otel-without-tracing/tests/transactions.test.ts +++ b/dev-packages/e2e-tests/test-applications/node-otel-without-tracing/tests/transactions.test.ts @@ -12,9 +12,7 @@ test('Sends an API route transaction to OTLP', async ({ baseURL }) => { const scopeSpans = json.resourceSpans?.[0]?.scopeSpans; - const httpScope = scopeSpans?.find( - scopeSpan => scopeSpan.scope.name === '@opentelemetry_sentry-patched/instrumentation-http', - ); + const httpScope = scopeSpans?.find(scopeSpan => scopeSpan.scope.name === '@opentelemetry/instrumentation-http'); return ( httpScope && @@ -40,9 +38,7 @@ test('Sends an API route transaction to OTLP', async ({ baseURL }) => { // But our default node-fetch spans are not emitted expect(scopeSpans.length).toEqual(2); - const httpScopes = scopeSpans?.filter( - scopeSpan => scopeSpan.scope.name === '@opentelemetry_sentry-patched/instrumentation-http', - ); + const httpScopes = scopeSpans?.filter(scopeSpan => scopeSpan.scope.name === '@opentelemetry/instrumentation-http'); const undiciScopes = scopeSpans?.filter( scopeSpan => scopeSpan.scope.name === '@opentelemetry/instrumentation-undici', ); @@ -114,7 +110,6 @@ test('Sends an API route transaction to OTLP', async ({ baseURL }) => { { key: 'net.peer.port', value: { intValue: expect.any(Number) } }, { key: 'http.status_code', value: { intValue: 200 } }, { key: 'http.status_text', value: { stringValue: 'OK' } }, - { key: 'sentry.origin', value: { stringValue: 'auto.http.otel.http' } }, ]), droppedAttributesCount: 0, events: [], diff --git a/packages/node/src/integrations/http.ts b/packages/node/src/integrations/http.ts deleted file mode 100644 index d6796aa866e5..000000000000 --- a/packages/node/src/integrations/http.ts +++ /dev/null @@ -1,312 +0,0 @@ -import type { ClientRequest, IncomingMessage, RequestOptions, ServerResponse } from 'node:http'; -import type { Span } from '@opentelemetry/api'; -import { diag } from '@opentelemetry/api'; -import { HttpInstrumentation } from '@opentelemetry/instrumentation-http'; -import { addOpenTelemetryInstrumentation } from '@sentry/opentelemetry'; - -import { - addBreadcrumb, - defineIntegration, - getCapturedScopesOnSpan, - getCurrentScope, - getIsolationScope, - setCapturedScopesOnSpan, -} from '@sentry/core'; -import { getClient } from '@sentry/opentelemetry'; -import type { IntegrationFn, SanitizedRequestData } from '@sentry/types'; - -import { - getBreadcrumbLogLevelFromHttpStatusCode, - getSanitizedUrlString, - parseUrl, - stripUrlQueryAndFragment, -} from '@sentry/utils'; -import type { NodeClient } from '../sdk/client'; -import { setIsolationScope } from '../sdk/scope'; -import type { HTTPModuleRequestIncomingMessage } from '../transports/http-module'; -import { addOriginToSpan } from '../utils/addOriginToSpan'; -import { getRequestUrl } from '../utils/getRequestUrl'; - -const INTEGRATION_NAME = 'Http'; - -const INSTRUMENTATION_NAME = '@opentelemetry_sentry-patched/instrumentation-http'; - -interface HttpOptions { - /** - * Whether breadcrumbs should be recorded for requests. - * Defaults to true - */ - breadcrumbs?: boolean; - - /** - * Do not capture spans or breadcrumbs for outgoing HTTP requests to URLs where the given callback returns `true`. - * This controls both span & breadcrumb creation - spans will be non recording if tracing is disabled. - * - * The `url` param contains the entire URL, including query string (if any), protocol, host, etc. of the outgoing request. - * For example: `'https://someService.com/users/details?id=123'` - * - * The `request` param contains the original {@type RequestOptions} object used to make the outgoing request. - * You can use it to filter on additional properties like method, headers, etc. - */ - ignoreOutgoingRequests?: (url: string, request: RequestOptions) => boolean; - - /** - * Do not capture spans or breadcrumbs for incoming HTTP requests to URLs where the given callback returns `true`. - * This controls both span & breadcrumb creation - 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: IncomingMessage) => boolean; - - /** - * 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; - applyCustomAttributesOnSpan?: ( - span: Span, - request: ClientRequest | HTTPModuleRequestIncomingMessage, - response: HTTPModuleRequestIncomingMessage | ServerResponse, - ) => void; - - /** - * You can pass any configuration through to the underlying instrumention. - * Note that there are no semver guarantees for this! - */ - _experimentalConfig?: ConstructorParameters[0]; - }; - - /** Allows to pass a custom version of HttpInstrumentation. We use this for Next.js. */ - _instrumentation?: typeof HttpInstrumentation; -} - -let _httpOptions: HttpOptions = {}; -let _httpInstrumentation: HttpInstrumentation | undefined; - -/** - * Instrument the HTTP module. - * This can only be instrumented once! If this called again later, we just update the options. - */ -export const instrumentHttp = Object.assign( - function (): void { - if (_httpInstrumentation) { - return; - } - - const _InstrumentationClass = _httpOptions._instrumentation || HttpInstrumentation; - - _httpInstrumentation = new _InstrumentationClass({ - ..._httpOptions.instrumentation?._experimentalConfig, - ignoreOutgoingRequestHook: request => { - const url = getRequestUrl(request); - - if (!url) { - return false; - } - - const _ignoreOutgoingRequests = _httpOptions.ignoreOutgoingRequests; - if (_ignoreOutgoingRequests && _ignoreOutgoingRequests(url, request)) { - return true; - } - - return false; - }, - - ignoreIncomingRequestHook: request => { - // request.url is the only property that holds any information about the url - // it only consists of the URL path and query string (if any) - const urlPath = request.url; - - const method = request.method?.toUpperCase(); - // We do not capture OPTIONS/HEAD requests as transactions - if (method === 'OPTIONS' || method === 'HEAD') { - return true; - } - - const _ignoreIncomingRequests = _httpOptions.ignoreIncomingRequests; - if (urlPath && _ignoreIncomingRequests && _ignoreIncomingRequests(urlPath, request)) { - return true; - } - - return false; - }, - - requireParentforOutgoingSpans: false, - requireParentforIncomingSpans: false, - requestHook: (span, req) => { - addOriginToSpan(span, 'auto.http.otel.http'); - - // both, incoming requests and "client" requests made within the app trigger the requestHook - // we only want to isolate and further annotate incoming requests (IncomingMessage) - if (_isClientRequest(req)) { - _httpOptions.instrumentation?.requestHook?.(span, req); - return; - } - - const scopes = getCapturedScopesOnSpan(span); - - const isolationScope = (scopes.isolationScope || getIsolationScope()).clone(); - const scope = scopes.scope || getCurrentScope(); - - // Update the isolation scope, isolate this request - isolationScope.setSDKProcessingMetadata({ request: req }); - - const client = getClient(); - if (client && client.getOptions().autoSessionTracking) { - isolationScope.setRequestSession({ status: 'ok' }); - } - setIsolationScope(isolationScope); - setCapturedScopesOnSpan(span, scope, isolationScope); - - // attempt to update the scope's `transactionName` based on the request URL - // Ideally, framework instrumentations coming after the HttpInstrumentation - // update the transactionName once we get a parameterized route. - const httpMethod = (req.method || 'GET').toUpperCase(); - const httpTarget = stripUrlQueryAndFragment(req.url || '/'); - - const bestEffortTransactionName = `${httpMethod} ${httpTarget}`; - - isolationScope.setTransactionName(bestEffortTransactionName); - - if (isKnownPrefetchRequest(req)) { - span.setAttribute('sentry.http.prefetch', true); - } - - _httpOptions.instrumentation?.requestHook?.(span, req); - }, - responseHook: (span, res) => { - const client = getClient(); - if (client && client.getOptions().autoSessionTracking) { - setImmediate(() => { - client['_captureRequestSession'](); - }); - } - - _httpOptions.instrumentation?.responseHook?.(span, res); - }, - applyCustomAttributesOnSpan: ( - span: Span, - request: ClientRequest | HTTPModuleRequestIncomingMessage, - response: HTTPModuleRequestIncomingMessage | ServerResponse, - ) => { - const _breadcrumbs = typeof _httpOptions.breadcrumbs === 'undefined' ? true : _httpOptions.breadcrumbs; - if (_breadcrumbs) { - _addRequestBreadcrumb(request, response); - } - - _httpOptions.instrumentation?.applyCustomAttributesOnSpan?.(span, request, response); - }, - }); - - // We want to update the logger namespace so we can better identify what is happening here - try { - _httpInstrumentation['_diag'] = diag.createComponentLogger({ - namespace: INSTRUMENTATION_NAME, - }); - - // @ts-expect-error This is marked as read-only, but we overwrite it anyhow - _httpInstrumentation.instrumentationName = INSTRUMENTATION_NAME; - } catch { - // ignore errors here... - } - addOpenTelemetryInstrumentation(_httpInstrumentation); - }, - { - id: INTEGRATION_NAME, - }, -); - -const _httpIntegration = ((options: HttpOptions = {}) => { - return { - name: INTEGRATION_NAME, - setupOnce() { - _httpOptions = options; - instrumentHttp(); - }, - }; -}) satisfies IntegrationFn; - -/** - * 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. - */ -export const httpIntegration = defineIntegration(_httpIntegration); - -/** Add a breadcrumb for outgoing requests. */ -function _addRequestBreadcrumb( - request: ClientRequest | HTTPModuleRequestIncomingMessage, - response: HTTPModuleRequestIncomingMessage | ServerResponse, -): void { - // Only generate breadcrumbs for outgoing requests - if (!_isClientRequest(request)) { - return; - } - - 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, - }, - ); -} - -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 {}; - } -} - -/** - * Determines if @param req is a ClientRequest, meaning the request was created within the express app - * and it's an outgoing request. - * Checking for properties instead of using `instanceOf` to avoid importing the request classes. - */ -function _isClientRequest(req: ClientRequest | HTTPModuleRequestIncomingMessage): req is ClientRequest { - return 'outputData' in req && 'outputSize' in req && !('client' in req) && !('statusCode' in req); -} - -/** - * Detects if an incoming request is a prefetch request. - */ -function isKnownPrefetchRequest(req: HTTPModuleRequestIncomingMessage): boolean { - // Currently only handles Next.js prefetch requests but may check other frameworks in the future. - return req.headers['next-router-prefetch'] === '1'; -} diff --git a/packages/node/src/integrations/http/SentryHttpInstrumentation.ts b/packages/node/src/integrations/http/SentryHttpInstrumentation.ts new file mode 100644 index 000000000000..1d256f8df8d5 --- /dev/null +++ b/packages/node/src/integrations/http/SentryHttpInstrumentation.ts @@ -0,0 +1,285 @@ +import type * as http from 'node:http'; +import type * as https from 'node:https'; +import { context } from '@opentelemetry/api'; +import { VERSION } from '@opentelemetry/core'; +import type { InstrumentationConfig } from '@opentelemetry/instrumentation'; +import { InstrumentationBase, InstrumentationNodeModuleDefinition } from '@opentelemetry/instrumentation'; +import { addBreadcrumb, getClient, getCurrentScope, getIsolationScope } from '@sentry/core'; +import { setScopesOnContext } from '@sentry/opentelemetry'; +import type { SanitizedRequestData } from '@sentry/types'; +import { + getBreadcrumbLogLevelFromHttpStatusCode, + getSanitizedUrlString, + parseUrl, + stripUrlQueryAndFragment, +} from '@sentry/utils'; +import type { NodeClient } from '../../sdk/client'; + +type Http = typeof http; +type Https = typeof https; + +type SentryHttpInstrumentationOptions = InstrumentationConfig & { + breadcrumbs?: boolean; +}; + +/** + * This custom HTTP instrumentation is used to isolate incoming requests and annotate them with additional information. + * It does not emit any spans. + * + * The reason this is isolated from the OpenTelemetry instrumentation is that users may overwrite this, + * which would lead to Sentry not working as expected. + * + * Important note: Contrary to other OTEL instrumentation, this one cannot be unwrapped. + * It only does minimal things though and does not emit any spans. + * + * This is heavily inspired & adapted from: + * https://github.com/open-telemetry/opentelemetry-js/blob/f8ab5592ddea5cba0a3b33bf8d74f27872c0367f/experimental/packages/opentelemetry-instrumentation-http/src/http.ts + */ +export class SentryHttpInstrumentation extends InstrumentationBase { + public constructor(config: SentryHttpInstrumentationOptions = {}) { + super('@sentry/instrumentation-http', VERSION, config); + } + + /** @inheritdoc */ + public init(): [InstrumentationNodeModuleDefinition, InstrumentationNodeModuleDefinition] { + return [this._getHttpsInstrumentation(), this._getHttpInstrumentation()]; + } + + /** Get the instrumentation for the http module. */ + private _getHttpInstrumentation(): InstrumentationNodeModuleDefinition { + return new InstrumentationNodeModuleDefinition( + 'http', + ['*'], + (moduleExports: Http): Http => { + // Patch incoming requests for request isolation + stealthWrap(moduleExports.Server.prototype, 'emit', this._getPatchIncomingRequestFunction()); + + // Patch outgoing requests for breadcrumbs + const patchedRequest = stealthWrap(moduleExports, 'request', this._getPatchOutgoingRequestFunction()); + stealthWrap(moduleExports, 'get', this._getPatchOutgoingGetFunction(patchedRequest)); + + return moduleExports; + }, + () => { + // no unwrap here + }, + ); + } + + /** Get the instrumentation for the https module. */ + private _getHttpsInstrumentation(): InstrumentationNodeModuleDefinition { + return new InstrumentationNodeModuleDefinition( + 'https', + ['*'], + (moduleExports: Https): Https => { + // Patch incoming requests for request isolation + stealthWrap(moduleExports.Server.prototype, 'emit', this._getPatchIncomingRequestFunction()); + + // Patch outgoing requests for breadcrumbs + const patchedRequest = stealthWrap(moduleExports, 'request', this._getPatchOutgoingRequestFunction()); + stealthWrap(moduleExports, 'get', this._getPatchOutgoingGetFunction(patchedRequest)); + + return moduleExports; + }, + () => { + // no unwrap here + }, + ); + } + + /** + * Patch the incoming request function for request isolation. + */ + private _getPatchIncomingRequestFunction(): ( + original: (event: string, ...args: unknown[]) => boolean, + ) => (this: unknown, event: string, ...args: unknown[]) => boolean { + // eslint-disable-next-line @typescript-eslint/no-this-alias + const instrumentation = this; + + return ( + original: (event: string, ...args: unknown[]) => boolean, + ): ((this: unknown, event: string, ...args: unknown[]) => boolean) => { + return function incomingRequest(this: unknown, event: string, ...args: unknown[]): boolean { + // Only traces request events + if (event !== 'request') { + return original.apply(this, [event, ...args]); + } + + instrumentation._diag.debug('http instrumentation for incoming request'); + + const request = args[0] as http.IncomingMessage; + + const isolationScope = getIsolationScope().clone(); + + // Update the isolation scope, isolate this request + isolationScope.setSDKProcessingMetadata({ request }); + + const client = getClient(); + if (client && client.getOptions().autoSessionTracking) { + isolationScope.setRequestSession({ status: 'ok' }); + } + + // attempt to update the scope's `transactionName` based on the request URL + // Ideally, framework instrumentations coming after the HttpInstrumentation + // update the transactionName once we get a parameterized route. + const httpMethod = (request.method || 'GET').toUpperCase(); + const httpTarget = stripUrlQueryAndFragment(request.url || '/'); + + const bestEffortTransactionName = `${httpMethod} ${httpTarget}`; + + isolationScope.setTransactionName(bestEffortTransactionName); + + const parentContext = context.active(); + const requestContext = setScopesOnContext(parentContext, { + scope: getCurrentScope(), + isolationScope, + }); + + return context.with(requestContext, () => { + return original.apply(this, [event, ...args]); + }); + }; + }; + } + + /** + * Patch the outgoing request function for breadcrumbs. + */ + private _getPatchOutgoingRequestFunction(): ( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + original: (...args: any[]) => http.ClientRequest, + ) => (...args: unknown[]) => http.ClientRequest { + // eslint-disable-next-line @typescript-eslint/no-this-alias + const instrumentation = this; + + return (original: (...args: unknown[]) => http.ClientRequest): ((...args: unknown[]) => http.ClientRequest) => { + return function outgoingRequest(this: unknown, ...args: unknown[]): http.ClientRequest { + instrumentation._diag.debug('http instrumentation for outgoing requests'); + + const request = original.apply(this, args) as ReturnType; + + request.prependListener('response', (response: http.IncomingMessage) => { + const breadcrumbs = instrumentation.getConfig().breadcrumbs; + const _breadcrumbs = typeof breadcrumbs === 'undefined' ? true : breadcrumbs; + if (_breadcrumbs) { + addRequestBreadcrumb(request, response); + } + }); + + return request; + }; + }; + } + + /** Path the outgoing get function for breadcrumbs. */ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + private _getPatchOutgoingGetFunction(clientRequest: (...args: any[]) => http.ClientRequest) { + return (_original: unknown): ((...args: unknown[]) => http.ClientRequest) => { + // Re-implement http.get. This needs to be done (instead of using + // getPatchOutgoingRequestFunction to patch it) because we need to + // set the trace context header before the returned http.ClientRequest is + // ended. The Node.js docs state that the only differences between + // request and get are that (1) get defaults to the HTTP GET method and + // (2) the returned request object is ended immediately. The former is + // already true (at least in supported Node versions up to v10), so we + // simply follow the latter. Ref: + // https://nodejs.org/dist/latest/docs/api/http.html#http_http_get_options_callback + // https://github.com/googleapis/cloud-trace-nodejs/blob/master/src/instrumentations/instrumentation-http.ts#L198 + return function outgoingGetRequest(...args: unknown[]): http.ClientRequest { + const req = clientRequest(...args); + req.end(); + return req; + }; + }; + } +} + +/** + * This is a minimal version of `wrap` from shimmer: + * https://github.com/othiym23/shimmer/blob/master/index.js + * + * In contrast to the original implementation, this version does not allow to unwrap, + * and does not make it clear that the method is wrapped. + * This is necessary because we want to wrap the http module with our own code, + * while still allowing to use the HttpInstrumentation from OTEL. + * + * Without this, if we'd just use `wrap` from shimmer, the OTEL instrumentation would remove our wrapping, + * because it only allows any module to be wrapped a single time. + */ +function stealthWrap( + nodule: Nodule, + name: FieldName, + wrapper: (original: Nodule[FieldName]) => Nodule[FieldName], +): Nodule[FieldName] { + const original = nodule[name]; + const wrapped = wrapper(original); + + defineProperty(nodule, name, wrapped); + return wrapped; +} + +// Sets a property on an object, preserving its enumerability. +function defineProperty( + obj: Nodule, + name: FieldName, + value: Nodule[FieldName], +): void { + const enumerable = !!obj[name] && Object.prototype.propertyIsEnumerable.call(obj, name); + + Object.defineProperty(obj, name, { + configurable: true, + enumerable: enumerable, + writable: true, + value: value, + }); +} + +/** Add a breadcrumb for outgoing requests. */ +function addRequestBreadcrumb(request: http.ClientRequest, response: http.IncomingMessage): 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, + }, + ); +} + +function getBreadcrumbData(request: http.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 {}; + } +} diff --git a/packages/node/src/integrations/http/index.ts b/packages/node/src/integrations/http/index.ts new file mode 100644 index 000000000000..414c1029333c --- /dev/null +++ b/packages/node/src/integrations/http/index.ts @@ -0,0 +1,222 @@ +import type { ClientRequest, IncomingMessage, RequestOptions, ServerResponse } from 'node:http'; +import { diag } from '@opentelemetry/api'; +import { HttpInstrumentation } from '@opentelemetry/instrumentation-http'; +import { addOpenTelemetryInstrumentation } from '@sentry/opentelemetry'; + +import { defineIntegration } from '@sentry/core'; +import { getClient } from '@sentry/opentelemetry'; +import type { IntegrationFn, Span } from '@sentry/types'; + +import type { NodeClient } from '../../sdk/client'; +import type { HTTPModuleRequestIncomingMessage } from '../../transports/http-module'; +import { addOriginToSpan } from '../../utils/addOriginToSpan'; +import { getRequestUrl } from '../../utils/getRequestUrl'; +import { SentryHttpInstrumentation } from './SentryHttpInstrumentation'; + +const INTEGRATION_NAME = 'Http'; + +const INSTRUMENTATION_NAME = '@opentelemetry_sentry-patched/instrumentation-http'; + +interface HttpOptions { + /** + * Whether breadcrumbs should be recorded for requests. + * Defaults to true + */ + breadcrumbs?: boolean; + + /** + * If set to false, do not emit any spans. + * This will ensure that the default HttpInstrumentation from OpenTelemetry is not setup, + * only the Sentry-specific instrumentation for request isolation is applied. + */ + spans?: boolean; + + /** + * Do not capture spans or breadcrumbs for outgoing HTTP requests to URLs where the given callback returns `true`. + * This controls both span & breadcrumb creation - spans will be non recording if tracing is disabled. + * + * The `url` param contains the entire URL, including query string (if any), protocol, host, etc. of the outgoing request. + * For example: `'https://someService.com/users/details?id=123'` + * + * The `request` param contains the original {@type RequestOptions} object used to make the outgoing request. + * You can use it to filter on additional properties like method, headers, etc. + */ + ignoreOutgoingRequests?: (url: string, request: RequestOptions) => boolean; + + /** + * Do not capture spans or breadcrumbs for incoming HTTP requests to URLs where the given callback returns `true`. + * This controls both span & breadcrumb creation - 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: IncomingMessage) => boolean; + + /** + * 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; + applyCustomAttributesOnSpan?: ( + span: Span, + request: ClientRequest | HTTPModuleRequestIncomingMessage, + response: HTTPModuleRequestIncomingMessage | ServerResponse, + ) => void; + + /** + * You can pass any configuration through to the underlying instrumention. + * Note that there are no semver guarantees for this! + */ + _experimentalConfig?: ConstructorParameters[0]; + }; + + /** Allows to pass a custom version of HttpInstrumentation. We use this for Next.js. */ + _instrumentation?: typeof HttpInstrumentation; +} + +let _httpOptions: HttpOptions = {}; +let _sentryHttpInstrumentation: SentryHttpInstrumentation | undefined; +let _httpInstrumentation: HttpInstrumentation | undefined; + +/** + * Instrument the HTTP module. + * This can only be instrumented once! If this called again later, we just update the options. + */ +export const instrumentHttp = Object.assign( + function (): void { + // This is the "regular" OTEL instrumentation that emits spans + if (_httpOptions.spans !== false && !_httpInstrumentation) { + const _InstrumentationClass = _httpOptions._instrumentation || HttpInstrumentation; + + _httpInstrumentation = new _InstrumentationClass({ + ..._httpOptions.instrumentation?._experimentalConfig, + ignoreOutgoingRequestHook: request => { + const url = getRequestUrl(request); + + if (!url) { + return false; + } + + const _ignoreOutgoingRequests = _httpOptions.ignoreOutgoingRequests; + if (_ignoreOutgoingRequests && _ignoreOutgoingRequests(url, request)) { + return true; + } + + return false; + }, + + ignoreIncomingRequestHook: request => { + // request.url is the only property that holds any information about the url + // it only consists of the URL path and query string (if any) + const urlPath = request.url; + + const method = request.method?.toUpperCase(); + // We do not capture OPTIONS/HEAD requests as transactions + if (method === 'OPTIONS' || method === 'HEAD') { + return true; + } + + const _ignoreIncomingRequests = _httpOptions.ignoreIncomingRequests; + if (urlPath && _ignoreIncomingRequests && _ignoreIncomingRequests(urlPath, request)) { + return true; + } + + return false; + }, + + requireParentforOutgoingSpans: false, + requireParentforIncomingSpans: false, + requestHook: (span, req) => { + addOriginToSpan(span, 'auto.http.otel.http'); + if (!_isClientRequest(req) && isKnownPrefetchRequest(req)) { + span.setAttribute('sentry.http.prefetch', true); + } + + _httpOptions.instrumentation?.requestHook?.(span, req); + }, + responseHook: (span, res) => { + const client = getClient(); + if (client && client.getOptions().autoSessionTracking) { + setImmediate(() => { + client['_captureRequestSession'](); + }); + } + + _httpOptions.instrumentation?.responseHook?.(span, res); + }, + applyCustomAttributesOnSpan: ( + span: Span, + request: ClientRequest | HTTPModuleRequestIncomingMessage, + response: HTTPModuleRequestIncomingMessage | ServerResponse, + ) => { + _httpOptions.instrumentation?.applyCustomAttributesOnSpan?.(span, request, response); + }, + }); + + // We want to update the logger namespace so we can better identify what is happening here + try { + _httpInstrumentation['_diag'] = diag.createComponentLogger({ + namespace: INSTRUMENTATION_NAME, + }); + // @ts-expect-error We are writing a read-only property here... + _httpInstrumentation.instrumentationName = INSTRUMENTATION_NAME; + } catch { + // ignore errors here... + } + + addOpenTelemetryInstrumentation(_httpInstrumentation); + } else if (_httpOptions.spans === false && _httpInstrumentation) { + _httpInstrumentation.disable(); + } + + // This is our custom instrumentation that is responsible for request isolation etc. + // We have to add it after the OTEL instrumentation to ensure that we wrap the already wrapped http module + // Otherwise, the isolation scope does not encompass the OTEL spans + if (!_sentryHttpInstrumentation) { + _sentryHttpInstrumentation = new SentryHttpInstrumentation({ breadcrumbs: _httpOptions.breadcrumbs }); + addOpenTelemetryInstrumentation(_sentryHttpInstrumentation); + } else { + _sentryHttpInstrumentation.setConfig({ breadcrumbs: _httpOptions.breadcrumbs }); + } + }, + { + id: INTEGRATION_NAME, + }, +); + +const _httpIntegration = ((options: HttpOptions = {}) => { + return { + name: INTEGRATION_NAME, + setupOnce() { + _httpOptions = options; + instrumentHttp(); + }, + }; +}) satisfies IntegrationFn; + +/** + * 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. + */ +export const httpIntegration = defineIntegration(_httpIntegration); + +/** + * Determines if @param req is a ClientRequest, meaning the request was created within the express app + * and it's an outgoing request. + * Checking for properties instead of using `instanceOf` to avoid importing the request classes. + */ +function _isClientRequest(req: ClientRequest | HTTPModuleRequestIncomingMessage): req is ClientRequest { + return 'outputData' in req && 'outputSize' in req && !('client' in req) && !('statusCode' in req); +} + +/** + * Detects if an incoming request is a prefetch request. + */ +function isKnownPrefetchRequest(req: HTTPModuleRequestIncomingMessage): boolean { + // Currently only handles Next.js prefetch requests but may check other frameworks in the future. + return req.headers['next-router-prefetch'] === '1'; +} diff --git a/packages/opentelemetry/src/index.ts b/packages/opentelemetry/src/index.ts index 98460b575c8d..6fcf40e197d6 100644 --- a/packages/opentelemetry/src/index.ts +++ b/packages/opentelemetry/src/index.ts @@ -7,7 +7,7 @@ export { wrapClientClass } from './custom/client'; export { getSpanKind } from './utils/getSpanKind'; -export { getScopesFromContext } from './utils/contextData'; +export { getScopesFromContext, setScopesOnContext } from './utils/contextData'; export { spanHasAttributes,