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 new file mode 100644 index 000000000000..65b264087c27 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/ignoreSpans-streamed/child/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/], + 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 new file mode 100644 index 000000000000..056264c17745 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/ignoreSpans-streamed/child/subject.js @@ -0,0 +1,23 @@ +Sentry.startSpan({ name: 'parent-span' }, () => { + Sentry.startSpan({ name: 'keep-me' }, () => {}); + + // This child matches ignoreSpans —> dropped + Sentry.startSpan({ name: 'ignore-child' }, () => { + // 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 new file mode 100644 index 000000000000..4cbc993ae346 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/ignoreSpans-streamed/child/test.ts @@ -0,0 +1,60 @@ +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', 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.length).toBe(6); + + 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 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(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/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..645668376b36 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/ignoreSpans-streamed/segment/subject.js @@ -0,0 +1,11 @@ +// 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-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-streamed/children/test.ts b/dev-packages/node-integration-tests/suites/tracing/ignoreSpans-streamed/children/test.ts new file mode 100644 index 000000000000..90c05e5aba96 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/ignoreSpans-streamed/children/test.ts @@ -0,0 +1,60 @@ +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)', () => { + afterAll(() => { + cleanupChildProcesses(); + }); + + createEsmAndCjsTests(__dirname, 'server.mjs', 'instrument.mjs', (createRunner, test) => { + test('child spans are dropped and remaining spans correctly parented', async () => { + 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 + // 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(); + }); + }); +}); 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/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/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/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 2d314ca453e9..d632c7892f87 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,8 @@ 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 { hasSpanStreamingEnabled } from './spans/hasSpanStreamingEnabled'; import { parseSampleRate } from '../utils/parseSampleRate'; import { generateTraceId } from '../utils/propagationContext'; import { safeMathRandom } from '../utils/randomSafeContext'; @@ -28,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__'; @@ -62,6 +69,15 @@ export function startSpan(options: StartSpanOptions, callback: (span: Span) = const scope = getCurrentScope(); const parentSpan = getParentSpan(scope, customParentSpan); + const client = getClient(); + if (_shouldIgnoreStreamedSpan(client, spanArguments)) { + return handleCallbackErrors( + () => callback(_createIgnoredSpan(client, parentSpan, scope)), + () => {}, + () => {}, + ); + } + const shouldSkipSpan = options.onlyIfParent && !parentSpan; const activeSpan = shouldSkipSpan ? new SentryNonRecordingSpan() @@ -120,6 +136,14 @@ export function startSpanManual(options: StartSpanOptions, callback: (span: S const scope = getCurrentScope(); const parentSpan = getParentSpan(scope, customParentSpan); + const client = getClient(); + if (_shouldIgnoreStreamedSpan(client, spanArguments)) { + return handleCallbackErrors( + () => callback(_createIgnoredSpan(client, parentSpan, scope), () => {}), + () => {}, + ); + } + const shouldSkipSpan = options.onlyIfParent && !parentSpan; const activeSpan = shouldSkipSpan ? new SentryNonRecordingSpan() @@ -180,6 +204,11 @@ export function startInactiveSpan(options: StartSpanOptions): Span { const scope = getCurrentScope(); const parentSpan = getParentSpan(scope, customParentSpan); + const client = getClient(); + if (_shouldIgnoreStreamedSpan(client, spanArguments)) { + return _createIgnoredSpan(client, parentSpan, scope); + } + const shouldSkipSpan = options.onlyIfParent && !parentSpan; if (shouldSkipSpan) { @@ -462,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) { @@ -492,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; } @@ -537,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 6be1bf0577f3..4f2da43c27e4 100644 --- a/packages/core/test/lib/tracing/trace.test.ts +++ b/packages/core/test/lib/tracing/trace.test.ts @@ -2410,3 +2410,208 @@ describe('startNewTrace', () => { }); }); }); + +describe('ignoreSpans (core path, streaming)', () => { + 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, + traceLifecycle: 'stream', + 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, + 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' }, span => { + expect(span).toBeInstanceOf(SentryNonRecordingSpan); + }); + }); + + expect(spyOnDroppedEvent).toHaveBeenCalledWith('ignored', 'span'); + }); + + it('children of ignored child spans parent to grandparent', () => { + const options = getDefaultTestClientOptions({ + tracesSampleRate: 1, + traceLifecycle: 'stream', + 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, + traceLifecycle: 'stream', + 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, + traceLifecycle: 'stream', + ignoreSpans: ['ignored-span'], + }); + client = new TestClient(options); + setCurrentClient(client); + client.init(); + + 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'); + }); +}); 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/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 375e42dfdd00..efbeb0670555 100644 --- a/packages/opentelemetry/src/constants.ts +++ b/packages/opentelemetry/src/constants.ts @@ -9,6 +9,20 @@ 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'; + +/** + * 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/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..ad0efa84f869 100644 --- a/packages/opentelemetry/src/sampler.ts +++ b/packages/opentelemetry/src/sampler.ts @@ -16,16 +16,20 @@ import { baggageHeaderToDynamicSamplingContext, debug, hasSpansEnabled, + hasSpanStreamingEnabled, parseSampleRate, 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, + SENTRY_TRACE_STATE_SEGMENT_IGNORED, SENTRY_TRACE_STATE_URL, } from './constants'; import { DEBUG_BUILD } from './debug-build'; @@ -39,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'); } @@ -55,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(); @@ -79,6 +86,31 @@ 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 (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({ decision: parentSampled ? SamplingDecision.RECORD_AND_SAMPLED : SamplingDecision.NOT_RECORD, context, @@ -102,6 +134,20 @@ export class SentrySampler implements Sampler { mergedAttributes[SEMANTIC_ATTRIBUTE_SENTRY_OP] = op; } + 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, + }); + } + const mutableSamplingDecision = { decision: true }; this._client.emit( 'beforeSampling', @@ -155,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 { @@ -211,12 +257,17 @@ export function wrapSamplingDecision({ spanAttributes, 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); @@ -232,6 +283,14 @@ export function wrapSamplingDecision({ traceState = traceState.set(SENTRY_TRACE_STATE_SAMPLE_RAND, `${sampleRand}`); } + if (ignoredSpan) { + 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/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..a6e76ab8ca7b 100644 --- a/packages/opentelemetry/test/sampler.test.ts +++ b/packages/opentelemetry/test/sampler.test.ts @@ -1,10 +1,14 @@ -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, + 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(); }); @@ -115,6 +120,165 @@ 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'); 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; }