Skip to content
Draft
Original file line number Diff line number Diff line change
Expand Up @@ -54,41 +54,44 @@ test('Sends an API route transaction', async ({ baseURL }) => {
origin: 'auto.http.otel.http',
});

const manualSpanExpectation = {
data: {
'sentry.origin': 'manual',
},
description: 'test-span',
parent_span_id: expect.stringMatching(/[a-f0-9]{16}/),
span_id: expect.stringMatching(/[a-f0-9]{16}/),
start_timestamp: expect.any(Number),
status: 'ok',
timestamp: expect.any(Number),
trace_id: expect.stringMatching(/[a-f0-9]{32}/),
origin: 'manual',
};

const connectSpanExpectation = {
data: {
'sentry.origin': 'auto.http.otel.connect',
'sentry.op': 'request_handler.connect',
'http.route': '/test-transaction',
'connect.type': 'request_handler',
'connect.name': '/test-transaction',
},
op: 'request_handler.connect',
description: '/test-transaction',
parent_span_id: expect.stringMatching(/[a-f0-9]{16}/),
span_id: expect.stringMatching(/[a-f0-9]{16}/),
start_timestamp: expect.any(Number),
status: 'ok',
timestamp: expect.any(Number),
trace_id: expect.stringMatching(/[a-f0-9]{32}/),
origin: 'auto.http.otel.connect',
};

expect(transactionEvent).toEqual(
expect.objectContaining({
spans: [
{
data: {
'sentry.origin': 'manual',
},
description: 'test-span',
parent_span_id: expect.stringMatching(/[a-f0-9]{16}/),
span_id: expect.stringMatching(/[a-f0-9]{16}/),
start_timestamp: expect.any(Number),
status: 'ok',
timestamp: expect.any(Number),
trace_id: expect.stringMatching(/[a-f0-9]{32}/),
origin: 'manual',
},
{
data: {
'sentry.origin': 'auto.http.otel.connect',
'sentry.op': 'request_handler.connect',
'http.route': '/test-transaction',
'connect.type': 'request_handler',
'connect.name': '/test-transaction',
},
op: 'request_handler.connect',
description: '/test-transaction',
parent_span_id: expect.stringMatching(/[a-f0-9]{16}/),
span_id: expect.stringMatching(/[a-f0-9]{16}/),
start_timestamp: expect.any(Number),
status: 'ok',
timestamp: expect.any(Number),
trace_id: expect.stringMatching(/[a-f0-9]{32}/),
origin: 'auto.http.otel.connect',
},
],
// The SentryTracerProvider serializes native child spans in start/tree order, so the
// Connect handler span appears before the manual span created inside it.
spans: [connectSpanExpectation, manualSpanExpectation],
transaction: 'GET /test-transaction',
type: 'transaction',
transaction_info: {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
fetch('http://localhost:9999/external').catch(() => {});
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,28 @@ describe('no_parent_span client report', () => {
});

createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument.mjs', (createRunner, test) => {
test('records no_parent_span outcome for http.client span without a local parent', async () => {
test('records no_parent_span outcome for an outgoing http request without a local parent', async () => {
const runner = createRunner()
.unignore('client_report')
.expect({
client_report: report => {
expect(report.discarded_events).toEqual([
{
category: 'span',
quantity: 1,
reason: 'no_parent_span',
},
]);
},
})
.start();

await runner.completed();
});
});

createEsmAndCjsTests(__dirname, 'scenario-fetch.mjs', 'instrument.mjs', (createRunner, test) => {
test('records no_parent_span outcome for an outgoing fetch request without a local parent', async () => {
const runner = createRunner()
.unignore('client_report')
.expect({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { errorMonitor } from 'node:events';
import type { IncomingHttpHeaders } from 'node:http';
import { context, SpanKind, trace } from '@opentelemetry/api';
import type { RPCMetadata } from '@opentelemetry/core';
import { getRPCMetadata, isTracingSuppressed, RPCType, setRPCMetadata } from '@opentelemetry/core';
import { isTracingSuppressed, RPCType, setRPCMetadata } from '@opentelemetry/core';
import {
HTTP_RESPONSE_STATUS_CODE,
HTTP_ROUTE,
Expand Down Expand Up @@ -196,7 +196,7 @@ const _httpServerSpansIntegration = ((options: HttpServerSpansIntegrationOptions

isEnded = true;

const newAttributes = getIncomingRequestAttributesOnResponse(request, response);
const newAttributes = getIncomingRequestAttributesOnResponse(request, response, rpcMetadata);
span.setAttributes(newAttributes);
span.setStatus(status);
span.end();
Expand Down Expand Up @@ -225,15 +225,25 @@ const _httpServerSpansIntegration = ((options: HttpServerSpansIntegrationOptions
});
},
processEvent(event) {
// Drop transaction if it has a status code that should be ignored
if (event.type === 'transaction') {
const statusCode = event.contexts?.trace?.data?.['http.response.status_code'];
if (typeof statusCode === 'number') {
const shouldDrop = shouldFilterStatusCode(statusCode, ignoreStatusCodes);
if (shouldDrop) {
// Drop transaction if it has a status code that should be ignored
if (shouldFilterStatusCode(statusCode, ignoreStatusCodes)) {
DEBUG_BUILD && debug.log('Dropping transaction due to status code', statusCode);
return null;
}

// Surface the HTTP status as the top-level `response` context. The OTel SDK span
// exporter already does this on its path; doing it here covers transactions produced
// by the `SentryTracerProvider`, which bypasses that exporter.
event.contexts = {
...event.contexts,
response: {
...event.contexts?.response,
status_code: statusCode,
},
};
}
}

Expand Down Expand Up @@ -368,6 +378,7 @@ function isCompressed(headers: IncomingHttpHeaders): boolean {
function getIncomingRequestAttributesOnResponse(
request: HttpIncomingMessage,
response: HttpServerResponse,
rpcMetadata?: RPCMetadata,
): SpanAttributes {
// take socket from the request,
// since it may be detached from the response object in keep-alive mode
Expand All @@ -381,7 +392,6 @@ function getIncomingRequestAttributesOnResponse(
'http.status_text': statusMessage?.toUpperCase(),
};

const rpcMetadata = getRPCMetadata(context.active());
if (socket) {
const { localAddress, localPort, remoteAddress, remotePort } = socket;
// eslint-disable-next-line typescript/no-deprecated
Expand Down
9 changes: 6 additions & 3 deletions packages/node-core/src/sdk/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ import * as os from 'node:os';
import type { Tracer } from '@opentelemetry/api';
import { trace } from '@opentelemetry/api';
import { registerInstrumentations } from '@opentelemetry/instrumentation';
import type { BasicTracerProvider } from '@opentelemetry/sdk-trace-base';
import type { DynamicSamplingContext, Scope, ServerRuntimeClientOptions, TraceContext } from '@sentry/core';
import {
_INTERNAL_clearAiProviderSkips,
Expand All @@ -12,7 +11,11 @@ import {
SDK_VERSION,
ServerRuntimeClient,
} from '@sentry/core';
import { type AsyncLocalStorageLookup, getTraceContextForScope } from '@sentry/opentelemetry';
import {
type AsyncLocalStorageLookup,
getTraceContextForScope,
type OpenTelemetryTracerProvider,
} from '@sentry/opentelemetry';
import { isMainThread, threadId } from 'worker_threads';
import { DEBUG_BUILD } from '../debug-build';
import type { NodeClientOptions } from '../types';
Expand All @@ -21,7 +24,7 @@ const DEFAULT_CLIENT_REPORT_FLUSH_INTERVAL_MS = 60_000; // 60s was chosen arbitr

/** A client for using Sentry with Node & OpenTelemetry. */
export class NodeClient extends ServerRuntimeClient<NodeClientOptions> {
public traceProvider: BasicTracerProvider | undefined;
public traceProvider: OpenTelemetryTracerProvider | undefined;
public asyncLocalStorageLookup: AsyncLocalStorageLookup | undefined;

private _tracer: Tracer | undefined;
Expand Down
6 changes: 4 additions & 2 deletions packages/node-core/src/sdk/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -168,7 +168,9 @@ export function validateOpenTelemetrySetup(): void {

const required: ReturnType<typeof openTelemetrySetupCheck> = ['SentryContextManager', 'SentryPropagator'];

if (hasSpansEnabled()) {
const hasSentryTracerProvider = setup.includes('SentryTracerProvider');

if (hasSpansEnabled() && !hasSentryTracerProvider) {
required.push('SentrySpanProcessor');
}

Expand All @@ -180,7 +182,7 @@ export function validateOpenTelemetrySetup(): void {
}
}

if (!setup.includes('SentrySampler')) {
if (!hasSentryTracerProvider && !setup.includes('SentrySampler')) {
debug.warn(
'You have to set up the SentrySampler. Without this, the OpenTelemetry & Sentry integration may still work, but sample rates set for the Sentry SDK will not be respected. If you use a custom sampler, make sure to use `wrapSamplingDecision`.',
);
Expand Down
14 changes: 14 additions & 0 deletions packages/node-core/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,20 @@ export interface OpenTelemetryServerRuntimeOptions extends ServerRuntimeOptions
* Provide an array of additional OpenTelemetry SpanProcessors that should be registered.
*/
openTelemetrySpanProcessors?: SpanProcessor[];

/**
* By default, the SDK uses Sentry's minimal OpenTelemetry tracer provider, which creates native
* Sentry spans directly instead of going through the full OpenTelemetry SDK span pipeline.
*
* Set this to `true` to use the full OpenTelemetry SDK `BasicTracerProvider` instead, e.g. if you
* rely on OpenTelemetry SDK features that the minimal provider does not support.
*
* Note: providing `openTelemetrySpanProcessors` also forces the full OpenTelemetry SDK provider,
* since custom span processors require the SDK span pipeline.
*
* @default false
*/
openTelemetryBasicTracerProvider?: boolean;
}

/**
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import { describe, expect, it } from 'vitest';
import { isStaticAssetRequest } from '../../src/integrations/http/httpServerSpansIntegration';
import {
httpServerSpansIntegration,
isStaticAssetRequest,
} from '../../src/integrations/http/httpServerSpansIntegration';

describe('httpIntegration', () => {
describe('isStaticAssetRequest', () => {
Expand Down Expand Up @@ -31,4 +34,50 @@ describe('httpIntegration', () => {
expect(isStaticAssetRequest(urlPath)).toBe(expected);
});
});

describe('processEvent', () => {
function runProcessEvent(event: Record<string, unknown>, options = {}): any {
const integration = httpServerSpansIntegration(options);
return (integration as any).processEvent(event, {}, {});
}

it('lifts the HTTP response status code into the top-level `response` context', () => {
const event = runProcessEvent(
{ type: 'transaction', contexts: { trace: { data: { 'http.response.status_code': 200 } } } },
{ ignoreStatusCodes: [] },
);

expect(event.contexts.response).toEqual({ status_code: 200 });
});

it('preserves existing `response` context fields', () => {
const event = runProcessEvent(
{
type: 'transaction',
contexts: { response: { body_size: 42 }, trace: { data: { 'http.response.status_code': 201 } } },
},
{ ignoreStatusCodes: [] },
);

expect(event.contexts.response).toEqual({ body_size: 42, status_code: 201 });
});

it('does not add a `response` context when there is no HTTP status code', () => {
const event = runProcessEvent(
{ type: 'transaction', contexts: { trace: { data: {} } } },
{ ignoreStatusCodes: [] },
);

expect(event.contexts.response).toBeUndefined();
});

it('drops transactions whose status code is in `ignoreStatusCodes`', () => {
const event = runProcessEvent(
{ type: 'transaction', contexts: { trace: { data: { 'http.response.status_code': 404 } } } },
{ ignoreStatusCodes: [404] },
);

expect(event).toBeNull();
});
});
});
22 changes: 19 additions & 3 deletions packages/node/src/integrations/node-fetch/vendored/undici.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@
* - Refactored to use Sentry's span APIs instead of OpenTelemetry tracing APIs
* - Dropped the OTel metrics (no MeterProvider is wired up) and the dead
* `requireParentforSpans` code path (the SDK always passes `false`)
* - An orphan `http.client` span (no local parent) is created suppressed/non-recording outside of
* span streaming, so it isn't emitted as a standalone transaction. It is still created so trace
* propagation headers are injected.
* - Dropped the `@opentelemetry/instrumentation` base (undici reports via `diagnostics_channel`,
* so no module patching was needed) — now a plain class wired up directly by the integration
*/
Expand All @@ -20,7 +23,9 @@ import type { Span, SpanAttributes } from '@sentry/core';
import {
debug,
getClient,
getSpanStatusFromHttpCode,
getTraceData,
hasSpanStreamingEnabled,
LRUMap,
shouldPropagateTraceForUrl,
SPAN_KIND,
Expand Down Expand Up @@ -242,10 +247,18 @@ export class UndiciInstrumentation {
});
}

// Outside of span streaming, only record an `http.client` span when it has a parent. An orphan
// one (no local parent) is left to the server for the downstream sampling decision: `onlyIfParent`
// still creates a non-recording span so trace propagation headers are injected, but it isn't
// emitted as a standalone transaction. This rule also lives in `SentrySampler`, but that only runs
// when an OpenTelemetry SDK tracer provider is set up, so we enforce it here too, which covers
// SDKs that don't use an OpenTelemetry tracer provider at all.
const client = getClient();
const span = startInactiveSpan({
name: requestMethod === '_OTHER' ? 'HTTP' : requestMethod,
kind: SPAN_KIND.CLIENT,
attributes,
onlyIfParent: !client || !hasSpanStreamingEnabled(client),
});

// Execute the request hook if defined
Expand Down Expand Up @@ -344,10 +357,13 @@ export class UndiciInstrumentation {

span.setAttributes(spanAttributes);

// The Sentry pipeline infers `ok` / `not_found` / etc. from `http.response.status_code` when the
// status is left unset, so we only need to flag erroneous responses explicitly.
// Resolve the HTTP status code to a Sentry span status here (like the raw http client/server
// instrumentation does) instead of setting a bare error and deferring to downstream inference.
// The SentryTracerProvider's status finalization reads the already-stringified span status, which
// can no longer be inferred back to `not_found` etc. the way the OpenTelemetry SDK exporter's
// `mapStatus` does from the raw `{ code, message }`.
if (response.statusCode >= 400) {
span.setStatus({ code: SPAN_STATUS_ERROR });
span.setStatus(getSpanStatusFromHttpCode(response.statusCode));
}
}

Expand Down
Loading
Loading