diff --git a/dev-packages/node-integration-tests/suites/tracing/ignoreSpans-streamed/continued-trace-child/instrument.mjs b/dev-packages/node-integration-tests/suites/tracing/ignoreSpans-streamed/continued-trace-child/instrument.mjs new file mode 100644 index 000000000000..75d3d12bc730 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/ignoreSpans-streamed/continued-trace-child/instrument.mjs @@ -0,0 +1,12 @@ +import * as Sentry from '@sentry/node'; +import { loggingTransport } from '@sentry-internal/node-integration-tests'; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + tracesSampleRate: 0, + transport: loggingTransport, + traceLifecycle: 'stream', + ignoreSpans: ['ignoredMiddleware'], + tracePropagationTargets: [process.env.SERVER_URL], + clientReportFlushInterval: 1_000, +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/ignoreSpans-streamed/continued-trace-child/server.mjs b/dev-packages/node-integration-tests/suites/tracing/ignoreSpans-streamed/continued-trace-child/server.mjs new file mode 100644 index 000000000000..c564f0a4e2ba --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/ignoreSpans-streamed/continued-trace-child/server.mjs @@ -0,0 +1,34 @@ +import * as Sentry from '@sentry/node'; +import { startExpressServerAndSendPortToRunner } from '@sentry-internal/node-integration-tests'; +import express from 'express'; +import * as http from 'http'; + +const app = express(); + +app.use(async function ignoredMiddleware(req, _res, next) { + if (req.path === '/ignored-child-http') { + await makeHttpRequest(`${process.env.SERVER_URL}/outgoing`); + } else { + await fetch(`${process.env.SERVER_URL}/outgoing`); + } + next(); +}); + +app.get(['/ignored-child', '/ignored-child-http'], (_req, res) => { + res.send({ status: 'ok' }); + setTimeout(() => { + Sentry.flush(); + }); +}); + +startExpressServerAndSendPortToRunner(app); + +function makeHttpRequest(url) { + return new Promise((resolve, reject) => { + const request = http.get(url, httpRes => { + httpRes.resume(); + httpRes.on('end', resolve); + }); + request.on('error', reject); + }); +} diff --git a/dev-packages/node-integration-tests/suites/tracing/ignoreSpans-streamed/continued-trace-child/test.ts b/dev-packages/node-integration-tests/suites/tracing/ignoreSpans-streamed/continued-trace-child/test.ts new file mode 100644 index 000000000000..a9c5574810d5 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/ignoreSpans-streamed/continued-trace-child/test.ts @@ -0,0 +1,63 @@ +import { createTestServer } from '@sentry-internal/test-utils'; +import { afterAll, describe, expect } from 'vitest'; +import { cleanupChildProcesses, createEsmAndCjsTests } from '../../../../utils/runner'; +import { SENTRY_OP } from '@sentry/conventions/attributes'; + +describe('ignoring a child of a continued server segment (streaming)', () => { + afterAll(() => { + cleanupChildProcesses(); + }); + + createEsmAndCjsTests(__dirname, 'server.mjs', 'instrument.mjs', (createRunner, test) => { + const testPropagation = async (path: string): Promise => { + const [SERVER_URL, closeTestServer] = await createTestServer() + .get('/outgoing', headers => { + expect(headers['sentry-trace']).toMatch(/^12345678901234567890123456789012-[\da-f]{16}-1$/); + + expect(headers['baggage']).toBe( + 'sentry-trace_id=12345678901234567890123456789012,sentry-sample_rate=1,sentry-sampled=true,sentry-public_key=public,sentry-sample_rand=0.5', + ); + }) + .start(); + const runner = createRunner() + .withEnv({ SERVER_URL }) + .unignore('client_report') + .expect({ + client_report: { + discarded_events: [{ category: 'span', quantity: 1, reason: 'ignored' }], + }, + }) + .expect({ + span: container => { + const httpServerSpan = container.items.find(item => item.attributes[SENTRY_OP]?.value === 'http.server'); + + expect(httpServerSpan?.is_segment).toBe(true); + expect(httpServerSpan?.trace_id).toBe('12345678901234567890123456789012'); + expect(container.items.some(item => item.name === 'ignoredMiddleware')).toBe(false); + }, + }) + .start(); + + try { + const response = await runner.makeRequest<{ status: string }>('get', path, { + headers: { + 'sentry-trace': '12345678901234567890123456789012-1234567890123456-1', + baggage: + 'sentry-trace_id=12345678901234567890123456789012,sentry-sample_rate=1,sentry-sampled=true,sentry-public_key=public,sentry-sample_rand=0.5', + }, + }); + + expect(response?.status).toBe('ok'); + await runner.completed(); + } finally { + closeTestServer(); + } + }; + + test('preserves the positive sampling decision on outgoing fetch requests', () => + testPropagation('/ignored-child')); + + test('preserves the positive sampling decision on outgoing node:http requests', () => + testPropagation('/ignored-child-http')); + }); +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/ignoreSpans-streamed/continued-trace-http-client/instrument.mjs b/dev-packages/node-integration-tests/suites/tracing/ignoreSpans-streamed/continued-trace-http-client/instrument.mjs new file mode 100644 index 000000000000..2d534bda43f2 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/ignoreSpans-streamed/continued-trace-http-client/instrument.mjs @@ -0,0 +1,12 @@ +import * as Sentry from '@sentry/node'; +import { loggingTransport } from '@sentry-internal/node-integration-tests'; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + tracesSampleRate: 0, + transport: loggingTransport, + traceLifecycle: 'stream', + ignoreSpans: [{ attributes: { 'url.path': '/outgoing' } }], + tracePropagationTargets: [process.env.SERVER_URL], + clientReportFlushInterval: 1_000, +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/ignoreSpans-streamed/continued-trace-http-client/server.mjs b/dev-packages/node-integration-tests/suites/tracing/ignoreSpans-streamed/continued-trace-http-client/server.mjs new file mode 100644 index 000000000000..463b08d529af --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/ignoreSpans-streamed/continued-trace-http-client/server.mjs @@ -0,0 +1,34 @@ +import * as Sentry from '@sentry/node'; +import { startExpressServerAndSendPortToRunner } from '@sentry-internal/node-integration-tests'; +import express from 'express'; +import * as http from 'http'; + +const app = express(); + +app.get('/ignored-http-client', async (_req, res) => { + await fetch(`${process.env.SERVER_URL}/outgoing`); + res.send({ status: 'ok' }); + setTimeout(() => { + Sentry.flush(); + }); +}); + +app.get('/ignored-node-http-client', async (_req, res) => { + await makeHttpRequest(`${process.env.SERVER_URL}/outgoing`); + res.send({ status: 'ok' }); + setTimeout(() => { + Sentry.flush(); + }); +}); + +startExpressServerAndSendPortToRunner(app); + +function makeHttpRequest(url) { + return new Promise((resolve, reject) => { + const request = http.get(url, httpRes => { + httpRes.resume(); + httpRes.on('end', resolve); + }); + request.on('error', reject); + }); +} diff --git a/dev-packages/node-integration-tests/suites/tracing/ignoreSpans-streamed/continued-trace-http-client/test.ts b/dev-packages/node-integration-tests/suites/tracing/ignoreSpans-streamed/continued-trace-http-client/test.ts new file mode 100644 index 000000000000..79e727254710 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/ignoreSpans-streamed/continued-trace-http-client/test.ts @@ -0,0 +1,67 @@ +import { createTestServer } from '@sentry-internal/test-utils'; +import { afterAll, describe, expect } from 'vitest'; +import { cleanupChildProcesses, createEsmAndCjsTests } from '../../../../utils/runner'; +import { SENTRY_OP } from '@sentry/conventions/attributes'; + +describe('ignoring an HTTP client child of a continued server segment (streaming)', () => { + afterAll(() => { + cleanupChildProcesses(); + }); + + createEsmAndCjsTests(__dirname, 'server.mjs', 'instrument.mjs', (createRunner, test) => { + const testPropagation = async (path: string): Promise => { + const [SERVER_URL, closeTestServer] = await createTestServer() + .get('/outgoing', headers => { + expect + .soft(headers['sentry-trace']) + .toEqual(expect.stringMatching(/^12345678901234567890123456789012-[\da-f]{16}-1$/)); + + expect(headers['baggage']).toBe( + 'sentry-trace_id=12345678901234567890123456789012,sentry-sample_rate=1,sentry-sampled=true,sentry-public_key=public,sentry-sample_rand=0.5', + ); + }) + .start(); + + const runner = createRunner() + .withEnv({ SERVER_URL }) + .unignore('client_report') + .expect({ + client_report: { + discarded_events: [{ category: 'span', quantity: 1, reason: 'ignored' }], + }, + }) + .expect({ + span: container => { + const httpServerSpan = container.items.find(item => item.attributes[SENTRY_OP]?.value === 'http.server'); + + expect(httpServerSpan?.is_segment).toBe(true); + expect(httpServerSpan?.trace_id).toBe('12345678901234567890123456789012'); + + expect(container.items.some(item => item.attributes[SENTRY_OP]?.value === 'http.client')).toBe(false); + }, + }) + .start(); + + try { + const response = await runner.makeRequest<{ status: string }>('get', path, { + headers: { + 'sentry-trace': '12345678901234567890123456789012-1234567890123456-1', + baggage: + 'sentry-trace_id=12345678901234567890123456789012,sentry-sample_rate=1,sentry-sampled=true,sentry-public_key=public,sentry-sample_rand=0.5', + }, + }); + + expect(response?.status).toBe('ok'); + await runner.completed(); + } finally { + closeTestServer(); + } + }; + + test('preserves the positive sampling decision on the outgoing fetch request', () => + testPropagation('/ignored-http-client')); + + test('preserves the positive sampling decision on the outgoing node:http request', () => + testPropagation('/ignored-node-http-client')); + }); +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/ignoreSpans-streamed/continued-trace-segment/instrument.mjs b/dev-packages/node-integration-tests/suites/tracing/ignoreSpans-streamed/continued-trace-segment/instrument.mjs new file mode 100644 index 000000000000..d6c2931b331f --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/ignoreSpans-streamed/continued-trace-segment/instrument.mjs @@ -0,0 +1,11 @@ +import * as Sentry from '@sentry/node'; +import { loggingTransport } from '@sentry-internal/node-integration-tests'; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + tracesSampleRate: 0, + transport: loggingTransport, + traceLifecycle: 'stream', + ignoreSpans: [{ op: 'http.server' }], + tracePropagationTargets: [process.env.SERVER_URL], +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/ignoreSpans-streamed/continued-trace-segment/server.mjs b/dev-packages/node-integration-tests/suites/tracing/ignoreSpans-streamed/continued-trace-segment/server.mjs new file mode 100644 index 000000000000..aeae85a6edc6 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/ignoreSpans-streamed/continued-trace-segment/server.mjs @@ -0,0 +1,27 @@ +import { startExpressServerAndSendPortToRunner } from '@sentry-internal/node-integration-tests'; +import express from 'express'; +import * as http from 'http'; + +const app = express(); + +app.get('/ignored', async (_req, res) => { + await fetch(`${process.env.SERVER_URL}/outgoing`); + res.send({ status: 'ok' }); +}); + +app.get('/ignored-http', async (_req, res) => { + await makeHttpRequest(`${process.env.SERVER_URL}/outgoing`); + res.send({ status: 'ok' }); +}); + +startExpressServerAndSendPortToRunner(app); + +function makeHttpRequest(url) { + return new Promise((resolve, reject) => { + const request = http.get(url, httpRes => { + httpRes.resume(); + httpRes.on('end', resolve); + }); + request.on('error', reject); + }); +} diff --git a/dev-packages/node-integration-tests/suites/tracing/ignoreSpans-streamed/continued-trace-segment/test.ts b/dev-packages/node-integration-tests/suites/tracing/ignoreSpans-streamed/continued-trace-segment/test.ts new file mode 100644 index 000000000000..ac7e950b58cb --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/ignoreSpans-streamed/continued-trace-segment/test.ts @@ -0,0 +1,46 @@ +import { createTestServer } from '@sentry-internal/test-utils'; +import { afterAll, describe, expect } from 'vitest'; +import { cleanupChildProcesses, createEsmAndCjsTests } from '../../../../utils/runner'; + +describe('ignoring a continued server segment (streaming)', () => { + afterAll(() => { + cleanupChildProcesses(); + }); + + createEsmAndCjsTests(__dirname, 'server.mjs', 'instrument.mjs', (createRunner, test) => { + const testPropagation = async (path: string): Promise => { + expect.assertions(3); + + const [SERVER_URL, closeTestServer] = await createTestServer() + .get('/outgoing', headers => { + const sentryTrace = headers['sentry-trace']; + const baggage = headers['baggage']; + + expect(sentryTrace).toMatch(/12345678901234567890123456789012-[\da-f]{16}-0/); + expect(baggage).toBe( + 'sentry-trace_id=12345678901234567890123456789012,sentry-sample_rate=1,sentry-sampled=false,sentry-public_key=public,sentry-sample_rand=0.5', + ); + }) + .start(); + const runner = createRunner().withEnv({ SERVER_URL }).start(); + + try { + const response = await runner.makeRequest<{ status: string }>('get', path, { + headers: { + 'sentry-trace': '12345678901234567890123456789012-1234567890123456-1', + baggage: + 'sentry-trace_id=12345678901234567890123456789012,sentry-sample_rate=1,sentry-sampled=true,sentry-public_key=public,sentry-sample_rand=0.5', + }, + }); + + expect(response?.status).toBe('ok'); + } finally { + closeTestServer(); + } + }; + + test('propagates a negative sampling decision to outgoing fetch requests', () => testPropagation('/ignored')); + test('propagates a negative sampling decision to outgoing node:http requests', () => + testPropagation('/ignored-http')); + }); +}); diff --git a/packages/core/src/tracing/dynamicSamplingContext.ts b/packages/core/src/tracing/dynamicSamplingContext.ts index a0af0ab23485..192459811798 100644 --- a/packages/core/src/tracing/dynamicSamplingContext.ts +++ b/packages/core/src/tracing/dynamicSamplingContext.ts @@ -107,20 +107,26 @@ export function getDynamicSamplingContextFromSpan(span: Span): Readonly { expect(dynamicSamplingContext).toStrictEqual({ environment: 'myEnv2' }); }); + test('preserves incoming DSC for ignored segment spans with a negative sampling decision', () => { + const traceId = '12345678901234567890123456789012'; + const scope = new Scope(); + scope.setPropagationContext({ + traceId, + parentSpanId: '1234567890123456', + sampled: true, + dsc: { + trace_id: traceId, + sample_rate: '1', + sampled: 'true', + public_key: 'public', + sample_rand: '0.5', + }, + sampleRand: 0.5, + }); + const rootSpan = new SentryNonRecordingSpan({ dropReason: 'ignored', traceId }); + setCapturedScopesOnSpan(rootSpan, scope, scope); + + const dynamicSamplingContext = getDynamicSamplingContextFromSpan(rootSpan); + + expect(dynamicSamplingContext).toEqual({ + trace_id: traceId, + sample_rate: '1', + sampled: 'false', + public_key: 'public', + sample_rand: '0.5', + }); + }); + test('returns a new DSC, if no DSC was provided during rootSpan creation (via attributes)', () => { const rootSpan = startInactiveSpan({ name: 'tx' }); diff --git a/packages/core/test/lib/tracing/trace.test.ts b/packages/core/test/lib/tracing/trace.test.ts index ae53cb7681d6..47f97425af64 100644 --- a/packages/core/test/lib/tracing/trace.test.ts +++ b/packages/core/test/lib/tracing/trace.test.ts @@ -2707,9 +2707,16 @@ describe('ignoreSpans (core path, streaming)', () => { client.init(); const spyOnDroppedEvent = vi.spyOn(client, 'recordDroppedEvent'); - startSpan({ name: 'root' }, () => { - startSpan({ name: 'ignored-child' }, span => { - expect(span).toBeInstanceOf(SentryNonRecordingSpan); + startSpan({ name: 'root' }, rootSpan => { + startSpan({ name: 'ignored-child' }, ignoredSpan1 => { + expect(ignoredSpan1).toBeInstanceOf(SentryNonRecordingSpan); + expect(getRootSpan(ignoredSpan1)).toBe(rootSpan); + + startSpan({ name: 'ignored-child' }, ignoredSpan2 => { + expect(ignoredSpan2).toBeInstanceOf(SentryNonRecordingSpan); + // since ignoredSpan1 is not active, ignoredSpan2 also has root as its parent + expect(getRootSpan(ignoredSpan2)).toBe(rootSpan); + }); }); }); diff --git a/packages/node-core/src/utils/outgoingFetchRequest.ts b/packages/node-core/src/utils/outgoingFetchRequest.ts index 76156bfe0f29..a94779633f43 100644 --- a/packages/node-core/src/utils/outgoingFetchRequest.ts +++ b/packages/node-core/src/utils/outgoingFetchRequest.ts @@ -1,6 +1,7 @@ import type { LRUMap, SanitizedRequestData, Span } from '@sentry/core'; import { addBreadcrumb, + getActiveSpan, getBreadcrumbLogLevelFromHttpStatusCode, getClient, getSanitizedUrlString, @@ -8,6 +9,7 @@ import { parseUrl, shouldPropagateTraceForUrl, mergeBaggageHeaders, + spanIsIgnored, withActiveSpan, } from '@sentry/core'; import type { UndiciRequest, UndiciResponse } from '../integrations/node-fetch/types'; @@ -45,11 +47,11 @@ export function addTracePropagationHeadersToFetchRequest( 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 })) + // An ignored child must not become the propagation parent because no span will be emitted for it. + // Otherwise, make the span active so the propagated headers reference it instead of its parent. + const spanForTraceHeaders = span && spanIsIgnored(span) && getActiveSpan() ? undefined : span; + const addedHeaders = spanForTraceHeaders + ? withActiveSpan(spanForTraceHeaders, () => getTraceData({ propagateTraceparent })) : getTraceData({ propagateTraceparent }); if (!addedHeaders) { diff --git a/packages/opentelemetry/src/nodeAsyncContextStrategy.ts b/packages/opentelemetry/src/nodeAsyncContextStrategy.ts index 2b55f21bad67..b4f64a7f305e 100644 --- a/packages/opentelemetry/src/nodeAsyncContextStrategy.ts +++ b/packages/opentelemetry/src/nodeAsyncContextStrategy.ts @@ -1,7 +1,8 @@ import * as api from '@opentelemetry/api'; import { setOpenTelemetryContextAsyncContextStrategy } from './asyncContextStrategy'; import { AsyncLocalStorage } from 'node:async_hooks'; -import type { TracingChannelBinding } from '@sentry/core'; +import { getRootSpan, spanIsIgnored, type TracingChannelBinding } from '@sentry/core'; +import { SENTRY_TRACE_STATE_CHILD_IGNORED } from './constants'; interface ContextApi { _getContextManager(): @@ -31,7 +32,7 @@ function getDefaultAsyncLocalStorageFactory(): () => TracingChannelBinding { return () => { return { asyncLocalStorage: defaultAsyncLocalStorage, - getStoreWithActiveSpan: span => api.trace.setSpan(api.context.active(), span), + getStoreWithActiveSpan, } satisfies TracingChannelBinding; }; } @@ -50,7 +51,7 @@ function getCustomAsyncLocalStorageFactory(): () => TracingChannelBinding | unde return asyncLocalStorage ? ({ asyncLocalStorage, - getStoreWithActiveSpan: span => api.trace.setSpan(api.context.active(), span as api.Span), + getStoreWithActiveSpan, } satisfies TracingChannelBinding) : undefined; } catch { @@ -58,3 +59,15 @@ function getCustomAsyncLocalStorageFactory(): () => TracingChannelBinding | unde } }; } + +function getStoreWithActiveSpan(span: Parameters[0]): api.Context { + const activeContext = api.context.active(); + + // Tracing channels bind directly to the context manager's AsyncLocalStorage and bypass + // SentryContextManager.with(), so ignored children must restore their parent here as well. + const isIgnoredChild = + (spanIsIgnored(span) && getRootSpan(span) !== span) || + span.spanContext().traceState?.get(SENTRY_TRACE_STATE_CHILD_IGNORED) === '1'; + + return isIgnoredChild ? activeContext : api.trace.setSpan(activeContext, span); +} diff --git a/packages/opentelemetry/src/utils/getSamplingDecision.ts b/packages/opentelemetry/src/utils/getSamplingDecision.ts index 6089126b04a0..710b5451dc3d 100644 --- a/packages/opentelemetry/src/utils/getSamplingDecision.ts +++ b/packages/opentelemetry/src/utils/getSamplingDecision.ts @@ -5,6 +5,7 @@ import { baggageHeaderToDynamicSamplingContext, getRootSpan, hasSpansEnabled, + spanIsIgnored, spanIsSampled, spanIsSentrySpan, } from '@sentry/core'; @@ -54,10 +55,10 @@ export function getSamplingDecision(spanContext: SpanContext): boolean | undefin * Prefer the OpenTelemetry trace state via {@link getSamplingDecision}. Native Sentry spans (created * by the `SentryTracerProvider`) don't carry that trace state, so when it's absent we fall back to the * span's own decision via `spanIsSampled` — but only for an *explicit* decision. An explicit decision - * always originates at a real `SentrySpan` root (a negatively sampled root, or a child of one). A - * non-recording placeholder root (an orphan/suppressed span, or a TwP placeholder) and a remote span - * have a *deferred* decision that lives elsewhere (the scope, or the incoming trace state), so we - * return `undefined` and leave the decision deferred rather than wrongly asserting `-0`. + * originates at a real `SentrySpan` root (a negatively sampled root, or a child of one) or an ignored + * segment root. Other non-recording placeholder roots (orphan/suppressed spans or TwP placeholders) + * and remote spans have a *deferred* decision that lives elsewhere (the scope, or the incoming trace + * state), so we return `undefined` rather than wrongly asserting `-0`. * * TODO(v11): Once the OTel SDK provider is gone and every local span is a native Sentry span, the * trace-state lookup only matters for remote (incoming) spans; the local path always reads the span's @@ -65,6 +66,7 @@ export function getSamplingDecision(spanContext: SpanContext): boolean | undefin */ export function getSampledForPropagation(span: Span, client: Client | undefined): boolean | undefined { const spanContext = span.spanContext(); + const rootSpan = getRootSpan(span); // Prefer the OTel trace state: it carries the decision for OTel SDK spans and for remote (incoming) // spans, and unambiguously separates sampled / unsampled / deferred. @@ -73,12 +75,16 @@ export function getSampledForPropagation(span: Span, client: Client | undefined) return samplingDecision; } + if (spanIsIgnored(rootSpan)) { + return false; + } + // No trace state in it. Only read the span's own decision (`spanIsSampled`) when it's an explicit // one, which lives on a native recording `SentrySpan` root (created by the SentryTracerProvider). // Everything else defers: TwP (deferred), remote spans (decision is in the incoming trace state), // and non-recording placeholder roots — whether a Sentry orphan/suppressed span or, on the OTel SDK // path, an OpenTelemetry `NonRecordingSpan` (which `spanIsSentrySpan` also excludes). - if (!hasSpansEnabled(client?.getOptions()) || spanContext.isRemote || !spanIsSentrySpan(getRootSpan(span))) { + if (!hasSpansEnabled(client?.getOptions()) || spanContext.isRemote || !spanIsSentrySpan(rootSpan)) { return undefined; } diff --git a/packages/opentelemetry/test/asyncContextStrategy.test.ts b/packages/opentelemetry/test/asyncContextStrategy.test.ts index 89a3ab856075..55499676d912 100644 --- a/packages/opentelemetry/test/asyncContextStrategy.test.ts +++ b/packages/opentelemetry/test/asyncContextStrategy.test.ts @@ -1,15 +1,23 @@ +import { context, trace, TraceFlags, type Context } from '@opentelemetry/api'; import type { BasicTracerProvider } from '@opentelemetry/sdk-trace-base'; import type { Scope } from '@sentry/core'; import { + addChildSpanToSpan, + getAsyncContextStrategy, getCurrentScope, getIsolationScope, + getMainCarrier, Scope as ScopeClass, + SentryNonRecordingSpan, setAsyncContextStrategy, withIsolationScope, withScope, } from '@sentry/core'; import { afterAll, afterEach, beforeEach, describe, expect, it, test } from 'vitest'; import { setOpenTelemetryContextAsyncContextStrategy } from '../src/asyncContextStrategy'; +import { SENTRY_TRACE_STATE_CHILD_IGNORED } from '../src/constants'; +import { setNodeOpenTelemetryContextAsyncContextStrategy } from '../src/nodeAsyncContextStrategy'; +import { TraceState } from '../src/utils/TraceState'; import { setupOtel } from './helpers/initOtel'; import { cleanupOtel } from './helpers/mockSdkInit'; import { getDefaultTestClientOptions, TestClient } from './helpers/TestClient'; @@ -81,6 +89,73 @@ describe('asyncContextStrategy', () => { }); }); + test('tracing channel binding keeps the parent active for an ignored child span', () => { + setNodeOpenTelemetryContextAsyncContextStrategy(); + + const parentSpan = trace.getTracer('test').startSpan('parent'); + const ignoredSpan = trace.wrapSpanContext({ + traceId: parentSpan.spanContext().traceId, + spanId: '1234567890123456', + traceFlags: TraceFlags.NONE, + traceState: new TraceState().set(SENTRY_TRACE_STATE_CHILD_IGNORED, '1'), + }); + + context.with(trace.setSpan(context.active(), parentSpan), () => { + const binding = getAsyncContextStrategy(getMainCarrier()).getTracingChannelBinding?.(); + const store = binding?.getStoreWithActiveSpan(ignoredSpan); + + expect(store).toBeDefined(); + expect(trace.getSpan(store as Context)).toBe(parentSpan); + }); + + parentSpan.end(); + }); + + test('tracing channel binding keeps the parent active for a native ignored child span', () => { + setNodeOpenTelemetryContextAsyncContextStrategy(); + + const parentSpan = trace.getTracer('test').startSpan('parent'); + const ignoredSpan = new SentryNonRecordingSpan({ + dropReason: 'ignored', + traceId: parentSpan.spanContext().traceId, + }); + addChildSpanToSpan(parentSpan, ignoredSpan); + + context.with(trace.setSpan(context.active(), parentSpan), () => { + const binding = getAsyncContextStrategy(getMainCarrier()).getTracingChannelBinding?.(); + const store = binding?.getStoreWithActiveSpan(ignoredSpan); + + expect(store).toBeDefined(); + expect(trace.getSpan(store as Context)).toBe(parentSpan); + }); + + parentSpan.end(); + }); + + test('tracing channel binding activates a native ignored root span with a remote parent', () => { + setNodeOpenTelemetryContextAsyncContextStrategy(); + + const traceId = '12345678901234567890123456789012'; + const remoteParent = trace.wrapSpanContext({ + traceId, + spanId: '1234567890123456', + traceFlags: TraceFlags.SAMPLED, + isRemote: true, + }); + const ignoredSpan = new SentryNonRecordingSpan({ + dropReason: 'ignored', + traceId, + }); + + context.with(trace.setSpan(context.active(), remoteParent), () => { + const binding = getAsyncContextStrategy(getMainCarrier()).getTracingChannelBinding?.(); + const store = binding?.getStoreWithActiveSpan(ignoredSpan); + + expect(store).toBeDefined(); + expect(trace.getSpan(store as Context)).toBe(ignoredSpan); + }); + }); + test('async scope inheritance', async () => { const initialScope = getCurrentScope(); const initialIsolationScope = getIsolationScope();