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 609df6f551a3..05f70c5a649f 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,6 +1,6 @@ import { expect } from '@playwright/test'; -import type { ClientReport } from '@sentry/core'; import { sentryTest } from '../../../utils/fixtures'; +import { getSpanOp, waitForStreamedSpan } from '../../../utils/spanUtils'; import { envelopeRequestParser, hidePage, @@ -9,7 +9,7 @@ import { } from '../../../utils/helpers'; 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()); @@ -23,22 +23,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/scenario-fetch.mjs b/dev-packages/node-integration-tests/suites/tracing/no-parent-span-client-report-streamed/scenario-fetch.mjs new file mode 100644 index 000000000000..6a1cc2c77ba3 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/no-parent-span-client-report-streamed/scenario-fetch.mjs @@ -0,0 +1,4 @@ +import * as Sentry from '@sentry/node'; +fetch('http://localhost:9999/external').catch(async () => { + await Sentry.flush(); +}); 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 deleted file mode 100644 index 18afc6db5113..000000000000 --- a/dev-packages/node-integration-tests/suites/tracing/no-parent-span-client-report-streamed/scenario.mjs +++ /dev/null @@ -1,2 +0,0 @@ -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 index 2b987f92d755..0b0ff7cac854 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 () => { + createEsmAndCjsTests(__dirname, 'scenario-fetch.mjs', 'instrument.mjs', (createRunner, test) => { + 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 .*\/external$/); }, }) .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/core/src/tracing/spans/captureSpan.ts b/packages/core/src/tracing/spans/captureSpan.ts index e41a9cfdf484..c06d4ce43560 100644 --- a/packages/core/src/tracing/spans/captureSpan.ts +++ b/packages/core/src/tracing/spans/captureSpan.ts @@ -18,6 +18,7 @@ import { } from '../../semanticAttributes'; import type { SerializedStreamedSpan, Span, StreamedSpanJSON } from '../../types-hoist/span'; import { getCombinedScopeData } from '../../utils/scopeData'; +import { getSanitizedUrlString, parseUrl, stripUrlQueryAndFragment } from '../../utils/url'; import { INTERNAL_getSegmentSpan, showSpanDropWarning, @@ -241,21 +242,55 @@ function inferHttpSpanData( return; } - // Only overwrite the span name when we have an explicit http.route — it's more specific than - // what OTel instrumentation sets as the span name. For all other cases (url.full, http.target), - // the OTel-set name is already good enough and we'd risk producing a worse name (e.g. full URL). const httpRoute = attributes['http.route']; if (typeof httpRoute === 'string') { spanJSON.name = `${httpMethod} ${httpRoute}`; safeSetSpanJSONAttributes(spanJSON, { [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route' }); } else { - // Fallback: set source to 'url' for HTTP spans without a route. - // The spec requires sentry.span.source on segment spans, and the non-streamed exporter - // always sets this — so we need to ensure it's present for streamed spans too. + // Infer span name from URL attributes, matching the non-streamed exporter's behavior. + // Only overwrite the name for OTel spans (known spanKind) + if (spanKind === SPAN_KIND_CLIENT || spanKind === SPAN_KIND_SERVER) { + const urlPath = getUrlPath(attributes, spanKind); + if (urlPath) { + spanJSON.name = `${httpMethod} ${urlPath}`; + } + } safeSetSpanJSONAttributes(spanJSON, { [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'url' }); } } +/** + * Extract a URL path from span attributes for use in the span name. + * Mirrors the logic in the non-streamed exporter's `getSanitizedUrl`. + */ +function getUrlPath( + attributes: RawAttributes>, + spanKind: number | undefined, +): string | undefined { + const httpUrl = attributes['http.url'] || attributes['url.full']; + const httpTarget = attributes['http.target']; + + const parsedUrl = typeof httpUrl === 'string' ? parseUrl(httpUrl) : undefined; + const sanitizedUrl = parsedUrl ? getSanitizedUrlString(parsedUrl) : undefined; + + // For server spans, prefer the relative target path + if (spanKind === SPAN_KIND_SERVER && typeof httpTarget === 'string') { + return stripUrlQueryAndFragment(httpTarget); + } + + // For client spans (and others), use the full sanitized URL + if (sanitizedUrl) { + return sanitizedUrl; + } + + // Fall back to target if no full URL is available + if (typeof httpTarget === 'string') { + return stripUrlQueryAndFragment(httpTarget); + } + + return undefined; +} + function inferDbSpanData(spanJSON: StreamedSpanJSON, attributes: RawAttributes>): void { safeSetSpanJSONAttributes(spanJSON, { [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'db' }); diff --git a/packages/core/test/lib/tracing/spans/captureSpan.test.ts b/packages/core/test/lib/tracing/spans/captureSpan.test.ts index 56b039d56b67..186f7f23a536 100644 --- a/packages/core/test/lib/tracing/spans/captureSpan.test.ts +++ b/packages/core/test/lib/tracing/spans/captureSpan.test.ts @@ -530,10 +530,10 @@ describe('inferSpanDataFromOtelAttributes', () => { expect(spanJSON.attributes?.['sentry.source']).toBe('route'); }); - it('does not overwrite name when no http.route but sets source to url', () => { + it('infers name from url.full when no http.route and sets source to url', () => { const spanJSON = makeSpanJSON('GET', { 'http.request.method': 'GET', 'url.full': 'http://example.com/api' }); inferSpanDataFromOtelAttributes(spanJSON, 2); - expect(spanJSON.name).toBe('GET'); + expect(spanJSON.name).toBe('GET http://example.com/api'); expect(spanJSON.attributes?.['sentry.source']).toBe('url'); }); diff --git a/packages/opentelemetry/src/sampler.ts b/packages/opentelemetry/src/sampler.ts index 235ff3247f5d..05dc0758458b 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(); + }); }); });