Skip to content
Open
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 number Diff line number Diff line change
@@ -0,0 +1,12 @@
import * as Sentry from '@sentry/browser';

window.Sentry = Sentry;

Sentry.init({
dsn: 'https://public@dsn.ingest.sentry.io/1337',
integrations: [Sentry.browserTracingIntegration(), Sentry.spanStreamingIntegration()],
ignoreSpans: [/ignored-click-listener/],
parentSpanIsAlwaysRootSpan: false,
tracePropagationTargets: ['sentry-test-external.io'],
tracesSampleRate: 0,
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
const fetchButton = document.getElementById('fetch');

fetchButton.addEventListener('click', async () => {
await Sentry.startSpan({ name: 'ignored-click-listener', op: 'ui.interaction.click' }, async () => {
await fetch('http://sentry-test-external.io');
});
});

fetchButton.click();
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<meta name="sentry-trace" content="12345678901234567890123456789012-1234567890123456-1" />
<meta
name="baggage"
content="sentry-trace_id=12345678901234567890123456789012,sentry-sample_rate=1,sentry-sampled=true,sentry-public_key=public,sentry-sample_rand=0.5"
/>
</head>
<body>
<button id="fetch">Fetch</button>
</body>
</html>
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { expect } from '@playwright/test';
import type { ClientReport } from '@sentry/core';
import { sentryTest } from '../../../../utils/fixtures';
import {
envelopeRequestParser,
hidePage,
shouldSkipTracingTest,
waitForClientReportRequest,
waitForTracingHeadersOnUrl,
} from '../../../../utils/helpers';
import { getSpanOp, waitForStreamedSpans } from '../../../../utils/spanUtils';

sentryTest(
'ignoring a child span preserves the positive sampling decision of a continued trace when propagating it',
async ({ getLocalTestUrl, page }) => {
sentryTest.skip(shouldSkipTracingTest());

const url = await getLocalTestUrl({ testDir: __dirname });

const clientReportPromise = waitForClientReportRequest(page);
const tracingHeadersPromise = waitForTracingHeadersOnUrl(page, 'http://sentry-test-external.io');
const spansPromise = waitForStreamedSpans(
page,
spans =>
spans.some(span => getSpanOp(span) === 'pageload') && spans.some(span => getSpanOp(span) === 'http.client'),
);

await page.goto(url);

const [{ baggage, sentryTrace }, spans] = await Promise.all([tracingHeadersPromise, spansPromise]);
const pageloadSpan = spans.find(span => getSpanOp(span) === 'pageload');
const httpClientSpan = spans.find(span => getSpanOp(span) === 'http.client');

expect(pageloadSpan?.is_segment).toBe(true);
expect(pageloadSpan?.trace_id).toBe('12345678901234567890123456789012');
expect(httpClientSpan?.parent_span_id).toBe(pageloadSpan?.span_id);
expect(sentryTrace).toBe(`${pageloadSpan?.trace_id}-${httpClientSpan?.span_id}-1`);
expect(baggage).toEqual(
'sentry-trace_id=12345678901234567890123456789012,sentry-sample_rate=1,sentry-sampled=true,sentry-public_key=public,sentry-sample_rand=0.5',
);

await hidePage(page);
const clientReport = envelopeRequestParser<ClientReport>(await clientReportPromise);
expect(clientReport.discarded_events).toEqual([{ category: 'span', quantity: 1, reason: 'ignored' }]);
},
);
Original file line number Diff line number Diff line change
@@ -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.browserTracingIntegration(), Sentry.spanStreamingIntegration()],
ignoreSpans: [{ attributes: { 'sentry.op': 'http.client' } }],
tracePropagationTargets: ['sentry-test-external.io'],
tracesSampleRate: 0,
});
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
setTimeout(() => fetch('http://sentry-test-external.io'));
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<meta name="sentry-trace" content="12345678901234567890123456789012-1234567890123456-1" />
<meta
name="baggage"
content="sentry-trace_id=12345678901234567890123456789012,sentry-sample_rate=1,sentry-sampled=true,sentry-public_key=public,sentry-sample_rand=0.5"
/>
</head>
<body></body>
</html>
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { expect } from '@playwright/test';
import type { ClientReport } from '@sentry/core';
import { sentryTest } from '../../../../utils/fixtures';
import {
envelopeRequestParser,
hidePage,
shouldSkipTracingTest,
waitForClientReportRequest,
waitForTracingHeadersOnUrl,
} from '../../../../utils/helpers';
import { getSpanOp, waitForStreamedSpan } from '../../../../utils/spanUtils';

sentryTest(
'ignoring an outgoing HTTP span preserves the positive sampling decision of a continued trace',
async ({ getLocalTestUrl, page }) => {
sentryTest.skip(shouldSkipTracingTest());

const url = await getLocalTestUrl({ testDir: __dirname });
const clientReportPromise = waitForClientReportRequest(page);
const tracingHeadersPromise = waitForTracingHeadersOnUrl(page, 'http://sentry-test-external.io');
const pageloadSpanPromise = waitForStreamedSpan(page, span => getSpanOp(span) === 'pageload');

await page.goto(url);

const [{ baggage, sentryTrace }, pageloadSpan] = await Promise.all([tracingHeadersPromise, pageloadSpanPromise]);
expect(pageloadSpan.is_segment).toBe(true);
expect(pageloadSpan.trace_id).toBe('12345678901234567890123456789012');
expect(sentryTrace).toBe(`12345678901234567890123456789012-${pageloadSpan.span_id}-1`);
expect(baggage).toEqual(
'sentry-trace_id=12345678901234567890123456789012,sentry-sample_rate=1,sentry-sampled=true,sentry-public_key=public,sentry-sample_rand=0.5',
);

await hidePage(page);
const clientReport = envelopeRequestParser<ClientReport>(await clientReportPromise);
expect(clientReport.discarded_events).toEqual([{ category: 'span', quantity: 1, reason: 'ignored' }]);
},
);
Original file line number Diff line number Diff line change
@@ -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.browserTracingIntegration(), Sentry.spanStreamingIntegration()],
ignoreSpans: [{ op: 'pageload' }],
tracePropagationTargets: ['sentry-test-external.io'],
tracesSampleRate: 0,
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
document.getElementById('fetch').addEventListener('click', () => {
fetch('http://sentry-test-external.io');
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<meta name="sentry-trace" content="12345678901234567890123456789012-1234567890123456-1" />
<meta
name="baggage"
content="sentry-trace_id=12345678901234567890123456789012,sentry-sample_rate=1,sentry-sampled=true,sentry-public_key=public,sentry-sample_rand=0.5"
/>
</head>
<body>
<button id="fetch">Fetch</button>
</body>
</html>
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { expect } from '@playwright/test';
import { sentryTest } from '../../../../utils/fixtures';
import { shouldSkipTracingTest, waitForTracingHeadersOnUrl } from '../../../../utils/helpers';

sentryTest(
'ignoring a trace-continued, positively sampled segment span propagates a negative sampling decision when propagating it',
async ({ getLocalTestUrl, page }) => {
sentryTest.skip(shouldSkipTracingTest());

const url = await getLocalTestUrl({ testDir: __dirname });

const tracingHeadersPromise = waitForTracingHeadersOnUrl(page, 'http://sentry-test-external.io');

await page.goto(url);
await page.locator('#fetch').click();

const { baggage, sentryTrace } = await tracingHeadersPromise;
expect(sentryTrace).toMatch(/12345678901234567890123456789012-[\da-f]{16}-0/);
expect(baggage).toEqual(
'sentry-trace_id=12345678901234567890123456789012,sentry-sample_rate=1,sentry-sampled=false,sentry-public_key=public,sentry-sample_rand=0.5',
);
},
);
8 changes: 7 additions & 1 deletion packages/browser/src/tracing/request.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import {
SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN,
SentryNonRecordingSpan,
setHttpStatus,
spanIsIgnored,
spanToJSON,
startInactiveSpan,
stringMatchesSomePattern,
Expand Down Expand Up @@ -325,6 +326,7 @@ export function shouldAttachHeaders(
*
* @returns Span if a span was created, otherwise void.
*/
// oxlint-disable-next-line complexity
function xhrCallback(
handlerData: HandlerDataXhr,
shouldCreateSpan: (url: string) => boolean,
Expand Down Expand Up @@ -401,6 +403,10 @@ function xhrCallback(
})
: new SentryNonRecordingSpan();

// If the span is ignored, we don't want to continue the trace from it (NonRecordingSpan) but rather
// from the active span. Passing `undefined` here will make `getTraceData` use the active span instead.
const spanForTraceHeaders = spanIsIgnored(span) && hasParent ? undefined : span;

if (shouldCreateSpanResult && !shouldEmitSpan) {
client?.recordDroppedEvent('no_parent_span', 'span');
}
Expand All @@ -414,7 +420,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() && shouldEmitSpan ? span : undefined,
hasSpansEnabled() && shouldEmitSpan ? spanForTraceHeaders : undefined,
propagateTraceparent,
);
}
Expand Down
49 changes: 49 additions & 0 deletions packages/browser/test/tracing/request.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ class MockClient implements Partial<Client> {
public getOptions() {
return {};
}

public emit(): void {}
}

describe('instrumentOutgoingRequests', () => {
Expand Down Expand Up @@ -51,6 +53,53 @@ describe('instrumentOutgoingRequests', () => {

expect(addXhrSpy).not.toHaveBeenCalled();
});

describe('XHR trace header span', () => {
afterEach(() => {
vi.restoreAllMocks();
});

it('uses the active propagation context for an ignored child span', () => {
const activeSpan = new utils.SentryNonRecordingSpan();
const ignoredSpan = new utils.SentryNonRecordingSpan({ dropReason: 'ignored' });
let xhrHandler: ((data: utils.HandlerDataXhr) => void) | undefined;

vi.spyOn(browserUtils, 'addXhrInstrumentationHandler').mockImplementation(handler => {
xhrHandler = handler;
});
vi.spyOn(utils, 'getClient').mockReturnValue(client);
vi.spyOn(utils, 'getActiveSpan').mockReturnValue(activeSpan);
vi.spyOn(utils, 'hasSpansEnabled').mockReturnValue(true);
vi.spyOn(utils, 'hasSpanStreamingEnabled').mockReturnValue(true);
vi.spyOn(utils, 'startInactiveSpan').mockReturnValue(ignoredSpan);
const getTraceDataSpy = vi.spyOn(utils, 'getTraceData').mockReturnValue({
'sentry-trace': '12345678901234567890123456789012-1234567890123456-1',
});

instrumentOutgoingRequests(client, {
traceFetch: false,
tracePropagationTargets: ['example.com'],
enableHTTPTimings: false,
});
xhrHandler?.({
xhr: {
[browserUtils.SENTRY_XHR_DATA_KEY]: {
method: 'GET',
url: 'https://example.com/outgoing',
request_headers: {},
},
setRequestHeader: vi.fn(),
},
startTimestamp: Date.now(),
} as utils.HandlerDataXhr);

expect(xhrHandler).toBeDefined();
expect(getTraceDataSpy).toHaveBeenCalledWith({
span: undefined,
propagateTraceparent: undefined,
});
});
});
});

describe('shouldAttachHeaders', () => {
Expand Down
5 changes: 3 additions & 2 deletions packages/core/src/fetch.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { getClient } from './currentScopes';
import { SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from './semanticAttributes';
import { setHttpStatus, SPAN_STATUS_ERROR, startInactiveSpan } from './tracing';
import { setHttpStatus, SPAN_STATUS_ERROR, spanIsIgnored, startInactiveSpan } from './tracing';
import { SentryNonRecordingSpan } from './tracing/sentryNonRecordingSpan';
import { hasSpanStreamingEnabled } from './tracing/spans/hasSpanStreamingEnabled';
import type { FetchBreadcrumbHint } from './types/breadcrumb';
Expand Down Expand Up @@ -118,6 +118,7 @@ export function instrumentFetchRequest(
shouldCreateSpanResult && shouldEmitSpan
? startInactiveSpan(getSpanStartOptions(url, method, spanOrigin))
: new SentryNonRecordingSpan();
const spanForTraceHeaders = spanIsIgnored(span) && hasParent ? undefined : span;

if (shouldCreateSpanResult && !shouldEmitSpan) {
client?.recordDroppedEvent('no_parent_span', 'span');
Expand All @@ -139,7 +140,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() && shouldEmitSpan ? span : undefined,
hasSpansEnabled() && shouldEmitSpan ? spanForTraceHeaders : undefined,
propagateTraceparent,
);
if (headers) {
Expand Down
41 changes: 40 additions & 1 deletion packages/core/test/lib/fetch.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { HandlerDataFetch } from '../../src';
import { _INTERNAL_getTracingHeadersForFetchRequest, instrumentFetchRequest } from '../../src/fetch';
import { SentryNonRecordingSpan } from '../../src/tracing/sentryNonRecordingSpan';
import type { Span } from '../../src/types/span';
import * as tracing from '../../src/tracing';
import * as spanUtils from '../../src/utils/spanUtils';
import * as traceData from '../../src/utils/traceData';

const { DEFAULT_SENTRY_TRACE, DEFAULT_BAGGAGE, hasSpansEnabled } = vi.hoisted(() => ({
DEFAULT_SENTRY_TRACE: 'defaultTraceId-defaultSpanId-1',
Expand Down Expand Up @@ -440,6 +444,41 @@ describe('_INTERNAL_getTracingHeadersForFetchRequest', () => {
});

describe('instrumentFetchRequest', () => {
describe('trace header span', () => {
beforeEach(() => {
vi.clearAllMocks();
});

afterEach(() => {
vi.restoreAllMocks();
});

it('uses the active propagation context for an ignored child span', () => {
const activeSpan = new SentryNonRecordingSpan();
hasSpansEnabled.mockReturnValue(true);
const ignoredSpan = new SentryNonRecordingSpan({ dropReason: 'ignored' });
vi.spyOn(spanUtils, 'getActiveSpan').mockReturnValue(activeSpan);
vi.spyOn(tracing, 'startInactiveSpan').mockReturnValue(ignoredSpan);

instrumentFetchRequest(
{
fetchData: { url: '/api/test', method: 'GET' },
args: ['/api/test'],
startTimestamp: Date.now(),
},
() => true,
() => true,
{},
{ spanOrigin: 'auto.http.fetch' },
);

expect(traceData.getTraceData).toHaveBeenCalledWith({
span: undefined,
propagateTraceparent: false,
});
});
});

describe('span cleanup', () => {
it.each([
{ name: 'non-recording', hasTracingEnabled: false },
Expand Down
Loading