Skip to content

Commit 73f356d

Browse files
authored
feat(browser): Add setActiveSpanInBrowser to set an active span in the browser (#17714)
This PR adds a long-requested feature to the browser SDKs (only!): Making an inactive span active. We do this to enable use cases where having a span only being active in the callback is not practical (see #13495 for examples). There are a couple of caveats to this feature: - This on purpose is only exported from the browser SDKs. We cannot support this in Node, due to OTel not allowing for a similar hack. Frankly, it also makes no sense in Node-based SDKs. - Calling `setActiveSpanInBrowser ` on a nested child span, will make that child span the active span as long as it is active. However, due to `parentSpanIsAlwaysRootSpan` defaulting to `true` any child span of the active child span, will still be parented to the root span. By setting `parentSpanIsAlwaysRootSpan: false`, the span hierarchy is respected and child spans are correctly parented to the active span. Note that this cannot be guaranteed to work perfectly, due to missing async context in the browser. See tests for the `parentSpanIsAlwaysRootSpan` behaviour. - A span once set active, cannot be set as inactive again. It will remain active until it is ended or until another span is set active. In the latter case, once that span ends, the initial span will be set as active again until it ends. This is reflected in the types where we by design only allow `Span` to be passed to `setActiveSpanInBrowser `. Technically, `setActiveSpanInBrowser` uses `_setSpanForScope` which I decided to re-export from core as `_INTERNAL_setSpanForScope`, similarly to how we do it with logs APIs. ### Usage This example shows one of the most frequent use cases where having an active, callback-unbound span is useful: ```js function instrumentMyRouter() { let routeSpan; on('routeStart', (from, to) => { routeSpan = Sentry.startInactiveSpan({name: `/${from} -> /${to}`}); Sentry. setActiveSpanInBrowser(rootSpan); }); // any span started in the meantime (e.g. fetch requests) will be // automatically parented to `routeSpan` on('routeEnd', () => { // automatically removes the span from the scope routeSpan.end(); }) } ``` closes #13495
1 parent 75e502f commit 73f356d

File tree

16 files changed

+387
-1
lines changed

16 files changed

+387
-1
lines changed
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
import * as Sentry from '@sentry/browser';
2+
3+
window.Sentry = Sentry;
4+
5+
Sentry.init({
6+
dsn: 'https://[email protected]/1337',
7+
tracesSampleRate: 1,
8+
});
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
const checkoutSpan = Sentry.startInactiveSpan({ name: 'checkout-flow' });
2+
Sentry.setActiveSpanInBrowser(checkoutSpan);
3+
4+
Sentry.startSpan({ name: 'checkout-step-1' }, () => {
5+
Sentry.startSpan({ name: 'checkout-step-1-1' }, () => {
6+
// ... `
7+
});
8+
});
9+
10+
Sentry.startSpan({ name: 'checkout-step-2' }, () => {
11+
// ... `
12+
});
13+
14+
checkoutSpan.end();
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import { expect } from '@playwright/test';
2+
import { sentryTest } from '../../../../utils/fixtures';
3+
import { envelopeRequestParser, shouldSkipTracingTest, waitForTransactionRequest } from '../../../../utils/helpers';
4+
5+
sentryTest('sets an inactive span active and adds child spans to it', async ({ getLocalTestUrl, page }) => {
6+
if (shouldSkipTracingTest()) {
7+
sentryTest.skip();
8+
}
9+
10+
const req = waitForTransactionRequest(page, e => e.transaction === 'checkout-flow');
11+
12+
const url = await getLocalTestUrl({ testDir: __dirname });
13+
await page.goto(url);
14+
15+
const checkoutEvent = envelopeRequestParser(await req);
16+
const checkoutSpanId = checkoutEvent.contexts?.trace?.span_id;
17+
expect(checkoutSpanId).toMatch(/[a-f0-9]{16}/);
18+
19+
expect(checkoutEvent.spans).toHaveLength(3);
20+
21+
const checkoutStep1 = checkoutEvent.spans?.find(s => s.description === 'checkout-step-1');
22+
const checkoutStep11 = checkoutEvent.spans?.find(s => s.description === 'checkout-step-1-1');
23+
const checkoutStep2 = checkoutEvent.spans?.find(s => s.description === 'checkout-step-2');
24+
25+
expect(checkoutStep1).toBeDefined();
26+
expect(checkoutStep11).toBeDefined();
27+
expect(checkoutStep2).toBeDefined();
28+
29+
expect(checkoutStep1?.parent_span_id).toBe(checkoutSpanId);
30+
expect(checkoutStep2?.parent_span_id).toBe(checkoutSpanId);
31+
32+
// despite 1-1 being called within 1, it's still parented to the root span
33+
// due to this being default behaviour in browser environments
34+
expect(checkoutStep11?.parent_span_id).toBe(checkoutSpanId);
35+
});
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import * as Sentry from '@sentry/browser';
2+
3+
window.Sentry = Sentry;
4+
5+
Sentry.init({
6+
dsn: 'https://[email protected]/1337',
7+
tracesSampleRate: 1,
8+
parentSpanIsAlwaysRootSpan: false,
9+
});
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
const checkoutSpan = Sentry.startInactiveSpan({ name: 'checkout-flow' });
2+
Sentry.setActiveSpanInBrowser(checkoutSpan);
3+
4+
Sentry.startSpan({ name: 'checkout-step-1' }, () => {});
5+
6+
const checkoutStep2 = Sentry.startInactiveSpan({ name: 'checkout-step-2' });
7+
Sentry.setActiveSpanInBrowser(checkoutStep2);
8+
9+
Sentry.startSpan({ name: 'checkout-step-2-1' }, () => {
10+
// ... `
11+
});
12+
checkoutStep2.end();
13+
14+
Sentry.startSpan({ name: 'checkout-step-3' }, () => {});
15+
16+
checkoutSpan.end();
17+
18+
Sentry.startSpan({ name: 'post-checkout' }, () => {
19+
Sentry.startSpan({ name: 'post-checkout-1' }, () => {
20+
// ... `
21+
});
22+
});
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import { expect } from '@playwright/test';
2+
import { sentryTest } from '../../../../utils/fixtures';
3+
import { envelopeRequestParser, shouldSkipTracingTest, waitForTransactionRequest } from '../../../../utils/helpers';
4+
5+
sentryTest(
6+
'nested calls to setActiveSpanInBrowser with parentSpanIsAlwaysRootSpan=false result in correct parenting',
7+
async ({ getLocalTestUrl, page }) => {
8+
if (shouldSkipTracingTest()) {
9+
sentryTest.skip();
10+
}
11+
12+
const req = waitForTransactionRequest(page, e => e.transaction === 'checkout-flow');
13+
const postCheckoutReq = waitForTransactionRequest(page, e => e.transaction === 'post-checkout');
14+
15+
const url = await getLocalTestUrl({ testDir: __dirname });
16+
await page.goto(url);
17+
18+
const checkoutEvent = envelopeRequestParser(await req);
19+
const postCheckoutEvent = envelopeRequestParser(await postCheckoutReq);
20+
21+
const checkoutSpanId = checkoutEvent.contexts?.trace?.span_id;
22+
const postCheckoutSpanId = postCheckoutEvent.contexts?.trace?.span_id;
23+
24+
expect(checkoutSpanId).toMatch(/[a-f0-9]{16}/);
25+
expect(postCheckoutSpanId).toMatch(/[a-f0-9]{16}/);
26+
27+
expect(checkoutEvent.spans).toHaveLength(4);
28+
expect(postCheckoutEvent.spans).toHaveLength(1);
29+
30+
const checkoutStep1 = checkoutEvent.spans?.find(s => s.description === 'checkout-step-1');
31+
const checkoutStep2 = checkoutEvent.spans?.find(s => s.description === 'checkout-step-2');
32+
const checkoutStep21 = checkoutEvent.spans?.find(s => s.description === 'checkout-step-2-1');
33+
const checkoutStep3 = checkoutEvent.spans?.find(s => s.description === 'checkout-step-3');
34+
35+
expect(checkoutStep1).toBeDefined();
36+
expect(checkoutStep2).toBeDefined();
37+
expect(checkoutStep21).toBeDefined();
38+
expect(checkoutStep3).toBeDefined();
39+
40+
expect(checkoutStep1?.parent_span_id).toBe(checkoutSpanId);
41+
expect(checkoutStep2?.parent_span_id).toBe(checkoutSpanId);
42+
43+
// with parentSpanIsAlwaysRootSpan=false, 2-1 is parented to 2 because
44+
// 2 was the active span when 2-1 was started
45+
expect(checkoutStep21?.parent_span_id).toBe(checkoutStep2?.span_id);
46+
47+
// since the parent of three is `checkoutSpan`, we correctly reset
48+
// the active span to `checkoutSpan` after 2 ended
49+
expect(checkoutStep3?.parent_span_id).toBe(checkoutSpanId);
50+
51+
// post-checkout trace is started as a new trace because ending checkoutSpan removes the active
52+
// span on the scope
53+
const postCheckoutStep1 = postCheckoutEvent.spans?.find(s => s.description === 'post-checkout-1');
54+
expect(postCheckoutStep1).toBeDefined();
55+
expect(postCheckoutStep1?.parent_span_id).toBe(postCheckoutSpanId);
56+
},
57+
);
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
import * as Sentry from '@sentry/browser';
2+
3+
window.Sentry = Sentry;
4+
5+
Sentry.init({
6+
dsn: 'https://[email protected]/1337',
7+
tracesSampleRate: 1,
8+
});
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
const checkoutSpan = Sentry.startInactiveSpan({ name: 'checkout-flow' });
2+
Sentry.setActiveSpanInBrowser(checkoutSpan);
3+
4+
Sentry.startSpan({ name: 'checkout-step-1' }, () => {});
5+
6+
const checkoutStep2 = Sentry.startInactiveSpan({ name: 'checkout-step-2' });
7+
Sentry.setActiveSpanInBrowser(checkoutStep2);
8+
9+
Sentry.startSpan({ name: 'checkout-step-2-1' }, () => {
10+
// ... `
11+
});
12+
checkoutStep2.end();
13+
14+
Sentry.startSpan({ name: 'checkout-step-3' }, () => {});
15+
16+
checkoutSpan.end();
17+
18+
Sentry.startSpan({ name: 'post-checkout' }, () => {
19+
Sentry.startSpan({ name: 'post-checkout-1' }, () => {
20+
// ... `
21+
});
22+
});
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import { expect } from '@playwright/test';
2+
import { sentryTest } from '../../../../utils/fixtures';
3+
import { envelopeRequestParser, shouldSkipTracingTest, waitForTransactionRequest } from '../../../../utils/helpers';
4+
5+
sentryTest(
6+
'nested calls to setActiveSpanInBrowser still parent to root span by default',
7+
async ({ getLocalTestUrl, page }) => {
8+
if (shouldSkipTracingTest()) {
9+
sentryTest.skip();
10+
}
11+
12+
const req = waitForTransactionRequest(page, e => e.transaction === 'checkout-flow');
13+
const postCheckoutReq = waitForTransactionRequest(page, e => e.transaction === 'post-checkout');
14+
15+
const url = await getLocalTestUrl({ testDir: __dirname });
16+
await page.goto(url);
17+
18+
const checkoutEvent = envelopeRequestParser(await req);
19+
const postCheckoutEvent = envelopeRequestParser(await postCheckoutReq);
20+
21+
const checkoutSpanId = checkoutEvent.contexts?.trace?.span_id;
22+
const postCheckoutSpanId = postCheckoutEvent.contexts?.trace?.span_id;
23+
24+
expect(checkoutSpanId).toMatch(/[a-f0-9]{16}/);
25+
expect(postCheckoutSpanId).toMatch(/[a-f0-9]{16}/);
26+
27+
expect(checkoutEvent.spans).toHaveLength(4);
28+
expect(postCheckoutEvent.spans).toHaveLength(1);
29+
30+
const checkoutStep1 = checkoutEvent.spans?.find(s => s.description === 'checkout-step-1');
31+
const checkoutStep2 = checkoutEvent.spans?.find(s => s.description === 'checkout-step-2');
32+
const checkoutStep21 = checkoutEvent.spans?.find(s => s.description === 'checkout-step-2-1');
33+
const checkoutStep3 = checkoutEvent.spans?.find(s => s.description === 'checkout-step-3');
34+
35+
expect(checkoutStep1).toBeDefined();
36+
expect(checkoutStep2).toBeDefined();
37+
expect(checkoutStep21).toBeDefined();
38+
expect(checkoutStep3).toBeDefined();
39+
40+
expect(checkoutStep1?.parent_span_id).toBe(checkoutSpanId);
41+
expect(checkoutStep2?.parent_span_id).toBe(checkoutSpanId);
42+
expect(checkoutStep3?.parent_span_id).toBe(checkoutSpanId);
43+
44+
// despite 2-1 being called within 2 AND setting 2 as active span, it's still parented to the
45+
// root span due to this being default behaviour in browser environments
46+
expect(checkoutStep21?.parent_span_id).toBe(checkoutSpanId);
47+
48+
const postCheckoutStep1 = postCheckoutEvent.spans?.find(s => s.description === 'post-checkout-1');
49+
expect(postCheckoutStep1).toBeDefined();
50+
expect(postCheckoutStep1?.parent_span_id).toBe(postCheckoutSpanId);
51+
},
52+
);

packages/browser/src/index.bundle.tracing.replay.feedback.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ export {
2222
startBrowserTracingNavigationSpan,
2323
startBrowserTracingPageLoadSpan,
2424
} from './tracing/browserTracingIntegration';
25+
export { setActiveSpanInBrowser } from './tracing/setActiveSpan';
2526

2627
export { reportPageLoaded } from './tracing/reportPageLoaded';
2728

0 commit comments

Comments
 (0)