From a3f1146eded6aba63f4d4f68484ff4a46773090d Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Thu, 16 Apr 2026 15:10:38 +0200 Subject: [PATCH 1/4] feat(core): Emit `no_parent_span` client outcomes for discarded spans requiring a parent --- .../init.js | 10 +++++ .../subject.js | 1 + .../test.ts | 45 +++++++++++++++++++ .../no-parent-span-client-report/init.js | 10 +++++ .../no-parent-span-client-report/subject.js | 2 + .../no-parent-span-client-report/test.ts | 45 +++++++++++++++++++ .../instrument.mjs | 11 +++++ .../scenario.mjs | 2 + .../test.ts | 29 ++++++++++++ .../instrument.mjs | 10 +++++ .../no-parent-span-client-report/scenario.mjs | 2 + .../no-parent-span-client-report/test.ts | 29 ++++++++++++ packages/browser/src/tracing/request.ts | 6 ++- packages/core/src/fetch.ts | 7 ++- packages/core/src/tracing/trace.ts | 19 +++++++- packages/core/src/types-hoist/clientreport.ts | 3 +- packages/core/test/lib/tracing/trace.test.ts | 26 +++++++++-- packages/opentelemetry/src/sampler.ts | 1 + packages/opentelemetry/src/trace.ts | 8 ++++ packages/opentelemetry/test/sampler.test.ts | 5 ++- packages/opentelemetry/test/trace.test.ts | 33 ++++++++++++-- 21 files changed, 290 insertions(+), 14 deletions(-) create mode 100644 dev-packages/browser-integration-tests/suites/tracing/no-parent-span-client-report-streamed/init.js create mode 100644 dev-packages/browser-integration-tests/suites/tracing/no-parent-span-client-report-streamed/subject.js create mode 100644 dev-packages/browser-integration-tests/suites/tracing/no-parent-span-client-report-streamed/test.ts create mode 100644 dev-packages/browser-integration-tests/suites/tracing/no-parent-span-client-report/init.js create mode 100644 dev-packages/browser-integration-tests/suites/tracing/no-parent-span-client-report/subject.js create mode 100644 dev-packages/browser-integration-tests/suites/tracing/no-parent-span-client-report/test.ts create mode 100644 dev-packages/node-integration-tests/suites/tracing/no-parent-span-client-report-streamed/instrument.mjs create mode 100644 dev-packages/node-integration-tests/suites/tracing/no-parent-span-client-report-streamed/scenario.mjs create mode 100644 dev-packages/node-integration-tests/suites/tracing/no-parent-span-client-report-streamed/test.ts create mode 100644 dev-packages/node-integration-tests/suites/tracing/no-parent-span-client-report/instrument.mjs create mode 100644 dev-packages/node-integration-tests/suites/tracing/no-parent-span-client-report/scenario.mjs create mode 100644 dev-packages/node-integration-tests/suites/tracing/no-parent-span-client-report/test.ts diff --git a/dev-packages/browser-integration-tests/suites/tracing/no-parent-span-client-report-streamed/init.js b/dev-packages/browser-integration-tests/suites/tracing/no-parent-span-client-report-streamed/init.js new file mode 100644 index 000000000000..454301926616 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/no-parent-span-client-report-streamed/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.browserTracingIntegration({ instrumentPageLoad: false, instrumentNavigation: false }), Sentry.spanStreamingIntegration()], + tracesSampleRate: 1, + sendClientReports: true, +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/no-parent-span-client-report-streamed/subject.js b/dev-packages/browser-integration-tests/suites/tracing/no-parent-span-client-report-streamed/subject.js new file mode 100644 index 000000000000..6ba8011d77ac --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/no-parent-span-client-report-streamed/subject.js @@ -0,0 +1 @@ +fetch('http://sentry-test-site.example/api/test'); diff --git a/dev-packages/browser-integration-tests/suites/tracing/no-parent-span-client-report-streamed/test.ts b/dev-packages/browser-integration-tests/suites/tracing/no-parent-span-client-report-streamed/test.ts new file mode 100644 index 000000000000..2672d41c17fb --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/no-parent-span-client-report-streamed/test.ts @@ -0,0 +1,45 @@ +import { expect } from '@playwright/test'; +import type { ClientReport } from '@sentry/core'; +import { sentryTest } from '../../../utils/fixtures'; +import { + envelopeRequestParser, + hidePage, + shouldSkipTracingTest, + testingCdnBundle, + waitForClientReportRequest, +} from '../../../utils/helpers'; + +sentryTest( + 'records no_parent_span client report for fetch requests without an active span', + async ({ getLocalTestUrl, page }) => { + sentryTest.skip(shouldSkipTracingTest() || testingCdnBundle()); + + await page.route('http://sentry-test-site.example/api/test', route => { + route.fulfill({ + status: 200, + body: 'ok', + headers: { 'Content-Type': 'text/plain' }, + }); + }); + + const url = await getLocalTestUrl({ testDir: __dirname }); + + const clientReportPromise = waitForClientReportRequest(page, report => { + return report.discarded_events.some(e => e.reason === 'no_parent_span'); + }); + + await page.goto(url); + + await hidePage(page); + + const clientReport = envelopeRequestParser(await clientReportPromise); + + expect(clientReport.discarded_events).toEqual([ + { + category: 'span', + quantity: 1, + reason: 'no_parent_span', + }, + ]); + }, +); diff --git a/dev-packages/browser-integration-tests/suites/tracing/no-parent-span-client-report/init.js b/dev-packages/browser-integration-tests/suites/tracing/no-parent-span-client-report/init.js new file mode 100644 index 000000000000..10e5ac1b84eb --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/no-parent-span-client-report/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.browserTracingIntegration({ instrumentPageLoad: false, instrumentNavigation: false })], + tracesSampleRate: 1, + sendClientReports: true, +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/no-parent-span-client-report/subject.js b/dev-packages/browser-integration-tests/suites/tracing/no-parent-span-client-report/subject.js new file mode 100644 index 000000000000..f55d101b930b --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/no-parent-span-client-report/subject.js @@ -0,0 +1,2 @@ +fetch('http://sentry-test-site.example/api/test'); + diff --git a/dev-packages/browser-integration-tests/suites/tracing/no-parent-span-client-report/test.ts b/dev-packages/browser-integration-tests/suites/tracing/no-parent-span-client-report/test.ts new file mode 100644 index 000000000000..2672d41c17fb --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/no-parent-span-client-report/test.ts @@ -0,0 +1,45 @@ +import { expect } from '@playwright/test'; +import type { ClientReport } from '@sentry/core'; +import { sentryTest } from '../../../utils/fixtures'; +import { + envelopeRequestParser, + hidePage, + shouldSkipTracingTest, + testingCdnBundle, + waitForClientReportRequest, +} from '../../../utils/helpers'; + +sentryTest( + 'records no_parent_span client report for fetch requests without an active span', + async ({ getLocalTestUrl, page }) => { + sentryTest.skip(shouldSkipTracingTest() || testingCdnBundle()); + + await page.route('http://sentry-test-site.example/api/test', route => { + route.fulfill({ + status: 200, + body: 'ok', + headers: { 'Content-Type': 'text/plain' }, + }); + }); + + const url = await getLocalTestUrl({ testDir: __dirname }); + + const clientReportPromise = waitForClientReportRequest(page, report => { + return report.discarded_events.some(e => e.reason === 'no_parent_span'); + }); + + await page.goto(url); + + await hidePage(page); + + const clientReport = envelopeRequestParser(await clientReportPromise); + + expect(clientReport.discarded_events).toEqual([ + { + category: 'span', + quantity: 1, + reason: 'no_parent_span', + }, + ]); + }, +); diff --git a/dev-packages/node-integration-tests/suites/tracing/no-parent-span-client-report-streamed/instrument.mjs b/dev-packages/node-integration-tests/suites/tracing/no-parent-span-client-report-streamed/instrument.mjs new file mode 100644 index 000000000000..b56505ef5e2d --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/no-parent-span-client-report-streamed/instrument.mjs @@ -0,0 +1,11 @@ +import * as Sentry from '@sentry/node'; +import { loggingTransport } from '@sentry-internal/node-integration-tests'; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + tracesSampleRate: 1.0, + transport: loggingTransport, + traceLifecycle: 'stream', + clientReportFlushInterval: 1_000, +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/no-parent-span-client-report-streamed/scenario.mjs b/dev-packages/node-integration-tests/suites/tracing/no-parent-span-client-report-streamed/scenario.mjs new file mode 100644 index 000000000000..18afc6db5113 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/no-parent-span-client-report-streamed/scenario.mjs @@ -0,0 +1,2 @@ +import http from 'http'; +http.get('http://localhost:9999/external', () => {}).on('error', () => {}); diff --git a/dev-packages/node-integration-tests/suites/tracing/no-parent-span-client-report-streamed/test.ts b/dev-packages/node-integration-tests/suites/tracing/no-parent-span-client-report-streamed/test.ts new file mode 100644 index 000000000000..2b987f92d755 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/no-parent-span-client-report-streamed/test.ts @@ -0,0 +1,29 @@ +import { afterAll, describe, expect } from 'vitest'; +import { cleanupChildProcesses, createEsmAndCjsTests } from '../../../utils/runner'; + +describe('no_parent_span client report (streaming)', () => { + afterAll(() => { + cleanupChildProcesses(); + }); + + createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument.mjs', (createRunner, test) => { + test('records no_parent_span outcome for http.client span without a local parent', async () => { + const runner = createRunner() + .unignore('client_report') + .expect({ + client_report: report => { + expect(report.discarded_events).toEqual([ + { + category: 'span', + quantity: 1, + reason: 'no_parent_span', + }, + ]); + }, + }) + .start(); + + await runner.completed(); + }); + }); +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/no-parent-span-client-report/instrument.mjs b/dev-packages/node-integration-tests/suites/tracing/no-parent-span-client-report/instrument.mjs new file mode 100644 index 000000000000..3a69e61ceb90 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/no-parent-span-client-report/instrument.mjs @@ -0,0 +1,10 @@ +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, + clientReportFlushInterval: 1_000, +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/no-parent-span-client-report/scenario.mjs b/dev-packages/node-integration-tests/suites/tracing/no-parent-span-client-report/scenario.mjs new file mode 100644 index 000000000000..18afc6db5113 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/no-parent-span-client-report/scenario.mjs @@ -0,0 +1,2 @@ +import http from 'http'; +http.get('http://localhost:9999/external', () => {}).on('error', () => {}); diff --git a/dev-packages/node-integration-tests/suites/tracing/no-parent-span-client-report/test.ts b/dev-packages/node-integration-tests/suites/tracing/no-parent-span-client-report/test.ts new file mode 100644 index 000000000000..2b987f92d755 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/no-parent-span-client-report/test.ts @@ -0,0 +1,29 @@ +import { afterAll, describe, expect } from 'vitest'; +import { cleanupChildProcesses, createEsmAndCjsTests } from '../../../utils/runner'; + +describe('no_parent_span client report (streaming)', () => { + afterAll(() => { + cleanupChildProcesses(); + }); + + createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument.mjs', (createRunner, test) => { + test('records no_parent_span outcome for http.client span without a local parent', async () => { + const runner = createRunner() + .unignore('client_report') + .expect({ + client_report: report => { + expect(report.discarded_events).toEqual([ + { + category: 'span', + quantity: 1, + reason: 'no_parent_span', + }, + ]); + }, + }) + .start(); + + await runner.completed(); + }); + }); +}); diff --git a/packages/browser/src/tracing/request.ts b/packages/browser/src/tracing/request.ts index 6211cf72947a..b393f0585b5b 100644 --- a/packages/browser/src/tracing/request.ts +++ b/packages/browser/src/tracing/request.ts @@ -404,6 +404,7 @@ function xhrCallback( const urlForSpanName = stripDataUrlContent(stripUrlQueryAndFragment(url)); + const client = getClient(); const hasParent = !!getActiveSpan(); const span = @@ -424,6 +425,10 @@ function xhrCallback( }) : new SentryNonRecordingSpan(); + if (shouldCreateSpanResult && !hasParent) { + client?.recordDroppedEvent('no_parent_span', 'span'); + } + xhr.__sentry_xhr_span_id__ = span.spanContext().spanId; spans[xhr.__sentry_xhr_span_id__] = span; @@ -438,7 +443,6 @@ function xhrCallback( ); } - const client = getClient(); if (client) { client.emit('beforeOutgoingRequestSpan', span, handlerData as XhrHint); } diff --git a/packages/core/src/fetch.ts b/packages/core/src/fetch.ts index 21a89b15c825..c65f147613dc 100644 --- a/packages/core/src/fetch.ts +++ b/packages/core/src/fetch.ts @@ -108,6 +108,7 @@ export function instrumentFetchRequest( const { spanOrigin = 'auto.http.browser', propagateTraceparent = false } = typeof spanOriginOrOptions === 'object' ? spanOriginOrOptions : { spanOrigin: spanOriginOrOptions }; + const client = getClient(); const hasParent = !!getActiveSpan(); const span = @@ -115,6 +116,10 @@ export function instrumentFetchRequest( ? startInactiveSpan(getSpanStartOptions(url, method, spanOrigin)) : new SentryNonRecordingSpan(); + if (shouldCreateSpanResult && !hasParent) { + client?.recordDroppedEvent('no_parent_span', 'span'); + } + handlerData.fetchData.__span = span.spanContext().spanId; spans[span.spanContext().spanId] = span; @@ -141,8 +146,6 @@ export function instrumentFetchRequest( } } - const client = getClient(); - if (client) { const fetchHint = { input: handlerData.args, diff --git a/packages/core/src/tracing/trace.ts b/packages/core/src/tracing/trace.ts index 5d40a0a30ebe..1d37ca2f76a4 100644 --- a/packages/core/src/tracing/trace.ts +++ b/packages/core/src/tracing/trace.ts @@ -68,6 +68,7 @@ export function startSpan(options: StartSpanOptions, callback: (span: Span) = return wrapper(() => { const scope = getCurrentScope(); const parentSpan = getParentSpan(scope, customParentSpan); + const client = getClient(); const shouldSkipSpan = options.onlyIfParent && !parentSpan; const activeSpan = shouldSkipSpan @@ -77,8 +78,13 @@ export function startSpan(options: StartSpanOptions, callback: (span: Span) = spanArguments, forceTransaction, scope, + client, }); + if (shouldSkipSpan) { + client?.recordDroppedEvent('no_parent_span', 'span'); + } + // Ignored root spans still need to be set on scope so that `getActiveSpan()` returns them // and descendants are also non-recording. Ignored child spans don't need this because // the parent span is already on scope. @@ -131,6 +137,7 @@ export function startSpanManual(options: StartSpanOptions, callback: (span: S return wrapper(() => { const scope = getCurrentScope(); const parentSpan = getParentSpan(scope, customParentSpan); + const client = getClient(); const shouldSkipSpan = options.onlyIfParent && !parentSpan; const activeSpan = shouldSkipSpan @@ -140,8 +147,13 @@ export function startSpanManual(options: StartSpanOptions, callback: (span: S spanArguments, forceTransaction, scope, + client, }); + if (shouldSkipSpan) { + client?.recordDroppedEvent('no_parent_span', 'span'); + } + // We don't set ignored child spans onto the scope because there likely is an active, // unignored span on the scope already. if (!_isIgnoredSpan(activeSpan) || !parentSpan) { @@ -195,10 +207,12 @@ export function startInactiveSpan(options: StartSpanOptions): Span { return wrapper(() => { const scope = getCurrentScope(); const parentSpan = getParentSpan(scope, customParentSpan); + const client = getClient(); const shouldSkipSpan = options.onlyIfParent && !parentSpan; if (shouldSkipSpan) { + client?.recordDroppedEvent('no_parent_span', 'span'); return new SentryNonRecordingSpan(); } @@ -207,6 +221,7 @@ export function startInactiveSpan(options: StartSpanOptions): Span { spanArguments, forceTransaction, scope, + client, }); }); } @@ -327,11 +342,13 @@ function createChildOrRootSpan({ spanArguments, forceTransaction, scope, + client: clientArg, }: { parentSpan: SentrySpan | undefined; spanArguments: SentrySpanArguments; forceTransaction?: boolean; scope: Scope; + client?: Client; }): Span { if (!hasSpansEnabled()) { const span = new SentryNonRecordingSpan(); @@ -351,7 +368,7 @@ function createChildOrRootSpan({ return span; } - const client = getClient(); + const client = clientArg || getClient(); if (_shouldIgnoreStreamedSpan(client, spanArguments)) { if (!_isTracingSuppressed(scope)) { // if tracing is actively suppressed (Sentry.suppressTracing(...)), diff --git a/packages/core/src/types-hoist/clientreport.ts b/packages/core/src/types-hoist/clientreport.ts index 4c07f1956014..154b58c5705e 100644 --- a/packages/core/src/types-hoist/clientreport.ts +++ b/packages/core/src/types-hoist/clientreport.ts @@ -11,7 +11,8 @@ export type EventDropReason = | 'internal_sdk_error' | 'buffer_overflow' | 'ignored' - | 'invalid'; + | 'invalid' + | 'no_parent_span'; export type Outcome = { reason: EventDropReason; diff --git a/packages/core/test/lib/tracing/trace.test.ts b/packages/core/test/lib/tracing/trace.test.ts index 0481f7f42687..146c257e913e 100644 --- a/packages/core/test/lib/tracing/trace.test.ts +++ b/packages/core/test/lib/tracing/trace.test.ts @@ -572,7 +572,7 @@ describe('startSpan', () => { }); describe('onlyIfParent', () => { - it('starts a non recording span if there is no parent', () => { + it('starts a non recording span and records no_parent_span client report if there is no parent', () => { const spyOnDroppedEvent = vi.spyOn(client, 'recordDroppedEvent'); const span = startSpan({ name: 'test span', onlyIfParent: true }, span => { @@ -581,10 +581,13 @@ describe('startSpan', () => { expect(span).toBeDefined(); expect(span).toBeInstanceOf(SentryNonRecordingSpan); - expect(spyOnDroppedEvent).not.toHaveBeenCalled(); + expect(spyOnDroppedEvent).toHaveBeenCalledWith('no_parent_span', 'span'); + expect(spyOnDroppedEvent).toHaveBeenCalledTimes(1); }); it('creates a span if there is a parent', () => { + const spyOnDroppedEvent = vi.spyOn(client, 'recordDroppedEvent'); + const span = startSpan({ name: 'parent span' }, () => { const span = startSpan({ name: 'test span', onlyIfParent: true }, span => { return span; @@ -595,6 +598,7 @@ describe('startSpan', () => { expect(span).toBeDefined(); expect(span).toBeInstanceOf(SentrySpan); + expect(spyOnDroppedEvent).not.toHaveBeenCalledWith('no_parent_span', 'span'); }); }); @@ -1189,15 +1193,21 @@ describe('startSpanManual', () => { }); describe('onlyIfParent', () => { - it('does not create a span if there is no parent', () => { + it('does not create a span and records no_parent_span client report if there is no parent', () => { + const spyOnDroppedEvent = vi.spyOn(client, 'recordDroppedEvent'); + const span = startSpanManual({ name: 'test span', onlyIfParent: true }, span => { return span; }); expect(span).toBeDefined(); expect(span).toBeInstanceOf(SentryNonRecordingSpan); + expect(spyOnDroppedEvent).toHaveBeenCalledWith('no_parent_span', 'span'); + expect(spyOnDroppedEvent).toHaveBeenCalledTimes(1); }); it('creates a span if there is a parent', () => { + const spyOnDroppedEvent = vi.spyOn(client, 'recordDroppedEvent'); + const span = startSpan({ name: 'parent span' }, () => { const span = startSpanManual({ name: 'test span', onlyIfParent: true }, span => { return span; @@ -1208,6 +1218,7 @@ describe('startSpanManual', () => { expect(span).toBeDefined(); expect(span).toBeInstanceOf(SentrySpan); + expect(spyOnDroppedEvent).not.toHaveBeenCalledWith('no_parent_span', 'span'); }); }); @@ -1592,14 +1603,20 @@ describe('startInactiveSpan', () => { }); describe('onlyIfParent', () => { - it('does not create a span if there is no parent', () => { + it('does not create a span and records no_parent_span client report if there is no parent', () => { + const spyOnDroppedEvent = vi.spyOn(client, 'recordDroppedEvent'); + const span = startInactiveSpan({ name: 'test span', onlyIfParent: true }); expect(span).toBeDefined(); expect(span).toBeInstanceOf(SentryNonRecordingSpan); + expect(spyOnDroppedEvent).toHaveBeenCalledWith('no_parent_span', 'span'); + expect(spyOnDroppedEvent).toHaveBeenCalledTimes(1); }); it('creates a span if there is a parent', () => { + const spyOnDroppedEvent = vi.spyOn(client, 'recordDroppedEvent'); + const span = startSpan({ name: 'parent span' }, () => { const span = startInactiveSpan({ name: 'test span', onlyIfParent: true }); return span; @@ -1607,6 +1624,7 @@ describe('startInactiveSpan', () => { expect(span).toBeDefined(); expect(span).toBeInstanceOf(SentrySpan); + expect(spyOnDroppedEvent).not.toHaveBeenCalledWith('no_parent_span', 'span'); }); }); diff --git a/packages/opentelemetry/src/sampler.ts b/packages/opentelemetry/src/sampler.ts index 5c0c47423284..1e65e9d15d14 100644 --- a/packages/opentelemetry/src/sampler.ts +++ b/packages/opentelemetry/src/sampler.ts @@ -77,6 +77,7 @@ export class SentrySampler implements Sampler { // If we have a http.client span that has no local parent, we never want to sample it // but we want to leave downstream sampling decisions up to the server if (spanKind === SpanKind.CLIENT && maybeSpanHttpMethod && (!parentSpan || parentContext?.isRemote)) { + this._client.recordDroppedEvent('no_parent_span', 'span'); return wrapSamplingDecision({ decision: undefined, context, spanAttributes }); } diff --git a/packages/opentelemetry/src/trace.ts b/packages/opentelemetry/src/trace.ts index 7c9d09a169b9..2d092e2b428c 100644 --- a/packages/opentelemetry/src/trace.ts +++ b/packages/opentelemetry/src/trace.ts @@ -51,6 +51,10 @@ function _startSpan(options: OpenTelemetrySpanContext, callback: (span: Span) const shouldSkipSpan = options.onlyIfParent && !trace.getSpan(activeCtx); const ctx = shouldSkipSpan ? suppressTracing(activeCtx) : activeCtx; + if (shouldSkipSpan) { + getClient()?.recordDroppedEvent('no_parent_span', 'span'); + } + const spanOptions = getSpanOptions(options); // If spans are not enabled, ensure we suppress tracing for the span creation @@ -154,6 +158,10 @@ export function startInactiveSpan(options: OpenTelemetrySpanContext): Span { const shouldSkipSpan = options.onlyIfParent && !trace.getSpan(activeCtx); let ctx = shouldSkipSpan ? suppressTracing(activeCtx) : activeCtx; + if (shouldSkipSpan) { + getClient()?.recordDroppedEvent('no_parent_span', 'span'); + } + const spanOptions = getSpanOptions(options); if (!hasSpansEnabled()) { diff --git a/packages/opentelemetry/test/sampler.test.ts b/packages/opentelemetry/test/sampler.test.ts index 654d96be91c4..22fa724fa161 100644 --- a/packages/opentelemetry/test/sampler.test.ts +++ b/packages/opentelemetry/test/sampler.test.ts @@ -120,7 +120,7 @@ describe('SentrySampler', () => { spyOnDroppedEvent.mockReset(); }); - it('ignores local http client root spans', () => { + it('ignores local http client root spans and records no_parent_span client report', () => { const client = new TestClient(getDefaultTestClientOptions({ tracesSampleRate: 0 })); const spyOnDroppedEvent = vi.spyOn(client, 'recordDroppedEvent'); const sampler = new SentrySampler(client); @@ -139,7 +139,8 @@ describe('SentrySampler', () => { decision: SamplingDecision.NOT_RECORD, traceState: new TraceState(), }); - expect(spyOnDroppedEvent).toHaveBeenCalledTimes(0); + expect(spyOnDroppedEvent).toHaveBeenCalledTimes(1); + expect(spyOnDroppedEvent).toHaveBeenCalledWith('no_parent_span', 'span'); spyOnDroppedEvent.mockReset(); }); diff --git a/packages/opentelemetry/test/trace.test.ts b/packages/opentelemetry/test/trace.test.ts index a6a7f35ab76a..14ef13ace6cf 100644 --- a/packages/opentelemetry/test/trace.test.ts +++ b/packages/opentelemetry/test/trace.test.ts @@ -530,15 +530,23 @@ describe('trace', () => { // TODO: propagation scope is not picked up by spans... describe('onlyIfParent', () => { - it('does not create a span if there is no parent', () => { + it('does not create a span and records no_parent_span client report if there is no parent', () => { + const client = getClient()!; + const spyOnDroppedEvent = vi.spyOn(client, 'recordDroppedEvent'); + const span = startSpan({ name: 'test span', onlyIfParent: true }, span => { return span; }); expect(isSpan(span)).toBe(false); + expect(spyOnDroppedEvent).toHaveBeenCalledWith('no_parent_span', 'span'); + expect(spyOnDroppedEvent).toHaveBeenCalledTimes(1); }); it('creates a span if there is a parent', () => { + const client = getClient()!; + const spyOnDroppedEvent = vi.spyOn(client, 'recordDroppedEvent'); + const span = startSpan({ name: 'parent span' }, () => { const span = startSpan({ name: 'test span', onlyIfParent: true }, span => { return span; @@ -548,6 +556,7 @@ describe('trace', () => { }); expect(isSpan(span)).toBe(true); + expect(spyOnDroppedEvent).not.toHaveBeenCalledWith('no_parent_span', 'span'); }); }); }); @@ -824,13 +833,21 @@ describe('trace', () => { }); describe('onlyIfParent', () => { - it('does not create a span if there is no parent', () => { + it('does not create a span and records no_parent_span client report if there is no parent', () => { + const client = getClient()!; + const spyOnDroppedEvent = vi.spyOn(client, 'recordDroppedEvent'); + const span = startInactiveSpan({ name: 'test span', onlyIfParent: true }); expect(isSpan(span)).toBe(false); + expect(spyOnDroppedEvent).toHaveBeenCalledWith('no_parent_span', 'span'); + expect(spyOnDroppedEvent).toHaveBeenCalledTimes(1); }); it('creates a span if there is a parent', () => { + const client = getClient()!; + const spyOnDroppedEvent = vi.spyOn(client, 'recordDroppedEvent'); + const span = startSpan({ name: 'parent span' }, () => { const span = startInactiveSpan({ name: 'test span', onlyIfParent: true }); @@ -838,6 +855,7 @@ describe('trace', () => { }); expect(isSpan(span)).toBe(true); + expect(spyOnDroppedEvent).not.toHaveBeenCalledWith('no_parent_span', 'span'); }); }); @@ -1192,15 +1210,23 @@ describe('trace', () => { }); describe('onlyIfParent', () => { - it('does not create a span if there is no parent', () => { + it('does not create a span and records no_parent_span client report if there is no parent', () => { + const client = getClient()!; + const spyOnDroppedEvent = vi.spyOn(client, 'recordDroppedEvent'); + const span = startSpanManual({ name: 'test span', onlyIfParent: true }, span => { return span; }); expect(isSpan(span)).toBe(false); + expect(spyOnDroppedEvent).toHaveBeenCalledWith('no_parent_span', 'span'); + expect(spyOnDroppedEvent).toHaveBeenCalledTimes(1); }); it('creates a span if there is a parent', () => { + const client = getClient()!; + const spyOnDroppedEvent = vi.spyOn(client, 'recordDroppedEvent'); + const span = startSpan({ name: 'parent span' }, () => { const span = startSpanManual({ name: 'test span', onlyIfParent: true }, span => { return span; @@ -1210,6 +1236,7 @@ describe('trace', () => { }); expect(isSpan(span)).toBe(true); + expect(spyOnDroppedEvent).not.toHaveBeenCalledWith('no_parent_span', 'span'); }); }); }); From c5f0f562db438500a85ae9b38df9344428bf4982 Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Thu, 16 Apr 2026 17:21:39 +0200 Subject: [PATCH 2/4] format --- .../tracing/no-parent-span-client-report-streamed/init.js | 5 ++++- .../suites/tracing/no-parent-span-client-report/subject.js | 1 - 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/dev-packages/browser-integration-tests/suites/tracing/no-parent-span-client-report-streamed/init.js b/dev-packages/browser-integration-tests/suites/tracing/no-parent-span-client-report-streamed/init.js index 454301926616..94db849f5fde 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/no-parent-span-client-report-streamed/init.js +++ b/dev-packages/browser-integration-tests/suites/tracing/no-parent-span-client-report-streamed/init.js @@ -4,7 +4,10 @@ window.Sentry = Sentry; Sentry.init({ dsn: 'https://public@dsn.ingest.sentry.io/1337', - integrations: [Sentry.browserTracingIntegration({ instrumentPageLoad: false, instrumentNavigation: false }), Sentry.spanStreamingIntegration()], + integrations: [ + Sentry.browserTracingIntegration({ instrumentPageLoad: false, instrumentNavigation: false }), + Sentry.spanStreamingIntegration(), + ], tracesSampleRate: 1, sendClientReports: true, }); diff --git a/dev-packages/browser-integration-tests/suites/tracing/no-parent-span-client-report/subject.js b/dev-packages/browser-integration-tests/suites/tracing/no-parent-span-client-report/subject.js index f55d101b930b..6ba8011d77ac 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/no-parent-span-client-report/subject.js +++ b/dev-packages/browser-integration-tests/suites/tracing/no-parent-span-client-report/subject.js @@ -1,2 +1 @@ fetch('http://sentry-test-site.example/api/test'); - From 9d838f9402755e89c2420b377a2f9d7f43278a85 Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Thu, 16 Apr 2026 17:28:24 +0200 Subject: [PATCH 3/4] size limit of course :( --- .size-limit.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.size-limit.js b/.size-limit.js index 86f3ef5ed87d..510e041db89c 100644 --- a/.size-limit.js +++ b/.size-limit.js @@ -276,7 +276,7 @@ module.exports = [ path: createCDNPath('bundle.tracing.replay.min.js'), gzip: false, brotli: false, - limit: '251 KB', + limit: '252 KB', }, { name: 'CDN Bundle (incl. Tracing, Replay, Logs, Metrics) - uncompressed', @@ -290,7 +290,7 @@ module.exports = [ path: createCDNPath('bundle.tracing.replay.feedback.min.js'), gzip: false, brotli: false, - limit: '264 KB', + limit: '265 KB', }, { name: 'CDN Bundle (incl. Tracing, Replay, Feedback, Logs, Metrics) - uncompressed', @@ -324,7 +324,7 @@ module.exports = [ import: createImport('init'), ignore: [...builtinModules, ...nodePrefixedBuiltinModules], gzip: true, - limit: '59 KB', + limit: '60 KB', }, // Node SDK (ESM) { From 941722da00f0f70f7e67d5634cc5a991e1ba5c70 Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Thu, 16 Apr 2026 17:32:54 +0200 Subject: [PATCH 4/4] cleanup client passing --- packages/core/src/tracing/trace.ts | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/packages/core/src/tracing/trace.ts b/packages/core/src/tracing/trace.ts index 1d37ca2f76a4..08d43853701f 100644 --- a/packages/core/src/tracing/trace.ts +++ b/packages/core/src/tracing/trace.ts @@ -78,7 +78,6 @@ export function startSpan(options: StartSpanOptions, callback: (span: Span) = spanArguments, forceTransaction, scope, - client, }); if (shouldSkipSpan) { @@ -137,7 +136,6 @@ export function startSpanManual(options: StartSpanOptions, callback: (span: S return wrapper(() => { const scope = getCurrentScope(); const parentSpan = getParentSpan(scope, customParentSpan); - const client = getClient(); const shouldSkipSpan = options.onlyIfParent && !parentSpan; const activeSpan = shouldSkipSpan @@ -147,11 +145,10 @@ export function startSpanManual(options: StartSpanOptions, callback: (span: S spanArguments, forceTransaction, scope, - client, }); if (shouldSkipSpan) { - client?.recordDroppedEvent('no_parent_span', 'span'); + getClient()?.recordDroppedEvent('no_parent_span', 'span'); } // We don't set ignored child spans onto the scope because there likely is an active, @@ -221,7 +218,6 @@ export function startInactiveSpan(options: StartSpanOptions): Span { spanArguments, forceTransaction, scope, - client, }); }); } @@ -342,13 +338,11 @@ function createChildOrRootSpan({ spanArguments, forceTransaction, scope, - client: clientArg, }: { parentSpan: SentrySpan | undefined; spanArguments: SentrySpanArguments; forceTransaction?: boolean; scope: Scope; - client?: Client; }): Span { if (!hasSpansEnabled()) { const span = new SentryNonRecordingSpan(); @@ -368,7 +362,7 @@ function createChildOrRootSpan({ return span; } - const client = clientArg || getClient(); + const client = getClient(); if (_shouldIgnoreStreamedSpan(client, spanArguments)) { if (!_isTracingSuppressed(scope)) { // if tracing is actively suppressed (Sentry.suppressTracing(...)),