From 410ca15c3f88bac6e8bf38b7b2905e0348bac1c1 Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Fri, 19 Sep 2025 16:39:36 +0200 Subject: [PATCH 1/6] feat(browser): Add `setSpanActive` to create an active root span in the browser --- .../tracing/setSpanActive/default/init.js | 8 +++ .../tracing/setSpanActive/default/subject.js | 14 +++++ .../tracing/setSpanActive/default/test.ts | 35 ++++++++++++ .../nested-parentAlwaysRoot/init.js | 9 +++ .../nested-parentAlwaysRoot/subject.js | 22 +++++++ .../nested-parentAlwaysRoot/test.ts | 57 +++++++++++++++++++ .../tracing/setSpanActive/nested/init.js | 8 +++ .../tracing/setSpanActive/nested/subject.js | 22 +++++++ .../tracing/setSpanActive/nested/test.ts | 49 ++++++++++++++++ .../index.bundle.tracing.replay.feedback.ts | 1 + .../src/index.bundle.tracing.replay.ts | 2 +- packages/browser/src/index.bundle.tracing.ts | 1 + packages/browser/src/index.ts | 2 + packages/browser/src/tracing/setSpanActive.ts | 56 ++++++++++++++++++ packages/core/src/index.ts | 1 + 15 files changed, 286 insertions(+), 1 deletion(-) create mode 100644 dev-packages/browser-integration-tests/suites/tracing/setSpanActive/default/init.js create mode 100644 dev-packages/browser-integration-tests/suites/tracing/setSpanActive/default/subject.js create mode 100644 dev-packages/browser-integration-tests/suites/tracing/setSpanActive/default/test.ts create mode 100644 dev-packages/browser-integration-tests/suites/tracing/setSpanActive/nested-parentAlwaysRoot/init.js create mode 100644 dev-packages/browser-integration-tests/suites/tracing/setSpanActive/nested-parentAlwaysRoot/subject.js create mode 100644 dev-packages/browser-integration-tests/suites/tracing/setSpanActive/nested-parentAlwaysRoot/test.ts create mode 100644 dev-packages/browser-integration-tests/suites/tracing/setSpanActive/nested/init.js create mode 100644 dev-packages/browser-integration-tests/suites/tracing/setSpanActive/nested/subject.js create mode 100644 dev-packages/browser-integration-tests/suites/tracing/setSpanActive/nested/test.ts create mode 100644 packages/browser/src/tracing/setSpanActive.ts diff --git a/dev-packages/browser-integration-tests/suites/tracing/setSpanActive/default/init.js b/dev-packages/browser-integration-tests/suites/tracing/setSpanActive/default/init.js new file mode 100644 index 000000000000..7c200c542c56 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/setSpanActive/default/init.js @@ -0,0 +1,8 @@ +import * as Sentry from '@sentry/browser'; + +window.Sentry = Sentry; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + tracesSampleRate: 1, +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/setSpanActive/default/subject.js b/dev-packages/browser-integration-tests/suites/tracing/setSpanActive/default/subject.js new file mode 100644 index 000000000000..66e0a9f7cfd3 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/setSpanActive/default/subject.js @@ -0,0 +1,14 @@ +const checkoutSpan = Sentry.startInactiveSpan({ name: 'checkout-flow' }); +Sentry.setSpanActive(checkoutSpan); + +Sentry.startSpan({ name: 'checkout-step-1' }, () => { + Sentry.startSpan({ name: 'checkout-step-1-1' }, () => { + // ... ` + }); +}); + +Sentry.startSpan({ name: 'checkout-step-2' }, () => { + // ... ` +}); + +checkoutSpan.end(); diff --git a/dev-packages/browser-integration-tests/suites/tracing/setSpanActive/default/test.ts b/dev-packages/browser-integration-tests/suites/tracing/setSpanActive/default/test.ts new file mode 100644 index 000000000000..080ebbfa6e84 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/setSpanActive/default/test.ts @@ -0,0 +1,35 @@ +import { expect } from '@playwright/test'; +import { sentryTest } from '../../../../utils/fixtures'; +import { envelopeRequestParser, shouldSkipTracingTest, waitForTransactionRequest } from '../../../../utils/helpers'; + +sentryTest('sets an inactive span active and adds child spans to it', async ({ getLocalTestUrl, page }) => { + if (shouldSkipTracingTest()) { + sentryTest.skip(); + } + + const req = waitForTransactionRequest(page, e => e.transaction === 'checkout-flow'); + + const url = await getLocalTestUrl({ testDir: __dirname }); + await page.goto(url); + + const checkoutEvent = envelopeRequestParser(await req); + const checkoutSpanId = checkoutEvent.contexts?.trace?.span_id; + expect(checkoutSpanId).toMatch(/[a-f0-9]{16}/); + + expect(checkoutEvent.spans).toHaveLength(3); + + const checkoutStep1 = checkoutEvent.spans?.find(s => s.description === 'checkout-step-1'); + const checkoutStep11 = checkoutEvent.spans?.find(s => s.description === 'checkout-step-1-1'); + const checkoutStep2 = checkoutEvent.spans?.find(s => s.description === 'checkout-step-2'); + + expect(checkoutStep1).toBeDefined(); + expect(checkoutStep11).toBeDefined(); + expect(checkoutStep2).toBeDefined(); + + expect(checkoutStep1?.parent_span_id).toBe(checkoutSpanId); + expect(checkoutStep2?.parent_span_id).toBe(checkoutSpanId); + + // despite 1-1 being called within 1, it's still parented to the root span + // due to this being default behaviour in browser environments + expect(checkoutStep11?.parent_span_id).toBe(checkoutSpanId); +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/setSpanActive/nested-parentAlwaysRoot/init.js b/dev-packages/browser-integration-tests/suites/tracing/setSpanActive/nested-parentAlwaysRoot/init.js new file mode 100644 index 000000000000..375a16cbf005 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/setSpanActive/nested-parentAlwaysRoot/init.js @@ -0,0 +1,9 @@ +import * as Sentry from '@sentry/browser'; + +window.Sentry = Sentry; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + tracesSampleRate: 1, + parentSpanIsAlwaysRootSpan: false, +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/setSpanActive/nested-parentAlwaysRoot/subject.js b/dev-packages/browser-integration-tests/suites/tracing/setSpanActive/nested-parentAlwaysRoot/subject.js new file mode 100644 index 000000000000..f4dfea25cf07 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/setSpanActive/nested-parentAlwaysRoot/subject.js @@ -0,0 +1,22 @@ +const checkoutSpan = Sentry.startInactiveSpan({ name: 'checkout-flow' }); +Sentry.setSpanActive(checkoutSpan); + +Sentry.startSpan({ name: 'checkout-step-1' }, () => {}); + +const checkoutStep2 = Sentry.startInactiveSpan({ name: 'checkout-step-2' }); +Sentry.setSpanActive(checkoutStep2); + +Sentry.startSpan({ name: 'checkout-step-2-1' }, () => { + // ... ` +}); +checkoutStep2.end(); + +Sentry.startSpan({ name: 'checkout-step-3' }, () => {}); + +checkoutSpan.end(); + +Sentry.startSpan({ name: 'post-checkout' }, () => { + Sentry.startSpan({ name: 'post-checkout-1' }, () => { + // ... ` + }); +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/setSpanActive/nested-parentAlwaysRoot/test.ts b/dev-packages/browser-integration-tests/suites/tracing/setSpanActive/nested-parentAlwaysRoot/test.ts new file mode 100644 index 000000000000..f49a374c8a27 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/setSpanActive/nested-parentAlwaysRoot/test.ts @@ -0,0 +1,57 @@ +import { expect } from '@playwright/test'; +import { sentryTest } from '../../../../utils/fixtures'; +import { envelopeRequestParser, shouldSkipTracingTest, waitForTransactionRequest } from '../../../../utils/helpers'; + +sentryTest( + 'nested calls to setSpanActive with parentSpanIsAlwaysRootSpan=false result in correct parenting', + async ({ getLocalTestUrl, page }) => { + if (shouldSkipTracingTest()) { + sentryTest.skip(); + } + + const req = waitForTransactionRequest(page, e => e.transaction === 'checkout-flow'); + const postCheckoutReq = waitForTransactionRequest(page, e => e.transaction === 'post-checkout'); + + const url = await getLocalTestUrl({ testDir: __dirname }); + await page.goto(url); + + const checkoutEvent = envelopeRequestParser(await req); + const postCheckoutEvent = envelopeRequestParser(await postCheckoutReq); + + const checkoutSpanId = checkoutEvent.contexts?.trace?.span_id; + const postCheckoutSpanId = postCheckoutEvent.contexts?.trace?.span_id; + + expect(checkoutSpanId).toMatch(/[a-f0-9]{16}/); + expect(postCheckoutSpanId).toMatch(/[a-f0-9]{16}/); + + expect(checkoutEvent.spans).toHaveLength(4); + expect(postCheckoutEvent.spans).toHaveLength(1); + + const checkoutStep1 = checkoutEvent.spans?.find(s => s.description === 'checkout-step-1'); + const checkoutStep2 = checkoutEvent.spans?.find(s => s.description === 'checkout-step-2'); + const checkoutStep21 = checkoutEvent.spans?.find(s => s.description === 'checkout-step-2-1'); + const checkoutStep3 = checkoutEvent.spans?.find(s => s.description === 'checkout-step-3'); + + expect(checkoutStep1).toBeDefined(); + expect(checkoutStep2).toBeDefined(); + expect(checkoutStep21).toBeDefined(); + expect(checkoutStep3).toBeDefined(); + + expect(checkoutStep1?.parent_span_id).toBe(checkoutSpanId); + expect(checkoutStep2?.parent_span_id).toBe(checkoutSpanId); + + // with parentSpanIsAlwaysRootSpan=false, 2-1 is parented to 2 because + // 2 was the active span when 2-1 was started + expect(checkoutStep21?.parent_span_id).toBe(checkoutStep2?.span_id); + + // since the parent of three is `checkoutSpan`, we correctly reset + // the active span to `checkoutSpan` after 2 ended + expect(checkoutStep3?.parent_span_id).toBe(checkoutSpanId); + + // post-checkout trace is started as a new trace because ending checkoutSpan removes the active + // span on the scope + const postCheckoutStep1 = postCheckoutEvent.spans?.find(s => s.description === 'post-checkout-1'); + expect(postCheckoutStep1).toBeDefined(); + expect(postCheckoutStep1?.parent_span_id).toBe(postCheckoutSpanId); + }, +); diff --git a/dev-packages/browser-integration-tests/suites/tracing/setSpanActive/nested/init.js b/dev-packages/browser-integration-tests/suites/tracing/setSpanActive/nested/init.js new file mode 100644 index 000000000000..7c200c542c56 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/setSpanActive/nested/init.js @@ -0,0 +1,8 @@ +import * as Sentry from '@sentry/browser'; + +window.Sentry = Sentry; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + tracesSampleRate: 1, +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/setSpanActive/nested/subject.js b/dev-packages/browser-integration-tests/suites/tracing/setSpanActive/nested/subject.js new file mode 100644 index 000000000000..f4dfea25cf07 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/setSpanActive/nested/subject.js @@ -0,0 +1,22 @@ +const checkoutSpan = Sentry.startInactiveSpan({ name: 'checkout-flow' }); +Sentry.setSpanActive(checkoutSpan); + +Sentry.startSpan({ name: 'checkout-step-1' }, () => {}); + +const checkoutStep2 = Sentry.startInactiveSpan({ name: 'checkout-step-2' }); +Sentry.setSpanActive(checkoutStep2); + +Sentry.startSpan({ name: 'checkout-step-2-1' }, () => { + // ... ` +}); +checkoutStep2.end(); + +Sentry.startSpan({ name: 'checkout-step-3' }, () => {}); + +checkoutSpan.end(); + +Sentry.startSpan({ name: 'post-checkout' }, () => { + Sentry.startSpan({ name: 'post-checkout-1' }, () => { + // ... ` + }); +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/setSpanActive/nested/test.ts b/dev-packages/browser-integration-tests/suites/tracing/setSpanActive/nested/test.ts new file mode 100644 index 000000000000..f2d322764c56 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/setSpanActive/nested/test.ts @@ -0,0 +1,49 @@ +import { expect } from '@playwright/test'; +import { sentryTest } from '../../../../utils/fixtures'; +import { envelopeRequestParser, shouldSkipTracingTest, waitForTransactionRequest } from '../../../../utils/helpers'; + +sentryTest('nested calls to setSpanActive still parent to root span by default', async ({ getLocalTestUrl, page }) => { + if (shouldSkipTracingTest()) { + sentryTest.skip(); + } + + const req = waitForTransactionRequest(page, e => e.transaction === 'checkout-flow'); + const postCheckoutReq = waitForTransactionRequest(page, e => e.transaction === 'post-checkout'); + + const url = await getLocalTestUrl({ testDir: __dirname }); + await page.goto(url); + + const checkoutEvent = envelopeRequestParser(await req); + const postCheckoutEvent = envelopeRequestParser(await postCheckoutReq); + + const checkoutSpanId = checkoutEvent.contexts?.trace?.span_id; + const postCheckoutSpanId = postCheckoutEvent.contexts?.trace?.span_id; + + expect(checkoutSpanId).toMatch(/[a-f0-9]{16}/); + expect(postCheckoutSpanId).toMatch(/[a-f0-9]{16}/); + + expect(checkoutEvent.spans).toHaveLength(4); + expect(postCheckoutEvent.spans).toHaveLength(1); + + const checkoutStep1 = checkoutEvent.spans?.find(s => s.description === 'checkout-step-1'); + const checkoutStep2 = checkoutEvent.spans?.find(s => s.description === 'checkout-step-2'); + const checkoutStep21 = checkoutEvent.spans?.find(s => s.description === 'checkout-step-2-1'); + const checkoutStep3 = checkoutEvent.spans?.find(s => s.description === 'checkout-step-3'); + + expect(checkoutStep1).toBeDefined(); + expect(checkoutStep2).toBeDefined(); + expect(checkoutStep21).toBeDefined(); + expect(checkoutStep3).toBeDefined(); + + expect(checkoutStep1?.parent_span_id).toBe(checkoutSpanId); + expect(checkoutStep2?.parent_span_id).toBe(checkoutSpanId); + expect(checkoutStep3?.parent_span_id).toBe(checkoutSpanId); + + // despite 2-1 being called within 2 AND setting 2 as active span, it's still parented to the + // root span due to this being default behaviour in browser environments + expect(checkoutStep21?.parent_span_id).toBe(checkoutSpanId); + + const postCheckoutStep1 = postCheckoutEvent.spans?.find(s => s.description === 'post-checkout-1'); + expect(postCheckoutStep1).toBeDefined(); + expect(postCheckoutStep1?.parent_span_id).toBe(postCheckoutSpanId); +}); diff --git a/packages/browser/src/index.bundle.tracing.replay.feedback.ts b/packages/browser/src/index.bundle.tracing.replay.feedback.ts index fc805c82a4e5..e11bd4efec2a 100644 --- a/packages/browser/src/index.bundle.tracing.replay.feedback.ts +++ b/packages/browser/src/index.bundle.tracing.replay.feedback.ts @@ -22,6 +22,7 @@ export { startBrowserTracingNavigationSpan, startBrowserTracingPageLoadSpan, } from './tracing/browserTracingIntegration'; +export { setSpanActive } from './tracing/setSpanActive'; export { reportPageLoaded } from './tracing/reportPageLoaded'; diff --git a/packages/browser/src/index.bundle.tracing.replay.ts b/packages/browser/src/index.bundle.tracing.replay.ts index f77d2774c36e..13b94df9799a 100644 --- a/packages/browser/src/index.bundle.tracing.replay.ts +++ b/packages/browser/src/index.bundle.tracing.replay.ts @@ -22,8 +22,8 @@ export { startBrowserTracingNavigationSpan, startBrowserTracingPageLoadSpan, } from './tracing/browserTracingIntegration'; - export { reportPageLoaded } from './tracing/reportPageLoaded'; +export { setSpanActive } from './tracing/setSpanActive'; export { feedbackIntegrationShim as feedbackAsyncIntegration, feedbackIntegrationShim as feedbackIntegration }; diff --git a/packages/browser/src/index.bundle.tracing.ts b/packages/browser/src/index.bundle.tracing.ts index c32e806f1de8..92a1c37399af 100644 --- a/packages/browser/src/index.bundle.tracing.ts +++ b/packages/browser/src/index.bundle.tracing.ts @@ -22,6 +22,7 @@ export { startBrowserTracingNavigationSpan, startBrowserTracingPageLoadSpan, } from './tracing/browserTracingIntegration'; +export { setSpanActive } from './tracing/setSpanActive'; export { reportPageLoaded } from './tracing/reportPageLoaded'; diff --git a/packages/browser/src/index.ts b/packages/browser/src/index.ts index f2a3e7dc179c..e7183da340bc 100644 --- a/packages/browser/src/index.ts +++ b/packages/browser/src/index.ts @@ -40,6 +40,8 @@ export { startBrowserTracingPageLoadSpan, } from './tracing/browserTracingIntegration'; export { reportPageLoaded } from './tracing/reportPageLoaded'; +export { setSpanActive } from './tracing/setSpanActive'; + export type { RequestInstrumentationOptions } from './tracing/request'; export { registerSpanErrorInstrumentation, diff --git a/packages/browser/src/tracing/setSpanActive.ts b/packages/browser/src/tracing/setSpanActive.ts new file mode 100644 index 000000000000..697f8a7eb75b --- /dev/null +++ b/packages/browser/src/tracing/setSpanActive.ts @@ -0,0 +1,56 @@ +import type { Span } from '@sentry/core'; +import { _INTERNAL_setSpanForScope, getActiveSpan, getCurrentScope } from '@sentry/core'; + +/** + * Sets an inactive span active on the current scope. + * + * This is useful in browser applications, if you want to create a span that cannot be finished + * within its callback. Any spans started while the given span is active, will be children of the span. + * + * If there already was an active span on the scope prior to calling this function, it is replaced + * with the given span and restored after the span ended. Otherwise, the span will simply be + * removed, resulting in no active span on the scope. + * + * IMPORTANT: This function can ONLY be used in the browser! Calling this function in a server + * environment (for example in a server-side rendered component) will result in undefined behaviour + * and is not supported. + * You MUST call `span.end()` manually, otherwise the span will never be finished. + * + * @example + * ```js + * let checkoutSpan; + * + * on('checkoutStarted', () => { + * checkoutSpan = Sentry.startInactiveSpan({ name: 'checkout-flow' }); + * Sentry.setSpanActive(checkoutSpan); + * }) + * + * // during this time, any spans started will be children of `checkoutSpan`: + * Sentry.startSpan({ name: 'checkout-step-1' }, () => { + * // ... ` + * }) + * + * on('checkoutCompleted', () => { + * checkoutSpan?.end(); + * }) + * ``` + * + * @param span - the span to set active + */ +export function setSpanActive(span: Span): void { + const maybePreviousActiveSpan = getActiveSpan(); + + const scope = getCurrentScope(); + + // Putting a small patch onto the span.end method to ensure we + // remove the span from the scope when it ends. + // eslint-disable-next-line @typescript-eslint/unbound-method + span.end = new Proxy(span.end, { + apply(target, thisArg, args: Parameters) { + _INTERNAL_setSpanForScope(scope, maybePreviousActiveSpan); + return Reflect.apply(target, thisArg, args); + }, + }); + + _INTERNAL_setSpanForScope(scope, span); +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 8a5566948f6e..e0daefd54d76 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -81,6 +81,7 @@ export { spanTimeInputToSeconds, updateSpanName, } from './utils/spanUtils'; +export { _setSpanForScope as _INTERNAL_setSpanForScope } from './utils/spanOnScope'; export { parseSampleRate } from './utils/parseSampleRate'; export { applySdkMetadata } from './utils/sdkMetadata'; export { getTraceData } from './utils/traceData'; From ee0fa54ee46b7bd047647829ab9123f9ec109211 Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Fri, 19 Sep 2025 17:20:37 +0200 Subject: [PATCH 2/6] add unit tests --- packages/browser/src/tracing/setSpanActive.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/packages/browser/src/tracing/setSpanActive.ts b/packages/browser/src/tracing/setSpanActive.ts index 697f8a7eb75b..d76f25c1c969 100644 --- a/packages/browser/src/tracing/setSpanActive.ts +++ b/packages/browser/src/tracing/setSpanActive.ts @@ -1,5 +1,5 @@ import type { Span } from '@sentry/core'; -import { _INTERNAL_setSpanForScope, getActiveSpan, getCurrentScope } from '@sentry/core'; +import { _INTERNAL_setSpanForScope, addNonEnumerableProperty, getActiveSpan, getCurrentScope } from '@sentry/core'; /** * Sets an inactive span active on the current scope. @@ -40,6 +40,14 @@ import { _INTERNAL_setSpanForScope, getActiveSpan, getCurrentScope } from '@sent export function setSpanActive(span: Span): void { const maybePreviousActiveSpan = getActiveSpan(); + // If the span is already active, there's no need to double-patch or set it again. + // This also guards against users (for whatever reason) calling setSpanActive on SDK-started + // idle spans like pageload or navigation spans. These will already be handled correctly by the SDK. + // For nested situations, we have to double-patch to ensure we restore the correct previous span (see tests) + if (maybePreviousActiveSpan === span) { + return; + } + const scope = getCurrentScope(); // Putting a small patch onto the span.end method to ensure we From f941afc1e68e2d75cc0a08940bd266d5986dc811 Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Fri, 19 Sep 2025 17:21:51 +0200 Subject: [PATCH 3/6] remove unused import --- packages/browser/src/tracing/setSpanActive.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/browser/src/tracing/setSpanActive.ts b/packages/browser/src/tracing/setSpanActive.ts index d76f25c1c969..033748192501 100644 --- a/packages/browser/src/tracing/setSpanActive.ts +++ b/packages/browser/src/tracing/setSpanActive.ts @@ -1,5 +1,5 @@ import type { Span } from '@sentry/core'; -import { _INTERNAL_setSpanForScope, addNonEnumerableProperty, getActiveSpan, getCurrentScope } from '@sentry/core'; +import { _INTERNAL_setSpanForScope, getActiveSpan, getCurrentScope } from '@sentry/core'; /** * Sets an inactive span active on the current scope. From c4883fae5c82df43228daf4dd2ef4bef4e4fd61d Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Mon, 22 Sep 2025 17:32:22 +0200 Subject: [PATCH 4/6] rename to `setActiveSpanInBrowser` --- .../nested-parentAlwaysRoot/test.ts | 2 +- .../tracing/setSpanActive/nested/test.ts | 93 ++++++++++--------- .../index.bundle.tracing.replay.feedback.ts | 2 +- .../src/index.bundle.tracing.replay.ts | 2 +- packages/browser/src/index.bundle.tracing.ts | 2 +- packages/browser/src/index.ts | 2 +- .../{setSpanActive.ts => setActiveSpan.ts} | 6 +- .../test/tracing/setActiveSpan.test.ts | 90 ++++++++++++++++++ 8 files changed, 146 insertions(+), 53 deletions(-) rename packages/browser/src/tracing/{setSpanActive.ts => setActiveSpan.ts} (94%) create mode 100644 packages/browser/test/tracing/setActiveSpan.test.ts diff --git a/dev-packages/browser-integration-tests/suites/tracing/setSpanActive/nested-parentAlwaysRoot/test.ts b/dev-packages/browser-integration-tests/suites/tracing/setSpanActive/nested-parentAlwaysRoot/test.ts index f49a374c8a27..2270b470123b 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/setSpanActive/nested-parentAlwaysRoot/test.ts +++ b/dev-packages/browser-integration-tests/suites/tracing/setSpanActive/nested-parentAlwaysRoot/test.ts @@ -3,7 +3,7 @@ import { sentryTest } from '../../../../utils/fixtures'; import { envelopeRequestParser, shouldSkipTracingTest, waitForTransactionRequest } from '../../../../utils/helpers'; sentryTest( - 'nested calls to setSpanActive with parentSpanIsAlwaysRootSpan=false result in correct parenting', + 'nested calls to setActiveSpanInBrowser with parentSpanIsAlwaysRootSpan=false result in correct parenting', async ({ getLocalTestUrl, page }) => { if (shouldSkipTracingTest()) { sentryTest.skip(); diff --git a/dev-packages/browser-integration-tests/suites/tracing/setSpanActive/nested/test.ts b/dev-packages/browser-integration-tests/suites/tracing/setSpanActive/nested/test.ts index f2d322764c56..094bb0ed3dd8 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/setSpanActive/nested/test.ts +++ b/dev-packages/browser-integration-tests/suites/tracing/setSpanActive/nested/test.ts @@ -2,48 +2,51 @@ import { expect } from '@playwright/test'; import { sentryTest } from '../../../../utils/fixtures'; import { envelopeRequestParser, shouldSkipTracingTest, waitForTransactionRequest } from '../../../../utils/helpers'; -sentryTest('nested calls to setSpanActive still parent to root span by default', async ({ getLocalTestUrl, page }) => { - if (shouldSkipTracingTest()) { - sentryTest.skip(); - } - - const req = waitForTransactionRequest(page, e => e.transaction === 'checkout-flow'); - const postCheckoutReq = waitForTransactionRequest(page, e => e.transaction === 'post-checkout'); - - const url = await getLocalTestUrl({ testDir: __dirname }); - await page.goto(url); - - const checkoutEvent = envelopeRequestParser(await req); - const postCheckoutEvent = envelopeRequestParser(await postCheckoutReq); - - const checkoutSpanId = checkoutEvent.contexts?.trace?.span_id; - const postCheckoutSpanId = postCheckoutEvent.contexts?.trace?.span_id; - - expect(checkoutSpanId).toMatch(/[a-f0-9]{16}/); - expect(postCheckoutSpanId).toMatch(/[a-f0-9]{16}/); - - expect(checkoutEvent.spans).toHaveLength(4); - expect(postCheckoutEvent.spans).toHaveLength(1); - - const checkoutStep1 = checkoutEvent.spans?.find(s => s.description === 'checkout-step-1'); - const checkoutStep2 = checkoutEvent.spans?.find(s => s.description === 'checkout-step-2'); - const checkoutStep21 = checkoutEvent.spans?.find(s => s.description === 'checkout-step-2-1'); - const checkoutStep3 = checkoutEvent.spans?.find(s => s.description === 'checkout-step-3'); - - expect(checkoutStep1).toBeDefined(); - expect(checkoutStep2).toBeDefined(); - expect(checkoutStep21).toBeDefined(); - expect(checkoutStep3).toBeDefined(); - - expect(checkoutStep1?.parent_span_id).toBe(checkoutSpanId); - expect(checkoutStep2?.parent_span_id).toBe(checkoutSpanId); - expect(checkoutStep3?.parent_span_id).toBe(checkoutSpanId); - - // despite 2-1 being called within 2 AND setting 2 as active span, it's still parented to the - // root span due to this being default behaviour in browser environments - expect(checkoutStep21?.parent_span_id).toBe(checkoutSpanId); - - const postCheckoutStep1 = postCheckoutEvent.spans?.find(s => s.description === 'post-checkout-1'); - expect(postCheckoutStep1).toBeDefined(); - expect(postCheckoutStep1?.parent_span_id).toBe(postCheckoutSpanId); -}); +sentryTest( + 'nested calls to setActiveSpanInBrowser still parent to root span by default', + async ({ getLocalTestUrl, page }) => { + if (shouldSkipTracingTest()) { + sentryTest.skip(); + } + + const req = waitForTransactionRequest(page, e => e.transaction === 'checkout-flow'); + const postCheckoutReq = waitForTransactionRequest(page, e => e.transaction === 'post-checkout'); + + const url = await getLocalTestUrl({ testDir: __dirname }); + await page.goto(url); + + const checkoutEvent = envelopeRequestParser(await req); + const postCheckoutEvent = envelopeRequestParser(await postCheckoutReq); + + const checkoutSpanId = checkoutEvent.contexts?.trace?.span_id; + const postCheckoutSpanId = postCheckoutEvent.contexts?.trace?.span_id; + + expect(checkoutSpanId).toMatch(/[a-f0-9]{16}/); + expect(postCheckoutSpanId).toMatch(/[a-f0-9]{16}/); + + expect(checkoutEvent.spans).toHaveLength(4); + expect(postCheckoutEvent.spans).toHaveLength(1); + + const checkoutStep1 = checkoutEvent.spans?.find(s => s.description === 'checkout-step-1'); + const checkoutStep2 = checkoutEvent.spans?.find(s => s.description === 'checkout-step-2'); + const checkoutStep21 = checkoutEvent.spans?.find(s => s.description === 'checkout-step-2-1'); + const checkoutStep3 = checkoutEvent.spans?.find(s => s.description === 'checkout-step-3'); + + expect(checkoutStep1).toBeDefined(); + expect(checkoutStep2).toBeDefined(); + expect(checkoutStep21).toBeDefined(); + expect(checkoutStep3).toBeDefined(); + + expect(checkoutStep1?.parent_span_id).toBe(checkoutSpanId); + expect(checkoutStep2?.parent_span_id).toBe(checkoutSpanId); + expect(checkoutStep3?.parent_span_id).toBe(checkoutSpanId); + + // despite 2-1 being called within 2 AND setting 2 as active span, it's still parented to the + // root span due to this being default behaviour in browser environments + expect(checkoutStep21?.parent_span_id).toBe(checkoutSpanId); + + const postCheckoutStep1 = postCheckoutEvent.spans?.find(s => s.description === 'post-checkout-1'); + expect(postCheckoutStep1).toBeDefined(); + expect(postCheckoutStep1?.parent_span_id).toBe(postCheckoutSpanId); + }, +); diff --git a/packages/browser/src/index.bundle.tracing.replay.feedback.ts b/packages/browser/src/index.bundle.tracing.replay.feedback.ts index e11bd4efec2a..d79fe81cd29a 100644 --- a/packages/browser/src/index.bundle.tracing.replay.feedback.ts +++ b/packages/browser/src/index.bundle.tracing.replay.feedback.ts @@ -22,7 +22,7 @@ export { startBrowserTracingNavigationSpan, startBrowserTracingPageLoadSpan, } from './tracing/browserTracingIntegration'; -export { setSpanActive } from './tracing/setSpanActive'; +export { setActiveSpanInBrowser } from './tracing/setActiveSpanInBrowser'; export { reportPageLoaded } from './tracing/reportPageLoaded'; diff --git a/packages/browser/src/index.bundle.tracing.replay.ts b/packages/browser/src/index.bundle.tracing.replay.ts index 13b94df9799a..2762ba8522db 100644 --- a/packages/browser/src/index.bundle.tracing.replay.ts +++ b/packages/browser/src/index.bundle.tracing.replay.ts @@ -23,7 +23,7 @@ export { startBrowserTracingPageLoadSpan, } from './tracing/browserTracingIntegration'; export { reportPageLoaded } from './tracing/reportPageLoaded'; -export { setSpanActive } from './tracing/setSpanActive'; +export { setActiveSpanInBrowser } from './tracing/setActiveSpanInBrowser'; export { feedbackIntegrationShim as feedbackAsyncIntegration, feedbackIntegrationShim as feedbackIntegration }; diff --git a/packages/browser/src/index.bundle.tracing.ts b/packages/browser/src/index.bundle.tracing.ts index 92a1c37399af..7bb70cecb22d 100644 --- a/packages/browser/src/index.bundle.tracing.ts +++ b/packages/browser/src/index.bundle.tracing.ts @@ -22,7 +22,7 @@ export { startBrowserTracingNavigationSpan, startBrowserTracingPageLoadSpan, } from './tracing/browserTracingIntegration'; -export { setSpanActive } from './tracing/setSpanActive'; +export { setActiveSpanInBrowser } from './tracing/setActiveSpanInBrowser'; export { reportPageLoaded } from './tracing/reportPageLoaded'; diff --git a/packages/browser/src/index.ts b/packages/browser/src/index.ts index e7183da340bc..0f416228a2aa 100644 --- a/packages/browser/src/index.ts +++ b/packages/browser/src/index.ts @@ -40,7 +40,7 @@ export { startBrowserTracingPageLoadSpan, } from './tracing/browserTracingIntegration'; export { reportPageLoaded } from './tracing/reportPageLoaded'; -export { setSpanActive } from './tracing/setSpanActive'; +export { setActiveSpanInBrowser } from './tracing/setActiveSpanInBrowser'; export type { RequestInstrumentationOptions } from './tracing/request'; export { diff --git a/packages/browser/src/tracing/setSpanActive.ts b/packages/browser/src/tracing/setActiveSpan.ts similarity index 94% rename from packages/browser/src/tracing/setSpanActive.ts rename to packages/browser/src/tracing/setActiveSpan.ts index 033748192501..5e3b537d4b6d 100644 --- a/packages/browser/src/tracing/setSpanActive.ts +++ b/packages/browser/src/tracing/setActiveSpan.ts @@ -22,7 +22,7 @@ import { _INTERNAL_setSpanForScope, getActiveSpan, getCurrentScope } from '@sent * * on('checkoutStarted', () => { * checkoutSpan = Sentry.startInactiveSpan({ name: 'checkout-flow' }); - * Sentry.setSpanActive(checkoutSpan); + * Sentry.setActiveSpanInBrowser(checkoutSpan); * }) * * // during this time, any spans started will be children of `checkoutSpan`: @@ -37,11 +37,11 @@ import { _INTERNAL_setSpanForScope, getActiveSpan, getCurrentScope } from '@sent * * @param span - the span to set active */ -export function setSpanActive(span: Span): void { +export function setActiveSpanInBrowser(span: Span): void { const maybePreviousActiveSpan = getActiveSpan(); // If the span is already active, there's no need to double-patch or set it again. - // This also guards against users (for whatever reason) calling setSpanActive on SDK-started + // This also guards against users (for whatever reason) calling setActiveSpanInBrowser on SDK-started // idle spans like pageload or navigation spans. These will already be handled correctly by the SDK. // For nested situations, we have to double-patch to ensure we restore the correct previous span (see tests) if (maybePreviousActiveSpan === span) { diff --git a/packages/browser/test/tracing/setActiveSpan.test.ts b/packages/browser/test/tracing/setActiveSpan.test.ts new file mode 100644 index 000000000000..d3c7ea79cf67 --- /dev/null +++ b/packages/browser/test/tracing/setActiveSpan.test.ts @@ -0,0 +1,90 @@ +import { getActiveSpan, SentrySpan } from '@sentry/core'; +import { describe, expect, it } from 'vitest'; +import { setActiveSpanInBrowser } from '../../src'; + +describe('setActiveSpanInBrowser', () => { + it('sets the passed span active the current scope', () => { + const span = new SentrySpan({ name: 'test' }); + setActiveSpanInBrowser(span); + expect(getActiveSpan()).toBe(span); + + span.end(); + expect(getActiveSpan()).toBeUndefined(); + }); + + it('handles multiple calls to setActiveSpanInBrowser', () => { + const span = new SentrySpan({ name: 'test' }); + setActiveSpanInBrowser(span); + setActiveSpanInBrowser(span); + setActiveSpanInBrowser(span); + expect(getActiveSpan()).toBe(span); + + span.end(); + expect(getActiveSpan()).toBeUndefined(); + }); + + it('handles changing active span while span is running', () => { + const span = new SentrySpan({ name: 'test' }); + setActiveSpanInBrowser(span); + + expect(getActiveSpan()).toBe(span); + + const span2 = new SentrySpan({ name: 'test2' }); + setActiveSpanInBrowser(span2); + expect(getActiveSpan()).toBe(span2); + + span2.end(); + expect(getActiveSpan()).toBe(span); + + span.end(); + expect(getActiveSpan()).toBeUndefined(); + }); + + it('handles multiple span.end calls', () => { + const span = new SentrySpan({ name: 'test' }); + setActiveSpanInBrowser(span); + setActiveSpanInBrowser(span); + + expect(getActiveSpan()).toBe(span); + + const span2 = new SentrySpan({ name: 'test2' }); + setActiveSpanInBrowser(span2); + expect(getActiveSpan()).toBe(span2); + + span2.end(); + span2.end(); + span2.end(); + expect(getActiveSpan()).toBe(span); + + span.end(); + span.end(); + expect(getActiveSpan()).toBeUndefined(); + }); + + it('handles nested activation of the same span', () => { + const span1 = new SentrySpan({ name: 'test1', sampled: true }); + const span2 = new SentrySpan({ name: 'test2', sampled: true }); + expect(span1.isRecording()).toBe(true); + expect(span2.isRecording()).toBe(true); + + setActiveSpanInBrowser(span1); + expect(getActiveSpan()).toBe(span1); + + setActiveSpanInBrowser(span2); + expect(getActiveSpan()).toBe(span2); + + setActiveSpanInBrowser(span1); + expect(getActiveSpan()).toBe(span1); + + span2.end(); + expect(getActiveSpan()).toBe(span1); + expect(span2.isRecording()).toBe(false); + expect(span1.isRecording()).toBe(true); + + span1.end(); + expect(getActiveSpan()).toBeUndefined(); + + expect(span1.isRecording()).toBe(false); + expect(span2.isRecording()).toBe(false); + }); +}); From 3d36ae0b1c590899e4cb240d5a73d9a3db5c958f Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Mon, 22 Sep 2025 18:02:10 +0200 Subject: [PATCH 5/6] fix build --- packages/browser/src/index.bundle.tracing.replay.feedback.ts | 2 +- packages/browser/src/index.bundle.tracing.replay.ts | 2 +- packages/browser/src/index.bundle.tracing.ts | 2 +- packages/browser/src/index.ts | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/browser/src/index.bundle.tracing.replay.feedback.ts b/packages/browser/src/index.bundle.tracing.replay.feedback.ts index d79fe81cd29a..7aa4b3ae778c 100644 --- a/packages/browser/src/index.bundle.tracing.replay.feedback.ts +++ b/packages/browser/src/index.bundle.tracing.replay.feedback.ts @@ -22,7 +22,7 @@ export { startBrowserTracingNavigationSpan, startBrowserTracingPageLoadSpan, } from './tracing/browserTracingIntegration'; -export { setActiveSpanInBrowser } from './tracing/setActiveSpanInBrowser'; +export { setActiveSpanInBrowser } from './tracing/setActiveSpan'; export { reportPageLoaded } from './tracing/reportPageLoaded'; diff --git a/packages/browser/src/index.bundle.tracing.replay.ts b/packages/browser/src/index.bundle.tracing.replay.ts index 2762ba8522db..3dc858d69cb5 100644 --- a/packages/browser/src/index.bundle.tracing.replay.ts +++ b/packages/browser/src/index.bundle.tracing.replay.ts @@ -23,7 +23,7 @@ export { startBrowserTracingPageLoadSpan, } from './tracing/browserTracingIntegration'; export { reportPageLoaded } from './tracing/reportPageLoaded'; -export { setActiveSpanInBrowser } from './tracing/setActiveSpanInBrowser'; +export { setActiveSpanInBrowser } from './tracing/setActiveSpan'; export { feedbackIntegrationShim as feedbackAsyncIntegration, feedbackIntegrationShim as feedbackIntegration }; diff --git a/packages/browser/src/index.bundle.tracing.ts b/packages/browser/src/index.bundle.tracing.ts index 7bb70cecb22d..62259b92ce7e 100644 --- a/packages/browser/src/index.bundle.tracing.ts +++ b/packages/browser/src/index.bundle.tracing.ts @@ -22,7 +22,7 @@ export { startBrowserTracingNavigationSpan, startBrowserTracingPageLoadSpan, } from './tracing/browserTracingIntegration'; -export { setActiveSpanInBrowser } from './tracing/setActiveSpanInBrowser'; +export { setActiveSpanInBrowser } from './tracing/setActiveSpan'; export { reportPageLoaded } from './tracing/reportPageLoaded'; diff --git a/packages/browser/src/index.ts b/packages/browser/src/index.ts index 0f416228a2aa..5e9924fe6da5 100644 --- a/packages/browser/src/index.ts +++ b/packages/browser/src/index.ts @@ -40,7 +40,7 @@ export { startBrowserTracingPageLoadSpan, } from './tracing/browserTracingIntegration'; export { reportPageLoaded } from './tracing/reportPageLoaded'; -export { setActiveSpanInBrowser } from './tracing/setActiveSpanInBrowser'; +export { setActiveSpanInBrowser } from './tracing/setActiveSpan'; export type { RequestInstrumentationOptions } from './tracing/request'; export { From fe6bacbe6bf651d6e52b3e36c28a26a8b784f637 Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Tue, 23 Sep 2025 10:30:01 +0200 Subject: [PATCH 6/6] fix tests --- .../suites/tracing/setSpanActive/default/subject.js | 2 +- .../tracing/setSpanActive/nested-parentAlwaysRoot/subject.js | 4 ++-- .../suites/tracing/setSpanActive/nested/subject.js | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/dev-packages/browser-integration-tests/suites/tracing/setSpanActive/default/subject.js b/dev-packages/browser-integration-tests/suites/tracing/setSpanActive/default/subject.js index 66e0a9f7cfd3..0ce39588eb1b 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/setSpanActive/default/subject.js +++ b/dev-packages/browser-integration-tests/suites/tracing/setSpanActive/default/subject.js @@ -1,5 +1,5 @@ const checkoutSpan = Sentry.startInactiveSpan({ name: 'checkout-flow' }); -Sentry.setSpanActive(checkoutSpan); +Sentry.setActiveSpanInBrowser(checkoutSpan); Sentry.startSpan({ name: 'checkout-step-1' }, () => { Sentry.startSpan({ name: 'checkout-step-1-1' }, () => { diff --git a/dev-packages/browser-integration-tests/suites/tracing/setSpanActive/nested-parentAlwaysRoot/subject.js b/dev-packages/browser-integration-tests/suites/tracing/setSpanActive/nested-parentAlwaysRoot/subject.js index f4dfea25cf07..dc601cbf4d30 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/setSpanActive/nested-parentAlwaysRoot/subject.js +++ b/dev-packages/browser-integration-tests/suites/tracing/setSpanActive/nested-parentAlwaysRoot/subject.js @@ -1,10 +1,10 @@ const checkoutSpan = Sentry.startInactiveSpan({ name: 'checkout-flow' }); -Sentry.setSpanActive(checkoutSpan); +Sentry.setActiveSpanInBrowser(checkoutSpan); Sentry.startSpan({ name: 'checkout-step-1' }, () => {}); const checkoutStep2 = Sentry.startInactiveSpan({ name: 'checkout-step-2' }); -Sentry.setSpanActive(checkoutStep2); +Sentry.setActiveSpanInBrowser(checkoutStep2); Sentry.startSpan({ name: 'checkout-step-2-1' }, () => { // ... ` diff --git a/dev-packages/browser-integration-tests/suites/tracing/setSpanActive/nested/subject.js b/dev-packages/browser-integration-tests/suites/tracing/setSpanActive/nested/subject.js index f4dfea25cf07..dc601cbf4d30 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/setSpanActive/nested/subject.js +++ b/dev-packages/browser-integration-tests/suites/tracing/setSpanActive/nested/subject.js @@ -1,10 +1,10 @@ const checkoutSpan = Sentry.startInactiveSpan({ name: 'checkout-flow' }); -Sentry.setSpanActive(checkoutSpan); +Sentry.setActiveSpanInBrowser(checkoutSpan); Sentry.startSpan({ name: 'checkout-step-1' }, () => {}); const checkoutStep2 = Sentry.startInactiveSpan({ name: 'checkout-step-2' }); -Sentry.setSpanActive(checkoutStep2); +Sentry.setActiveSpanInBrowser(checkoutStep2); Sentry.startSpan({ name: 'checkout-step-2-1' }, () => { // ... `