diff --git a/dev-packages/browser-integration-tests/suites/tracing/trace-lifetime/startNewTrace/subject.js b/dev-packages/browser-integration-tests/suites/tracing/trace-lifetime/startNewTrace/subject.js new file mode 100644 index 000000000000..5b28df9da5e8 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/trace-lifetime/startNewTrace/subject.js @@ -0,0 +1,15 @@ +const newTraceBtn = document.getElementById('newTrace'); +newTraceBtn.addEventListener('click', async () => { + Sentry.startNewTrace(() => { + Sentry.startSpan({ op: 'ui.interaction.click', name: 'new-trace' }, async () => { + await fetch('http://example.com'); + }); + }); +}); + +const oldTraceBtn = document.getElementById('oldTrace'); +oldTraceBtn.addEventListener('click', async () => { + Sentry.startSpan({ op: 'ui.interaction.click', name: 'old-trace' }, async () => { + await fetch('http://example.com'); + }); +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/trace-lifetime/startNewTrace/template.html b/dev-packages/browser-integration-tests/suites/tracing/trace-lifetime/startNewTrace/template.html new file mode 100644 index 000000000000..7d3c25bf7b84 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/trace-lifetime/startNewTrace/template.html @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/dev-packages/browser-integration-tests/suites/tracing/trace-lifetime/startNewTrace/test.ts b/dev-packages/browser-integration-tests/suites/tracing/trace-lifetime/startNewTrace/test.ts new file mode 100644 index 000000000000..3ddca4787aee --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/trace-lifetime/startNewTrace/test.ts @@ -0,0 +1,106 @@ +import { expect } from '@playwright/test'; +import { sentryTest } from '../../../../utils/fixtures'; +import type { EventAndTraceHeader } from '../../../../utils/helpers'; +import { + eventAndTraceHeaderRequestParser, + getFirstSentryEnvelopeRequest, + getMultipleSentryEnvelopeRequests, + shouldSkipTracingTest, +} from '../../../../utils/helpers'; + +sentryTest( + 'creates a new trace if `startNewTrace` is called and leaves old trace valid outside the callback', + async ({ getLocalTestUrl, page }) => { + if (shouldSkipTracingTest()) { + sentryTest.skip(); + } + + const url = await getLocalTestUrl({ testDir: __dirname }); + + await page.route('http://example.com/**', route => { + return route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({}), + }); + }); + + const [pageloadEvent, pageloadTraceHeaders] = await getFirstSentryEnvelopeRequest( + page, + url, + eventAndTraceHeaderRequestParser, + ); + + const pageloadTraceContext = pageloadEvent.contexts?.trace; + + expect(pageloadEvent.type).toEqual('transaction'); + + expect(pageloadTraceContext).toMatchObject({ + op: 'pageload', + trace_id: expect.stringMatching(/^[0-9a-f]{32}$/), + span_id: expect.stringMatching(/^[0-9a-f]{16}$/), + }); + expect(pageloadTraceContext).not.toHaveProperty('parent_span_id'); + + expect(pageloadTraceHeaders).toEqual({ + environment: 'production', + public_key: 'public', + sample_rate: '1', + sampled: 'true', + trace_id: pageloadTraceContext?.trace_id, + }); + + const transactionPromises = getMultipleSentryEnvelopeRequests( + page, + 2, + { envelopeType: 'transaction' }, + eventAndTraceHeaderRequestParser, + ); + + await page.locator('#newTrace').click(); + await page.locator('#oldTrace').click(); + + const [txnEvent1, txnEvent2] = await transactionPromises; + + const [newTraceTransactionEvent, newTraceTransactionTraceHeaders] = + txnEvent1[0].transaction === 'new-trace' ? txnEvent1 : txnEvent2; + const [oldTraceTransactionEvent, oldTraceTransactionTraceHeaders] = + txnEvent1[0].transaction === 'old-trace' ? txnEvent1 : txnEvent2; + + const newTraceTransactionTraceContext = newTraceTransactionEvent.contexts?.trace; + expect(newTraceTransactionTraceContext).toMatchObject({ + op: 'ui.interaction.click', + trace_id: expect.stringMatching(/^[0-9a-f]{32}$/), + span_id: expect.stringMatching(/^[0-9a-f]{16}$/), + }); + + expect(newTraceTransactionTraceHeaders).toEqual({ + environment: 'production', + public_key: 'public', + sample_rate: '1', + sampled: 'true', + trace_id: newTraceTransactionTraceContext?.trace_id, + transaction: 'new-trace', + }); + + const oldTraceTransactionEventTraceContext = oldTraceTransactionEvent.contexts?.trace; + expect(oldTraceTransactionEventTraceContext).toMatchObject({ + op: 'ui.interaction.click', + trace_id: expect.stringMatching(/^[0-9a-f]{32}$/), + span_id: expect.stringMatching(/^[0-9a-f]{16}$/), + }); + + expect(oldTraceTransactionTraceHeaders).toEqual({ + environment: 'production', + public_key: 'public', + sample_rate: '1', + sampled: 'true', + trace_id: oldTraceTransactionTraceHeaders?.trace_id, + // transaction: 'old-trace', <-- this is not in the DSC because the DSC is continued from the pageload transaction + // which does not have a `transaction` field because its source is URL. + }); + + expect(oldTraceTransactionEventTraceContext?.trace_id).toEqual(pageloadTraceContext?.trace_id); + expect(newTraceTransactionTraceContext?.trace_id).not.toEqual(pageloadTraceContext?.trace_id); + }, +); diff --git a/packages/astro/src/index.server.ts b/packages/astro/src/index.server.ts index a4f3ca59fb1f..6657b3030cb1 100644 --- a/packages/astro/src/index.server.ts +++ b/packages/astro/src/index.server.ts @@ -64,6 +64,7 @@ export { startSpan, startInactiveSpan, startSpanManual, + startNewTrace, withActiveSpan, getSpanDescendants, continueTrace, diff --git a/packages/aws-serverless/src/index.ts b/packages/aws-serverless/src/index.ts index 1d2323df06e5..62165a710127 100644 --- a/packages/aws-serverless/src/index.ts +++ b/packages/aws-serverless/src/index.ts @@ -62,6 +62,7 @@ export { startSpan, startInactiveSpan, startSpanManual, + startNewTrace, withActiveSpan, getRootSpan, getSpanDescendants, diff --git a/packages/browser/src/index.bundle.tracing.replay.feedback.ts b/packages/browser/src/index.bundle.tracing.replay.feedback.ts index de8453db8784..b9dc457640be 100644 --- a/packages/browser/src/index.bundle.tracing.replay.feedback.ts +++ b/packages/browser/src/index.bundle.tracing.replay.feedback.ts @@ -10,6 +10,7 @@ export { startSpan, startInactiveSpan, startSpanManual, + startNewTrace, withActiveSpan, getSpanDescendants, setMeasurement, diff --git a/packages/browser/src/index.bundle.tracing.replay.ts b/packages/browser/src/index.bundle.tracing.replay.ts index 3b8a51e661dc..a0e4d4736384 100644 --- a/packages/browser/src/index.bundle.tracing.replay.ts +++ b/packages/browser/src/index.bundle.tracing.replay.ts @@ -10,6 +10,7 @@ export { startSpan, startInactiveSpan, startSpanManual, + startNewTrace, withActiveSpan, getSpanDescendants, setMeasurement, diff --git a/packages/browser/src/index.bundle.tracing.ts b/packages/browser/src/index.bundle.tracing.ts index e93bf68994e3..d540ff0bd6f9 100644 --- a/packages/browser/src/index.bundle.tracing.ts +++ b/packages/browser/src/index.bundle.tracing.ts @@ -11,6 +11,7 @@ export { startSpan, startInactiveSpan, startSpanManual, + startNewTrace, withActiveSpan, getSpanDescendants, setMeasurement, diff --git a/packages/browser/src/index.ts b/packages/browser/src/index.ts index 86e6ea20fe81..245eaa966859 100644 --- a/packages/browser/src/index.ts +++ b/packages/browser/src/index.ts @@ -59,6 +59,7 @@ export { startInactiveSpan, startSpanManual, withActiveSpan, + startNewTrace, getSpanDescendants, setMeasurement, getSpanStatusFromHttpCode, diff --git a/packages/browser/src/tracing/browserTracingIntegration.ts b/packages/browser/src/tracing/browserTracingIntegration.ts index b3d530ee3653..f6528e4d155d 100644 --- a/packages/browser/src/tracing/browserTracingIntegration.ts +++ b/packages/browser/src/tracing/browserTracingIntegration.ts @@ -27,10 +27,10 @@ import type { Client, IntegrationFn, StartSpanOptions, TransactionSource } from import type { Span } from '@sentry/types'; import { browserPerformanceTimeOrigin, + generatePropagationContext, getDomElement, logger, propagationContextFromHeaders, - uuid4, } from '@sentry/utils'; import { DEBUG_BUILD } from '../debug-build'; @@ -412,8 +412,8 @@ export function startBrowserTracingPageLoadSpan( * This will only do something if a browser tracing integration has been setup. */ export function startBrowserTracingNavigationSpan(client: Client, spanOptions: StartSpanOptions): Span | undefined { - getCurrentScope().setPropagationContext(generatePropagationContext()); getIsolationScope().setPropagationContext(generatePropagationContext()); + getCurrentScope().setPropagationContext(generatePropagationContext()); client.emit('startNavigationSpan', spanOptions); @@ -487,10 +487,3 @@ function registerInteractionListener( addEventListener('click', registerInteractionTransaction, { once: false, capture: true }); } } - -function generatePropagationContext(): { traceId: string; spanId: string } { - return { - traceId: uuid4(), - spanId: uuid4().substring(16), - }; -} diff --git a/packages/bun/src/index.ts b/packages/bun/src/index.ts index fd7671b34b09..c3e8eff8beac 100644 --- a/packages/bun/src/index.ts +++ b/packages/bun/src/index.ts @@ -82,6 +82,7 @@ export { startSpan, startInactiveSpan, startSpanManual, + startNewTrace, withActiveSpan, getRootSpan, getSpanDescendants, diff --git a/packages/core/src/scope.ts b/packages/core/src/scope.ts index 724c9b621ce9..ff89c0d593a9 100644 --- a/packages/core/src/scope.ts +++ b/packages/core/src/scope.ts @@ -21,7 +21,7 @@ import type { SeverityLevel, User, } from '@sentry/types'; -import { dateTimestampInSeconds, isPlainObject, logger, uuid4 } from '@sentry/utils'; +import { dateTimestampInSeconds, generatePropagationContext, isPlainObject, logger, uuid4 } from '@sentry/utils'; import { updateSession } from './session'; import { _getSpanForScope, _setSpanForScope } from './utils/spanOnScope'; @@ -600,10 +600,3 @@ export const Scope = ScopeClass; * Holds additional event information. */ export type Scope = ScopeInterface; - -function generatePropagationContext(): PropagationContext { - return { - traceId: uuid4(), - spanId: uuid4().substring(16), - }; -} diff --git a/packages/core/src/tracing/index.ts b/packages/core/src/tracing/index.ts index 90a5ac737aa1..0c08101acb68 100644 --- a/packages/core/src/tracing/index.ts +++ b/packages/core/src/tracing/index.ts @@ -17,6 +17,7 @@ export { continueTrace, withActiveSpan, suppressTracing, + startNewTrace, } from './trace'; export { getDynamicSamplingContextFromClient, diff --git a/packages/core/src/tracing/trace.ts b/packages/core/src/tracing/trace.ts index 4d910f54e996..e34c2c1a62d3 100644 --- a/packages/core/src/tracing/trace.ts +++ b/packages/core/src/tracing/trace.ts @@ -1,11 +1,12 @@ import type { ClientOptions, Scope, SentrySpanArguments, Span, SpanTimeInput, StartSpanOptions } from '@sentry/types'; -import { propagationContextFromHeaders } from '@sentry/utils'; +import { generatePropagationContext, logger, propagationContextFromHeaders } from '@sentry/utils'; import type { AsyncContextStrategy } from '../asyncContext/types'; import { getMainCarrier } from '../carrier'; import { getClient, getCurrentScope, getIsolationScope, withScope } from '../currentScopes'; import { getAsyncContextStrategy } from '../asyncContext'; +import { DEBUG_BUILD } from '../debug-build'; import { SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE, SEMANTIC_ATTRIBUTE_SENTRY_SOURCE } from '../semanticAttributes'; import { handleCallbackErrors } from '../utils/handleCallbackErrors'; import { hasTracingEnabled } from '../utils/hasTracingEnabled'; @@ -212,6 +213,30 @@ export function suppressTracing(callback: () => T): T { }); } +/** + * Starts a new trace for the duration of the provided callback. Spans started within the + * callback will be part of the new trace instead of a potentially previously started trace. + * + * Important: Only use this function if you want to override the default trace lifetime and + * propagation mechanism of the SDK for the duration and scope of the provided callback. + * The newly created trace will also be the root of a new distributed trace, for example if + * you make http requests within the callback. + * This function might be useful if the operation you want to instrument should not be part + * of a potentially ongoing trace. + * + * Default behavior: + * - Server-side: A new trace is started for each incoming request. + * - Browser: A new trace is started for each page our route. Navigating to a new route + * or page will automatically create a new trace. + */ +export function startNewTrace(callback: () => T): T { + return withScope(scope => { + scope.setPropagationContext(generatePropagationContext()); + DEBUG_BUILD && logger.info(`Starting a new trace with id ${scope.getPropagationContext().traceId}`); + return withActiveSpan(null, callback); + }); +} + function createChildOrRootSpan({ parentSpan, spanContext, diff --git a/packages/core/test/lib/tracing/trace.test.ts b/packages/core/test/lib/tracing/trace.test.ts index f2aa8460dba4..9399aa6b5a57 100644 --- a/packages/core/test/lib/tracing/trace.test.ts +++ b/packages/core/test/lib/tracing/trace.test.ts @@ -24,6 +24,7 @@ import { withActiveSpan, } from '../../../src/tracing'; import { SentryNonRecordingSpan } from '../../../src/tracing/sentryNonRecordingSpan'; +import { startNewTrace } from '../../../src/tracing/trace'; import { _setSpanForScope } from '../../../src/utils/spanOnScope'; import { getActiveSpan, getRootSpan, getSpanDescendants, spanIsSampled } from '../../../src/utils/spanUtils'; import { TestClient, getDefaultTestClientOptions } from '../../mocks/client'; @@ -1590,3 +1591,32 @@ describe('suppressTracing', () => { }); }); }); + +describe('startNewTrace', () => { + beforeEach(() => { + getCurrentScope().clear(); + getIsolationScope().clear(); + }); + + it('creates a new propagation context on the current scope', () => { + const oldCurrentScopeItraceId = getCurrentScope().getPropagationContext().traceId; + + startNewTrace(() => { + const newCurrentScopeItraceId = getCurrentScope().getPropagationContext().traceId; + + expect(newCurrentScopeItraceId).toMatch(/^[a-f0-9]{32}$/); + expect(newCurrentScopeItraceId).not.toEqual(oldCurrentScopeItraceId); + }); + }); + + it('keeps the propagation context on the isolation scope as-is', () => { + const oldIsolationScopeTraceId = getIsolationScope().getPropagationContext().traceId; + + startNewTrace(() => { + const newIsolationScopeTraceId = getIsolationScope().getPropagationContext().traceId; + + expect(newIsolationScopeTraceId).toMatch(/^[a-f0-9]{32}$/); + expect(newIsolationScopeTraceId).toEqual(oldIsolationScopeTraceId); + }); + }); +}); diff --git a/packages/deno/src/index.ts b/packages/deno/src/index.ts index 1857de352798..aa30c762d624 100644 --- a/packages/deno/src/index.ts +++ b/packages/deno/src/index.ts @@ -58,6 +58,7 @@ export { startSpan, startInactiveSpan, startSpanManual, + startNewTrace, metricsDefault as metrics, inboundFiltersIntegration, linkedErrorsIntegration, diff --git a/packages/google-cloud-serverless/src/index.ts b/packages/google-cloud-serverless/src/index.ts index 067c27818a10..6affee429e1f 100644 --- a/packages/google-cloud-serverless/src/index.ts +++ b/packages/google-cloud-serverless/src/index.ts @@ -62,6 +62,7 @@ export { startSpan, startInactiveSpan, startSpanManual, + startNewTrace, withActiveSpan, getRootSpan, getSpanDescendants, diff --git a/packages/node/src/index.ts b/packages/node/src/index.ts index 5cc16772189b..47d3d5d7735f 100644 --- a/packages/node/src/index.ts +++ b/packages/node/src/index.ts @@ -111,6 +111,7 @@ export { startSpan, startSpanManual, startInactiveSpan, + startNewTrace, getActiveSpan, withActiveSpan, getRootSpan, diff --git a/packages/remix/src/index.server.ts b/packages/remix/src/index.server.ts index ab35b4bf4847..a6476b692fbf 100644 --- a/packages/remix/src/index.server.ts +++ b/packages/remix/src/index.server.ts @@ -67,6 +67,7 @@ export { startSpan, startSpanManual, startInactiveSpan, + startNewTrace, withActiveSpan, getSpanDescendants, continueTrace, diff --git a/packages/sveltekit/src/server/index.ts b/packages/sveltekit/src/server/index.ts index d7d50f64481e..c8b97029e456 100644 --- a/packages/sveltekit/src/server/index.ts +++ b/packages/sveltekit/src/server/index.ts @@ -60,6 +60,7 @@ export { startSpan, startInactiveSpan, startSpanManual, + startNewTrace, withActiveSpan, continueTrace, cron, diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts index a0649cef48ad..2fb6f420ab58 100644 --- a/packages/utils/src/index.ts +++ b/packages/utils/src/index.ts @@ -35,3 +35,4 @@ export * from './eventbuilder'; export * from './anr'; export * from './lru'; export * from './buildPolyfills'; +export * from './propagationContext'; diff --git a/packages/utils/src/propagationContext.ts b/packages/utils/src/propagationContext.ts new file mode 100644 index 000000000000..745531c8aa98 --- /dev/null +++ b/packages/utils/src/propagationContext.ts @@ -0,0 +1,12 @@ +import type { PropagationContext } from '@sentry/types'; +import { uuid4 } from './misc'; + +/** + * Returns a new minimal propagation context + */ +export function generatePropagationContext(): PropagationContext { + return { + traceId: uuid4(), + spanId: uuid4().substring(16), + }; +} diff --git a/packages/utils/test/proagationContext.test.ts b/packages/utils/test/proagationContext.test.ts new file mode 100644 index 000000000000..01c8569bde9b --- /dev/null +++ b/packages/utils/test/proagationContext.test.ts @@ -0,0 +1,10 @@ +import { generatePropagationContext } from '../src/propagationContext'; + +describe('generatePropagationContext', () => { + it('generates a new minimal propagation context', () => { + expect(generatePropagationContext()).toEqual({ + traceId: expect.stringMatching(/^[0-9a-f]{32}$/), + spanId: expect.stringMatching(/^[0-9a-f]{16}$/), + }); + }); +}); diff --git a/packages/vercel-edge/src/index.ts b/packages/vercel-edge/src/index.ts index ce4ef113908b..79c6d77c9d21 100644 --- a/packages/vercel-edge/src/index.ts +++ b/packages/vercel-edge/src/index.ts @@ -58,6 +58,7 @@ export { startSpan, startInactiveSpan, startSpanManual, + startNewTrace, withActiveSpan, getSpanDescendants, continueTrace,