Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/node-core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ export {
SentryNodeFetchInstrumentation,
type SentryNodeFetchInstrumentationOptions,
} from './integrations/node-fetch/SentryNodeFetchInstrumentation';
export { addFetchRequestBreadcrumb, addTracePropagationHeadersToFetchRequest } from './utils/outgoingFetchRequest';

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

super-l: Is this meant for users to be used as well? If not, I think prefixing it with _INTERNAL_ would be nice - not sure if we have a best practice for that, at least I always did it. And/or mark them as @internal in the JSDoc

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah. It seems it will be dropped again in the next PR :D

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yeah, this is a bit tricky 😅 should be cleaned up when I merge the stack together!


export { SentryContextManager } from './otel/contextManager';
export { setupOpenTelemetryLogger } from './otel/logger';
Expand Down
24 changes: 20 additions & 4 deletions packages/node-core/src/utils/outgoingFetchRequest.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { LRUMap, SanitizedRequestData } from '@sentry/core';
import type { LRUMap, SanitizedRequestData, Span } from '@sentry/core';
import {
addBreadcrumb,
getBreadcrumbLogLevelFromHttpStatusCode,
Expand All @@ -8,6 +8,7 @@ import {
parseUrl,
shouldPropagateTraceForUrl,
mergeBaggageHeaders,
withActiveSpan,
} from '@sentry/core';
import type { UndiciRequest, UndiciResponse } from '../integrations/node-fetch/types';
import { debug } from '@sentry/core';
Expand All @@ -19,11 +20,18 @@ const W3C_TRACEPARENT_HEADER = 'traceparent';
*
* Checks if the request URL matches trace propagation targets,
* then injects sentry-trace, traceparent, and baggage headers.
*
* When a `span` is passed (the outgoing `http.client` span), its trace data is propagated so downstream
* services are parented to that span. Without a span, the active scope's trace data is used.
*
* Existing trace headers (e.g. set manually by the user via `getTraceData()`) always take precedence and
* are de-duplicated rather than overwritten, so we never emit two `sentry-trace`/`baggage` entries.
*/
// eslint-disable-next-line complexity
export function addTracePropagationHeadersToFetchRequest(
request: UndiciRequest,
propagationDecisionMap: LRUMap<string, boolean>,
span?: Span,
): void {
const url = getAbsoluteUrl(request.origin, request.path);

Expand All @@ -32,9 +40,17 @@ export function addTracePropagationHeadersToFetchRequest(
// Which we do not have in this case
// The propagator _may_ overwrite this, but this should be fine as it is the same data
const { tracePropagationTargets, propagateTraceparent } = getClient()?.getOptions() || {};
const addedHeaders = shouldPropagateTraceForUrl(url, tracePropagationTargets, propagationDecisionMap)
? getTraceData({ propagateTraceparent })
: undefined;

if (!shouldPropagateTraceForUrl(url, tracePropagationTargets, propagationDecisionMap)) {
return;
}

// When a span is provided, make it active so the propagated headers reference it (and not the parent
// span). Passing `{ span }` to `getTraceData()` is not enough: for an inactive span it resolves to the
// span's captured scope, whose active span is still the parent.
const addedHeaders = span
? withActiveSpan(span, () => getTraceData({ propagateTraceparent }))
: getTraceData({ propagateTraceparent });

if (!addedHeaders) {
return;
Expand Down
34 changes: 6 additions & 28 deletions packages/node/src/integrations/node-fetch/index.ts
Original file line number Diff line number Diff line change
@@ -1,41 +1,19 @@
import { instrumentUndici } from './undici-instrumentation';
import type { NodeFetchOptions } from './types';
import type { IntegrationFn } from '@sentry/core';
import { defineIntegration, getClient, hasSpansEnabled } from '@sentry/core';
import type { NodeClient } from '@sentry/node-core';
import { generateInstrumentOnce, SentryNodeFetchInstrumentation } from '@sentry/node-core';
import type { NodeClientOptions } from '../../types';

const INTEGRATION_NAME = 'NodeFetch';

const instrumentSentryNodeFetch = generateInstrumentOnce(
`${INTEGRATION_NAME}.sentry`,
SentryNodeFetchInstrumentation,
(options: NodeFetchOptions) => {
return options;
},
);
import type { NodeFetchOptions } from './types';
import { instrumentUndici } from './undici-instrumentation';

const _nativeNodeFetchIntegration = ((options: NodeFetchOptions = {}) => {
return {
name: 'NodeFetch' as const,
setupOnce() {
const instrumentSpans = _shouldInstrumentSpans(options, getClient<NodeClient>()?.getOptions());

// This is the instrumentation that emits spans & propagates traces for outgoing fetch requests
if (instrumentSpans) {
instrumentUndici({
ignoreOutgoingRequests: options.ignoreOutgoingRequests,
requestHook: options.requestHook,
responseHook: options.responseHook,
headersToSpanAttributes: options.headersToSpanAttributes,
});
}
const spans = _shouldInstrumentSpans(options, getClient<NodeClient>()?.getOptions());

// This is the Sentry-specific instrumentation that creates breadcrumbs & propagates traces.
// It must subscribe to the diagnostics channels after the span instrumentation above, so the core
// trace propagation logic takes precedence. Otherwise, the sentry-trace header may be set multiple times.
instrumentSentryNodeFetch(options);
// This single instrumentation emits spans (when `spans` is enabled), records breadcrumbs, and
// propagates traces for outgoing fetch/undici requests.
instrumentUndici({ ...options, spans });
},
};
}) satisfies IntegrationFn;
Expand Down
3 changes: 1 addition & 2 deletions packages/node/src/integrations/node-fetch/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,8 +104,7 @@ export interface NodeFetchOptions extends UndiciInstrumentationConfig {

/**
* If set to false, do not emit any spans.
* This will ensure that the default UndiciInstrumentation from OpenTelemetry is not setup,
* only the Sentry-specific instrumentation for breadcrumbs & trace propagation is applied.
* Breadcrumbs and trace propagation for outgoing fetch requests are still applied.
*
* If `skipOpenTelemetrySetup: true` is configured, this defaults to `false`, otherwise it defaults to `true`.
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,18 +22,16 @@ import { URL } from 'url';
import type { Span, SpanAttributes } from '@sentry/core';
import {
debug,
getClient,
getTraceData,
isTracingSuppressed,
LRUMap,
SEMANTIC_ATTRIBUTE_SENTRY_CUSTOM_SPAN_NAME,
SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN,
shouldPropagateTraceForUrl,
SPAN_KIND,
SPAN_STATUS_ERROR,
startInactiveSpan,
stripDataUrlContent,
withActiveSpan,
} from '@sentry/core';
import { addFetchRequestBreadcrumb, addTracePropagationHeadersToFetchRequest } from '@sentry/node-core';
import {
HTTP_REQUEST_METHOD,
HTTP_RESPONSE_STATUS_CODE,
Expand All @@ -49,7 +47,7 @@ import {
} from '@sentry/conventions/attributes';
import { DEBUG_BUILD } from '../../debug-build';
import type {
UndiciInstrumentationConfig,
NodeFetchOptions,
UndiciRequest,
RequestErrorMessage,
RequestHeadersMessage,
Expand All @@ -65,12 +63,19 @@ const ATTR_HTTP_REQUEST_METHOD_ORIGINAL = 'http.request.method_original';
// We can replace this with _isInstrumented once we drop support for Node.js 18.18.0
const _channelSubs: Array<unknown> = [];
const spanFromReq = new WeakMap<UndiciRequest, Span>();
// Whether breadcrumbs (and span-less trace propagation) should be skipped for a given request.
// We evaluate this at request-creation time because the active context is no longer correct by the
// time the response-headers event fires.
const ignoreRequestMap = new WeakMap<UndiciRequest, boolean>();
// Caches trace-propagation decisions per URL so we don't recompute the `tracePropagationTargets` regexes per request.
const propagationDecisionMap = new LRUMap<string, boolean>(100);

/**
* Instrument outgoing HTTP requests made through `undici` or the global `fetch` API: emit `http.client`
* spans and propagate traces into the outgoing request headers.
* spans, record breadcrumbs, and propagate traces into the outgoing request headers.
*
* Span creation is gated by the `spans` option (defaults to `true`). When spans are disabled, breadcrumbs
* are still recorded and trace propagation headers are still injected (gated by `tracePropagation`).
*
* undici reports its request lifecycle via `diagnostics_channel`, so rather than patching any module we
* subscribe to those channels directly. This is idempotent — subsequent calls are no-ops once the
Expand All @@ -79,7 +84,7 @@ const propagationDecisionMap = new LRUMap<string, boolean>(100);
* A combination of https://github.com/elastic/apm-agent-nodejs and
* https://github.com/gadget-inc/opentelemetry-instrumentations/blob/main/packages/opentelemetry-instrumentation-undici/src/index.ts
*/
export function instrumentUndici(config: UndiciInstrumentationConfig = {}): void {
export function instrumentUndici(config: NodeFetchOptions = {}): void {
// Avoid duplicate subscriptions
if (_channelSubs.length) {
return;
Expand Down Expand Up @@ -170,18 +175,32 @@ function parseRequestHeaders(request: UndiciRequest): Map<string, string | strin
// This is the 1st message we receive for each request (fired after request creation). Here we will
// create the span and populate some atttributes, then link the span to the request for further
// span processing
function onRequestCreated(config: UndiciInstrumentationConfig, { request }: RequestMessage): void {
function onRequestCreated(config: NodeFetchOptions, { request }: RequestMessage): void {
const url = getAbsoluteUrl(request.origin, request.path);

// Ignore if:
// - the outgoing request is ignored via config
// - method is 'CONNECT'
const shouldIgnoreReq = safeExecute(
() => request.method === 'CONNECT' || !!config.ignoreOutgoingRequests?.(url),
const ignoredByCallback = safeExecute(
() => !!config.ignoreOutgoingRequests?.(url),
e => e && DEBUG_BUILD && debug.error('caught ignoreOutgoingRequests error: ', e),
);

if (shouldIgnoreReq) {
// Breadcrumbs & span-less trace propagation are additionally skipped when tracing is suppressed.
// We store this here because the active context is no longer correct in later channel events.
const ignoreForBreadcrumbs = isTracingSuppressed() || !!ignoredByCallback;
ignoreRequestMap.set(request, ignoreForBreadcrumbs);

// When spans are disabled we do not create a span, but we still inject trace propagation headers
// directly (unless disabled via `tracePropagation`).
if (config.spans === false) {
if (config.tracePropagation !== false && !ignoreForBreadcrumbs) {
addTracePropagationHeadersToFetchRequest(request, propagationDecisionMap);
}
return;
}

// Ignore span creation if:
// - the outgoing request is ignored via config
// - method is 'CONNECT'
if (request.method === 'CONNECT' || ignoredByCallback) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Skipped requests lose trace headers

Medium Severity

With spans enabled, onRequestCreated returns before calling addTracePropagationHeadersToFetchRequest for CONNECT requests and when new URL fails, so outgoing headers are never injected. The removed SentryNodeFetchInstrumentation still ran on those requests and propagated trace data from the active scope.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit d43ca81. Configure here.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this is actually correct/expected.

return;
}

Expand Down Expand Up @@ -249,15 +268,16 @@ function onRequestCreated(config: UndiciInstrumentationConfig, { request }: Requ
// Context propagation goes last so no hook can tamper the propagation headers.
// We propagate the trace data of the freshly created client span (not the active parent span)
// so downstream services are parented to the http.client span, matching the upstream behavior.
injectTracePropagationHeaders(span, request, requestUrl.toString());
// This also de-duplicates against any trace headers the user set manually (e.g. via `getTraceData()`).
addTracePropagationHeadersToFetchRequest(request, propagationDecisionMap, span);

spanFromReq.set(request, span);
}

// This is the 2nd message we receive for each request. It is fired when connection with
// the remote is established and about to send the first byte. Here we do have info about the
// remote address and port so we can populate some `network.*` attributes into the span
function onRequestHeaders(config: UndiciInstrumentationConfig, { request, socket }: RequestHeadersMessage): void {
function onRequestHeaders(config: NodeFetchOptions, { request, socket }: RequestHeadersMessage): void {
const span = spanFromReq.get(request);

if (!span) {
Expand Down Expand Up @@ -290,7 +310,13 @@ function onRequestHeaders(config: UndiciInstrumentationConfig, { request, socket
// This is the 3rd message we get for each request and it's fired when the server
// headers are received, body may not be accessible yet.
// From the response headers we can set the status and content length
function onResponseHeaders(config: UndiciInstrumentationConfig, { request, response }: ResponseHeadersMessage): void {
function onResponseHeaders(config: NodeFetchOptions, { request, response }: ResponseHeadersMessage): void {
// Breadcrumbs are recorded independently of spans, so this runs even when span creation is disabled.
const breadcrumbsEnabled = config.breadcrumbs !== false;
if (breadcrumbsEnabled && !ignoreRequestMap.get(request)) {
addFetchRequestBreadcrumb(request, response);
}

const span = spanFromReq.get(request);

if (!span) {
Expand Down Expand Up @@ -379,37 +405,6 @@ function onError({ request, error }: RequestErrorMessage): void {
spanFromReq.delete(request);
}

// Propagate the trace data of the given (client) span into the outgoing request headers, gated by
// `tracePropagationTargets`. Mirrors what `propagation.inject()` did with the SentryPropagator, but
// via Sentry's `getTraceData()` so we stay off OpenTelemetry's propagation API.
function injectTracePropagationHeaders(span: Span, request: UndiciRequest, url: string): void {
const { tracePropagationTargets, propagateTraceparent } = getClient()?.getOptions() ?? {};

if (!shouldPropagateTraceForUrl(url, tracePropagationTargets, propagationDecisionMap)) {
return;
}

// We make the freshly created client span active so the propagated headers reference it (and not
// the parent span). Passing `{ span }` to `getTraceData()` is not enough: for an inactive span it
// resolves to the span's *captured* scope, whose active span is still the parent.
const addedHeaders = withActiveSpan(span, () => getTraceData({ propagateTraceparent }));

for (const [k, v] of Object.entries(addedHeaders)) {
if (!v) {
continue;
}

if (typeof request.addHeader === 'function') {
request.addHeader(k, v);
} else if (typeof request.headers === 'string') {
request.headers += `${k}: ${v}\r\n`;
} else if (Array.isArray(request.headers)) {
// undici@6.11.0 accidentally, briefly removed `request.addHeader()`.
request.headers.push(k, v);
}
}
}

function getRequestMethod(original: string): string {
const knownMethods = {
CONNECT: true,
Expand Down
Loading