Skip to content
Draft
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
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
Expand Up @@ -15,7 +15,9 @@ test('updates the span name when calling `span.updateName` (streamed)', async ()
name: 'new name',
is_segment: true,
attributes: {
[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: { type: 'string', value: 'url' },
// `updateName` marks the name as explicitly chosen, so the source becomes `custom`,
// overriding the `url` source set at span start (a stale `url` no longer describes the name).
[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: { type: 'string', value: 'custom' },
},
},
],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,16 +7,18 @@ afterAll(() => {
});

test('updates the span name when calling `span.updateName`', async () => {
createRunner(__dirname, 'scenario.ts')
await createRunner(__dirname, 'scenario.ts')
.expect({
transaction: {
transaction: 'new name',
transaction_info: { source: 'url' },
// `updateName` marks the name as explicitly chosen, so the source becomes `custom`,
// overriding the `url` source set at span start (a stale `url` no longer describes the name).
transaction_info: { source: 'custom' },
contexts: {
trace: {
span_id: expect.any(String),
trace_id: expect.any(String),
data: { [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'url' },
data: { [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'custom' },
},
},
},
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