From 861feee7a5e861d0d63b28956eb314dba4d1a01b Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Mon, 23 Mar 2026 14:40:44 +0100 Subject: [PATCH 1/5] feat(core): Apply `ignoreSpans` when span streaming is enabled --- .../ignoreSpans-streamed/child/init.js | 10 ++ .../ignoreSpans-streamed/child/subject.js | 12 ++ .../ignoreSpans-streamed/child/test.ts | 55 +++++++++ .../ignoreSpans-streamed/segment/init.js | 11 ++ .../ignoreSpans-streamed/segment/subject.js | 12 ++ .../ignoreSpans-streamed/segment/test.ts | 45 ++++++++ .../tracing/ignoreSpans/children/server.js | 38 +++++++ .../tracing/ignoreSpans/children/test.ts | 70 ++++++++++++ .../tracing/ignoreSpans/segments/server.js | 33 ++++++ .../tracing/ignoreSpans/segments/test.ts | 48 ++++++++ packages/core/src/index.ts | 1 + packages/core/src/tracing/trace.ts | 53 ++++++++- packages/core/test/lib/tracing/trace.test.ts | 89 +++++++++++++++ .../test/lib/utils/should-ignore-span.test.ts | 14 +++ packages/opentelemetry/src/constants.ts | 6 + packages/opentelemetry/src/contextManager.ts | 19 +++- packages/opentelemetry/src/sampler.ts | 34 ++++++ packages/opentelemetry/src/trace.ts | 22 ++++ .../opentelemetry/test/contextManager.test.ts | 48 ++++++++ packages/opentelemetry/test/sampler.test.ts | 107 +++++++++++++++++- packages/opentelemetry/test/trace.test.ts | 49 ++++++++ 21 files changed, 768 insertions(+), 8 deletions(-) create mode 100644 dev-packages/browser-integration-tests/suites/tracing/ignoreSpans-streamed/child/init.js create mode 100644 dev-packages/browser-integration-tests/suites/tracing/ignoreSpans-streamed/child/subject.js create mode 100644 dev-packages/browser-integration-tests/suites/tracing/ignoreSpans-streamed/child/test.ts create mode 100644 dev-packages/browser-integration-tests/suites/tracing/ignoreSpans-streamed/segment/init.js create mode 100644 dev-packages/browser-integration-tests/suites/tracing/ignoreSpans-streamed/segment/subject.js create mode 100644 dev-packages/browser-integration-tests/suites/tracing/ignoreSpans-streamed/segment/test.ts create mode 100644 dev-packages/node-integration-tests/suites/tracing/ignoreSpans/children/server.js create mode 100644 dev-packages/node-integration-tests/suites/tracing/ignoreSpans/children/test.ts create mode 100644 dev-packages/node-integration-tests/suites/tracing/ignoreSpans/segments/server.js create mode 100644 dev-packages/node-integration-tests/suites/tracing/ignoreSpans/segments/test.ts create mode 100644 packages/opentelemetry/test/contextManager.test.ts diff --git a/dev-packages/browser-integration-tests/suites/tracing/ignoreSpans-streamed/child/init.js b/dev-packages/browser-integration-tests/suites/tracing/ignoreSpans-streamed/child/init.js new file mode 100644 index 000000000000..01f36733fe67 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/ignoreSpans-streamed/child/init.js @@ -0,0 +1,10 @@ +import * as Sentry from '@sentry/browser'; + +window.Sentry = Sentry; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + integrations: [Sentry.spanStreamingIntegration()], + ignoreSpans: [/ignore/], + tracesSampleRate: 1, +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/ignoreSpans-streamed/child/subject.js b/dev-packages/browser-integration-tests/suites/tracing/ignoreSpans-streamed/child/subject.js new file mode 100644 index 000000000000..512188b3dcad --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/ignoreSpans-streamed/child/subject.js @@ -0,0 +1,12 @@ +Sentry.startSpan({ name: 'parent-span' }, () => { + Sentry.startSpan({ name: 'keep-me' }, () => {}); + + // This child matches ignoreSpans — should be dropped + Sentry.startSpan({ name: 'ignore-child' }, () => { + // Grandchild should be reparented to 'parent-span' + Sentry.startSpan({ name: 'grandchild-1' }, () => {}); + Sentry.startSpan({ name: 'grandchild-2' }, () => {}); + }); + + Sentry.startSpan({ name: 'another-keeper' }, () => {}); +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/ignoreSpans-streamed/child/test.ts b/dev-packages/browser-integration-tests/suites/tracing/ignoreSpans-streamed/child/test.ts new file mode 100644 index 000000000000..baf751768fc1 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/ignoreSpans-streamed/child/test.ts @@ -0,0 +1,55 @@ +import { expect } from '@playwright/test'; +import { sentryTest } from '../../../../utils/fixtures'; +import { + envelopeRequestParser, + hidePage, + shouldSkipTracingTest, + waitForClientReportRequest, +} from '../../../../utils/helpers'; +import { waitForStreamedSpans } from '../../../../utils/spanUtils'; +import type { ClientReport } from '@sentry/core'; + +sentryTest( + 'ignored child spans are dropped and their children are reparented to the grandparent', + async ({ getLocalTestUrl, page }) => { + if (shouldSkipTracingTest()) { + sentryTest.skip(); + } + + const spansPromise = waitForStreamedSpans(page, spans => !!spans?.find(s => s.name === 'parent-span')); + + const clientReportPromise = waitForClientReportRequest(page); + + const url = await getLocalTestUrl({ testDir: __dirname }); + await page.goto(url); + + const spans = await spansPromise; + + await hidePage(page); + + const clientReport = envelopeRequestParser(await clientReportPromise); + + const segmentSpanId = spans.find(s => s.name === 'parent-span')?.span_id; + + expect(spans.some(s => s.name === 'keep-me')).toBe(true); + expect(spans.some(s => s.name === 'another-keeper')).toBe(true); + + expect(spans.some(s => s.name?.includes('ignore'))).toBe(false); + + const grandchild1 = spans.find(s => s.name === 'grandchild-1'); + const grandchild2 = spans.find(s => s.name === 'grandchild-2'); + expect(grandchild1).toBeDefined(); + expect(grandchild2).toBeDefined(); + + expect(grandchild1?.parent_span_id).toBe(segmentSpanId); + expect(grandchild2?.parent_span_id).toBe(segmentSpanId); + + expect(clientReport.discarded_events).toEqual([ + { + category: 'span', + quantity: 1, + reason: 'ignored', + }, + ]); + }, +); diff --git a/dev-packages/browser-integration-tests/suites/tracing/ignoreSpans-streamed/segment/init.js b/dev-packages/browser-integration-tests/suites/tracing/ignoreSpans-streamed/segment/init.js new file mode 100644 index 000000000000..3a710324f0e1 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/ignoreSpans-streamed/segment/init.js @@ -0,0 +1,11 @@ +import * as Sentry from '@sentry/browser'; + +window.Sentry = Sentry; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + integrations: [Sentry.spanStreamingIntegration()], + ignoreSpans: [/ignore/], + tracesSampleRate: 1, + debug: true, +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/ignoreSpans-streamed/segment/subject.js b/dev-packages/browser-integration-tests/suites/tracing/ignoreSpans-streamed/segment/subject.js new file mode 100644 index 000000000000..bb21253c6909 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/ignoreSpans-streamed/segment/subject.js @@ -0,0 +1,12 @@ +// This segment span matches ignoreSpans — should NOT produce a transaction +Sentry.startSpan({ name: 'ignore-segment' }, () => { + Sentry.startSpan({ name: 'child-of-ignored-segment' }, () => {}); +}); + +setTimeout(() => { + // This segment span does NOT match — should produce a transaction + Sentry.startSpan({ name: 'normal-segment' }, () => { + Sentry.startSpan({ name: 'child-span' }, () => {}); + }); +}, 1000); + diff --git a/dev-packages/browser-integration-tests/suites/tracing/ignoreSpans-streamed/segment/test.ts b/dev-packages/browser-integration-tests/suites/tracing/ignoreSpans-streamed/segment/test.ts new file mode 100644 index 000000000000..655f00316981 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/ignoreSpans-streamed/segment/test.ts @@ -0,0 +1,45 @@ +import { expect } from '@playwright/test'; +import { sentryTest } from '../../../../utils/fixtures'; +import { + envelopeRequestParser, + hidePage, + shouldSkipTracingTest, + waitForClientReportRequest, +} from '../../../../utils/helpers'; +import { observeStreamedSpan, waitForStreamedSpans } from '../../../../utils/spanUtils'; +import type { ClientReport } from '@sentry/core'; + +sentryTest('ignored segment span drops entire trace', async ({ getLocalTestUrl, page }) => { + if (shouldSkipTracingTest()) { + sentryTest.skip(); + } + + const url = await getLocalTestUrl({ testDir: __dirname }); + + observeStreamedSpan(page, span => { + if (span.name === 'ignore-segment' || span.name === 'child-of-ignored-segment') { + throw new Error('Ignored span found'); + } + return false; // means we keep on looking for unwanted spans + }); + + const spansPromise = waitForStreamedSpans(page, spans => !!spans?.find(s => s.name === 'normal-segment')); + + const clientReportPromise = waitForClientReportRequest(page); + + await page.goto(url); + + expect((await spansPromise)?.length).toBe(2); + + await hidePage(page); + + const clientReport = envelopeRequestParser(await clientReportPromise); + + expect(clientReport.discarded_events).toEqual([ + { + category: 'span', + quantity: 2, // segment + child span + reason: 'ignored', + }, + ]); +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/ignoreSpans/children/server.js b/dev-packages/node-integration-tests/suites/tracing/ignoreSpans/children/server.js new file mode 100644 index 000000000000..52b8c1d04b3f --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/ignoreSpans/children/server.js @@ -0,0 +1,38 @@ +const { loggingTransport } = require('@sentry-internal/node-integration-tests'); +const Sentry = require('@sentry/node'); + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + tracePropagationTargets: [/^(?!.*test).*$/], + tracesSampleRate: 1.0, + transport: loggingTransport, + traceLifecycle: 'stream', + ignoreSpans: ['middleware - expressInit', 'custom-to-drop'], + clientReportFlushInterval: 1_000, +}); + +const express = require('express'); +const cors = require('cors'); +const { startExpressServerAndSendPortToRunner } = require('@sentry-internal/node-integration-tests'); + +const app = express(); + +app.use(cors()); + +app.get('/test/express', (_req, res) => { + Sentry.startSpan({ + name: 'custom-to-drop', + op: 'custom', + }, () => { + Sentry.startSpan({ + name: 'custom', + op: 'custom', + }, () => {}); + }); + res.send({ response: 'response 1' }); +}); + +Sentry.setupExpressErrorHandler(app); + +startExpressServerAndSendPortToRunner(app); diff --git a/dev-packages/node-integration-tests/suites/tracing/ignoreSpans/children/test.ts b/dev-packages/node-integration-tests/suites/tracing/ignoreSpans/children/test.ts new file mode 100644 index 000000000000..24b3802c5eb3 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/ignoreSpans/children/test.ts @@ -0,0 +1,70 @@ +import { afterAll, describe, expect, test } from 'vitest'; +import { cleanupChildProcesses, createRunner } from '../../../../utils/runner'; +import { SEMANTIC_ATTRIBUTE_SENTRY_OP } from '@sentry/core'; + +describe('filtering child spans with ignoreSpans (streaming)', () => { + afterAll(() => { + cleanupChildProcesses(); + }); + + describe('CJS', () => { + test('child spans are dropped and remaining spans correctly parented', async () => { + const runner = createRunner(__dirname, 'server.js') + .expect({ + span: container => { + // 5 spans: 1 root, 2 middleware, 1 request handler, 1 custom + // Would be 7 if we didn't ignore the 'middleware - expressInit' and 'custom-to-drop' spans + expect(container.items).toHaveLength(5); + const getSpan = (name: string, op: string) => + container.items.find( + item => item.name === name && item.attributes?.[SEMANTIC_ATTRIBUTE_SENTRY_OP]?.value === op, + ); + const queryMiddlewareSpan = getSpan('query', 'middleware.express'); + const corsMiddlewareSpan = getSpan('corsMiddleware', 'middleware.express'); + const requestHandlerSpan = getSpan('/test/express', 'request_handler.express'); + const httpServerSpan = getSpan('GET /test/express', 'http.server'); + const customSpan = getSpan('custom', 'custom'); + + expect(queryMiddlewareSpan).toBeDefined(); + expect(corsMiddlewareSpan).toBeDefined(); + expect(requestHandlerSpan).toBeDefined(); + expect(httpServerSpan).toBeDefined(); + expect(customSpan).toBeDefined(); + + expect(customSpan?.parent_span_id).toBe(requestHandlerSpan?.span_id); + expect(requestHandlerSpan?.parent_span_id).toBe(httpServerSpan?.span_id); + expect(queryMiddlewareSpan?.parent_span_id).toBe(httpServerSpan?.span_id); + expect(corsMiddlewareSpan?.parent_span_id).toBe(httpServerSpan?.span_id); + expect(httpServerSpan?.parent_span_id).toBeUndefined(); + }, + }) + .start(); + + runner.makeRequest('get', '/test/express'); + + await runner.completed(); + }); + + test('client report contains discarded spans', async () => { + const runner = createRunner(__dirname, 'server.js') + .ignore('span') + .unignore('client_report') + .expect({ + client_report: { + discarded_events: [ + { + category: 'span', + quantity: 2, + reason: 'ignored', + }, + ], + }, + }) + .start(); + + runner.makeRequest('get', '/test/express'); + + await runner.completed(); + }); + }); +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/ignoreSpans/segments/server.js b/dev-packages/node-integration-tests/suites/tracing/ignoreSpans/segments/server.js new file mode 100644 index 000000000000..a073eae5af7f --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/ignoreSpans/segments/server.js @@ -0,0 +1,33 @@ +const { loggingTransport } = require('@sentry-internal/node-integration-tests'); +const Sentry = require('@sentry/node'); + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + tracePropagationTargets: [/^(?!.*test).*$/], + tracesSampleRate: 1.0, + transport: loggingTransport, + traceLifecycle: 'stream', + ignoreSpans: [/\/health/], + clientReportFlushInterval: 1_000, +}); + +const express = require('express'); +const cors = require('cors'); +const { startExpressServerAndSendPortToRunner } = require('@sentry-internal/node-integration-tests'); + +const app = express(); + +app.use(cors()); + +app.get('/health', (_req, res) => { + res.send({ status: 'ok' }); +}); + +app.get('/ok', (_req, res) => { + res.send({ status: 'ok' }); +}); + +Sentry.setupExpressErrorHandler(app); + +startExpressServerAndSendPortToRunner(app); diff --git a/dev-packages/node-integration-tests/suites/tracing/ignoreSpans/segments/test.ts b/dev-packages/node-integration-tests/suites/tracing/ignoreSpans/segments/test.ts new file mode 100644 index 000000000000..263a4c6cda2e --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/ignoreSpans/segments/test.ts @@ -0,0 +1,48 @@ +import { afterAll, describe, expect, test } from 'vitest'; +import { cleanupChildProcesses, createRunner } from '../../../../utils/runner'; +import { SEMANTIC_ATTRIBUTE_SENTRY_OP } from '@sentry/core'; + +describe('filtering segment spans with ignoreSpans (streaming)', () => { + afterAll(() => { + cleanupChildProcesses(); + }); + + describe('CJS', () => { + test('segment spans matching ignoreSpans are dropped including all children', async () => { + const runner = createRunner(__dirname, 'server.js') + .unignore('client_report') + .expect({ + client_report: { + discarded_events: [ + { + category: 'span', + quantity: 1, + reason: 'ignored', + }, + ], + }, + }) + .expect({ + span: { + items: expect.arrayContaining([ + expect.objectContaining({ + name: 'GET /ok', + attributes: expect.objectContaining({ + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: { + value: 'http.server', + type: 'string', + }, + }), + }), + ]), + }, + }) + .start(); + + runner.makeRequest('get', '/health'); + runner.makeRequest('get', '/ok'); + + await runner.completed(); + }); + }); +}); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 440169cded38..d6cf0b290509 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -105,6 +105,7 @@ export { getTraceData } from './utils/traceData'; export { shouldPropagateTraceForUrl } from './utils/tracePropagationTargets'; export { getTraceMetaTags } from './utils/meta'; export { debounce } from './utils/debounce'; +export { shouldIgnoreSpan } from './utils/should-ignore-span'; export { winterCGHeadersToDict, winterCGRequestToRequestData, diff --git a/packages/core/src/tracing/trace.ts b/packages/core/src/tracing/trace.ts index 2d314ca453e9..94d19463a2ab 100644 --- a/packages/core/src/tracing/trace.ts +++ b/packages/core/src/tracing/trace.ts @@ -6,7 +6,11 @@ import { getMainCarrier } from '../carrier'; import { getClient, getCurrentScope, getIsolationScope, withScope } from '../currentScopes'; import { DEBUG_BUILD } from '../debug-build'; import type { Scope } from '../scope'; -import { SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE, SEMANTIC_ATTRIBUTE_SENTRY_SOURCE } from '../semanticAttributes'; +import { + SEMANTIC_ATTRIBUTE_SENTRY_OP, + SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE, + SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, +} from '../semanticAttributes'; import type { DynamicSamplingContext } from '../types-hoist/envelope'; import type { ClientOptions } from '../types-hoist/options'; import type { SentrySpanArguments, Span, SpanTimeInput } from '../types-hoist/span'; @@ -15,6 +19,7 @@ import { baggageHeaderToDynamicSamplingContext } from '../utils/baggage'; import { debug } from '../utils/debug-logger'; import { handleCallbackErrors } from '../utils/handleCallbackErrors'; import { hasSpansEnabled } from '../utils/hasSpansEnabled'; +import { shouldIgnoreSpan } from '../utils/should-ignore-span'; import { parseSampleRate } from '../utils/parseSampleRate'; import { generateTraceId } from '../utils/propagationContext'; import { safeMathRandom } from '../utils/randomSafeContext'; @@ -62,6 +67,25 @@ export function startSpan(options: StartSpanOptions, callback: (span: Span) = const scope = getCurrentScope(); const parentSpan = getParentSpan(scope, customParentSpan); + const client = getClient(); + const ignoreSpans = client?.getOptions().ignoreSpans; + if (ignoreSpans?.length) { + const op = spanArguments.attributes?.[SEMANTIC_ATTRIBUTE_SENTRY_OP]; + if (shouldIgnoreSpan({ description: spanArguments.name || '', op }, ignoreSpans)) { + client?.recordDroppedEvent('ignored', 'span'); + const nonRecordingSpan = new SentryNonRecordingSpan(); + // For root spans, set on scope (like unsampled). For child spans, don't — keep parent active. + if (!parentSpan) { + _setSpanForScope(scope, nonRecordingSpan); + } + return handleCallbackErrors( + () => callback(nonRecordingSpan), + () => {}, + () => nonRecordingSpan.end(), + ); + } + } + const shouldSkipSpan = options.onlyIfParent && !parentSpan; const activeSpan = shouldSkipSpan ? new SentryNonRecordingSpan() @@ -120,6 +144,23 @@ export function startSpanManual(options: StartSpanOptions, callback: (span: S const scope = getCurrentScope(); const parentSpan = getParentSpan(scope, customParentSpan); + const client = getClient(); + const ignoreSpans = client?.getOptions().ignoreSpans; + if (ignoreSpans?.length) { + const op = spanArguments.attributes?.[SEMANTIC_ATTRIBUTE_SENTRY_OP]; + if (shouldIgnoreSpan({ description: spanArguments.name || '', op }, ignoreSpans)) { + client?.recordDroppedEvent('ignored', 'span'); + const nonRecordingSpan = new SentryNonRecordingSpan(); + if (!parentSpan) { + _setSpanForScope(scope, nonRecordingSpan); + } + return handleCallbackErrors( + () => callback(nonRecordingSpan, () => nonRecordingSpan.end()), + () => {}, + ); + } + } + const shouldSkipSpan = options.onlyIfParent && !parentSpan; const activeSpan = shouldSkipSpan ? new SentryNonRecordingSpan() @@ -180,6 +221,16 @@ export function startInactiveSpan(options: StartSpanOptions): Span { const scope = getCurrentScope(); const parentSpan = getParentSpan(scope, customParentSpan); + const client = getClient(); + const ignoreSpans = client?.getOptions().ignoreSpans; + if (ignoreSpans?.length) { + const op = spanArguments.attributes?.[SEMANTIC_ATTRIBUTE_SENTRY_OP]; + if (shouldIgnoreSpan({ description: spanArguments.name || '', op }, ignoreSpans)) { + client?.recordDroppedEvent('ignored', 'span'); + return new SentryNonRecordingSpan(); + } + } + const shouldSkipSpan = options.onlyIfParent && !parentSpan; if (shouldSkipSpan) { diff --git a/packages/core/test/lib/tracing/trace.test.ts b/packages/core/test/lib/tracing/trace.test.ts index 6be1bf0577f3..49e19cc6b660 100644 --- a/packages/core/test/lib/tracing/trace.test.ts +++ b/packages/core/test/lib/tracing/trace.test.ts @@ -2410,3 +2410,92 @@ describe('startNewTrace', () => { }); }); }); + +describe('ignoreSpans (core path)', () => { + beforeEach(() => { + registerSpanErrorInstrumentation(); + getCurrentScope().clear(); + getIsolationScope().clear(); + getGlobalScope().clear(); + setAsyncContextStrategy(undefined); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it('returns SentryNonRecordingSpan for root span matching ignoreSpans', () => { + const options = getDefaultTestClientOptions({ tracesSampleRate: 1, ignoreSpans: ['GET /health'] }); + client = new TestClient(options); + setCurrentClient(client); + client.init(); + const spyOnDroppedEvent = vi.spyOn(client, 'recordDroppedEvent'); + + startSpan({ name: 'GET /health' }, span => { + expect(span).toBeInstanceOf(SentryNonRecordingSpan); + expect(spanIsSampled(span)).toBe(false); + startSpan({ name: 'child' }, childSpan => { + expect(childSpan).toBeInstanceOf(SentryNonRecordingSpan); + expect(spanIsSampled(childSpan)).toBe(false); + }); + }); + + expect(spyOnDroppedEvent).toHaveBeenCalledWith('ignored', 'span'); + }); + + it('returns SentryNonRecordingSpan for child span matching ignoreSpans', () => { + const options = getDefaultTestClientOptions({ tracesSampleRate: 1, ignoreSpans: ['ignored-child'] }); + client = new TestClient(options); + setCurrentClient(client); + client.init(); + const spyOnDroppedEvent = vi.spyOn(client, 'recordDroppedEvent'); + + startSpan({ name: 'root' }, () => { + startSpan({ name: 'ignored-child' }, span => { + expect(span).toBeInstanceOf(SentryNonRecordingSpan); + }); + }); + + expect(spyOnDroppedEvent).toHaveBeenCalledWith('ignored', 'span'); + }); + + it('children of ignored child spans parent to grandparent', () => { + const options = getDefaultTestClientOptions({ tracesSampleRate: 1, ignoreSpans: ['ignored-span'] }); + client = new TestClient(options); + setCurrentClient(client); + client.init(); + + startSpan({ name: 'root' }, rootSpan => { + startSpan({ name: 'ignored-span' }, () => { + expect(getActiveSpan()).toBe(rootSpan); + + startSpan({ name: 'grandchild' }, grandchildSpan => { + const json = spanToJSON(grandchildSpan); + expect(json.parent_span_id).toBe(rootSpan.spanContext().spanId); + }); + }); + }); + }); + + it('does not ignore non-matching spans', () => { + const options = getDefaultTestClientOptions({ tracesSampleRate: 1, ignoreSpans: ['GET /health'] }); + client = new TestClient(options); + setCurrentClient(client); + client.init(); + + startSpan({ name: 'GET /users' }, span => { + expect(span).toBeInstanceOf(SentrySpan); + expect(spanIsSampled(span)).toBe(true); + }); + }); + + it('returns SentryNonRecordingSpan for startInactiveSpan matching ignoreSpans', () => { + const options = getDefaultTestClientOptions({ tracesSampleRate: 1, ignoreSpans: ['ignored-span'] }); + client = new TestClient(options); + setCurrentClient(client); + client.init(); + + const span = startInactiveSpan({ name: 'ignored-span' }); + expect(span).toBeInstanceOf(SentryNonRecordingSpan); + }); +}); diff --git a/packages/core/test/lib/utils/should-ignore-span.test.ts b/packages/core/test/lib/utils/should-ignore-span.test.ts index dc03d6e032ea..a9aa3953b458 100644 --- a/packages/core/test/lib/utils/should-ignore-span.test.ts +++ b/packages/core/test/lib/utils/should-ignore-span.test.ts @@ -89,6 +89,20 @@ describe('shouldIgnoreSpan', () => { expect(shouldIgnoreSpan(span12, ignoreSpans)).toBe(false); }); + it('matches inferred HTTP span names', () => { + expect(shouldIgnoreSpan({ description: 'GET /health', op: 'http.server' }, ['GET /health'])).toBe(true); + }); + + it('matches middleware span names with regex', () => { + expect( + shouldIgnoreSpan({ description: 'middleware - expressInit', op: 'middleware.express' }, [/middleware/]), + ).toBe(true); + }); + + it('matches IgnoreSpanFilter with op only', () => { + expect(shouldIgnoreSpan({ description: 'GET /health', op: 'http.server' }, [{ op: 'http.server' }])).toBe(true); + }); + it('emits a debug log when a span is ignored', () => { const debugLogSpy = vi.spyOn(debug, 'log'); const span = { description: 'testDescription', op: 'testOp' }; diff --git a/packages/opentelemetry/src/constants.ts b/packages/opentelemetry/src/constants.ts index 375e42dfdd00..d8723f3940cc 100644 --- a/packages/opentelemetry/src/constants.ts +++ b/packages/opentelemetry/src/constants.ts @@ -9,6 +9,12 @@ export const SENTRY_TRACE_STATE_URL = 'sentry.url'; export const SENTRY_TRACE_STATE_SAMPLE_RAND = 'sentry.sample_rand'; export const SENTRY_TRACE_STATE_SAMPLE_RATE = 'sentry.sample_rate'; +/** + * A flag marking a context as ignored because the span associated with the context + * is ignored (`ignoreSpans` filter). + */ +export const SENTRY_TRACE_STATE_IGNORED = 'sentry.ignored'; + export const SENTRY_SCOPES_CONTEXT_KEY = createContextKey('sentry_scopes'); export const SENTRY_FORK_ISOLATION_SCOPE_CONTEXT_KEY = createContextKey('sentry_fork_isolation_scope'); diff --git a/packages/opentelemetry/src/contextManager.ts b/packages/opentelemetry/src/contextManager.ts index ac8b2eab5c9b..a8f364a606ca 100644 --- a/packages/opentelemetry/src/contextManager.ts +++ b/packages/opentelemetry/src/contextManager.ts @@ -1,5 +1,6 @@ import type { AsyncLocalStorage } from 'node:async_hooks'; import type { Context, ContextManager } from '@opentelemetry/api'; +import { trace } from '@opentelemetry/api'; import type { Scope } from '@sentry/core'; import { getCurrentScope, getIsolationScope } from '@sentry/core'; import { @@ -7,6 +8,7 @@ import { SENTRY_FORK_SET_ISOLATION_SCOPE_CONTEXT_KEY, SENTRY_FORK_SET_SCOPE_CONTEXT_KEY, SENTRY_SCOPES_CONTEXT_KEY, + SENTRY_TRACE_STATE_IGNORED, } from './constants'; import { getScopesFromContext, setContextOnScope, setScopesOnContext } from './utils/contextData'; import { setIsSetup } from './utils/setupCheck'; @@ -57,20 +59,27 @@ export function wrapContextManagerClass, ...args: A ): ReturnType { - const currentScopes = getScopesFromContext(context); + // Remove ignored spans from context so children naturally parent to the grandparent + const span = trace.getSpan(context); + const effectiveContext = + span?.spanContext().traceState?.get(SENTRY_TRACE_STATE_IGNORED) === '1' ? trace.deleteSpan(context) : context; + + const currentScopes = getScopesFromContext(effectiveContext); const currentScope = currentScopes?.scope || getCurrentScope(); const currentIsolationScope = currentScopes?.isolationScope || getIsolationScope(); - const shouldForkIsolationScope = context.getValue(SENTRY_FORK_ISOLATION_SCOPE_CONTEXT_KEY) === true; - const scope = context.getValue(SENTRY_FORK_SET_SCOPE_CONTEXT_KEY) as Scope | undefined; - const isolationScope = context.getValue(SENTRY_FORK_SET_ISOLATION_SCOPE_CONTEXT_KEY) as Scope | undefined; + const shouldForkIsolationScope = effectiveContext.getValue(SENTRY_FORK_ISOLATION_SCOPE_CONTEXT_KEY) === true; + const scope = effectiveContext.getValue(SENTRY_FORK_SET_SCOPE_CONTEXT_KEY) as Scope | undefined; + const isolationScope = effectiveContext.getValue(SENTRY_FORK_SET_ISOLATION_SCOPE_CONTEXT_KEY) as + | Scope + | undefined; const newCurrentScope = scope || currentScope.clone(); const newIsolationScope = isolationScope || (shouldForkIsolationScope ? currentIsolationScope.clone() : currentIsolationScope); const scopes = { scope: newCurrentScope, isolationScope: newIsolationScope }; - const ctx1 = setScopesOnContext(context, scopes); + const ctx1 = setScopesOnContext(effectiveContext, scopes); // Remove the unneeded values again const ctx2 = ctx1 diff --git a/packages/opentelemetry/src/sampler.ts b/packages/opentelemetry/src/sampler.ts index 7f7edd441612..0977b0dd7493 100644 --- a/packages/opentelemetry/src/sampler.ts +++ b/packages/opentelemetry/src/sampler.ts @@ -20,9 +20,11 @@ import { sampleSpan, SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE, + shouldIgnoreSpan, } from '@sentry/core'; import { SENTRY_TRACE_STATE_DSC, + SENTRY_TRACE_STATE_IGNORED, SENTRY_TRACE_STATE_SAMPLE_RAND, SENTRY_TRACE_STATE_SAMPLE_RATE, SENTRY_TRACE_STATE_SAMPLED_NOT_RECORDING, @@ -79,6 +81,22 @@ export class SentrySampler implements Sampler { // We only sample based on parameters (like tracesSampleRate or tracesSampler) for root spans (which is done in sampleSpan). // Non-root-spans simply inherit the sampling decision from their parent. if (!isRootSpan) { + if (parentSampled) { + const { ignoreSpans } = options; + if (ignoreSpans?.length) { + const { description: inferredChildName, op: childOp } = inferSpanData(spanName, spanAttributes, spanKind); + if (shouldIgnoreSpan({ description: inferredChildName, op: childOp }, ignoreSpans)) { + this._client.recordDroppedEvent('ignored', 'span'); + return wrapSamplingDecision({ + decision: SamplingDecision.NOT_RECORD, + context, + spanAttributes, + ignoredSpan: true, + }); + } + } + } + return wrapSamplingDecision({ decision: parentSampled ? SamplingDecision.RECORD_AND_SAMPLED : SamplingDecision.NOT_RECORD, context, @@ -102,6 +120,16 @@ export class SentrySampler implements Sampler { mergedAttributes[SEMANTIC_ATTRIBUTE_SENTRY_OP] = op; } + const { ignoreSpans } = options; + if (ignoreSpans?.length && shouldIgnoreSpan({ description: inferredSpanName, op }, ignoreSpans)) { + this._client.recordDroppedEvent('ignored', 'span'); + return wrapSamplingDecision({ + decision: SamplingDecision.NOT_RECORD, + context, + spanAttributes, + }); + } + const mutableSamplingDecision = { decision: true }; this._client.emit( 'beforeSampling', @@ -211,12 +239,14 @@ export function wrapSamplingDecision({ spanAttributes, sampleRand, downstreamTraceSampleRate, + ignoredSpan, }: { decision: SamplingDecision | undefined; context: Context; spanAttributes: SpanAttributes; sampleRand?: number; downstreamTraceSampleRate?: number; + ignoredSpan?: boolean; }): SamplingResult { let traceState = getBaseTraceState(context, spanAttributes); @@ -232,6 +262,10 @@ export function wrapSamplingDecision({ traceState = traceState.set(SENTRY_TRACE_STATE_SAMPLE_RAND, `${sampleRand}`); } + if (ignoredSpan) { + traceState = traceState.set(SENTRY_TRACE_STATE_IGNORED, '1'); + } + // If the decision is undefined, we treat it as NOT_RECORDING, but we don't propagate this decision to downstream SDKs // Which is done by not setting `SENTRY_TRACE_STATE_SAMPLED_NOT_RECORDING` traceState if (decision == undefined) { diff --git a/packages/opentelemetry/src/trace.ts b/packages/opentelemetry/src/trace.ts index 7c9d09a169b9..f67203191d18 100644 --- a/packages/opentelemetry/src/trace.ts +++ b/packages/opentelemetry/src/trace.ts @@ -23,6 +23,8 @@ import { hasSpansEnabled, SDK_VERSION, SEMANTIC_ATTRIBUTE_SENTRY_OP, + SentryNonRecordingSpan, + shouldIgnoreSpan, spanToJSON, spanToTraceContext, } from '@sentry/core'; @@ -47,6 +49,16 @@ function _startSpan(options: OpenTelemetrySpanContext, callback: (span: Span) const wrapper = getActiveSpanWrapper(customParentSpan); return wrapper(() => { + const client = getClient(); + const ignoreSpans = client?.getOptions().ignoreSpans; + if (ignoreSpans?.length) { + const op = options.op || options.attributes?.[SEMANTIC_ATTRIBUTE_SENTRY_OP]; + if (shouldIgnoreSpan({ description: name, op }, ignoreSpans)) { + client?.recordDroppedEvent('ignored', 'span'); + return callback(new SentryNonRecordingSpan()); + } + } + const activeCtx = getContext(options.scope, options.forceTransaction); const shouldSkipSpan = options.onlyIfParent && !trace.getSpan(activeCtx); const ctx = shouldSkipSpan ? suppressTracing(activeCtx) : activeCtx; @@ -150,6 +162,16 @@ export function startInactiveSpan(options: OpenTelemetrySpanContext): Span { const wrapper = getActiveSpanWrapper(customParentSpan); return wrapper(() => { + const client = getClient(); + const ignoreSpans = client?.getOptions().ignoreSpans; + if (ignoreSpans?.length) { + const op = options.op || options.attributes?.[SEMANTIC_ATTRIBUTE_SENTRY_OP]; + if (shouldIgnoreSpan({ description: name, op }, ignoreSpans)) { + client?.recordDroppedEvent('ignored', 'span'); + return new SentryNonRecordingSpan(); + } + } + const activeCtx = getContext(options.scope, options.forceTransaction); const shouldSkipSpan = options.onlyIfParent && !trace.getSpan(activeCtx); let ctx = shouldSkipSpan ? suppressTracing(activeCtx) : activeCtx; diff --git a/packages/opentelemetry/test/contextManager.test.ts b/packages/opentelemetry/test/contextManager.test.ts new file mode 100644 index 000000000000..1a51d70afed2 --- /dev/null +++ b/packages/opentelemetry/test/contextManager.test.ts @@ -0,0 +1,48 @@ +import { context, trace, TraceFlags } from '@opentelemetry/api'; +import { TraceState } from '@opentelemetry/core'; +import { afterEach, describe, expect, it } from 'vitest'; +import { SENTRY_TRACE_STATE_IGNORED } from '../src/constants'; +import { cleanupOtel, mockSdkInit } from './helpers/mockSdkInit'; + +describe('SentryContextManager', () => { + afterEach(async () => { + await cleanupOtel(); + }); + + it('removes ignored spans from context so children parent to grandparent', () => { + mockSdkInit({ tracesSampleRate: 1 }); + + const ignoredTraceState = new TraceState().set(SENTRY_TRACE_STATE_IGNORED, '1'); + const ignoredSpanContext = { + traceId: '00000000000000000000000000000001', + spanId: '0000000000000001', + traceFlags: TraceFlags.NONE, + traceState: ignoredTraceState, + }; + + const ctxWithIgnored = trace.setSpanContext(context.active(), ignoredSpanContext); + + context.with(ctxWithIgnored, () => { + const activeSpan = trace.getSpan(context.active()); + expect(activeSpan).toBeUndefined(); + }); + }); + + it('preserves non-ignored spans in context', () => { + mockSdkInit({ tracesSampleRate: 1 }); + + const normalSpanContext = { + traceId: '00000000000000000000000000000001', + spanId: '0000000000000001', + traceFlags: TraceFlags.SAMPLED, + }; + + const ctxWithSpan = trace.setSpanContext(context.active(), normalSpanContext); + + context.with(ctxWithSpan, () => { + const activeSpan = trace.getSpan(context.active()); + expect(activeSpan).toBeDefined(); + expect(activeSpan?.spanContext().spanId).toBe('0000000000000001'); + }); + }); +}); diff --git a/packages/opentelemetry/test/sampler.test.ts b/packages/opentelemetry/test/sampler.test.ts index b7ffd9522837..18c2fcc1f02d 100644 --- a/packages/opentelemetry/test/sampler.test.ts +++ b/packages/opentelemetry/test/sampler.test.ts @@ -1,10 +1,10 @@ -import { context, SpanKind, trace } from '@opentelemetry/api'; +import { context, SpanKind, trace, TraceFlags } from '@opentelemetry/api'; import { TraceState } from '@opentelemetry/core'; import { SamplingDecision } from '@opentelemetry/sdk-trace-base'; import { ATTR_HTTP_REQUEST_METHOD } from '@opentelemetry/semantic-conventions'; import { generateSpanId, generateTraceId } from '@sentry/core'; import { afterEach, describe, expect, it, vi } from 'vitest'; -import { SENTRY_TRACE_STATE_SAMPLED_NOT_RECORDING } from '../src/constants'; +import { SENTRY_TRACE_STATE_IGNORED, SENTRY_TRACE_STATE_SAMPLED_NOT_RECORDING } from '../src/constants'; import { SentrySampler } from '../src/sampler'; import { cleanupOtel } from './helpers/mockSdkInit'; import { getDefaultTestClientOptions, TestClient } from './helpers/TestClient'; @@ -115,6 +115,109 @@ describe('SentrySampler', () => { spyOnDroppedEvent.mockReset(); }); + it('returns NOT_RECORD for root span matching ignoreSpans string pattern', () => { + const client = new TestClient(getDefaultTestClientOptions({ tracesSampleRate: 1, ignoreSpans: ['GET /health'] })); + const spyOnDroppedEvent = vi.spyOn(client, 'recordDroppedEvent'); + const sampler = new SentrySampler(client); + + const ctx = context.active(); + const traceId = generateTraceId(); + const spanName = 'GET /health'; + const spanKind = SpanKind.SERVER; + const spanAttributes = {}; + + const actual = sampler.shouldSample(ctx, traceId, spanName, spanKind, spanAttributes, undefined); + expect(actual.decision).toBe(SamplingDecision.NOT_RECORD); + expect(spyOnDroppedEvent).toHaveBeenCalledWith('ignored', 'span'); + }); + + it('returns NOT_RECORD for root span matching ignoreSpans regex pattern', () => { + const client = new TestClient(getDefaultTestClientOptions({ tracesSampleRate: 1, ignoreSpans: [/health/] })); + const sampler = new SentrySampler(client); + + const ctx = context.active(); + const traceId = generateTraceId(); + const spanName = 'GET /healthcheck'; + const spanKind = SpanKind.SERVER; + const spanAttributes = {}; + + const actual = sampler.shouldSample(ctx, traceId, spanName, spanKind, spanAttributes, undefined); + expect(actual.decision).toBe(SamplingDecision.NOT_RECORD); + }); + + it('returns NOT_RECORD for root span matching ignoreSpans IgnoreSpanFilter with name and op', () => { + const client = new TestClient( + getDefaultTestClientOptions({ + tracesSampleRate: 1, + ignoreSpans: [{ name: 'GET /health', op: 'http.server' }], + }), + ); + const sampler = new SentrySampler(client); + + const ctx = context.active(); + const traceId = generateTraceId(); + const spanName = 'GET /health'; + const spanKind = SpanKind.SERVER; + const spanAttributes = { [ATTR_HTTP_REQUEST_METHOD]: 'GET' }; + + const actual = sampler.shouldSample(ctx, traceId, spanName, spanKind, spanAttributes, undefined); + expect(actual.decision).toBe(SamplingDecision.NOT_RECORD); + }); + + it('does not ignore root span that does not match ignoreSpans', () => { + const client = new TestClient(getDefaultTestClientOptions({ tracesSampleRate: 1, ignoreSpans: ['GET /health'] })); + const sampler = new SentrySampler(client); + + const ctx = context.active(); + const traceId = generateTraceId(); + const spanName = 'GET /users'; + const spanKind = SpanKind.SERVER; + const spanAttributes = {}; + + const actual = sampler.shouldSample(ctx, traceId, spanName, spanKind, spanAttributes, undefined); + expect(actual.decision).toBe(SamplingDecision.RECORD_AND_SAMPLED); + }); + + it('returns NOT_RECORD with sentry.ignored traceState for child span matching ignoreSpans', () => { + const client = new TestClient( + getDefaultTestClientOptions({ tracesSampleRate: 1, ignoreSpans: ['middleware - expressInit'] }), + ); + const sampler = new SentrySampler(client); + + const traceId = generateTraceId(); + const ctx = trace.setSpanContext(context.active(), { + traceId, + spanId: generateSpanId(), + traceFlags: TraceFlags.SAMPLED, + isRemote: false, + }); + + const actual = sampler.shouldSample(ctx, traceId, 'middleware - expressInit', SpanKind.INTERNAL, {}, undefined); + + expect(actual.decision).toBe(SamplingDecision.NOT_RECORD); + expect(actual.traceState?.get(SENTRY_TRACE_STATE_IGNORED)).toBe('1'); + }); + + it('does not set sentry.ignored for child span not matching ignoreSpans', () => { + const client = new TestClient( + getDefaultTestClientOptions({ tracesSampleRate: 1, ignoreSpans: ['middleware - expressInit'] }), + ); + const sampler = new SentrySampler(client); + + const traceId = generateTraceId(); + const ctx = trace.setSpanContext(context.active(), { + traceId, + spanId: generateSpanId(), + traceFlags: TraceFlags.SAMPLED, + isRemote: false, + }); + + const actual = sampler.shouldSample(ctx, traceId, 'db.query SELECT 1', SpanKind.CLIENT, {}, undefined); + + expect(actual.decision).toBe(SamplingDecision.RECORD_AND_SAMPLED); + expect(actual.traceState?.get(SENTRY_TRACE_STATE_IGNORED)).toBeUndefined(); + }); + it('ignores local http client root spans', () => { const client = new TestClient(getDefaultTestClientOptions({ tracesSampleRate: 0 })); const spyOnDroppedEvent = vi.spyOn(client, 'recordDroppedEvent'); diff --git a/packages/opentelemetry/test/trace.test.ts b/packages/opentelemetry/test/trace.test.ts index a6a7f35ab76a..1dcd0cf0e113 100644 --- a/packages/opentelemetry/test/trace.test.ts +++ b/packages/opentelemetry/test/trace.test.ts @@ -2190,6 +2190,55 @@ function getSpanName(span: AbstractSpan): string | undefined { return spanHasName(span) ? span.name : undefined; } +describe('trace (ignoreSpans)', () => { + afterEach(async () => { + await cleanupOtel(); + }); + + it('returns a non-recording span for an ignored span via startSpan', () => { + mockSdkInit({ tracesSampleRate: 1, ignoreSpans: ['ignored-span'] }); + + startSpan({ name: 'root' }, () => { + startSpan({ name: 'ignored-span' }, span => { + expect(spanIsSampled(span)).toBe(false); + expect(span.isRecording()).toBe(false); + }); + }); + }); + + it('children of ignored spans parent to grandparent via startSpan', () => { + mockSdkInit({ tracesSampleRate: 1, ignoreSpans: ['ignored-span'] }); + + startSpan({ name: 'root' }, rootSpan => { + const rootSpanId = rootSpan.spanContext().spanId; + + startSpan({ name: 'ignored-span' }, () => { + startSpan({ name: 'grandchild' }, grandchildSpan => { + const parentId = getSpanParentSpanId(grandchildSpan); + expect(parentId).toBe(rootSpanId); + }); + }); + }); + }); + + it('returns a non-recording span for an ignored span via startInactiveSpan', () => { + mockSdkInit({ tracesSampleRate: 1, ignoreSpans: ['ignored-span'] }); + + const span = startInactiveSpan({ name: 'ignored-span' }); + expect(spanIsSampled(span)).toBe(false); + expect(span.isRecording()).toBe(false); + }); + + it('does not ignore non-matching spans', () => { + mockSdkInit({ tracesSampleRate: 1, ignoreSpans: ['ignored-span'] }); + + startSpan({ name: 'normal-span' }, span => { + expect(spanIsSampled(span)).toBe(true); + expect(span.isRecording()).toBe(true); + }); + }); +}); + function getSpanEndTime(span: AbstractSpan): [number, number] | undefined { return (span as ReadableSpan).endTime; } From 11cfe2b2832b2c261565eb8b277c446da0e7d964 Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Mon, 23 Mar 2026 15:03:31 +0100 Subject: [PATCH 2/5] format --- .../ignoreSpans-streamed/segment/subject.js | 1 - .../tracing/ignoreSpans/children/server.js | 22 ++++++++++++------- 2 files changed, 14 insertions(+), 9 deletions(-) diff --git a/dev-packages/browser-integration-tests/suites/tracing/ignoreSpans-streamed/segment/subject.js b/dev-packages/browser-integration-tests/suites/tracing/ignoreSpans-streamed/segment/subject.js index bb21253c6909..645668376b36 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/ignoreSpans-streamed/segment/subject.js +++ b/dev-packages/browser-integration-tests/suites/tracing/ignoreSpans-streamed/segment/subject.js @@ -9,4 +9,3 @@ setTimeout(() => { Sentry.startSpan({ name: 'child-span' }, () => {}); }); }, 1000); - diff --git a/dev-packages/node-integration-tests/suites/tracing/ignoreSpans/children/server.js b/dev-packages/node-integration-tests/suites/tracing/ignoreSpans/children/server.js index 52b8c1d04b3f..d29686a29faf 100644 --- a/dev-packages/node-integration-tests/suites/tracing/ignoreSpans/children/server.js +++ b/dev-packages/node-integration-tests/suites/tracing/ignoreSpans/children/server.js @@ -21,15 +21,21 @@ const app = express(); app.use(cors()); app.get('/test/express', (_req, res) => { - Sentry.startSpan({ - name: 'custom-to-drop', - op: 'custom', - }, () => { - Sentry.startSpan({ - name: 'custom', + Sentry.startSpan( + { + name: 'custom-to-drop', op: 'custom', - }, () => {}); - }); + }, + () => { + Sentry.startSpan( + { + name: 'custom', + op: 'custom', + }, + () => {}, + ); + }, + ); res.send({ response: 'response 1' }); }); From c5da9765383e04b00684dae7e4ce0e9f0cae1242 Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Mon, 30 Mar 2026 15:43:10 +0200 Subject: [PATCH 3/5] add client report generation, also for child spans, add tests for sampling --- .../children/instrument.mjs | 12 ++++ .../ignoreSpans-streamed/children/server.mjs | 31 +++++++++ .../children/test.ts | 42 +++++------- .../segments/instrument.mjs | 12 ++++ .../ignoreSpans-streamed/segments/server.mjs | 20 ++++++ .../ignoreSpans-streamed/segments/test.ts | 45 +++++++++++++ .../tracing/ignoreSpans/children/server.js | 44 ------------- .../tracing/ignoreSpans/segments/server.js | 33 ---------- .../tracing/ignoreSpans/segments/test.ts | 48 -------------- .../tracing/sampling-static/instrument.mjs | 15 +++++ .../suites/tracing/sampling-static/server.mjs | 20 ++++++ .../suites/tracing/sampling-static/test.ts | 40 ++++++++++++ .../tracing/sampling-streamed/instrument.mjs | 16 +++++ .../tracing/sampling-streamed/server.mjs | 20 ++++++ .../suites/tracing/sampling-streamed/test.ts | 44 +++++++++++++ packages/effect/src/index.types.ts | 1 + packages/opentelemetry/src/constants.ts | 8 +++ packages/opentelemetry/src/sampler.ts | 55 +++++++++++----- packages/opentelemetry/test/sampler.test.ts | 65 ++++++++++++++++++- 19 files changed, 403 insertions(+), 168 deletions(-) create mode 100644 dev-packages/node-integration-tests/suites/tracing/ignoreSpans-streamed/children/instrument.mjs create mode 100644 dev-packages/node-integration-tests/suites/tracing/ignoreSpans-streamed/children/server.mjs rename dev-packages/node-integration-tests/suites/tracing/{ignoreSpans => ignoreSpans-streamed}/children/test.ts (82%) create mode 100644 dev-packages/node-integration-tests/suites/tracing/ignoreSpans-streamed/segments/instrument.mjs create mode 100644 dev-packages/node-integration-tests/suites/tracing/ignoreSpans-streamed/segments/server.mjs create mode 100644 dev-packages/node-integration-tests/suites/tracing/ignoreSpans-streamed/segments/test.ts delete mode 100644 dev-packages/node-integration-tests/suites/tracing/ignoreSpans/children/server.js delete mode 100644 dev-packages/node-integration-tests/suites/tracing/ignoreSpans/segments/server.js delete mode 100644 dev-packages/node-integration-tests/suites/tracing/ignoreSpans/segments/test.ts create mode 100644 dev-packages/node-integration-tests/suites/tracing/sampling-static/instrument.mjs create mode 100644 dev-packages/node-integration-tests/suites/tracing/sampling-static/server.mjs create mode 100644 dev-packages/node-integration-tests/suites/tracing/sampling-static/test.ts create mode 100644 dev-packages/node-integration-tests/suites/tracing/sampling-streamed/instrument.mjs create mode 100644 dev-packages/node-integration-tests/suites/tracing/sampling-streamed/server.mjs create mode 100644 dev-packages/node-integration-tests/suites/tracing/sampling-streamed/test.ts diff --git a/dev-packages/node-integration-tests/suites/tracing/ignoreSpans-streamed/children/instrument.mjs b/dev-packages/node-integration-tests/suites/tracing/ignoreSpans-streamed/children/instrument.mjs new file mode 100644 index 000000000000..3ce78fbf3452 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/ignoreSpans-streamed/children/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', + release: '1.0', + tracesSampleRate: 1.0, + transport: loggingTransport, + traceLifecycle: 'stream', + ignoreSpans: ['middleware - expressInit', 'custom-to-drop'], + clientReportFlushInterval: 1_000, +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/ignoreSpans-streamed/children/server.mjs b/dev-packages/node-integration-tests/suites/tracing/ignoreSpans-streamed/children/server.mjs new file mode 100644 index 000000000000..554609a8d602 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/ignoreSpans-streamed/children/server.mjs @@ -0,0 +1,31 @@ +import express from 'express'; +import cors from 'cors'; +import * as Sentry from '@sentry/node'; +import { startExpressServerAndSendPortToRunner } from '@sentry-internal/node-integration-tests'; + +const app = express(); + +app.use(cors()); + +app.get('/test/express', (_req, res) => { + Sentry.startSpan( + { + name: 'custom-to-drop', + op: 'custom', + }, + () => { + Sentry.startSpan( + { + name: 'custom', + op: 'custom', + }, + () => {}, + ); + }, + ); + res.send({ response: 'response 1' }); +}); + +Sentry.setupExpressErrorHandler(app); + +startExpressServerAndSendPortToRunner(app); diff --git a/dev-packages/node-integration-tests/suites/tracing/ignoreSpans/children/test.ts b/dev-packages/node-integration-tests/suites/tracing/ignoreSpans-streamed/children/test.ts similarity index 82% rename from dev-packages/node-integration-tests/suites/tracing/ignoreSpans/children/test.ts rename to dev-packages/node-integration-tests/suites/tracing/ignoreSpans-streamed/children/test.ts index 24b3802c5eb3..90c05e5aba96 100644 --- a/dev-packages/node-integration-tests/suites/tracing/ignoreSpans/children/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/ignoreSpans-streamed/children/test.ts @@ -1,5 +1,5 @@ -import { afterAll, describe, expect, test } from 'vitest'; -import { cleanupChildProcesses, createRunner } from '../../../../utils/runner'; +import { afterAll, describe, expect } from 'vitest'; +import { cleanupChildProcesses, createEsmAndCjsTests } from '../../../../utils/runner'; import { SEMANTIC_ATTRIBUTE_SENTRY_OP } from '@sentry/core'; describe('filtering child spans with ignoreSpans (streaming)', () => { @@ -7,9 +7,21 @@ describe('filtering child spans with ignoreSpans (streaming)', () => { cleanupChildProcesses(); }); - describe('CJS', () => { + createEsmAndCjsTests(__dirname, 'server.mjs', 'instrument.mjs', (createRunner, test) => { test('child spans are dropped and remaining spans correctly parented', async () => { - const runner = createRunner(__dirname, 'server.js') + const runner = createRunner() + .unignore('client_report') + .expect({ + client_report: { + discarded_events: [ + { + category: 'span', + quantity: 2, + reason: 'ignored', + }, + ], + }, + }) .expect({ span: container => { // 5 spans: 1 root, 2 middleware, 1 request handler, 1 custom @@ -44,27 +56,5 @@ describe('filtering child spans with ignoreSpans (streaming)', () => { await runner.completed(); }); - - test('client report contains discarded spans', async () => { - const runner = createRunner(__dirname, 'server.js') - .ignore('span') - .unignore('client_report') - .expect({ - client_report: { - discarded_events: [ - { - category: 'span', - quantity: 2, - reason: 'ignored', - }, - ], - }, - }) - .start(); - - runner.makeRequest('get', '/test/express'); - - await runner.completed(); - }); }); }); diff --git a/dev-packages/node-integration-tests/suites/tracing/ignoreSpans-streamed/segments/instrument.mjs b/dev-packages/node-integration-tests/suites/tracing/ignoreSpans-streamed/segments/instrument.mjs new file mode 100644 index 000000000000..4d14e615745b --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/ignoreSpans-streamed/segments/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', + release: '1.0', + tracesSampleRate: 1.0, + transport: loggingTransport, + traceLifecycle: 'stream', + ignoreSpans: [/\/health/], + clientReportFlushInterval: 1_000, +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/ignoreSpans-streamed/segments/server.mjs b/dev-packages/node-integration-tests/suites/tracing/ignoreSpans-streamed/segments/server.mjs new file mode 100644 index 000000000000..f9c7f136aef2 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/ignoreSpans-streamed/segments/server.mjs @@ -0,0 +1,20 @@ +import express from 'express'; +import cors from 'cors'; +import * as Sentry from '@sentry/node'; +import { startExpressServerAndSendPortToRunner } from '@sentry-internal/node-integration-tests'; + +const app = express(); + +app.use(cors()); + +app.get('/health', (_req, res) => { + res.send({ status: 'ok-health' }); +}); + +app.get('/ok', (_req, res) => { + res.send({ status: 'ok' }); +}); + +Sentry.setupExpressErrorHandler(app); + +startExpressServerAndSendPortToRunner(app); diff --git a/dev-packages/node-integration-tests/suites/tracing/ignoreSpans-streamed/segments/test.ts b/dev-packages/node-integration-tests/suites/tracing/ignoreSpans-streamed/segments/test.ts new file mode 100644 index 000000000000..538c9c77a532 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/ignoreSpans-streamed/segments/test.ts @@ -0,0 +1,45 @@ +import { afterAll, describe, expect } from 'vitest'; +import { cleanupChildProcesses, createEsmAndCjsTests } from '../../../../utils/runner'; + +describe('filtering segment spans with ignoreSpans (streaming)', () => { + afterAll(() => { + cleanupChildProcesses(); + }); + + createEsmAndCjsTests(__dirname, 'server.mjs', 'instrument.mjs', (createRunner, test) => { + test('segment spans matching ignoreSpans are dropped including all children', async () => { + const runner = createRunner() + .unignore('client_report') + .expect({ + client_report: { + discarded_events: [ + { + category: 'span', + quantity: 5, // 1 segment ignored + 4 child spans (implicitly ignored) + reason: 'ignored', + }, + ], + }, + }) + .expect({ + span: container => { + expect(container.items).toHaveLength(5); + const segmentSpan = container.items.find(s => s.name === 'GET /ok' && !!s.is_segment); + + expect(segmentSpan).toBeDefined(); + expect(container.items.every(s => s.trace_id === segmentSpan!.trace_id)).toBe(true); + }, + }) + + .start(); + + const res = await runner.makeRequest('get', '/health'); + expect((res as { status: string }).status).toBe('ok-health'); + + const res2 = await runner.makeRequest('get', '/ok'); // contains all spans + expect((res2 as { status: string }).status).toBe('ok'); + + await runner.completed(); + }); + }); +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/ignoreSpans/children/server.js b/dev-packages/node-integration-tests/suites/tracing/ignoreSpans/children/server.js deleted file mode 100644 index d29686a29faf..000000000000 --- a/dev-packages/node-integration-tests/suites/tracing/ignoreSpans/children/server.js +++ /dev/null @@ -1,44 +0,0 @@ -const { loggingTransport } = require('@sentry-internal/node-integration-tests'); -const Sentry = require('@sentry/node'); - -Sentry.init({ - dsn: 'https://public@dsn.ingest.sentry.io/1337', - release: '1.0', - tracePropagationTargets: [/^(?!.*test).*$/], - tracesSampleRate: 1.0, - transport: loggingTransport, - traceLifecycle: 'stream', - ignoreSpans: ['middleware - expressInit', 'custom-to-drop'], - clientReportFlushInterval: 1_000, -}); - -const express = require('express'); -const cors = require('cors'); -const { startExpressServerAndSendPortToRunner } = require('@sentry-internal/node-integration-tests'); - -const app = express(); - -app.use(cors()); - -app.get('/test/express', (_req, res) => { - Sentry.startSpan( - { - name: 'custom-to-drop', - op: 'custom', - }, - () => { - Sentry.startSpan( - { - name: 'custom', - op: 'custom', - }, - () => {}, - ); - }, - ); - res.send({ response: 'response 1' }); -}); - -Sentry.setupExpressErrorHandler(app); - -startExpressServerAndSendPortToRunner(app); diff --git a/dev-packages/node-integration-tests/suites/tracing/ignoreSpans/segments/server.js b/dev-packages/node-integration-tests/suites/tracing/ignoreSpans/segments/server.js deleted file mode 100644 index a073eae5af7f..000000000000 --- a/dev-packages/node-integration-tests/suites/tracing/ignoreSpans/segments/server.js +++ /dev/null @@ -1,33 +0,0 @@ -const { loggingTransport } = require('@sentry-internal/node-integration-tests'); -const Sentry = require('@sentry/node'); - -Sentry.init({ - dsn: 'https://public@dsn.ingest.sentry.io/1337', - release: '1.0', - tracePropagationTargets: [/^(?!.*test).*$/], - tracesSampleRate: 1.0, - transport: loggingTransport, - traceLifecycle: 'stream', - ignoreSpans: [/\/health/], - clientReportFlushInterval: 1_000, -}); - -const express = require('express'); -const cors = require('cors'); -const { startExpressServerAndSendPortToRunner } = require('@sentry-internal/node-integration-tests'); - -const app = express(); - -app.use(cors()); - -app.get('/health', (_req, res) => { - res.send({ status: 'ok' }); -}); - -app.get('/ok', (_req, res) => { - res.send({ status: 'ok' }); -}); - -Sentry.setupExpressErrorHandler(app); - -startExpressServerAndSendPortToRunner(app); diff --git a/dev-packages/node-integration-tests/suites/tracing/ignoreSpans/segments/test.ts b/dev-packages/node-integration-tests/suites/tracing/ignoreSpans/segments/test.ts deleted file mode 100644 index 263a4c6cda2e..000000000000 --- a/dev-packages/node-integration-tests/suites/tracing/ignoreSpans/segments/test.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { afterAll, describe, expect, test } from 'vitest'; -import { cleanupChildProcesses, createRunner } from '../../../../utils/runner'; -import { SEMANTIC_ATTRIBUTE_SENTRY_OP } from '@sentry/core'; - -describe('filtering segment spans with ignoreSpans (streaming)', () => { - afterAll(() => { - cleanupChildProcesses(); - }); - - describe('CJS', () => { - test('segment spans matching ignoreSpans are dropped including all children', async () => { - const runner = createRunner(__dirname, 'server.js') - .unignore('client_report') - .expect({ - client_report: { - discarded_events: [ - { - category: 'span', - quantity: 1, - reason: 'ignored', - }, - ], - }, - }) - .expect({ - span: { - items: expect.arrayContaining([ - expect.objectContaining({ - name: 'GET /ok', - attributes: expect.objectContaining({ - [SEMANTIC_ATTRIBUTE_SENTRY_OP]: { - value: 'http.server', - type: 'string', - }, - }), - }), - ]), - }, - }) - .start(); - - runner.makeRequest('get', '/health'); - runner.makeRequest('get', '/ok'); - - await runner.completed(); - }); - }); -}); diff --git a/dev-packages/node-integration-tests/suites/tracing/sampling-static/instrument.mjs b/dev-packages/node-integration-tests/suites/tracing/sampling-static/instrument.mjs new file mode 100644 index 000000000000..cbf0bbf2e2fc --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/sampling-static/instrument.mjs @@ -0,0 +1,15 @@ +import * as Sentry from '@sentry/node'; +import { loggingTransport } from '@sentry-internal/node-integration-tests'; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + tracesSampler: ({ inheritOrSampleWith, name }) => { + if (name === 'GET /health') { + return inheritOrSampleWith(0); + } + return inheritOrSampleWith(1); + }, + transport: loggingTransport, + clientReportFlushInterval: 1_000, +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/sampling-static/server.mjs b/dev-packages/node-integration-tests/suites/tracing/sampling-static/server.mjs new file mode 100644 index 000000000000..f9c7f136aef2 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/sampling-static/server.mjs @@ -0,0 +1,20 @@ +import express from 'express'; +import cors from 'cors'; +import * as Sentry from '@sentry/node'; +import { startExpressServerAndSendPortToRunner } from '@sentry-internal/node-integration-tests'; + +const app = express(); + +app.use(cors()); + +app.get('/health', (_req, res) => { + res.send({ status: 'ok-health' }); +}); + +app.get('/ok', (_req, res) => { + res.send({ status: 'ok' }); +}); + +Sentry.setupExpressErrorHandler(app); + +startExpressServerAndSendPortToRunner(app); diff --git a/dev-packages/node-integration-tests/suites/tracing/sampling-static/test.ts b/dev-packages/node-integration-tests/suites/tracing/sampling-static/test.ts new file mode 100644 index 000000000000..8eb57ccdd54e --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/sampling-static/test.ts @@ -0,0 +1,40 @@ +import { afterAll, describe, expect } from 'vitest'; +import { cleanupChildProcesses, createEsmAndCjsTests } from '../../../utils/runner'; + +describe('negative sampling (static)', () => { + afterAll(() => { + cleanupChildProcesses(); + }); + + createEsmAndCjsTests(__dirname, 'server.mjs', 'instrument.mjs', (createRunner, test) => { + test('records sample_rate outcome for root span/transaction', async () => { + const runner = createRunner() + .unignore('client_report') + .expect({ + transaction: { + transaction: 'GET /ok', + }, + }) + .expect({ + client_report: { + discarded_events: [ + { + category: 'transaction', + quantity: 1, + reason: 'sample_rate', + }, + ], + }, + }) + .start(); + + const res = await runner.makeRequest('get', '/health'); + expect((res as { status: string }).status).toBe('ok-health'); + + const res2 = await runner.makeRequest('get', '/ok'); // contains all spans + expect((res2 as { status: string }).status).toBe('ok'); + + await runner.completed(); + }); + }); +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/sampling-streamed/instrument.mjs b/dev-packages/node-integration-tests/suites/tracing/sampling-streamed/instrument.mjs new file mode 100644 index 000000000000..713a676ede3d --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/sampling-streamed/instrument.mjs @@ -0,0 +1,16 @@ +import * as Sentry from '@sentry/node'; +import { loggingTransport } from '@sentry-internal/node-integration-tests'; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + tracesSampler: ({ inheritOrSampleWith, name }) => { + if (name === 'GET /health') { + return inheritOrSampleWith(0); + } + return inheritOrSampleWith(1); + }, + transport: loggingTransport, + traceLifecycle: 'stream', + clientReportFlushInterval: 1_000, +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/sampling-streamed/server.mjs b/dev-packages/node-integration-tests/suites/tracing/sampling-streamed/server.mjs new file mode 100644 index 000000000000..f9c7f136aef2 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/sampling-streamed/server.mjs @@ -0,0 +1,20 @@ +import express from 'express'; +import cors from 'cors'; +import * as Sentry from '@sentry/node'; +import { startExpressServerAndSendPortToRunner } from '@sentry-internal/node-integration-tests'; + +const app = express(); + +app.use(cors()); + +app.get('/health', (_req, res) => { + res.send({ status: 'ok-health' }); +}); + +app.get('/ok', (_req, res) => { + res.send({ status: 'ok' }); +}); + +Sentry.setupExpressErrorHandler(app); + +startExpressServerAndSendPortToRunner(app); diff --git a/dev-packages/node-integration-tests/suites/tracing/sampling-streamed/test.ts b/dev-packages/node-integration-tests/suites/tracing/sampling-streamed/test.ts new file mode 100644 index 000000000000..d98d2f82afb0 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/sampling-streamed/test.ts @@ -0,0 +1,44 @@ +import { afterAll, describe, expect } from 'vitest'; +import { cleanupChildProcesses, createEsmAndCjsTests } from '../../../utils/runner'; + +describe('negative sampling (streaming)', () => { + afterAll(() => { + cleanupChildProcesses(); + }); + + createEsmAndCjsTests(__dirname, 'server.mjs', 'instrument.mjs', (createRunner, test) => { + test('records sample_rate outcome for segment and child spans of negatively sampled segment', async () => { + const runner = createRunner() + .unignore('client_report') + .expect({ + client_report: { + discarded_events: [ + { + category: 'span', + quantity: 5, // 1 segment ignored + 4 child spans (implicitly ignored) + reason: 'sample_rate', + }, + ], + }, + }) + .expect({ + span: container => { + expect(container.items).toHaveLength(5); + const segmentSpan = container.items.find(s => s.name === 'GET /ok' && !!s.is_segment); + + expect(segmentSpan).toBeDefined(); + expect(container.items.every(s => s.trace_id === segmentSpan!.trace_id)).toBe(true); + }, + }) + .start(); + + const res = await runner.makeRequest('get', '/health'); + expect((res as { status: string }).status).toBe('ok-health'); + + const res2 = await runner.makeRequest('get', '/ok'); // contains all spans + expect((res2 as { status: string }).status).toBe('ok'); + + await runner.completed(); + }); + }); +}); diff --git a/packages/effect/src/index.types.ts b/packages/effect/src/index.types.ts index e0a6e9512eeb..e811a9bc7c81 100644 --- a/packages/effect/src/index.types.ts +++ b/packages/effect/src/index.types.ts @@ -21,6 +21,7 @@ export declare function effectLayer( export declare function init(options: Options | clientSdk.BrowserOptions | serverSdk.NodeOptions): Client | undefined; export declare const linkedErrorsIntegration: typeof clientSdk.linkedErrorsIntegration; export declare const contextLinesIntegration: typeof clientSdk.contextLinesIntegration; +export declare const spanStreamingIntegration: typeof clientSdk.spanStreamingIntegration; export declare const getDefaultIntegrations: (options: Options) => Integration[]; export declare const defaultStackParser: StackParser; export declare const logger: typeof clientSdk.logger | typeof serverSdk.logger; diff --git a/packages/opentelemetry/src/constants.ts b/packages/opentelemetry/src/constants.ts index d8723f3940cc..efbeb0670555 100644 --- a/packages/opentelemetry/src/constants.ts +++ b/packages/opentelemetry/src/constants.ts @@ -15,6 +15,14 @@ export const SENTRY_TRACE_STATE_SAMPLE_RATE = 'sentry.sample_rate'; */ export const SENTRY_TRACE_STATE_IGNORED = 'sentry.ignored'; +/** + * A flag marking a segment span as ignored because it matched the `ignoreSpans` filter. + * Unlike `SENTRY_TRACE_STATE_IGNORED` (used for child spans), this flag is NOT consumed + * by the context manager for re-parenting. Instead, it propagates to child spans so they + * can record the correct client report outcome (`ignored` instead of `sample_rate`). + */ +export const SENTRY_TRACE_STATE_SEGMENT_IGNORED = 'sentry.segment_ignored'; + export const SENTRY_SCOPES_CONTEXT_KEY = createContextKey('sentry_scopes'); export const SENTRY_FORK_ISOLATION_SCOPE_CONTEXT_KEY = createContextKey('sentry_fork_isolation_scope'); diff --git a/packages/opentelemetry/src/sampler.ts b/packages/opentelemetry/src/sampler.ts index 0977b0dd7493..ad0efa84f869 100644 --- a/packages/opentelemetry/src/sampler.ts +++ b/packages/opentelemetry/src/sampler.ts @@ -16,6 +16,7 @@ import { baggageHeaderToDynamicSamplingContext, debug, hasSpansEnabled, + hasSpanStreamingEnabled, parseSampleRate, sampleSpan, SEMANTIC_ATTRIBUTE_SENTRY_OP, @@ -28,6 +29,7 @@ import { SENTRY_TRACE_STATE_SAMPLE_RAND, SENTRY_TRACE_STATE_SAMPLE_RATE, SENTRY_TRACE_STATE_SAMPLED_NOT_RECORDING, + SENTRY_TRACE_STATE_SEGMENT_IGNORED, SENTRY_TRACE_STATE_URL, } from './constants'; import { DEBUG_BUILD } from './debug-build'; @@ -41,9 +43,11 @@ import { setIsSetup } from './utils/setupCheck'; */ export class SentrySampler implements Sampler { private _client: Client; + private _isSpanStreaming: boolean; public constructor(client: Client) { this._client = client; + this._isSpanStreaming = hasSpanStreamingEnabled(client); setIsSetup('SentrySampler'); } @@ -57,6 +61,7 @@ export class SentrySampler implements Sampler { _links: unknown, ): SamplingResult { const options = this._client.getOptions(); + const { ignoreSpans } = options; const parentSpan = getValidSpan(context); const parentContext = parentSpan?.spanContext(); @@ -81,20 +86,29 @@ export class SentrySampler implements Sampler { // We only sample based on parameters (like tracesSampleRate or tracesSampler) for root spans (which is done in sampleSpan). // Non-root-spans simply inherit the sampling decision from their parent. if (!isRootSpan) { - if (parentSampled) { - const { ignoreSpans } = options; - if (ignoreSpans?.length) { - const { description: inferredChildName, op: childOp } = inferSpanData(spanName, spanAttributes, spanKind); - if (shouldIgnoreSpan({ description: inferredChildName, op: childOp }, ignoreSpans)) { - this._client.recordDroppedEvent('ignored', 'span'); - return wrapSamplingDecision({ - decision: SamplingDecision.NOT_RECORD, - context, - spanAttributes, - ignoredSpan: true, - }); + if (this._isSpanStreaming) { + // `ignoreSpans` is only applied at span start for streamed spans. + // Static spans/transactions get filtered at transaction end. + // Likewise, we only record client outcomes for child spans when streaming + if (parentSampled) { + if (ignoreSpans?.length) { + const { description: inferredChildName, op: childOp } = inferSpanData(spanName, spanAttributes, spanKind); + if (shouldIgnoreSpan({ description: inferredChildName, op: childOp }, ignoreSpans)) { + this._client.recordDroppedEvent('ignored', 'span'); + return wrapSamplingDecision({ + decision: SamplingDecision.NOT_RECORD, + context, + spanAttributes, + ignoredSpan: true, + }); + } } } + + if (!parentSampled) { + const parentSegmentIgnored = parentContext?.traceState?.get(SENTRY_TRACE_STATE_SEGMENT_IGNORED) === '1'; + this._client.recordDroppedEvent(parentSegmentIgnored ? 'ignored' : 'sample_rate', 'span'); + } } return wrapSamplingDecision({ @@ -120,13 +134,17 @@ export class SentrySampler implements Sampler { mergedAttributes[SEMANTIC_ATTRIBUTE_SENTRY_OP] = op; } - const { ignoreSpans } = options; - if (ignoreSpans?.length && shouldIgnoreSpan({ description: inferredSpanName, op }, ignoreSpans)) { + if ( + this._isSpanStreaming && + ignoreSpans?.length && + shouldIgnoreSpan({ description: inferredSpanName, op }, ignoreSpans) + ) { this._client.recordDroppedEvent('ignored', 'span'); return wrapSamplingDecision({ decision: SamplingDecision.NOT_RECORD, context, spanAttributes, + ignoredSegmentSpan: true, }); } @@ -183,7 +201,7 @@ export class SentrySampler implements Sampler { parentSampled === undefined ) { DEBUG_BUILD && debug.log('[Tracing] Discarding root span because its trace was not chosen to be sampled.'); - this._client.recordDroppedEvent('sample_rate', 'transaction'); + this._client.recordDroppedEvent('sample_rate', this._isSpanStreaming ? 'span' : 'transaction'); } return { @@ -240,13 +258,16 @@ export function wrapSamplingDecision({ sampleRand, downstreamTraceSampleRate, ignoredSpan, + ignoredSegmentSpan, }: { decision: SamplingDecision | undefined; context: Context; spanAttributes: SpanAttributes; sampleRand?: number; downstreamTraceSampleRate?: number; + // flags for ignored streamed spans (only set for span streaming) ignoredSpan?: boolean; + ignoredSegmentSpan?: boolean; }): SamplingResult { let traceState = getBaseTraceState(context, spanAttributes); @@ -266,6 +287,10 @@ export function wrapSamplingDecision({ traceState = traceState.set(SENTRY_TRACE_STATE_IGNORED, '1'); } + if (ignoredSegmentSpan) { + traceState = traceState.set(SENTRY_TRACE_STATE_SEGMENT_IGNORED, '1'); + } + // If the decision is undefined, we treat it as NOT_RECORDING, but we don't propagate this decision to downstream SDKs // Which is done by not setting `SENTRY_TRACE_STATE_SAMPLED_NOT_RECORDING` traceState if (decision == undefined) { diff --git a/packages/opentelemetry/test/sampler.test.ts b/packages/opentelemetry/test/sampler.test.ts index 18c2fcc1f02d..a6e76ab8ca7b 100644 --- a/packages/opentelemetry/test/sampler.test.ts +++ b/packages/opentelemetry/test/sampler.test.ts @@ -4,7 +4,11 @@ import { SamplingDecision } from '@opentelemetry/sdk-trace-base'; import { ATTR_HTTP_REQUEST_METHOD } from '@opentelemetry/semantic-conventions'; import { generateSpanId, generateTraceId } from '@sentry/core'; import { afterEach, describe, expect, it, vi } from 'vitest'; -import { SENTRY_TRACE_STATE_IGNORED, SENTRY_TRACE_STATE_SAMPLED_NOT_RECORDING } from '../src/constants'; +import { + SENTRY_TRACE_STATE_IGNORED, + SENTRY_TRACE_STATE_SAMPLED_NOT_RECORDING, + SENTRY_TRACE_STATE_SEGMENT_IGNORED, +} from '../src/constants'; import { SentrySampler } from '../src/sampler'; import { cleanupOtel } from './helpers/mockSdkInit'; import { getDefaultTestClientOptions, TestClient } from './helpers/TestClient'; @@ -63,7 +67,8 @@ describe('SentrySampler', () => { decision: SamplingDecision.NOT_RECORD, traceState: new TraceState().set(SENTRY_TRACE_STATE_SAMPLED_NOT_RECORDING, '1'), }); - expect(spyOnDroppedEvent).toHaveBeenCalledTimes(0); + expect(spyOnDroppedEvent).toHaveBeenCalledTimes(1); + expect(spyOnDroppedEvent).toHaveBeenCalledWith('sample_rate', 'span'); spyOnDroppedEvent.mockReset(); }); @@ -218,6 +223,62 @@ describe('SentrySampler', () => { expect(actual.traceState?.get(SENTRY_TRACE_STATE_IGNORED)).toBeUndefined(); }); + it('sets sentry.segment_ignored traceState for root span matching ignoreSpans', () => { + const client = new TestClient(getDefaultTestClientOptions({ tracesSampleRate: 1, ignoreSpans: ['GET /health'] })); + const sampler = new SentrySampler(client); + + const ctx = context.active(); + const traceId = generateTraceId(); + const spanName = 'GET /health'; + const spanKind = SpanKind.SERVER; + const spanAttributes = {}; + + const actual = sampler.shouldSample(ctx, traceId, spanName, spanKind, spanAttributes, undefined); + expect(actual.decision).toBe(SamplingDecision.NOT_RECORD); + expect(actual.traceState?.get(SENTRY_TRACE_STATE_SEGMENT_IGNORED)).toBe('1'); + expect(actual.traceState?.get(SENTRY_TRACE_STATE_IGNORED)).toBeUndefined(); + }); + + it('records ignored outcome for child span of ignored segment', () => { + const client = new TestClient(getDefaultTestClientOptions({ tracesSampleRate: 1, ignoreSpans: ['GET /health'] })); + const spyOnDroppedEvent = vi.spyOn(client, 'recordDroppedEvent'); + const sampler = new SentrySampler(client); + + const traceId = generateTraceId(); + const ctx = trace.setSpanContext(context.active(), { + spanId: generateSpanId(), + traceId, + traceFlags: 0, + traceState: new TraceState() + .set(SENTRY_TRACE_STATE_SAMPLED_NOT_RECORDING, '1') + .set(SENTRY_TRACE_STATE_SEGMENT_IGNORED, '1'), + }); + + const actual = sampler.shouldSample(ctx, traceId, 'db.query SELECT 1', SpanKind.CLIENT, {}, undefined); + expect(actual.decision).toBe(SamplingDecision.NOT_RECORD); + expect(spyOnDroppedEvent).toHaveBeenCalledTimes(1); + expect(spyOnDroppedEvent).toHaveBeenCalledWith('ignored', 'span'); + }); + + it('records sample_rate outcome for child span of negatively sampled segment', () => { + const client = new TestClient(getDefaultTestClientOptions({ tracesSampleRate: 0 })); + const spyOnDroppedEvent = vi.spyOn(client, 'recordDroppedEvent'); + const sampler = new SentrySampler(client); + + const traceId = generateTraceId(); + const ctx = trace.setSpanContext(context.active(), { + spanId: generateSpanId(), + traceId, + traceFlags: 0, + traceState: new TraceState().set(SENTRY_TRACE_STATE_SAMPLED_NOT_RECORDING, '1'), + }); + + const actual = sampler.shouldSample(ctx, traceId, 'db.query SELECT 1', SpanKind.CLIENT, {}, undefined); + expect(actual.decision).toBe(SamplingDecision.NOT_RECORD); + expect(spyOnDroppedEvent).toHaveBeenCalledTimes(1); + expect(spyOnDroppedEvent).toHaveBeenCalledWith('sample_rate', 'span'); + }); + it('ignores local http client root spans', () => { const client = new TestClient(getDefaultTestClientOptions({ tracesSampleRate: 0 })); const spyOnDroppedEvent = vi.spyOn(client, 'recordDroppedEvent'); From 1bc5f72fba0885c8b07a1985ed69d48f715422e2 Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Mon, 30 Mar 2026 18:53:38 +0200 Subject: [PATCH 4/5] handle client reports for core/browser --- .../consistent-sampling/meta-negative/test.ts | 6 +- .../meta-precedence/test.ts | 6 +- .../tracesSampler-precedence/test.ts | 4 +- .../ignoreSpans-streamed/child/init.js | 1 + .../ignoreSpans-streamed/child/subject.js | 17 ++- .../ignoreSpans-streamed/child/test.ts | 69 +++++----- .../src/tracing/sentryNonRecordingSpan.ts | 15 +- packages/core/src/tracing/trace.ts | 115 ++++++++++------ packages/core/test/lib/tracing/trace.test.ts | 128 +++++++++++++++++- 9 files changed, 269 insertions(+), 92 deletions(-) diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/consistent-sampling/meta-negative/test.ts b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/consistent-sampling/meta-negative/test.ts index 73b4bea99e22..ea50f09f2361 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/consistent-sampling/meta-negative/test.ts +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/consistent-sampling/meta-negative/test.ts @@ -83,12 +83,14 @@ sentryTest.describe('When `consistentTraceSampling` is `true` and page contains timestamp: expect.any(Number), discarded_events: [ { - category: 'transaction', - quantity: 4, + category: 'span', + quantity: expect.any(Number), reason: 'sample_rate', }, ], }); + // exact number depends on performance observer emissions + expect(clientReport.discarded_events[0].quantity).toBeGreaterThanOrEqual(10); }); expect(spansReceived).toHaveLength(0); diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/consistent-sampling/meta-precedence/test.ts b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/consistent-sampling/meta-precedence/test.ts index 4cafe023b57d..367b48e70eda 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/consistent-sampling/meta-precedence/test.ts +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/consistent-sampling/meta-precedence/test.ts @@ -69,12 +69,14 @@ sentryTest.describe('When `consistentTraceSampling` is `true` and page contains timestamp: expect.any(Number), discarded_events: [ { - category: 'transaction', - quantity: 2, + category: 'span', + quantity: expect.any(Number), reason: 'sample_rate', }, ], }); + // exact number depends on performance observer emissions + expect(clientReport.discarded_events[0].quantity).toBeGreaterThanOrEqual(3); }); await sentryTest.step('Navigate to another page with meta tags', async () => { diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/consistent-sampling/tracesSampler-precedence/test.ts b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/consistent-sampling/tracesSampler-precedence/test.ts index 46805496a676..d661a4548e94 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/consistent-sampling/tracesSampler-precedence/test.ts +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/consistent-sampling/tracesSampler-precedence/test.ts @@ -53,7 +53,7 @@ sentryTest.describe('When `consistentTraceSampling` is `true`', () => { timestamp: expect.any(Number), discarded_events: [ { - category: 'transaction', + category: 'span', quantity: 1, reason: 'sample_rate', }, @@ -76,7 +76,7 @@ sentryTest.describe('When `consistentTraceSampling` is `true`', () => { timestamp: expect.any(Number), discarded_events: [ { - category: 'transaction', + category: 'span', quantity: 1, reason: 'sample_rate', }, diff --git a/dev-packages/browser-integration-tests/suites/tracing/ignoreSpans-streamed/child/init.js b/dev-packages/browser-integration-tests/suites/tracing/ignoreSpans-streamed/child/init.js index 01f36733fe67..65b264087c27 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/ignoreSpans-streamed/child/init.js +++ b/dev-packages/browser-integration-tests/suites/tracing/ignoreSpans-streamed/child/init.js @@ -6,5 +6,6 @@ Sentry.init({ dsn: 'https://public@dsn.ingest.sentry.io/1337', integrations: [Sentry.spanStreamingIntegration()], ignoreSpans: [/ignore/], + parentSpanIsAlwaysRootSpan: false, tracesSampleRate: 1, }); diff --git a/dev-packages/browser-integration-tests/suites/tracing/ignoreSpans-streamed/child/subject.js b/dev-packages/browser-integration-tests/suites/tracing/ignoreSpans-streamed/child/subject.js index 512188b3dcad..056264c17745 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/ignoreSpans-streamed/child/subject.js +++ b/dev-packages/browser-integration-tests/suites/tracing/ignoreSpans-streamed/child/subject.js @@ -1,12 +1,23 @@ Sentry.startSpan({ name: 'parent-span' }, () => { Sentry.startSpan({ name: 'keep-me' }, () => {}); - // This child matches ignoreSpans — should be dropped + // This child matches ignoreSpans —> dropped Sentry.startSpan({ name: 'ignore-child' }, () => { - // Grandchild should be reparented to 'parent-span' - Sentry.startSpan({ name: 'grandchild-1' }, () => {}); + // dropped + Sentry.startSpan({ name: 'ignore-grandchild-1' }, () => { + // kept + Sentry.startSpan({ name: 'great-grandchild-1' }, () => { + // dropped + Sentry.startSpan({ name: 'ignore-great-great-grandchild-1' }, () => { + // kept + Sentry.startSpan({ name: 'great-great-great-grandchild-1' }, () => {}); + }); + }); + }); + // Grandchild is reparented to 'parent-span' —> kept Sentry.startSpan({ name: 'grandchild-2' }, () => {}); }); + // kept Sentry.startSpan({ name: 'another-keeper' }, () => {}); }); diff --git a/dev-packages/browser-integration-tests/suites/tracing/ignoreSpans-streamed/child/test.ts b/dev-packages/browser-integration-tests/suites/tracing/ignoreSpans-streamed/child/test.ts index baf751768fc1..4cbc993ae346 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/ignoreSpans-streamed/child/test.ts +++ b/dev-packages/browser-integration-tests/suites/tracing/ignoreSpans-streamed/child/test.ts @@ -9,47 +9,52 @@ import { import { waitForStreamedSpans } from '../../../../utils/spanUtils'; import type { ClientReport } from '@sentry/core'; -sentryTest( - 'ignored child spans are dropped and their children are reparented to the grandparent', - async ({ getLocalTestUrl, page }) => { - if (shouldSkipTracingTest()) { - sentryTest.skip(); - } +sentryTest('ignored child spans are dropped and their children are reparented', async ({ getLocalTestUrl, page }) => { + if (shouldSkipTracingTest()) { + sentryTest.skip(); + } - const spansPromise = waitForStreamedSpans(page, spans => !!spans?.find(s => s.name === 'parent-span')); + const spansPromise = waitForStreamedSpans(page, spans => !!spans?.find(s => s.name === 'parent-span')); - const clientReportPromise = waitForClientReportRequest(page); + const clientReportPromise = waitForClientReportRequest(page); - const url = await getLocalTestUrl({ testDir: __dirname }); - await page.goto(url); + const url = await getLocalTestUrl({ testDir: __dirname }); + await page.goto(url); - const spans = await spansPromise; + const spans = await spansPromise; - await hidePage(page); + await hidePage(page); - const clientReport = envelopeRequestParser(await clientReportPromise); + const clientReport = envelopeRequestParser(await clientReportPromise); - const segmentSpanId = spans.find(s => s.name === 'parent-span')?.span_id; + const segmentSpanId = spans.find(s => s.name === 'parent-span')?.span_id; - expect(spans.some(s => s.name === 'keep-me')).toBe(true); - expect(spans.some(s => s.name === 'another-keeper')).toBe(true); + expect(spans.length).toBe(6); - expect(spans.some(s => s.name?.includes('ignore'))).toBe(false); + expect(spans.some(s => s.name === 'keep-me')).toBe(true); + expect(spans.some(s => s.name === 'another-keeper')).toBe(true); - const grandchild1 = spans.find(s => s.name === 'grandchild-1'); - const grandchild2 = spans.find(s => s.name === 'grandchild-2'); - expect(grandchild1).toBeDefined(); - expect(grandchild2).toBeDefined(); + expect(spans.some(s => s.name?.includes('ignore'))).toBe(false); - expect(grandchild1?.parent_span_id).toBe(segmentSpanId); - expect(grandchild2?.parent_span_id).toBe(segmentSpanId); + const greatGrandChild1 = spans.find(s => s.name === 'great-grandchild-1'); + const grandchild2 = spans.find(s => s.name === 'grandchild-2'); + const greatGreatGreatGrandChild1 = spans.find(s => s.name === 'great-great-great-grandchild-1'); - expect(clientReport.discarded_events).toEqual([ - { - category: 'span', - quantity: 1, - reason: 'ignored', - }, - ]); - }, -); + expect(greatGrandChild1).toBeDefined(); + expect(grandchild2).toBeDefined(); + expect(greatGreatGreatGrandChild1).toBeDefined(); + + expect(greatGrandChild1?.parent_span_id).toBe(segmentSpanId); + expect(grandchild2?.parent_span_id).toBe(segmentSpanId); + expect(greatGreatGreatGrandChild1?.parent_span_id).toBe(greatGrandChild1?.span_id); + + expect(spans.every(s => s.name === 'parent-span' || !s.is_segment)).toBe(true); + + expect(clientReport.discarded_events).toEqual([ + { + category: 'span', + quantity: 3, // 3 ignored child spans + reason: 'ignored', + }, + ]); +}); diff --git a/packages/core/src/tracing/sentryNonRecordingSpan.ts b/packages/core/src/tracing/sentryNonRecordingSpan.ts index 2f65e0eb8c08..c1f01182b248 100644 --- a/packages/core/src/tracing/sentryNonRecordingSpan.ts +++ b/packages/core/src/tracing/sentryNonRecordingSpan.ts @@ -1,3 +1,4 @@ +import type { EventDropReason } from '../types-hoist/clientreport'; import type { SentrySpanArguments, Span, @@ -10,6 +11,10 @@ import type { SpanStatus } from '../types-hoist/spanStatus'; import { generateSpanId, generateTraceId } from '../utils/propagationContext'; import { TRACE_FLAG_NONE } from '../utils/spanUtils'; +interface SentryNonRecordingSpanArguments extends SentrySpanArguments { + dropReason?: EventDropReason; +} + /** * A Sentry Span that is non-recording, meaning it will not be sent to Sentry. */ @@ -17,9 +22,17 @@ export class SentryNonRecordingSpan implements Span { private _traceId: string; private _spanId: string; - public constructor(spanContext: SentrySpanArguments = {}) { + /** + * Reason why this span was dropped, if applicable ('ignored' or 'sample_rate'). + * Used to propagate the correct client report outcome to descendant spans + * when span streaming is enabled. + */ + public dropReason?: EventDropReason; + + public constructor(spanContext: SentryNonRecordingSpanArguments = {}) { this._traceId = spanContext.traceId || generateTraceId(); this._spanId = spanContext.spanId || generateSpanId(); + this.dropReason = spanContext.dropReason; } /** @inheritdoc */ diff --git a/packages/core/src/tracing/trace.ts b/packages/core/src/tracing/trace.ts index 94d19463a2ab..d632c7892f87 100644 --- a/packages/core/src/tracing/trace.ts +++ b/packages/core/src/tracing/trace.ts @@ -20,6 +20,7 @@ import { debug } from '../utils/debug-logger'; import { handleCallbackErrors } from '../utils/handleCallbackErrors'; import { hasSpansEnabled } from '../utils/hasSpansEnabled'; import { shouldIgnoreSpan } from '../utils/should-ignore-span'; +import { hasSpanStreamingEnabled } from './spans/hasSpanStreamingEnabled'; import { parseSampleRate } from '../utils/parseSampleRate'; import { generateTraceId } from '../utils/propagationContext'; import { safeMathRandom } from '../utils/randomSafeContext'; @@ -33,6 +34,7 @@ import { SentryNonRecordingSpan } from './sentryNonRecordingSpan'; import { SentrySpan } from './sentrySpan'; import { SPAN_STATUS_ERROR } from './spanstatus'; import { setCapturedScopesOnSpan } from './utils'; +import type { Client } from '../client'; const SUPPRESS_TRACING_KEY = '__SENTRY_SUPPRESS_TRACING__'; @@ -68,22 +70,12 @@ export function startSpan(options: StartSpanOptions, callback: (span: Span) = const parentSpan = getParentSpan(scope, customParentSpan); const client = getClient(); - const ignoreSpans = client?.getOptions().ignoreSpans; - if (ignoreSpans?.length) { - const op = spanArguments.attributes?.[SEMANTIC_ATTRIBUTE_SENTRY_OP]; - if (shouldIgnoreSpan({ description: spanArguments.name || '', op }, ignoreSpans)) { - client?.recordDroppedEvent('ignored', 'span'); - const nonRecordingSpan = new SentryNonRecordingSpan(); - // For root spans, set on scope (like unsampled). For child spans, don't — keep parent active. - if (!parentSpan) { - _setSpanForScope(scope, nonRecordingSpan); - } - return handleCallbackErrors( - () => callback(nonRecordingSpan), - () => {}, - () => nonRecordingSpan.end(), - ); - } + if (_shouldIgnoreStreamedSpan(client, spanArguments)) { + return handleCallbackErrors( + () => callback(_createIgnoredSpan(client, parentSpan, scope)), + () => {}, + () => {}, + ); } const shouldSkipSpan = options.onlyIfParent && !parentSpan; @@ -145,20 +137,11 @@ export function startSpanManual(options: StartSpanOptions, callback: (span: S const parentSpan = getParentSpan(scope, customParentSpan); const client = getClient(); - const ignoreSpans = client?.getOptions().ignoreSpans; - if (ignoreSpans?.length) { - const op = spanArguments.attributes?.[SEMANTIC_ATTRIBUTE_SENTRY_OP]; - if (shouldIgnoreSpan({ description: spanArguments.name || '', op }, ignoreSpans)) { - client?.recordDroppedEvent('ignored', 'span'); - const nonRecordingSpan = new SentryNonRecordingSpan(); - if (!parentSpan) { - _setSpanForScope(scope, nonRecordingSpan); - } - return handleCallbackErrors( - () => callback(nonRecordingSpan, () => nonRecordingSpan.end()), - () => {}, - ); - } + if (_shouldIgnoreStreamedSpan(client, spanArguments)) { + return handleCallbackErrors( + () => callback(_createIgnoredSpan(client, parentSpan, scope), () => {}), + () => {}, + ); } const shouldSkipSpan = options.onlyIfParent && !parentSpan; @@ -222,13 +205,8 @@ export function startInactiveSpan(options: StartSpanOptions): Span { const parentSpan = getParentSpan(scope, customParentSpan); const client = getClient(); - const ignoreSpans = client?.getOptions().ignoreSpans; - if (ignoreSpans?.length) { - const op = spanArguments.attributes?.[SEMANTIC_ATTRIBUTE_SENTRY_OP]; - if (shouldIgnoreSpan({ description: spanArguments.name || '', op }, ignoreSpans)) { - client?.recordDroppedEvent('ignored', 'span'); - return new SentryNonRecordingSpan(); - } + if (_shouldIgnoreStreamedSpan(client, spanArguments)) { + return _createIgnoredSpan(client, parentSpan, scope); } const shouldSkipSpan = options.onlyIfParent && !parentSpan; @@ -513,7 +491,7 @@ function _startRootSpan(spanArguments: SentrySpanArguments, scope: Scope, parent if (!sampled && client) { DEBUG_BUILD && debug.log('[Tracing] Discarding root span because its trace was not chosen to be sampled.'); - client.recordDroppedEvent('sample_rate', 'transaction'); + client.recordDroppedEvent('sample_rate', hasSpanStreamingEnabled(client) ? 'span' : 'transaction'); } if (client) { @@ -543,15 +521,33 @@ function _startChildSpan(parentSpan: Span, scope: Scope, spanArguments: SentrySp addChildSpanToSpan(parentSpan, childSpan); const client = getClient(); - if (client) { - client.emit('spanStart', childSpan); - // If it has an endTimestamp, it's already ended - if (spanArguments.endTimestamp) { - client.emit('spanEnd', childSpan); - client.emit('afterSpanEnd', childSpan); + + if (!client) { + return childSpan; + } + + if (hasSpanStreamingEnabled(client) && childSpan instanceof SentryNonRecordingSpan) { + if (parentSpan instanceof SentryNonRecordingSpan && parentSpan.dropReason) { + // We land here if the parent span was a segment span that was ignored (`ignoreSpans`). + // In this case, the child was also ignored (see `sampled` above) but we need to + // record a client outcome for the child. + childSpan.dropReason = parentSpan.dropReason; + client.recordDroppedEvent(parentSpan.dropReason, 'span'); + } else { + // Otherwise, the child is not sampled due to sampling of the parent span, + // hence we record a sample_rate client outcome for the child. + childSpan.dropReason = 'sample_rate'; + client.recordDroppedEvent('sample_rate', 'span'); } } + client.emit('spanStart', childSpan); + // If it has an endTimestamp, it's already ended + if (spanArguments.endTimestamp) { + client.emit('spanEnd', childSpan); + client.emit('afterSpanEnd', childSpan); + } + return childSpan; } @@ -588,3 +584,34 @@ function getActiveSpanWrapper(parentSpan: Span | undefined | null): (callback } : (callback: () => T) => callback(); } + +/* Checks if `ignoreSpans` applies (extracted for bundle size)*/ +function _shouldIgnoreStreamedSpan(client: Client | undefined, spanArguments: SentrySpanArguments): boolean { + const ignoreSpans = client?.getOptions().ignoreSpans; + + if (!client || !hasSpanStreamingEnabled(client) || !ignoreSpans?.length) { + return false; + } + + return shouldIgnoreSpan( + { description: spanArguments.name || '', op: spanArguments.attributes?.[SEMANTIC_ATTRIBUTE_SENTRY_OP] }, + ignoreSpans, + ); +} + +/* creates a non-recording span that is marked as ignored and sets it on the scope if applicable */ +function _createIgnoredSpan( + client: Client | undefined, + parentSpan: SentrySpan | undefined, + scope: Scope, +): SentryNonRecordingSpan { + client?.recordDroppedEvent('ignored', 'span'); + const nonRecordingSpan = new SentryNonRecordingSpan({ dropReason: 'ignored' }); + if (!parentSpan) { + // Put the ignored non-recording segment span onto the scope so that `getActiveSpan()` returns it + // For child spans, we don't do this because there _is_ an active span on the scope. We can change + // this if necessary. + _setSpanForScope(scope, nonRecordingSpan); + } + return nonRecordingSpan; +} diff --git a/packages/core/test/lib/tracing/trace.test.ts b/packages/core/test/lib/tracing/trace.test.ts index 49e19cc6b660..4f2da43c27e4 100644 --- a/packages/core/test/lib/tracing/trace.test.ts +++ b/packages/core/test/lib/tracing/trace.test.ts @@ -2411,7 +2411,7 @@ describe('startNewTrace', () => { }); }); -describe('ignoreSpans (core path)', () => { +describe('ignoreSpans (core path, streaming)', () => { beforeEach(() => { registerSpanErrorInstrumentation(); getCurrentScope().clear(); @@ -2425,7 +2425,11 @@ describe('ignoreSpans (core path)', () => { }); it('returns SentryNonRecordingSpan for root span matching ignoreSpans', () => { - const options = getDefaultTestClientOptions({ tracesSampleRate: 1, ignoreSpans: ['GET /health'] }); + const options = getDefaultTestClientOptions({ + tracesSampleRate: 1, + traceLifecycle: 'stream', + ignoreSpans: ['GET /health'], + }); client = new TestClient(options); setCurrentClient(client); client.init(); @@ -2444,7 +2448,11 @@ describe('ignoreSpans (core path)', () => { }); it('returns SentryNonRecordingSpan for child span matching ignoreSpans', () => { - const options = getDefaultTestClientOptions({ tracesSampleRate: 1, ignoreSpans: ['ignored-child'] }); + const options = getDefaultTestClientOptions({ + tracesSampleRate: 1, + traceLifecycle: 'stream', + ignoreSpans: ['ignored-child'], + }); client = new TestClient(options); setCurrentClient(client); client.init(); @@ -2460,7 +2468,11 @@ describe('ignoreSpans (core path)', () => { }); it('children of ignored child spans parent to grandparent', () => { - const options = getDefaultTestClientOptions({ tracesSampleRate: 1, ignoreSpans: ['ignored-span'] }); + const options = getDefaultTestClientOptions({ + tracesSampleRate: 1, + traceLifecycle: 'stream', + ignoreSpans: ['ignored-span'], + }); client = new TestClient(options); setCurrentClient(client); client.init(); @@ -2478,7 +2490,11 @@ describe('ignoreSpans (core path)', () => { }); it('does not ignore non-matching spans', () => { - const options = getDefaultTestClientOptions({ tracesSampleRate: 1, ignoreSpans: ['GET /health'] }); + const options = getDefaultTestClientOptions({ + tracesSampleRate: 1, + traceLifecycle: 'stream', + ignoreSpans: ['GET /health'], + }); client = new TestClient(options); setCurrentClient(client); client.init(); @@ -2490,7 +2506,11 @@ describe('ignoreSpans (core path)', () => { }); it('returns SentryNonRecordingSpan for startInactiveSpan matching ignoreSpans', () => { - const options = getDefaultTestClientOptions({ tracesSampleRate: 1, ignoreSpans: ['ignored-span'] }); + const options = getDefaultTestClientOptions({ + tracesSampleRate: 1, + traceLifecycle: 'stream', + ignoreSpans: ['ignored-span'], + }); client = new TestClient(options); setCurrentClient(client); client.init(); @@ -2498,4 +2518,100 @@ describe('ignoreSpans (core path)', () => { const span = startInactiveSpan({ name: 'ignored-span' }); expect(span).toBeInstanceOf(SentryNonRecordingSpan); }); + + it('does not apply ignoreSpans on the static (non-streaming) path', () => { + const options = getDefaultTestClientOptions({ + tracesSampleRate: 1, + ignoreSpans: ['GET /health'], + }); + client = new TestClient(options); + setCurrentClient(client); + client.init(); + + startSpan({ name: 'GET /health' }, span => { + expect(span).toBeInstanceOf(SentrySpan); + expect(spanIsSampled(span)).toBe(true); + }); + }); + + it('records ignored outcome for root span and all child spans', () => { + const options = getDefaultTestClientOptions({ + tracesSampleRate: 1, + traceLifecycle: 'stream', + ignoreSpans: ['GET /health'], + }); + client = new TestClient(options); + setCurrentClient(client); + client.init(); + const spyOnDroppedEvent = vi.spyOn(client, 'recordDroppedEvent'); + + startSpan({ name: 'GET /health' }, () => { + startSpan({ name: 'db.query' }, () => { + startSpan({ name: 'cache.lookup' }, () => {}); + }); + }); + + expect(spyOnDroppedEvent).toHaveBeenCalledTimes(3); + expect(spyOnDroppedEvent).toHaveBeenNthCalledWith(1, 'ignored', 'span'); + expect(spyOnDroppedEvent).toHaveBeenNthCalledWith(2, 'ignored', 'span'); + expect(spyOnDroppedEvent).toHaveBeenNthCalledWith(3, 'ignored', 'span'); + }); + + it('records sample_rate outcome for unsampled root span and all child spans when streaming', () => { + const options = getDefaultTestClientOptions({ + tracesSampleRate: 0, + traceLifecycle: 'stream', + }); + client = new TestClient(options); + setCurrentClient(client); + client.init(); + const spyOnDroppedEvent = vi.spyOn(client, 'recordDroppedEvent'); + + startSpan({ name: 'GET /foo' }, () => { + startSpan({ name: 'db.query' }, () => {}); + }); + + expect(spyOnDroppedEvent).toHaveBeenCalledTimes(2); + expect(spyOnDroppedEvent).toHaveBeenNthCalledWith(1, 'sample_rate', 'span'); + expect(spyOnDroppedEvent).toHaveBeenNthCalledWith(2, 'sample_rate', 'span'); + }); + + it('records sample_rate/transaction for unsampled root span on static path', () => { + const options = getDefaultTestClientOptions({ + tracesSampleRate: 0, + }); + client = new TestClient(options); + setCurrentClient(client); + client.init(); + const spyOnDroppedEvent = vi.spyOn(client, 'recordDroppedEvent'); + + startSpan({ name: 'GET /foo' }, () => { + startSpan({ name: 'db.query' }, () => {}); + }); + + expect(spyOnDroppedEvent).toHaveBeenCalledTimes(1); + expect(spyOnDroppedEvent).toHaveBeenCalledWith('sample_rate', 'transaction'); + }); + + it('records only one ignored outcome for directly ignored child span', () => { + const options = getDefaultTestClientOptions({ + tracesSampleRate: 1, + traceLifecycle: 'stream', + ignoreSpans: ['ignored-child'], + }); + client = new TestClient(options); + setCurrentClient(client); + client.init(); + const spyOnDroppedEvent = vi.spyOn(client, 'recordDroppedEvent'); + + startSpan({ name: 'root' }, () => { + startSpan({ name: 'ignored-child' }, () => {}); + startSpan({ name: 'normal-child' }, normalChild => { + expect(normalChild).toBeInstanceOf(SentrySpan); + }); + }); + + expect(spyOnDroppedEvent).toHaveBeenCalledTimes(1); + expect(spyOnDroppedEvent).toHaveBeenCalledWith('ignored', 'span'); + }); }); From 6bca13742d442b2086519f436fc2b92625708cd4 Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Tue, 31 Mar 2026 11:46:32 +0200 Subject: [PATCH 5/5] rename trace state flag, fix integration tests, rework unit tests clankers gonna clank --- .size-limit.js | 2 +- .../ignoreSpans-streamed/child/test.ts | 5 +- .../ignoreSpans-streamed/segment/test.ts | 5 +- packages/opentelemetry/src/constants.ts | 4 +- packages/opentelemetry/src/contextManager.ts | 6 +- packages/opentelemetry/src/sampler.ts | 4 +- .../opentelemetry/test/contextManager.test.ts | 4 +- packages/opentelemetry/test/sampler.test.ts | 378 ++++++++++-------- 8 files changed, 227 insertions(+), 181 deletions(-) diff --git a/.size-limit.js b/.size-limit.js index e9c701406823..cc9d543b20c4 100644 --- a/.size-limit.js +++ b/.size-limit.js @@ -356,7 +356,7 @@ module.exports = [ import: createImport('init'), ignore: [...builtinModules, ...nodePrefixedBuiltinModules], gzip: true, - limit: '116 KB', + limit: '117 KB', }, ]; diff --git a/dev-packages/browser-integration-tests/suites/tracing/ignoreSpans-streamed/child/test.ts b/dev-packages/browser-integration-tests/suites/tracing/ignoreSpans-streamed/child/test.ts index 4cbc993ae346..afff199ce8e0 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/ignoreSpans-streamed/child/test.ts +++ b/dev-packages/browser-integration-tests/suites/tracing/ignoreSpans-streamed/child/test.ts @@ -4,15 +4,14 @@ import { envelopeRequestParser, hidePage, shouldSkipTracingTest, + testingCdnBundle, waitForClientReportRequest, } from '../../../../utils/helpers'; import { waitForStreamedSpans } from '../../../../utils/spanUtils'; import type { ClientReport } from '@sentry/core'; sentryTest('ignored child spans are dropped and their children are reparented', async ({ getLocalTestUrl, page }) => { - if (shouldSkipTracingTest()) { - sentryTest.skip(); - } + sentryTest.skip(shouldSkipTracingTest() || testingCdnBundle()); const spansPromise = waitForStreamedSpans(page, spans => !!spans?.find(s => s.name === 'parent-span')); diff --git a/dev-packages/browser-integration-tests/suites/tracing/ignoreSpans-streamed/segment/test.ts b/dev-packages/browser-integration-tests/suites/tracing/ignoreSpans-streamed/segment/test.ts index 655f00316981..93042cc5469e 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/ignoreSpans-streamed/segment/test.ts +++ b/dev-packages/browser-integration-tests/suites/tracing/ignoreSpans-streamed/segment/test.ts @@ -4,15 +4,14 @@ import { envelopeRequestParser, hidePage, shouldSkipTracingTest, + testingCdnBundle, waitForClientReportRequest, } from '../../../../utils/helpers'; import { observeStreamedSpan, waitForStreamedSpans } from '../../../../utils/spanUtils'; import type { ClientReport } from '@sentry/core'; sentryTest('ignored segment span drops entire trace', async ({ getLocalTestUrl, page }) => { - if (shouldSkipTracingTest()) { - sentryTest.skip(); - } + sentryTest.skip(shouldSkipTracingTest() || testingCdnBundle()); const url = await getLocalTestUrl({ testDir: __dirname }); diff --git a/packages/opentelemetry/src/constants.ts b/packages/opentelemetry/src/constants.ts index efbeb0670555..699868e3290a 100644 --- a/packages/opentelemetry/src/constants.ts +++ b/packages/opentelemetry/src/constants.ts @@ -13,11 +13,11 @@ export const SENTRY_TRACE_STATE_SAMPLE_RATE = 'sentry.sample_rate'; * A flag marking a context as ignored because the span associated with the context * is ignored (`ignoreSpans` filter). */ -export const SENTRY_TRACE_STATE_IGNORED = 'sentry.ignored'; +export const SENTRY_TRACE_STATE_CHILD_IGNORED = 'sentry.ignored'; /** * A flag marking a segment span as ignored because it matched the `ignoreSpans` filter. - * Unlike `SENTRY_TRACE_STATE_IGNORED` (used for child spans), this flag is NOT consumed + * Unlike `SENTRY_TRACE_STATE_CHILD_IGNORED` (used for child spans), this flag is NOT consumed * by the context manager for re-parenting. Instead, it propagates to child spans so they * can record the correct client report outcome (`ignored` instead of `sample_rate`). */ diff --git a/packages/opentelemetry/src/contextManager.ts b/packages/opentelemetry/src/contextManager.ts index a8f364a606ca..2e4d506ab6b1 100644 --- a/packages/opentelemetry/src/contextManager.ts +++ b/packages/opentelemetry/src/contextManager.ts @@ -8,7 +8,7 @@ import { SENTRY_FORK_SET_ISOLATION_SCOPE_CONTEXT_KEY, SENTRY_FORK_SET_SCOPE_CONTEXT_KEY, SENTRY_SCOPES_CONTEXT_KEY, - SENTRY_TRACE_STATE_IGNORED, + SENTRY_TRACE_STATE_CHILD_IGNORED, } from './constants'; import { getScopesFromContext, setContextOnScope, setScopesOnContext } from './utils/contextData'; import { setIsSetup } from './utils/setupCheck'; @@ -62,7 +62,9 @@ export function wrapContextManagerClass { @@ -12,7 +12,7 @@ describe('SentryContextManager', () => { it('removes ignored spans from context so children parent to grandparent', () => { mockSdkInit({ tracesSampleRate: 1 }); - const ignoredTraceState = new TraceState().set(SENTRY_TRACE_STATE_IGNORED, '1'); + const ignoredTraceState = new TraceState().set(SENTRY_TRACE_STATE_CHILD_IGNORED, '1'); const ignoredSpanContext = { traceId: '00000000000000000000000000000001', spanId: '0000000000000001', diff --git a/packages/opentelemetry/test/sampler.test.ts b/packages/opentelemetry/test/sampler.test.ts index a6e76ab8ca7b..654d96be91c4 100644 --- a/packages/opentelemetry/test/sampler.test.ts +++ b/packages/opentelemetry/test/sampler.test.ts @@ -5,7 +5,7 @@ import { ATTR_HTTP_REQUEST_METHOD } from '@opentelemetry/semantic-conventions'; import { generateSpanId, generateTraceId } from '@sentry/core'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { - SENTRY_TRACE_STATE_IGNORED, + SENTRY_TRACE_STATE_CHILD_IGNORED, SENTRY_TRACE_STATE_SAMPLED_NOT_RECORDING, SENTRY_TRACE_STATE_SEGMENT_IGNORED, } from '../src/constants'; @@ -18,7 +18,7 @@ describe('SentrySampler', () => { await cleanupOtel(); }); - it('works with tracesSampleRate=0', () => { + it('samples negatively with tracesSampleRate=0 and records a sample_rate outcome', () => { const client = new TestClient(getDefaultTestClientOptions({ tracesSampleRate: 0 })); const spyOnDroppedEvent = vi.spyOn(client, 'recordDroppedEvent'); const sampler = new SentrySampler(client); @@ -45,7 +45,7 @@ describe('SentrySampler', () => { spyOnDroppedEvent.mockReset(); }); - it('works with tracesSampleRate=0 & for a child span', () => { + it('samples a child span negatively based on tracesSampleRate=0', () => { const client = new TestClient(getDefaultTestClientOptions({ tracesSampleRate: 0 })); const spyOnDroppedEvent = vi.spyOn(client, 'recordDroppedEvent'); const sampler = new SentrySampler(client); @@ -67,13 +67,13 @@ describe('SentrySampler', () => { decision: SamplingDecision.NOT_RECORD, traceState: new TraceState().set(SENTRY_TRACE_STATE_SAMPLED_NOT_RECORDING, '1'), }); - expect(spyOnDroppedEvent).toHaveBeenCalledTimes(1); - expect(spyOnDroppedEvent).toHaveBeenCalledWith('sample_rate', 'span'); + // Does not record a client outcome for child spans when in static trace lifecycle (i.e. transactions) + expect(spyOnDroppedEvent).not.toHaveBeenCalled(); spyOnDroppedEvent.mockReset(); }); - it('works with tracesSampleRate=1', () => { + it('samples positively with tracesSampleRate=1', () => { const client = new TestClient(getDefaultTestClientOptions({ tracesSampleRate: 1 })); const spyOnDroppedEvent = vi.spyOn(client, 'recordDroppedEvent'); const sampler = new SentrySampler(client); @@ -98,7 +98,7 @@ describe('SentrySampler', () => { spyOnDroppedEvent.mockReset(); }); - it('works with traceSampleRate=undefined', () => { + it('defers sampling with traceSampleRate=undefined', () => { const client = new TestClient(getDefaultTestClientOptions({ tracesSampleRate: undefined })); const spyOnDroppedEvent = vi.spyOn(client, 'recordDroppedEvent'); const sampler = new SentrySampler(client); @@ -120,165 +120,6 @@ describe('SentrySampler', () => { spyOnDroppedEvent.mockReset(); }); - it('returns NOT_RECORD for root span matching ignoreSpans string pattern', () => { - const client = new TestClient(getDefaultTestClientOptions({ tracesSampleRate: 1, ignoreSpans: ['GET /health'] })); - const spyOnDroppedEvent = vi.spyOn(client, 'recordDroppedEvent'); - const sampler = new SentrySampler(client); - - const ctx = context.active(); - const traceId = generateTraceId(); - const spanName = 'GET /health'; - const spanKind = SpanKind.SERVER; - const spanAttributes = {}; - - const actual = sampler.shouldSample(ctx, traceId, spanName, spanKind, spanAttributes, undefined); - expect(actual.decision).toBe(SamplingDecision.NOT_RECORD); - expect(spyOnDroppedEvent).toHaveBeenCalledWith('ignored', 'span'); - }); - - it('returns NOT_RECORD for root span matching ignoreSpans regex pattern', () => { - const client = new TestClient(getDefaultTestClientOptions({ tracesSampleRate: 1, ignoreSpans: [/health/] })); - const sampler = new SentrySampler(client); - - const ctx = context.active(); - const traceId = generateTraceId(); - const spanName = 'GET /healthcheck'; - const spanKind = SpanKind.SERVER; - const spanAttributes = {}; - - const actual = sampler.shouldSample(ctx, traceId, spanName, spanKind, spanAttributes, undefined); - expect(actual.decision).toBe(SamplingDecision.NOT_RECORD); - }); - - it('returns NOT_RECORD for root span matching ignoreSpans IgnoreSpanFilter with name and op', () => { - const client = new TestClient( - getDefaultTestClientOptions({ - tracesSampleRate: 1, - ignoreSpans: [{ name: 'GET /health', op: 'http.server' }], - }), - ); - const sampler = new SentrySampler(client); - - const ctx = context.active(); - const traceId = generateTraceId(); - const spanName = 'GET /health'; - const spanKind = SpanKind.SERVER; - const spanAttributes = { [ATTR_HTTP_REQUEST_METHOD]: 'GET' }; - - const actual = sampler.shouldSample(ctx, traceId, spanName, spanKind, spanAttributes, undefined); - expect(actual.decision).toBe(SamplingDecision.NOT_RECORD); - }); - - it('does not ignore root span that does not match ignoreSpans', () => { - const client = new TestClient(getDefaultTestClientOptions({ tracesSampleRate: 1, ignoreSpans: ['GET /health'] })); - const sampler = new SentrySampler(client); - - const ctx = context.active(); - const traceId = generateTraceId(); - const spanName = 'GET /users'; - const spanKind = SpanKind.SERVER; - const spanAttributes = {}; - - const actual = sampler.shouldSample(ctx, traceId, spanName, spanKind, spanAttributes, undefined); - expect(actual.decision).toBe(SamplingDecision.RECORD_AND_SAMPLED); - }); - - it('returns NOT_RECORD with sentry.ignored traceState for child span matching ignoreSpans', () => { - const client = new TestClient( - getDefaultTestClientOptions({ tracesSampleRate: 1, ignoreSpans: ['middleware - expressInit'] }), - ); - const sampler = new SentrySampler(client); - - const traceId = generateTraceId(); - const ctx = trace.setSpanContext(context.active(), { - traceId, - spanId: generateSpanId(), - traceFlags: TraceFlags.SAMPLED, - isRemote: false, - }); - - const actual = sampler.shouldSample(ctx, traceId, 'middleware - expressInit', SpanKind.INTERNAL, {}, undefined); - - expect(actual.decision).toBe(SamplingDecision.NOT_RECORD); - expect(actual.traceState?.get(SENTRY_TRACE_STATE_IGNORED)).toBe('1'); - }); - - it('does not set sentry.ignored for child span not matching ignoreSpans', () => { - const client = new TestClient( - getDefaultTestClientOptions({ tracesSampleRate: 1, ignoreSpans: ['middleware - expressInit'] }), - ); - const sampler = new SentrySampler(client); - - const traceId = generateTraceId(); - const ctx = trace.setSpanContext(context.active(), { - traceId, - spanId: generateSpanId(), - traceFlags: TraceFlags.SAMPLED, - isRemote: false, - }); - - const actual = sampler.shouldSample(ctx, traceId, 'db.query SELECT 1', SpanKind.CLIENT, {}, undefined); - - expect(actual.decision).toBe(SamplingDecision.RECORD_AND_SAMPLED); - expect(actual.traceState?.get(SENTRY_TRACE_STATE_IGNORED)).toBeUndefined(); - }); - - it('sets sentry.segment_ignored traceState for root span matching ignoreSpans', () => { - const client = new TestClient(getDefaultTestClientOptions({ tracesSampleRate: 1, ignoreSpans: ['GET /health'] })); - const sampler = new SentrySampler(client); - - const ctx = context.active(); - const traceId = generateTraceId(); - const spanName = 'GET /health'; - const spanKind = SpanKind.SERVER; - const spanAttributes = {}; - - const actual = sampler.shouldSample(ctx, traceId, spanName, spanKind, spanAttributes, undefined); - expect(actual.decision).toBe(SamplingDecision.NOT_RECORD); - expect(actual.traceState?.get(SENTRY_TRACE_STATE_SEGMENT_IGNORED)).toBe('1'); - expect(actual.traceState?.get(SENTRY_TRACE_STATE_IGNORED)).toBeUndefined(); - }); - - it('records ignored outcome for child span of ignored segment', () => { - const client = new TestClient(getDefaultTestClientOptions({ tracesSampleRate: 1, ignoreSpans: ['GET /health'] })); - const spyOnDroppedEvent = vi.spyOn(client, 'recordDroppedEvent'); - const sampler = new SentrySampler(client); - - const traceId = generateTraceId(); - const ctx = trace.setSpanContext(context.active(), { - spanId: generateSpanId(), - traceId, - traceFlags: 0, - traceState: new TraceState() - .set(SENTRY_TRACE_STATE_SAMPLED_NOT_RECORDING, '1') - .set(SENTRY_TRACE_STATE_SEGMENT_IGNORED, '1'), - }); - - const actual = sampler.shouldSample(ctx, traceId, 'db.query SELECT 1', SpanKind.CLIENT, {}, undefined); - expect(actual.decision).toBe(SamplingDecision.NOT_RECORD); - expect(spyOnDroppedEvent).toHaveBeenCalledTimes(1); - expect(spyOnDroppedEvent).toHaveBeenCalledWith('ignored', 'span'); - }); - - it('records sample_rate outcome for child span of negatively sampled segment', () => { - const client = new TestClient(getDefaultTestClientOptions({ tracesSampleRate: 0 })); - const spyOnDroppedEvent = vi.spyOn(client, 'recordDroppedEvent'); - const sampler = new SentrySampler(client); - - const traceId = generateTraceId(); - const ctx = trace.setSpanContext(context.active(), { - spanId: generateSpanId(), - traceId, - traceFlags: 0, - traceState: new TraceState().set(SENTRY_TRACE_STATE_SAMPLED_NOT_RECORDING, '1'), - }); - - const actual = sampler.shouldSample(ctx, traceId, 'db.query SELECT 1', SpanKind.CLIENT, {}, undefined); - expect(actual.decision).toBe(SamplingDecision.NOT_RECORD); - expect(spyOnDroppedEvent).toHaveBeenCalledTimes(1); - expect(spyOnDroppedEvent).toHaveBeenCalledWith('sample_rate', 'span'); - }); - it('ignores local http client root spans', () => { const client = new TestClient(getDefaultTestClientOptions({ tracesSampleRate: 0 })); const spyOnDroppedEvent = vi.spyOn(client, 'recordDroppedEvent'); @@ -302,4 +143,209 @@ describe('SentrySampler', () => { spyOnDroppedEvent.mockReset(); }); + + describe('when span streaming is enabled', () => { + /* + For span streaming, we use the Sampler to "sample" spans based on `ignoreSpans`. In reality though, + we don't apply sampling options (rate, traces_sampler) but just filter spans via `ignoreSpans`. + The sampler allows us to modify context and tracestate to correctly propagate filtering decisions + to potential child spans (e.g. when a segment is ignored, so that all its children are also ignored). + */ + it('returns NOT_RECORD for root span matching ignoreSpans string pattern', () => { + const client = new TestClient( + getDefaultTestClientOptions({ tracesSampleRate: 1, traceLifecycle: 'stream', ignoreSpans: ['GET /health'] }), + ); + const spyOnDroppedEvent = vi.spyOn(client, 'recordDroppedEvent'); + const sampler = new SentrySampler(client); + + const ctx = context.active(); + const traceId = generateTraceId(); + const spanName = 'GET /health'; + const spanKind = SpanKind.SERVER; + const spanAttributes = {}; + + const actual = sampler.shouldSample(ctx, traceId, spanName, spanKind, spanAttributes, undefined); + expect(actual.decision).toBe(SamplingDecision.NOT_RECORD); + expect(spyOnDroppedEvent).toHaveBeenCalledWith('ignored', 'span'); + expect(spyOnDroppedEvent).toHaveBeenCalledOnce(); + }); + + it('returns NOT_RECORD for root span matching ignoreSpans regex pattern', () => { + const client = new TestClient( + getDefaultTestClientOptions({ tracesSampleRate: 1, traceLifecycle: 'stream', ignoreSpans: [/health/] }), + ); + const spyOnDroppedEvent = vi.spyOn(client, 'recordDroppedEvent'); + const sampler = new SentrySampler(client); + + const ctx = context.active(); + const traceId = generateTraceId(); + const spanName = 'GET /healthcheck'; + const spanKind = SpanKind.SERVER; + const spanAttributes = {}; + + const actual = sampler.shouldSample(ctx, traceId, spanName, spanKind, spanAttributes, undefined); + expect(actual.decision).toBe(SamplingDecision.NOT_RECORD); + expect(spyOnDroppedEvent).toHaveBeenCalledWith('ignored', 'span'); + expect(spyOnDroppedEvent).toHaveBeenCalledOnce(); + }); + + it('returns NOT_RECORD for root span matching ignoreSpans IgnoreSpanFilter with name and op', () => { + const client = new TestClient( + getDefaultTestClientOptions({ + tracesSampleRate: 1, + traceLifecycle: 'stream', + ignoreSpans: [{ name: 'GET /health', op: 'http.server' }], + }), + ); + const spyOnDroppedEvent = vi.spyOn(client, 'recordDroppedEvent'); + const sampler = new SentrySampler(client); + + const ctx = context.active(); + const traceId = generateTraceId(); + const spanName = 'GET /health'; + const spanKind = SpanKind.SERVER; + const spanAttributes = { [ATTR_HTTP_REQUEST_METHOD]: 'GET' }; + + const actual = sampler.shouldSample(ctx, traceId, spanName, spanKind, spanAttributes, undefined); + expect(actual.decision).toBe(SamplingDecision.NOT_RECORD); + expect(spyOnDroppedEvent).toHaveBeenCalledWith('ignored', 'span'); + expect(spyOnDroppedEvent).toHaveBeenCalledOnce(); + }); + + it("doesn't ignore root span that does not match ignoreSpans", () => { + const client = new TestClient( + getDefaultTestClientOptions({ tracesSampleRate: 1, traceLifecycle: 'stream', ignoreSpans: ['GET /health'] }), + ); + const spyOnDroppedEvent = vi.spyOn(client, 'recordDroppedEvent'); + const sampler = new SentrySampler(client); + + const ctx = context.active(); + const traceId = generateTraceId(); + const spanName = 'GET /users'; + const spanKind = SpanKind.SERVER; + const spanAttributes = {}; + + const actual = sampler.shouldSample(ctx, traceId, spanName, spanKind, spanAttributes, undefined); + expect(actual.decision).toBe(SamplingDecision.RECORD_AND_SAMPLED); + expect(spyOnDroppedEvent).not.toHaveBeenCalled(); + }); + + it('returns NOT_RECORD with sentry.ignored traceState for child span matching ignoreSpans', () => { + const client = new TestClient( + getDefaultTestClientOptions({ + tracesSampleRate: 1, + traceLifecycle: 'stream', + ignoreSpans: ['middleware - expressInit'], + }), + ); + const spyOnDroppedEvent = vi.spyOn(client, 'recordDroppedEvent'); + const sampler = new SentrySampler(client); + + const traceId = generateTraceId(); + const ctx = trace.setSpanContext(context.active(), { + traceId, + spanId: generateSpanId(), + traceFlags: TraceFlags.SAMPLED, + isRemote: false, + }); + + const actual = sampler.shouldSample(ctx, traceId, 'middleware - expressInit', SpanKind.INTERNAL, {}, undefined); + + expect(actual.decision).toBe(SamplingDecision.NOT_RECORD); + expect(actual.traceState?.get(SENTRY_TRACE_STATE_CHILD_IGNORED)).toBe('1'); + expect(spyOnDroppedEvent).toHaveBeenCalledWith('ignored', 'span'); + expect(spyOnDroppedEvent).toHaveBeenCalledOnce(); + }); + + it("doesn't set SENTRY_TRACE_STATE_CHILD_IGNORED for child span not matching ignoreSpans", () => { + const client = new TestClient( + getDefaultTestClientOptions({ + tracesSampleRate: 1, + traceLifecycle: 'stream', + ignoreSpans: ['middleware - expressInit'], + }), + ); + const spyOnDroppedEvent = vi.spyOn(client, 'recordDroppedEvent'); + const sampler = new SentrySampler(client); + + const traceId = generateTraceId(); + const ctx = trace.setSpanContext(context.active(), { + traceId, + spanId: generateSpanId(), + traceFlags: TraceFlags.SAMPLED, + isRemote: false, + }); + + const actual = sampler.shouldSample(ctx, traceId, 'db.query SELECT 1', SpanKind.CLIENT, {}, undefined); + + expect(actual.decision).toBe(SamplingDecision.RECORD_AND_SAMPLED); + expect(actual.traceState?.get(SENTRY_TRACE_STATE_CHILD_IGNORED)).toBeUndefined(); + expect(spyOnDroppedEvent).not.toHaveBeenCalled(); + }); + + it('sets sentry.segment_ignored traceState for a segment span matching ignoreSpans', () => { + const client = new TestClient( + getDefaultTestClientOptions({ tracesSampleRate: 1, traceLifecycle: 'stream', ignoreSpans: ['GET /health'] }), + ); + const spyOnDroppedEvent = vi.spyOn(client, 'recordDroppedEvent'); + const sampler = new SentrySampler(client); + + const ctx = context.active(); + const traceId = generateTraceId(); + const spanName = 'GET /health'; + const spanKind = SpanKind.SERVER; + const spanAttributes = {}; + + const actual = sampler.shouldSample(ctx, traceId, spanName, spanKind, spanAttributes, undefined); + expect(actual.decision).toBe(SamplingDecision.NOT_RECORD); + expect(actual.traceState?.get(SENTRY_TRACE_STATE_SEGMENT_IGNORED)).toBe('1'); + expect(actual.traceState?.get(SENTRY_TRACE_STATE_CHILD_IGNORED)).toBeUndefined(); + expect(spyOnDroppedEvent).toHaveBeenCalledWith('ignored', 'span'); + expect(spyOnDroppedEvent).toHaveBeenCalledOnce(); + }); + + it('records ignored outcome for child span of ignored segment', () => { + const client = new TestClient( + getDefaultTestClientOptions({ tracesSampleRate: 1, traceLifecycle: 'stream', ignoreSpans: ['GET /health'] }), + ); + const spyOnDroppedEvent = vi.spyOn(client, 'recordDroppedEvent'); + const sampler = new SentrySampler(client); + + const traceId = generateTraceId(); + const ctx = trace.setSpanContext(context.active(), { + spanId: generateSpanId(), + traceId, + traceFlags: 0, + traceState: new TraceState() + .set(SENTRY_TRACE_STATE_SAMPLED_NOT_RECORDING, '1') + .set(SENTRY_TRACE_STATE_SEGMENT_IGNORED, '1'), + }); + + const actual = sampler.shouldSample(ctx, traceId, 'db.query SELECT 1', SpanKind.CLIENT, {}, undefined); + expect(actual.decision).toBe(SamplingDecision.NOT_RECORD); + expect(spyOnDroppedEvent).toHaveBeenCalledOnce(); + expect(spyOnDroppedEvent).toHaveBeenCalledWith('ignored', 'span'); + }); + + it('records sample_rate outcome for child span of negatively sampled segment', () => { + // For span streaming, we also record a sample_rate outcome for a child span of a negatively sampled trace. + + const client = new TestClient(getDefaultTestClientOptions({ tracesSampleRate: 0, traceLifecycle: 'stream' })); + const spyOnDroppedEvent = vi.spyOn(client, 'recordDroppedEvent'); + const sampler = new SentrySampler(client); + + const traceId = generateTraceId(); + const ctx = trace.setSpanContext(context.active(), { + spanId: generateSpanId(), + traceId, + traceFlags: 0, + traceState: new TraceState().set(SENTRY_TRACE_STATE_SAMPLED_NOT_RECORDING, '1'), + }); + + const actual = sampler.shouldSample(ctx, traceId, 'db.query SELECT 1', SpanKind.CLIENT, {}, undefined); + expect(actual.decision).toBe(SamplingDecision.NOT_RECORD); + expect(spyOnDroppedEvent).toHaveBeenCalledTimes(1); + expect(spyOnDroppedEvent).toHaveBeenCalledWith('sample_rate', 'span'); + }); + }); });