diff --git a/packages/node-core/src/integrations/http/SentryHttpInstrumentation.ts b/packages/node-core/src/integrations/http/SentryHttpInstrumentation.ts index daee4440e40c..805fb275047c 100644 --- a/packages/node-core/src/integrations/http/SentryHttpInstrumentation.ts +++ b/packages/node-core/src/integrations/http/SentryHttpInstrumentation.ts @@ -1,39 +1,21 @@ -/* eslint-disable max-lines */ import type { ChannelListener } from 'node:diagnostics_channel'; import { subscribe, unsubscribe } from 'node:diagnostics_channel'; import type * as http from 'node:http'; import type * as https from 'node:https'; -import type { EventEmitter } from 'node:stream'; -import { context, propagation } from '@opentelemetry/api'; +import { context } from '@opentelemetry/api'; import { isTracingSuppressed } from '@opentelemetry/core'; import type { InstrumentationConfig } from '@opentelemetry/instrumentation'; import { InstrumentationBase, InstrumentationNodeModuleDefinition } from '@opentelemetry/instrumentation'; -import type { AggregationCounts, Client, SanitizedRequestData, Scope } from '@sentry/core'; -import { - addBreadcrumb, - addNonEnumerableProperty, - debug, - generateSpanId, - getBreadcrumbLogLevelFromHttpStatusCode, - getClient, - getCurrentScope, - getIsolationScope, - getSanitizedUrlString, - getTraceData, - httpRequestToRequestData, - isError, - LRUMap, - parseUrl, - SDK_VERSION, - stripUrlQueryAndFragment, - withIsolationScope, -} from '@sentry/core'; -import { shouldPropagateTraceForUrl } from '@sentry/opentelemetry'; +import { debug, LRUMap, SDK_VERSION } from '@sentry/core'; import { DEBUG_BUILD } from '../../debug-build'; -import { mergeBaggageHeaders } from '../../utils/baggage'; import { getRequestUrl } from '../../utils/getRequestUrl'; - -const INSTRUMENTATION_NAME = '@sentry/instrumentation-http'; +import { INSTRUMENTATION_NAME } from './constants'; +import { instrumentServer } from './incoming-requests'; +import { + addRequestBreadcrumb, + addTracePropagationHeadersToOutgoingRequest, + getRequestOptions, +} from './outgoing-requests'; type Http = typeof http; type Https = typeof https; @@ -116,9 +98,6 @@ export type SentryHttpInstrumentationOptions = InstrumentationConfig & { sessionFlushingDelayMS?: number; }; -// We only want to capture request bodies up to 1mb. -const MAX_BODY_BYTE_LENGTH = 1024 * 1024; - /** * This custom HTTP instrumentation is used to isolate incoming requests and annotate them with additional information. * It does not emit any spans. @@ -151,7 +130,12 @@ export class SentryHttpInstrumentation extends InstrumentationBase { const data = _data as { server: http.Server }; - this._patchServerEmitOnce(data.server); + instrumentServer(data.server, { + ignoreIncomingRequestBody: this.getConfig().ignoreIncomingRequestBody, + maxIncomingRequestBodySize: this.getConfig().maxIncomingRequestBodySize, + trackIncomingRequestsAsSessions: this.getConfig().trackIncomingRequestsAsSessions, + sessionFlushingDelayMS: this.getConfig().sessionFlushingDelayMS ?? 60_000, + }); }) satisfies ChannelListener; const onHttpClientResponseFinish = ((_data: unknown) => { @@ -245,142 +229,7 @@ export class SentryHttpInstrumentation extends InstrumentationBase { - // Set a new propagationSpanId for this request - // We rely on the fact that `withIsolationScope()` will implicitly also fork the current scope - // This way we can save an "unnecessary" `withScope()` invocation - getCurrentScope().getPropagationContext().propagationSpanId = generateSpanId(); - - // If we don't want to extract the trace from the header, we can skip this - if (!instrumentation.getConfig().extractIncomingTraceFromHeader) { - return target.apply(thisArg, args); - } - - const ctx = propagation.extract(context.active(), normalizedRequest.headers); - return context.with(ctx, () => { - return target.apply(thisArg, args); - }); - }); - }, - }); - - addNonEnumerableProperty(newEmit, '__sentry_patched__', true); - - server.emit = newEmit; + addTracePropagationHeadersToOutgoingRequest(request, this._propagationDecisionMap); } /** @@ -402,262 +251,3 @@ export class SentryHttpInstrumentation extends InstrumentationBase { - 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 {}; - } -} - -/** - * This method patches the request object to capture the body. - * Instead of actually consuming the streamed body ourselves, which has potential side effects, - * we monkey patch `req.on('data')` to intercept the body chunks. - * This way, we only read the body if the user also consumes the body, ensuring we do not change any behavior in unexpected ways. - */ -function patchRequestToCaptureBody( - req: http.IncomingMessage, - isolationScope: Scope, - maxIncomingRequestBodySize: 'small' | 'medium' | 'always', -): void { - let bodyByteLength = 0; - const chunks: Buffer[] = []; - - DEBUG_BUILD && debug.log(INSTRUMENTATION_NAME, 'Patching request.on'); - - /** - * We need to keep track of the original callbacks, in order to be able to remove listeners again. - * Since `off` depends on having the exact same function reference passed in, we need to be able to map - * original listeners to our wrapped ones. - */ - const callbackMap = new WeakMap(); - - const maxBodySize = - maxIncomingRequestBodySize === 'small' - ? 1_000 - : maxIncomingRequestBodySize === 'medium' - ? 10_000 - : MAX_BODY_BYTE_LENGTH; - - try { - // eslint-disable-next-line @typescript-eslint/unbound-method - req.on = new Proxy(req.on, { - apply: (target, thisArg, args: Parameters) => { - const [event, listener, ...restArgs] = args; - - if (event === 'data') { - DEBUG_BUILD && - debug.log(INSTRUMENTATION_NAME, `Handling request.on("data") with maximum body size of ${maxBodySize}b`); - - const callback = new Proxy(listener, { - apply: (target, thisArg, args: Parameters) => { - try { - const chunk = args[0] as Buffer | string; - const bufferifiedChunk = Buffer.from(chunk); - - if (bodyByteLength < maxBodySize) { - chunks.push(bufferifiedChunk); - bodyByteLength += bufferifiedChunk.byteLength; - } else if (DEBUG_BUILD) { - debug.log( - INSTRUMENTATION_NAME, - `Dropping request body chunk because maximum body length of ${maxBodySize}b is exceeded.`, - ); - } - } catch (err) { - DEBUG_BUILD && debug.error(INSTRUMENTATION_NAME, 'Encountered error while storing body chunk.'); - } - - return Reflect.apply(target, thisArg, args); - }, - }); - - callbackMap.set(listener, callback); - - return Reflect.apply(target, thisArg, [event, callback, ...restArgs]); - } - - return Reflect.apply(target, thisArg, args); - }, - }); - - // Ensure we also remove callbacks correctly - // eslint-disable-next-line @typescript-eslint/unbound-method - req.off = new Proxy(req.off, { - apply: (target, thisArg, args: Parameters) => { - const [, listener] = args; - - const callback = callbackMap.get(listener); - if (callback) { - callbackMap.delete(listener); - - const modifiedArgs = args.slice(); - modifiedArgs[1] = callback; - return Reflect.apply(target, thisArg, modifiedArgs); - } - - return Reflect.apply(target, thisArg, args); - }, - }); - - req.on('end', () => { - try { - const body = Buffer.concat(chunks).toString('utf-8'); - if (body) { - // Using Buffer.byteLength here, because the body may contain characters that are not 1 byte long - const bodyByteLength = Buffer.byteLength(body, 'utf-8'); - const truncatedBody = - bodyByteLength > maxBodySize - ? `${Buffer.from(body) - .subarray(0, maxBodySize - 3) - .toString('utf-8')}...` - : body; - - isolationScope.setSDKProcessingMetadata({ normalizedRequest: { data: truncatedBody } }); - } - } catch (error) { - if (DEBUG_BUILD) { - debug.error(INSTRUMENTATION_NAME, 'Error building captured request body', error); - } - } - }); - } catch (error) { - if (DEBUG_BUILD) { - debug.error(INSTRUMENTATION_NAME, 'Error patching request to capture body', error); - } - } -} - -function getRequestOptions(request: http.ClientRequest): http.RequestOptions { - return { - method: request.method, - protocol: request.protocol, - host: request.host, - hostname: request.host, - path: request.path, - headers: request.getHeaders(), - }; -} - -/** - * Starts a session and tracks it in the context of a given isolation scope. - * When the passed response is finished, the session is put into a task and is - * aggregated with other sessions that may happen in a certain time window - * (sessionFlushingDelayMs). - * - * The sessions are always aggregated by the client that is on the current scope - * at the time of ending the response (if there is one). - */ -// Exported for unit tests -export function recordRequestSession({ - requestIsolationScope, - response, - sessionFlushingDelayMS, -}: { - requestIsolationScope: Scope; - response: EventEmitter; - sessionFlushingDelayMS?: number; -}): void { - requestIsolationScope.setSDKProcessingMetadata({ - requestSession: { status: 'ok' }, - }); - response.once('close', () => { - // We need to grab the client off the current scope instead of the isolation scope because the isolation scope doesn't hold any client out of the box. - const client = getClient(); - const requestSession = requestIsolationScope.getScopeData().sdkProcessingMetadata.requestSession; - - if (client && requestSession) { - DEBUG_BUILD && debug.log(`Recorded request session with status: ${requestSession.status}`); - - const roundedDate = new Date(); - roundedDate.setSeconds(0, 0); - const dateBucketKey = roundedDate.toISOString(); - - const existingClientAggregate = clientToRequestSessionAggregatesMap.get(client); - const bucket = existingClientAggregate?.[dateBucketKey] || { exited: 0, crashed: 0, errored: 0 }; - bucket[({ ok: 'exited', crashed: 'crashed', errored: 'errored' } as const)[requestSession.status]]++; - - if (existingClientAggregate) { - existingClientAggregate[dateBucketKey] = bucket; - } else { - DEBUG_BUILD && debug.log('Opened new request session aggregate.'); - const newClientAggregate = { [dateBucketKey]: bucket }; - clientToRequestSessionAggregatesMap.set(client, newClientAggregate); - - const flushPendingClientAggregates = (): void => { - clearTimeout(timeout); - unregisterClientFlushHook(); - clientToRequestSessionAggregatesMap.delete(client); - - const aggregatePayload: AggregationCounts[] = Object.entries(newClientAggregate).map( - ([timestamp, value]) => ({ - started: timestamp, - exited: value.exited, - errored: value.errored, - crashed: value.crashed, - }), - ); - client.sendSession({ aggregates: aggregatePayload }); - }; - - const unregisterClientFlushHook = client.on('flush', () => { - DEBUG_BUILD && debug.log('Sending request session aggregate due to client flush'); - flushPendingClientAggregates(); - }); - const timeout = setTimeout(() => { - DEBUG_BUILD && debug.log('Sending request session aggregate due to flushing schedule'); - flushPendingClientAggregates(); - }, sessionFlushingDelayMS).unref(); - } - } - }); -} - -const clientToRequestSessionAggregatesMap = new Map< - Client, - { [timestampRoundedToSeconds: string]: { exited: number; crashed: number; errored: number } } ->(); diff --git a/packages/node-core/src/integrations/http/constants.ts b/packages/node-core/src/integrations/http/constants.ts new file mode 100644 index 000000000000..6ad7b4319758 --- /dev/null +++ b/packages/node-core/src/integrations/http/constants.ts @@ -0,0 +1,4 @@ +export const INSTRUMENTATION_NAME = '@sentry/instrumentation-http'; + +/** We only want to capture request bodies up to 1mb. */ +export const MAX_BODY_BYTE_LENGTH = 1024 * 1024; diff --git a/packages/node-core/src/integrations/http/incoming-requests.ts b/packages/node-core/src/integrations/http/incoming-requests.ts new file mode 100644 index 000000000000..2d18d1c064c4 --- /dev/null +++ b/packages/node-core/src/integrations/http/incoming-requests.ts @@ -0,0 +1,304 @@ +import { context, propagation } from '@opentelemetry/api'; +import type { AggregationCounts, Client, Scope } from '@sentry/core'; +import { + addNonEnumerableProperty, + debug, + generateSpanId, + getClient, + getCurrentScope, + getIsolationScope, + httpRequestToRequestData, + stripUrlQueryAndFragment, + withIsolationScope, +} from '@sentry/core'; +import type EventEmitter from 'events'; +import type { IncomingMessage, OutgoingMessage, Server } from 'http'; +import { DEBUG_BUILD } from '../../debug-build'; +import { INSTRUMENTATION_NAME, MAX_BODY_BYTE_LENGTH } from './constants'; + +const clientToRequestSessionAggregatesMap = new Map< + Client, + { [timestampRoundedToSeconds: string]: { exited: number; crashed: number; errored: number } } +>(); + +/** + * Instrument a server to capture incoming requests. + * + */ +export function instrumentServer( + server: Server, + { + ignoreIncomingRequestBody, + maxIncomingRequestBodySize = 'medium', + trackIncomingRequestsAsSessions = true, + sessionFlushingDelayMS, + }: { + ignoreIncomingRequestBody?: (url: string, request: IncomingMessage) => boolean; + maxIncomingRequestBodySize?: 'small' | 'medium' | 'always' | 'none'; + trackIncomingRequestsAsSessions?: boolean; + sessionFlushingDelayMS: number; + }, +): void { + // eslint-disable-next-line @typescript-eslint/unbound-method + const originalEmit = server.emit; + + // This means it was already patched, do nothing + if ((originalEmit as { __sentry_patched__?: boolean }).__sentry_patched__) { + return; + } + + const newEmit = new Proxy(originalEmit, { + apply(target, thisArg, args: [event: string, ...args: unknown[]]) { + // Only traces request events + if (args[0] !== 'request') { + return target.apply(thisArg, args); + } + + DEBUG_BUILD && debug.log(INSTRUMENTATION_NAME, 'Handling incoming request'); + + const isolationScope = getIsolationScope().clone(); + const request = args[1] as IncomingMessage; + const response = args[2] as OutgoingMessage; + + const normalizedRequest = httpRequestToRequestData(request); + + // request.ip is non-standard but some frameworks set this + const ipAddress = (request as { ip?: string }).ip || request.socket?.remoteAddress; + + const url = request.url || '/'; + if (!ignoreIncomingRequestBody?.(url, request) && maxIncomingRequestBodySize !== 'none') { + patchRequestToCaptureBody(request, isolationScope, maxIncomingRequestBodySize); + } + + // Update the isolation scope, isolate this request + isolationScope.setSDKProcessingMetadata({ normalizedRequest, ipAddress }); + + // 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(url); + + const bestEffortTransactionName = `${httpMethod} ${httpTarget}`; + + isolationScope.setTransactionName(bestEffortTransactionName); + + if (trackIncomingRequestsAsSessions !== false) { + recordRequestSession({ + requestIsolationScope: isolationScope, + response, + sessionFlushingDelayMS: sessionFlushingDelayMS ?? 60_000, + }); + } + + return withIsolationScope(isolationScope, () => { + // Set a new propagationSpanId for this request + // We rely on the fact that `withIsolationScope()` will implicitly also fork the current scope + // This way we can save an "unnecessary" `withScope()` invocation + getCurrentScope().getPropagationContext().propagationSpanId = generateSpanId(); + + const ctx = propagation.extract(context.active(), normalizedRequest.headers); + return context.with(ctx, () => { + return target.apply(thisArg, args); + }); + }); + }, + }); + + addNonEnumerableProperty(newEmit, '__sentry_patched__', true); + + server.emit = newEmit; +} + +/** + * Starts a session and tracks it in the context of a given isolation scope. + * When the passed response is finished, the session is put into a task and is + * aggregated with other sessions that may happen in a certain time window + * (sessionFlushingDelayMs). + * + * The sessions are always aggregated by the client that is on the current scope + * at the time of ending the response (if there is one). + */ +// Exported for unit tests +export function recordRequestSession({ + requestIsolationScope, + response, + sessionFlushingDelayMS, +}: { + requestIsolationScope: Scope; + response: EventEmitter; + sessionFlushingDelayMS?: number; +}): void { + requestIsolationScope.setSDKProcessingMetadata({ + requestSession: { status: 'ok' }, + }); + response.once('close', () => { + // We need to grab the client off the current scope instead of the isolation scope because the isolation scope doesn't hold any client out of the box. + const client = getClient(); + const requestSession = requestIsolationScope.getScopeData().sdkProcessingMetadata.requestSession; + + if (client && requestSession) { + DEBUG_BUILD && debug.log(`Recorded request session with status: ${requestSession.status}`); + + const roundedDate = new Date(); + roundedDate.setSeconds(0, 0); + const dateBucketKey = roundedDate.toISOString(); + + const existingClientAggregate = clientToRequestSessionAggregatesMap.get(client); + const bucket = existingClientAggregate?.[dateBucketKey] || { exited: 0, crashed: 0, errored: 0 }; + bucket[({ ok: 'exited', crashed: 'crashed', errored: 'errored' } as const)[requestSession.status]]++; + + if (existingClientAggregate) { + existingClientAggregate[dateBucketKey] = bucket; + } else { + DEBUG_BUILD && debug.log('Opened new request session aggregate.'); + const newClientAggregate = { [dateBucketKey]: bucket }; + clientToRequestSessionAggregatesMap.set(client, newClientAggregate); + + const flushPendingClientAggregates = (): void => { + clearTimeout(timeout); + unregisterClientFlushHook(); + clientToRequestSessionAggregatesMap.delete(client); + + const aggregatePayload: AggregationCounts[] = Object.entries(newClientAggregate).map( + ([timestamp, value]) => ({ + started: timestamp, + exited: value.exited, + errored: value.errored, + crashed: value.crashed, + }), + ); + client.sendSession({ aggregates: aggregatePayload }); + }; + + const unregisterClientFlushHook = client.on('flush', () => { + DEBUG_BUILD && debug.log('Sending request session aggregate due to client flush'); + flushPendingClientAggregates(); + }); + const timeout = setTimeout(() => { + DEBUG_BUILD && debug.log('Sending request session aggregate due to flushing schedule'); + flushPendingClientAggregates(); + }, sessionFlushingDelayMS).unref(); + } + } + }); +} + +/** + * This method patches the request object to capture the body. + * Instead of actually consuming the streamed body ourselves, which has potential side effects, + * we monkey patch `req.on('data')` to intercept the body chunks. + * This way, we only read the body if the user also consumes the body, ensuring we do not change any behavior in unexpected ways. + */ +function patchRequestToCaptureBody( + req: IncomingMessage, + isolationScope: Scope, + maxIncomingRequestBodySize: 'small' | 'medium' | 'always', +): void { + let bodyByteLength = 0; + const chunks: Buffer[] = []; + + DEBUG_BUILD && debug.log(INSTRUMENTATION_NAME, 'Patching request.on'); + + /** + * We need to keep track of the original callbacks, in order to be able to remove listeners again. + * Since `off` depends on having the exact same function reference passed in, we need to be able to map + * original listeners to our wrapped ones. + */ + const callbackMap = new WeakMap(); + + const maxBodySize = + maxIncomingRequestBodySize === 'small' + ? 1_000 + : maxIncomingRequestBodySize === 'medium' + ? 10_000 + : MAX_BODY_BYTE_LENGTH; + + try { + // eslint-disable-next-line @typescript-eslint/unbound-method + req.on = new Proxy(req.on, { + apply: (target, thisArg, args: Parameters) => { + const [event, listener, ...restArgs] = args; + + if (event === 'data') { + DEBUG_BUILD && + debug.log(INSTRUMENTATION_NAME, `Handling request.on("data") with maximum body size of ${maxBodySize}b`); + + const callback = new Proxy(listener, { + apply: (target, thisArg, args: Parameters) => { + try { + const chunk = args[0] as Buffer | string; + const bufferifiedChunk = Buffer.from(chunk); + + if (bodyByteLength < maxBodySize) { + chunks.push(bufferifiedChunk); + bodyByteLength += bufferifiedChunk.byteLength; + } else if (DEBUG_BUILD) { + debug.log( + INSTRUMENTATION_NAME, + `Dropping request body chunk because maximum body length of ${maxBodySize}b is exceeded.`, + ); + } + } catch (err) { + DEBUG_BUILD && debug.error(INSTRUMENTATION_NAME, 'Encountered error while storing body chunk.'); + } + + return Reflect.apply(target, thisArg, args); + }, + }); + + callbackMap.set(listener, callback); + + return Reflect.apply(target, thisArg, [event, callback, ...restArgs]); + } + + return Reflect.apply(target, thisArg, args); + }, + }); + + // Ensure we also remove callbacks correctly + // eslint-disable-next-line @typescript-eslint/unbound-method + req.off = new Proxy(req.off, { + apply: (target, thisArg, args: Parameters) => { + const [, listener] = args; + + const callback = callbackMap.get(listener); + if (callback) { + callbackMap.delete(listener); + + const modifiedArgs = args.slice(); + modifiedArgs[1] = callback; + return Reflect.apply(target, thisArg, modifiedArgs); + } + + return Reflect.apply(target, thisArg, args); + }, + }); + + req.on('end', () => { + try { + const body = Buffer.concat(chunks).toString('utf-8'); + if (body) { + // Using Buffer.byteLength here, because the body may contain characters that are not 1 byte long + const bodyByteLength = Buffer.byteLength(body, 'utf-8'); + const truncatedBody = + bodyByteLength > maxBodySize + ? `${Buffer.from(body) + .subarray(0, maxBodySize - 3) + .toString('utf-8')}...` + : body; + + isolationScope.setSDKProcessingMetadata({ normalizedRequest: { data: truncatedBody } }); + } + } catch (error) { + if (DEBUG_BUILD) { + debug.error(INSTRUMENTATION_NAME, 'Error building captured request body', error); + } + } + }); + } catch (error) { + if (DEBUG_BUILD) { + debug.error(INSTRUMENTATION_NAME, 'Error patching request to capture body', error); + } + } +} diff --git a/packages/node-core/src/integrations/http/outgoing-requests.ts b/packages/node-core/src/integrations/http/outgoing-requests.ts new file mode 100644 index 000000000000..17f806ab322f --- /dev/null +++ b/packages/node-core/src/integrations/http/outgoing-requests.ts @@ -0,0 +1,144 @@ +import type { LRUMap, SanitizedRequestData } from '@sentry/core'; +import { + addBreadcrumb, + debug, + getBreadcrumbLogLevelFromHttpStatusCode, + getClient, + getSanitizedUrlString, + getTraceData, + isError, + parseUrl, +} from '@sentry/core'; +import { shouldPropagateTraceForUrl } from '@sentry/opentelemetry'; +import type { ClientRequest, IncomingMessage, RequestOptions } from 'http'; +import { DEBUG_BUILD } from '../../debug-build'; +import { mergeBaggageHeaders } from '../../utils/baggage'; +import { INSTRUMENTATION_NAME } from './constants'; + +/** 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! + */ +export function addTracePropagationHeadersToOutgoingRequest( + request: ClientRequest, + propagationDecisionMap: LRUMap, +): void { + const url = getRequestUrl(request); + + // Manually add the trace headers, if it applies + // Note: We do not use `propagation.inject()` here, because our propagator relies on an active span + // Which we do not have in this case + const tracePropagationTargets = getClient()?.getOptions().tracePropagationTargets; + const headersToAdd = shouldPropagateTraceForUrl(url, tracePropagationTargets, propagationDecisionMap) + ? getTraceData() + : undefined; + + if (!headersToAdd) { + return; + } + + const { 'sentry-trace': sentryTrace, baggage } = headersToAdd; + + // We do not want to overwrite existing header here, if it was already set + if (sentryTrace && !request.getHeader('sentry-trace')) { + try { + request.setHeader('sentry-trace', sentryTrace); + DEBUG_BUILD && debug.log(INSTRUMENTATION_NAME, 'Added sentry-trace header to outgoing request'); + } catch (error) { + DEBUG_BUILD && + debug.error( + INSTRUMENTATION_NAME, + 'Failed to add sentry-trace header to outgoing request:', + isError(error) ? error.message : 'Unknown error', + ); + } + } + + if (baggage) { + // For baggage, we make sure to merge this into a possibly existing header + const newBaggage = mergeBaggageHeaders(request.getHeader('baggage'), baggage); + if (newBaggage) { + try { + request.setHeader('baggage', newBaggage); + DEBUG_BUILD && debug.log(INSTRUMENTATION_NAME, 'Added baggage header to outgoing request'); + } catch (error) { + DEBUG_BUILD && + debug.error( + INSTRUMENTATION_NAME, + '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 {}; + } +} + +/** Convert an outgoing request to request options. */ +export function getRequestOptions(request: ClientRequest): RequestOptions { + return { + method: request.method, + protocol: request.protocol, + host: request.host, + hostname: request.host, + path: request.path, + headers: request.getHeaders(), + }; +} + +function getRequestUrl(request: ClientRequest): string { + const hostname = request.getHeader('host') || request.host; + const protocol = request.protocol; + const path = request.path; + + return `${protocol}//${hostname}${path}`; +} diff --git a/packages/node-core/test/integrations/request-session-tracking.test.ts b/packages/node-core/test/integrations/request-session-tracking.test.ts index 02446eee875d..b7d7ec4f2354 100644 --- a/packages/node-core/test/integrations/request-session-tracking.test.ts +++ b/packages/node-core/test/integrations/request-session-tracking.test.ts @@ -2,7 +2,7 @@ import type { Client } from '@sentry/core'; import { createTransport, Scope, ServerRuntimeClient, withScope } from '@sentry/core'; import { EventEmitter } from 'stream'; import { describe, expect, it, vi } from 'vitest'; -import { recordRequestSession } from '../../src/integrations/http/SentryHttpInstrumentation'; +import { recordRequestSession } from '../../src/integrations/http/incoming-requests'; vi.useFakeTimers(); diff --git a/packages/node/src/integrations/http/index.ts b/packages/node/src/integrations/http.ts similarity index 99% rename from packages/node/src/integrations/http/index.ts rename to packages/node/src/integrations/http.ts index e56842be85cb..0f2e87e54280 100644 --- a/packages/node/src/integrations/http/index.ts +++ b/packages/node/src/integrations/http.ts @@ -13,7 +13,7 @@ import { NODE_VERSION, SentryHttpInstrumentation, } from '@sentry/node-core'; -import type { NodeClientOptions } from '../../types'; +import type { NodeClientOptions } from '../types'; const INTEGRATION_NAME = 'Http'; diff --git a/packages/node/src/integrations/node-fetch/index.ts b/packages/node/src/integrations/node-fetch.ts similarity index 98% rename from packages/node/src/integrations/node-fetch/index.ts rename to packages/node/src/integrations/node-fetch.ts index 7929d00c87e0..437806e16dbc 100644 --- a/packages/node/src/integrations/node-fetch/index.ts +++ b/packages/node/src/integrations/node-fetch.ts @@ -4,7 +4,7 @@ import type { IntegrationFn } from '@sentry/core'; import { defineIntegration, getClient, hasSpansEnabled, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from '@sentry/core'; import type { NodeClient } from '@sentry/node-core'; import { generateInstrumentOnce, SentryNodeFetchInstrumentation } from '@sentry/node-core'; -import type { NodeClientOptions } from '../../types'; +import type { NodeClientOptions } from '../types'; const INTEGRATION_NAME = 'NodeFetch'; diff --git a/packages/node/src/integrations/node-fetch/types.ts b/packages/node/src/integrations/node-fetch/types.ts deleted file mode 100644 index 0139dadde413..000000000000 --- a/packages/node/src/integrations/node-fetch/types.ts +++ /dev/null @@ -1,47 +0,0 @@ -/* - * 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. - */ - -/** - * Vendored from https://github.com/open-telemetry/opentelemetry-js-contrib/blob/28e209a9da36bc4e1f8c2b0db7360170ed46cb80/plugins/node/instrumentation-undici/src/types.ts - */ - -export interface UndiciRequest { - origin: string; - method: string; - path: string; - /** - * Serialized string of headers in the form `name: value\r\n` for v5 - * Array of strings v6 - */ - headers: string | string[]; - /** - * Helper method to add headers (from v6) - */ - addHeader: (name: string, value: string) => void; - throwOnError: boolean; - completed: boolean; - aborted: boolean; - idempotent: boolean; - contentLength: number | null; - contentType: string | null; - body: unknown; -} - -export interface UndiciResponse { - headers: Buffer[]; - statusCode: number; - statusText: string; -}