Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line numberDiff line numberDiff line change
@@ -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,
Expand All@@ -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());

Expand All@@ -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<ClientReport>(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' });
},
);
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
import * as Sentry from '@sentry/node';
fetch('http://localhost:9999/external').catch(async () => {
await Sentry.flush();
});

This file was deleted.

Original file line numberDiff line numberDiff line change
@@ -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();
Expand Down
8 changes: 5 additions & 3 deletions packages/browser/src/tracing/request.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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: {
Expand All@@ -425,7 +427,7 @@ function xhrCallback(
})
: new SentryNonRecordingSpan();

if (shouldCreateSpanResult && !hasParent) {
if (shouldCreateSpanResult && !shouldEmitSpan) {
client?.recordDroppedEvent('no_parent_span', 'span');
}

Expand All@@ -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,
);
}
Expand Down
9 changes: 6 additions & 3 deletions packages/core/src/fetch.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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';
Expand DownExpand Up@@ -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');
}

Expand All@@ -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) {
Expand Down
47 changes: 41 additions & 6 deletions packages/core/src/tracing/spans/captureSpan.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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,
Expand DownExpand Up@@ -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<Record<string, unknown>>,
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<Record<string, unknown>>): void {
safeSetSpanJSONAttributes(spanJSON, { [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'db' });

Expand Down
4 changes: 2 additions & 2 deletions packages/core/test/lib/tracing/spans/captureSpan.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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');
});

Expand Down
9 changes: 6 additions & 3 deletions packages/opentelemetry/src/sampler.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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;
Expand Down
18 changes: 18 additions & 0 deletions packages/opentelemetry/test/sampler.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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();
});
});
});
Loading