diff --git a/.size-limit.js b/.size-limit.js index 24772d8380f5..5eb520d68684 100644 --- a/.size-limit.js +++ b/.size-limit.js @@ -243,7 +243,7 @@ module.exports = [ import: createImport('init'), ignore: ['$app/stores'], gzip: true, - limit: '42 KB', + limit: '42.5 KB', }, // Node-Core SDK (ESM) { diff --git a/dev-packages/browser-integration-tests/suites/tracing/trace-lifetime/pageload-headers/subject.js b/dev-packages/browser-integration-tests/suites/tracing/trace-lifetime/pageload-headers/subject.js new file mode 100644 index 000000000000..bfaa54db4d01 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/trace-lifetime/pageload-headers/subject.js @@ -0,0 +1,16 @@ +const errorBtn = document.getElementById('errorBtn'); +errorBtn.addEventListener('click', () => { + throw new Error(`Sentry Test Error ${Math.random()}`); +}); + +const fetchBtn = document.getElementById('fetchBtn'); +fetchBtn.addEventListener('click', async () => { + await fetch('http://sentry-test-site.example'); +}); + +const xhrBtn = document.getElementById('xhrBtn'); +xhrBtn.addEventListener('click', () => { + const xhr = new XMLHttpRequest(); + xhr.open('GET', 'http://sentry-test-site.example'); + xhr.send(); +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/trace-lifetime/pageload-headers/template.html b/dev-packages/browser-integration-tests/suites/tracing/trace-lifetime/pageload-headers/template.html new file mode 100644 index 000000000000..a3c17f442605 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/trace-lifetime/pageload-headers/template.html @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/dev-packages/browser-integration-tests/suites/tracing/trace-lifetime/pageload-headers/test.ts b/dev-packages/browser-integration-tests/suites/tracing/trace-lifetime/pageload-headers/test.ts new file mode 100644 index 000000000000..76e015dcb07a --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/trace-lifetime/pageload-headers/test.ts @@ -0,0 +1,85 @@ +import { expect } from '@playwright/test'; +import { sentryTest } from '../../../../utils/fixtures'; +import type { EventAndTraceHeader } from '../../../../utils/helpers'; +import { + eventAndTraceHeaderRequestParser, + getFirstSentryEnvelopeRequest, + shouldSkipTracingTest, +} from '../../../../utils/helpers'; + +const META_TAG_TRACE_ID = '12345678901234567890123456789012'; +const META_TAG_PARENT_SPAN_ID = '1234567890123456'; +const META_TAG_BAGGAGE = + 'sentry-trace_id=12345678901234567890123456789012,sentry-sample_rate=0.2,sentry-sampled=true,sentry-transaction=my-transaction,sentry-public_key=public,sentry-release=1.0.0,sentry-environment=prod,sentry-sample_rand=0.42'; + +sentryTest( + 'create a new trace for a navigation after the server timing headers', + async ({ getLocalTestUrl, page, enableConsole }) => { + if (shouldSkipTracingTest()) { + sentryTest.skip(); + } + + enableConsole(); + + const url = await getLocalTestUrl({ + testDir: __dirname, + responseHeaders: { + 'Server-Timing': `sentry-trace;desc=${META_TAG_TRACE_ID}-${META_TAG_PARENT_SPAN_ID}-1, baggage;desc="${META_TAG_BAGGAGE}"`, + }, + }); + + const [pageloadEvent, pageloadTraceHeader] = await getFirstSentryEnvelopeRequest( + page, + url, + eventAndTraceHeaderRequestParser, + ); + const [navigationEvent, navigationTraceHeader] = await getFirstSentryEnvelopeRequest( + page, + `${url}#foo`, + eventAndTraceHeaderRequestParser, + ); + + const pageloadTraceContext = pageloadEvent.contexts?.trace; + const navigationTraceContext = navigationEvent.contexts?.trace; + + expect(pageloadEvent.type).toEqual('transaction'); + expect(pageloadTraceContext).toMatchObject({ + op: 'pageload', + trace_id: META_TAG_TRACE_ID, + parent_span_id: META_TAG_PARENT_SPAN_ID, + span_id: expect.stringMatching(/^[\da-f]{16}$/), + }); + + expect(pageloadTraceHeader).toEqual({ + environment: 'prod', + release: '1.0.0', + sample_rate: '0.2', + sampled: 'true', + transaction: 'my-transaction', + public_key: 'public', + trace_id: META_TAG_TRACE_ID, + sample_rand: '0.42', + }); + + expect(navigationEvent.type).toEqual('transaction'); + expect(navigationTraceContext).toMatchObject({ + op: 'navigation', + trace_id: expect.stringMatching(/^[\da-f]{32}$/), + span_id: expect.stringMatching(/^[\da-f]{16}$/), + }); + // navigation span is head of trace, so there's no parent span: + expect(navigationTraceContext).not.toHaveProperty('parent_span_id'); + + expect(navigationTraceHeader).toEqual({ + environment: 'production', + public_key: 'public', + sample_rate: '1', + sampled: 'true', + trace_id: navigationTraceContext?.trace_id, + sample_rand: expect.any(String), + }); + + expect(pageloadTraceContext?.trace_id).not.toEqual(navigationTraceContext?.trace_id); + expect(pageloadTraceHeader?.sample_rand).not.toEqual(navigationTraceHeader?.sample_rand); + }, +); diff --git a/dev-packages/browser-integration-tests/suites/tracing/trace-lifetime/pageload-meta/test.ts b/dev-packages/browser-integration-tests/suites/tracing/trace-lifetime/pageload-meta/test.ts index 4269f91c2c07..c66d8793e610 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/trace-lifetime/pageload-meta/test.ts +++ b/dev-packages/browser-integration-tests/suites/tracing/trace-lifetime/pageload-meta/test.ts @@ -64,7 +64,7 @@ sentryTest( span_id: expect.stringMatching(/^[\da-f]{16}$/), }); // navigation span is head of trace, so there's no parent span: - expect(navigationTraceContext?.trace_id).not.toHaveProperty('parent_span_id'); + expect(navigationTraceContext).not.toHaveProperty('parent_span_id'); expect(navigationTraceHeader).toEqual({ environment: 'production', diff --git a/dev-packages/browser-integration-tests/suites/tracing/trace-lifetime/tracing-without-performance-headers/init.js b/dev-packages/browser-integration-tests/suites/tracing/trace-lifetime/tracing-without-performance-headers/init.js new file mode 100644 index 000000000000..94222159056c --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/trace-lifetime/tracing-without-performance-headers/init.js @@ -0,0 +1,10 @@ +import * as Sentry from '@sentry/browser'; + +window.Sentry = Sentry; + +Sentry.init({ + // in browser TwP means not setting tracesSampleRate but adding browserTracingIntegration, + dsn: 'https://public@dsn.ingest.sentry.io/1337', + integrations: [Sentry.browserTracingIntegration()], + tracePropagationTargets: ['http://sentry-test-site.example'], +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/trace-lifetime/tracing-without-performance-headers/template.html b/dev-packages/browser-integration-tests/suites/tracing/trace-lifetime/tracing-without-performance-headers/template.html new file mode 100644 index 000000000000..a29ad2056a45 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/trace-lifetime/tracing-without-performance-headers/template.html @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/dev-packages/browser-integration-tests/suites/tracing/trace-lifetime/tracing-without-performance-headers/test.ts b/dev-packages/browser-integration-tests/suites/tracing/trace-lifetime/tracing-without-performance-headers/test.ts new file mode 100644 index 000000000000..3d0262b717d9 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/trace-lifetime/tracing-without-performance-headers/test.ts @@ -0,0 +1,51 @@ +import { expect } from '@playwright/test'; +import { sentryTest } from '../../../../utils/fixtures'; +import type { EventAndTraceHeader } from '../../../../utils/helpers'; +import { + eventAndTraceHeaderRequestParser, + getFirstSentryEnvelopeRequest, + shouldSkipTracingTest, +} from '../../../../utils/helpers'; + +const META_TAG_TRACE_ID = '12345678901234567890123456789012'; +const META_TAG_PARENT_SPAN_ID = '1234567890123456'; +const META_TAG_BAGGAGE = + 'sentry-trace_id=12345678901234567890123456789012,sentry-public_key=public,sentry-release=1.0.0,sentry-environment=prod,sentry-sample_rand=0.42'; + +sentryTest('error on initial page has traceId from server timing headers', async ({ getLocalTestUrl, page }) => { + if (shouldSkipTracingTest()) { + sentryTest.skip(); + } + + const url = await getLocalTestUrl({ + testDir: __dirname, + responseHeaders: { + 'Server-Timing': `sentry-trace;desc=${META_TAG_TRACE_ID}-${META_TAG_PARENT_SPAN_ID}, baggage;desc="${META_TAG_BAGGAGE}"`, + }, + }); + await page.goto(url); + + const errorEventPromise = getFirstSentryEnvelopeRequest( + page, + undefined, + eventAndTraceHeaderRequestParser, + ); + + await page.locator('#errorBtn').click(); + const [errorEvent, errorTraceHeader] = await errorEventPromise; + + expect(errorEvent.type).toEqual(undefined); + expect(errorEvent.contexts?.trace).toEqual({ + trace_id: META_TAG_TRACE_ID, + parent_span_id: META_TAG_PARENT_SPAN_ID, + span_id: expect.stringMatching(/^[\da-f]{16}$/), + }); + + expect(errorTraceHeader).toEqual({ + environment: 'prod', + public_key: 'public', + release: '1.0.0', + trace_id: META_TAG_TRACE_ID, + sample_rand: '0.42', + }); +}); diff --git a/packages/browser/src/tracing/browserTracingIntegration.ts b/packages/browser/src/tracing/browserTracingIntegration.ts index 2e3eebe86845..c71acf106258 100644 --- a/packages/browser/src/tracing/browserTracingIntegration.ts +++ b/packages/browser/src/tracing/browserTracingIntegration.ts @@ -594,8 +594,9 @@ export const browserTracingIntegration = ((options: Partial entry.name === name); + return entry?.description; +} + /** Start listener for interaction transactions */ function registerInteractionListener( client: Client, diff --git a/packages/browser/test/tracing/browserTracingIntegration.test.ts b/packages/browser/test/tracing/browserTracingIntegration.test.ts index e3f1060655c2..991fcc1393a4 100644 --- a/packages/browser/test/tracing/browserTracingIntegration.test.ts +++ b/packages/browser/test/tracing/browserTracingIntegration.test.ts @@ -25,6 +25,7 @@ import { BrowserClient } from '../../src/client'; import { WINDOW } from '../../src/helpers'; import { browserTracingIntegration, + getServerTiming, startBrowserTracingNavigationSpan, startBrowserTracingPageLoadSpan, } from '../../src/tracing/browserTracingIntegration'; @@ -72,6 +73,7 @@ describe('browserTracingIntegration', () => { afterEach(() => { getActiveSpan()?.end(); vi.useRealTimers(); + vi.restoreAllMocks(); performance.clearMarks(); }); @@ -1029,6 +1031,212 @@ describe('browserTracingIntegration', () => { }); }); + describe('getServerTiming', () => { + it('retrieves server timing description when available', () => { + // Mock the performance API + const mockServerTiming = [ + { name: 'sentry-trace', duration: 0, description: '12312012123120121231201212312012-1121201211212012-1' }, + { name: 'baggage', duration: 0, description: 'sentry-release=2.1.14,sentry-sample_rand=0.456' }, + ]; + + const mockNavigationEntry = { + serverTiming: mockServerTiming, + }; + + vi.spyOn(WINDOW.performance, 'getEntriesByType').mockReturnValue([mockNavigationEntry as any]); + + const sentryTrace = getServerTiming('sentry-trace'); + const baggage = getServerTiming('baggage'); + + expect(sentryTrace).toBe('12312012123120121231201212312012-1121201211212012-1'); + expect(baggage).toBe('sentry-release=2.1.14,sentry-sample_rand=0.456'); + }); + + it('returns undefined when server timing entry is not found', () => { + const mockServerTiming = [{ name: 'other-timing', duration: 0, description: 'some-value' }]; + + const mockNavigationEntry = { + serverTiming: mockServerTiming, + }; + + vi.spyOn(WINDOW.performance, 'getEntriesByType').mockReturnValue([mockNavigationEntry as any]); + + const result = getServerTiming('sentry-trace'); + + expect(result).toBeUndefined(); + }); + + it('returns undefined when performance API is not available', () => { + const originalPerformance = WINDOW.performance; + // @ts-expect-error - intentionally setting to undefined + WINDOW.performance = undefined; + + const result = getServerTiming('sentry-trace'); + + expect(result).toBeUndefined(); + + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore its read only + WINDOW.performance = originalPerformance; + }); + + it('returns undefined when serverTiming is not available', () => { + const mockNavigationEntry = { + serverTiming: undefined, + }; + + vi.spyOn(WINDOW.performance, 'getEntriesByType').mockReturnValue([mockNavigationEntry as any]); + + const result = getServerTiming('sentry-trace'); + + expect(result).toBeUndefined(); + }); + }); + + describe('using Server-Timing headers', () => { + it('uses Server-Timing headers for pageload span', () => { + // Mock the performance API with Server-Timing data + const mockServerTiming = [ + { name: 'sentry-trace', duration: 0, description: '12312012123120121231201212312012-1121201211212012-0' }, + { name: 'baggage', duration: 0, description: 'sentry-release=2.1.14,foo=bar,sentry-sample_rand=0.123' }, + ]; + + const mockNavigationEntry = { + serverTiming: mockServerTiming, + }; + + vi.spyOn(WINDOW.performance, 'getEntriesByType').mockReturnValue([mockNavigationEntry as any]); + + const client = new BrowserClient( + getDefaultBrowserClientOptions({ + tracesSampleRate: 1, + integrations: [browserTracingIntegration()], + }), + ); + setCurrentClient(client); + + // pageload transactions are created as part of the browserTracingIntegration's initialization + client.init(); + + const idleSpan = getActiveSpan()!; + expect(idleSpan).toBeDefined(); + + const dynamicSamplingContext = getDynamicSamplingContextFromSpan(idleSpan); + const propagationContext = getCurrentScope().getPropagationContext(); + + // Span is correct + expect(spanToJSON(idleSpan).op).toBe('pageload'); + expect(spanToJSON(idleSpan).trace_id).toEqual('12312012123120121231201212312012'); + expect(spanToJSON(idleSpan).parent_span_id).toEqual('1121201211212012'); + expect(spanIsSampled(idleSpan)).toBe(false); + + expect(dynamicSamplingContext).toBeDefined(); + expect(dynamicSamplingContext).toStrictEqual({ release: '2.1.14', sample_rand: '0.123' }); + + // Propagation context keeps the Server-Timing trace data for later events on the same route to add them to the trace + expect(propagationContext.traceId).toEqual('12312012123120121231201212312012'); + expect(propagationContext.parentSpanId).toEqual('1121201211212012'); + expect(propagationContext.sampleRand).toBe(0.123); + }); + + it('meta tags take precedence over Server-Timing headers', () => { + // Set up both meta tags and Server-Timing headers + document.head.innerHTML = + '' + + ''; + + const mockServerTiming = [ + { name: 'sentry-trace', duration: 0, description: '12312012123120121231201212312012-1121201211212012-0' }, + { name: 'baggage', duration: 0, description: 'sentry-release=2.1.14,sentry-sample_rand=0.123' }, + ]; + + const mockNavigationEntry = { + serverTiming: mockServerTiming, + }; + + vi.spyOn(WINDOW.performance, 'getEntriesByType').mockReturnValue([mockNavigationEntry as any]); + + const client = new BrowserClient( + getDefaultBrowserClientOptions({ + tracesSampleRate: 1, + integrations: [browserTracingIntegration()], + }), + ); + setCurrentClient(client); + + client.init(); + + const idleSpan = getActiveSpan()!; + expect(idleSpan).toBeDefined(); + + const dynamicSamplingContext = getDynamicSamplingContextFromSpan(idleSpan); + const propagationContext = getCurrentScope().getPropagationContext(); + + // Span should use meta tag data, not Server-Timing data + expect(spanToJSON(idleSpan).trace_id).toEqual('11111111111111111111111111111111'); + expect(spanToJSON(idleSpan).parent_span_id).toEqual('2222222222222222'); + expect(spanIsSampled(idleSpan)).toBe(true); + + expect(dynamicSamplingContext).toStrictEqual({ release: '3.0.0', sample_rand: '0.999' }); + + expect(propagationContext.traceId).toEqual('11111111111111111111111111111111'); + expect(propagationContext.parentSpanId).toEqual('2222222222222222'); + expect(propagationContext.sampleRand).toBe(0.999); + }); + + it('uses passed in tracing data over Server-Timing headers', () => { + const mockServerTiming = [ + { name: 'sentry-trace', duration: 0, description: '12312012123120121231201212312012-1121201211212012-0' }, + { name: 'baggage', duration: 0, description: 'sentry-release=2.1.14,sentry-sample_rand=0.123' }, + ]; + + const mockNavigationEntry = { + serverTiming: mockServerTiming, + }; + + vi.spyOn(WINDOW.performance, 'getEntriesByType').mockReturnValue([mockNavigationEntry as any]); + + const client = new BrowserClient( + getDefaultBrowserClientOptions({ + tracesSampleRate: 1, + integrations: [browserTracingIntegration({ instrumentPageLoad: false })], + }), + ); + setCurrentClient(client); + + client.init(); + + // manually create a pageload span with tracing data + startBrowserTracingPageLoadSpan( + client, + { + name: 'test span', + }, + { + sentryTrace: '99999999999999999999999999999999-8888888888888888-1', + baggage: 'sentry-release=4.0.0,sentry-sample_rand=0.777', + }, + ); + + const idleSpan = getActiveSpan()!; + expect(idleSpan).toBeDefined(); + + const dynamicSamplingContext = getDynamicSamplingContextFromSpan(idleSpan); + const propagationContext = getCurrentScope().getPropagationContext(); + + // Span should use passed-in data, not Server-Timing data + expect(spanToJSON(idleSpan).trace_id).toEqual('99999999999999999999999999999999'); + expect(spanToJSON(idleSpan).parent_span_id).toEqual('8888888888888888'); + expect(spanIsSampled(idleSpan)).toBe(true); + + expect(dynamicSamplingContext).toStrictEqual({ release: '4.0.0', sample_rand: '0.777' }); + + expect(propagationContext.traceId).toEqual('99999999999999999999999999999999'); + expect(propagationContext.parentSpanId).toEqual('8888888888888888'); + expect(propagationContext.sampleRand).toBe(0.777); + }); + }); + describe('idleTimeout', () => { it('is created by default', () => { vi.useFakeTimers();