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 index 2672d41c17fb..9a75d076542c 100644 --- 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 @@ -1,16 +1,10 @@ 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'; +import { shouldSkipTracingTest, testingCdnBundle } from '../../../utils/helpers'; +import { getSpanOp, waitForStreamedSpan } from '../../../utils/spanUtils'; sentryTest( - 'records no_parent_span client report for fetch requests without an active span', + 'sends http.client span for fetch requests without an active span when span streaming is enabled', async ({ getLocalTestUrl, page }) => { sentryTest.skip(shouldSkipTracingTest() || testingCdnBundle()); @@ -24,22 +18,14 @@ sentryTest( const url = await getLocalTestUrl({ testDir: __dirname }); - const clientReportPromise = waitForClientReportRequest(page, report => { - return report.discarded_events.some(e => e.reason === 'no_parent_span'); - }); + const spanPromise = waitForStreamedSpan(page, span => getSpanOp(span) === 'http.client'); await page.goto(url); - await hidePage(page); - - const clientReport = envelopeRequestParser(await clientReportPromise); + const span = await spanPromise; - expect(clientReport.discarded_events).toEqual([ - { - category: 'span', - quantity: 1, - reason: 'no_parent_span', - }, - ]); + expect(span.name).toMatch(/^GET /); + expect(span.attributes?.['sentry.origin']).toEqual({ type: 'string', value: 'auto.http.browser' }); + expect(span.attributes?.['sentry.op']).toEqual({ type: 'string', value: 'http.client' }); }, ); 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 index 2b987f92d755..6a05e640da4f 100644 --- 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 @@ -1,24 +1,24 @@ import { afterAll, describe, expect } from 'vitest'; import { cleanupChildProcesses, createEsmAndCjsTests } from '../../../utils/runner'; -describe('no_parent_span client report (streaming)', () => { +describe('no_parent_span with streaming enabled', () => { 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 () => { + test('sends http.client span without a local parent when span streaming is enabled', async () => { const runner = createRunner() - .unignore('client_report') .expect({ - client_report: report => { - expect(report.discarded_events).toEqual([ - { - category: 'span', - quantity: 1, - reason: 'no_parent_span', - }, - ]); + span: span => { + const httpClientSpan = span.items.find(item => + item.attributes?.['sentry.op'] + ? item.attributes['sentry.op'].type === 'string' && item.attributes['sentry.op'].value === 'http.client' + : false, + ); + + expect(httpClientSpan).toBeDefined(); + expect(httpClientSpan?.name).toMatch(/^GET /); }, }) .start(); diff --git a/packages/browser/src/tracing/request.ts b/packages/browser/src/tracing/request.ts index b393f0585b5b..9cbf45563f0b 100644 --- a/packages/browser/src/tracing/request.ts +++ b/packages/browser/src/tracing/request.ts @@ -406,9 +406,11 @@ function xhrCallback( const client = getClient(); const hasParent = !!getActiveSpan(); + // With span streaming, we always emit http.client spans, even without a parent span + const shouldEmitSpan = hasParent || (!!client && hasSpanStreamingEnabled(client)); const span = - shouldCreateSpanResult && hasParent + shouldCreateSpanResult && shouldEmitSpan ? startInactiveSpan({ name: `${method} ${urlForSpanName}`, attributes: { @@ -425,7 +427,7 @@ function xhrCallback( }) : new SentryNonRecordingSpan(); - if (shouldCreateSpanResult && !hasParent) { + if (shouldCreateSpanResult && !shouldEmitSpan) { client?.recordDroppedEvent('no_parent_span', 'span'); } @@ -438,7 +440,7 @@ function xhrCallback( // If performance is disabled (TWP) or there's no active root span (pageload/navigation/interaction), // we do not want to use the span as base for the trace headers, // which means that the headers will be generated from the scope and the sampling decision is deferred - hasSpansEnabled() && hasParent ? span : undefined, + hasSpansEnabled() && shouldEmitSpan ? span : undefined, propagateTraceparent, ); } diff --git a/packages/core/src/fetch.ts b/packages/core/src/fetch.ts index c65f147613dc..a64a98255fa9 100644 --- a/packages/core/src/fetch.ts +++ b/packages/core/src/fetch.ts @@ -2,6 +2,7 @@ import { getClient } from './currentScopes'; import { SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from './semanticAttributes'; import { setHttpStatus, SPAN_STATUS_ERROR, startInactiveSpan } from './tracing'; import { SentryNonRecordingSpan } from './tracing/sentryNonRecordingSpan'; +import { hasSpanStreamingEnabled } from './tracing/spans/hasSpanStreamingEnabled'; import type { FetchBreadcrumbHint } from './types-hoist/breadcrumb'; import type { HandlerDataFetch } from './types-hoist/instrument'; import type { ResponseHookInfo } from './types-hoist/request'; @@ -110,13 +111,15 @@ export function instrumentFetchRequest( const client = getClient(); const hasParent = !!getActiveSpan(); + // With span streaming, we always emit http.client spans, even without a parent span + const shouldEmitSpan = hasParent || (!!client && hasSpanStreamingEnabled(client)); const span = - shouldCreateSpanResult && hasParent + shouldCreateSpanResult && shouldEmitSpan ? startInactiveSpan(getSpanStartOptions(url, method, spanOrigin)) : new SentryNonRecordingSpan(); - if (shouldCreateSpanResult && !hasParent) { + if (shouldCreateSpanResult && !shouldEmitSpan) { client?.recordDroppedEvent('no_parent_span', 'span'); } @@ -136,7 +139,7 @@ export function instrumentFetchRequest( // If performance is disabled (TWP) or there's no active root span (pageload/navigation/interaction), // we do not want to use the span as base for the trace headers, // which means that the headers will be generated from the scope and the sampling decision is deferred - hasSpansEnabled() && hasParent ? span : undefined, + hasSpansEnabled() && shouldEmitSpan ? span : undefined, propagateTraceparent, ); if (headers) { diff --git a/packages/opentelemetry/src/sampler.ts b/packages/opentelemetry/src/sampler.ts index 1e65e9d15d14..e21f722551a7 100644 --- a/packages/opentelemetry/src/sampler.ts +++ b/packages/opentelemetry/src/sampler.ts @@ -75,10 +75,13 @@ export class SentrySampler implements Sampler { const maybeSpanHttpMethod = spanAttributes[SEMATTRS_HTTP_METHOD] || spanAttributes[ATTR_HTTP_REQUEST_METHOD]; // 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 + // but we want to leave downstream sampling decisions up to the server. + // Exception: when span streaming is enabled, we always emit these spans. if (spanKind === SpanKind.CLIENT && maybeSpanHttpMethod && (!parentSpan || parentContext?.isRemote)) { - this._client.recordDroppedEvent('no_parent_span', 'span'); - return wrapSamplingDecision({ decision: undefined, context, spanAttributes }); + if (!this._isSpanStreaming) { + this._client.recordDroppedEvent('no_parent_span', 'span'); + return wrapSamplingDecision({ decision: undefined, context, spanAttributes }); + } } const parentSampled = parentSpan ? getParentSampled(parentSpan, traceId, spanName) : undefined; diff --git a/packages/opentelemetry/test/sampler.test.ts b/packages/opentelemetry/test/sampler.test.ts index 22fa724fa161..55c3cff8ac32 100644 --- a/packages/opentelemetry/test/sampler.test.ts +++ b/packages/opentelemetry/test/sampler.test.ts @@ -348,5 +348,23 @@ describe('SentrySampler', () => { expect(spyOnDroppedEvent).toHaveBeenCalledTimes(1); expect(spyOnDroppedEvent).toHaveBeenCalledWith('sample_rate', 'span'); }); + + it('always emits streamed http.client spans without a local parent', () => { + const client = new TestClient(getDefaultTestClientOptions({ tracesSampleRate: 1, traceLifecycle: 'stream' })); + const spyOnDroppedEvent = vi.spyOn(client, 'recordDroppedEvent'); + const sampler = new SentrySampler(client); + + const ctx = context.active(); + const traceId = generateTraceId(); + const spanName = 'GET http://example.com/api'; + const spanKind = SpanKind.CLIENT; + const spanAttributes = { + [ATTR_HTTP_REQUEST_METHOD]: 'GET', + }; + + const actual = sampler.shouldSample(ctx, traceId, spanName, spanKind, spanAttributes, undefined); + expect(actual.decision).toBe(SamplingDecision.RECORD_AND_SAMPLED); + expect(spyOnDroppedEvent).not.toHaveBeenCalled(); + }); }); });