Skip to content

Commit eb0f7e8

Browse files
logaretmclaude
andauthored
test(e2e): Cover soft navigation web vitals across router instrumentations (#24324)
Adds an e2e test per router instrumentation asserting that a soft navigation's web vitals are attributed to the navigation span they were measured on. This covers most frameworks that can reliably test this, tanstack seems to have an edge case that I need to investigate separately. --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 65631dc commit eb0f7e8

12 files changed

Lines changed: 204 additions & 66 deletions

File tree

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import { expect, test } from '@playwright/test';
2+
import { collectStreamedSpans, getSpanOp, hidePage } from '@sentry-internal/test-utils';
3+
4+
// The correlation between a soft navigation and the SDK's navigation span hangs off the interaction
5+
// that triggered it, so it only holds while the navigation span is started before the interaction's
6+
// Event Timing entry is delivered. Angular's router instrumentation starts the navigation span off the router's own event stream.
7+
test('attributes soft navigation web vitals to the navigation span they were measured on', async ({ page }) => {
8+
const spansPromise = collectStreamedSpans(
9+
'angular-22',
10+
spansOfTrace =>
11+
spansOfTrace.some(span => getSpanOp(span) === 'navigation' && span.is_segment) &&
12+
spansOfTrace.some(span => getSpanOp(span) === 'ui.webvital.cls'),
13+
);
14+
15+
await page.goto('/');
16+
await page.locator('#navLink').click();
17+
18+
// A soft navigation's vitals are finalized at the next soft navigation or on pagehide, so nothing
19+
// is reported for it until the page goes away.
20+
await hidePage(page);
21+
22+
const spans = await spansPromise;
23+
24+
const navigationSpan = spans.find(span => getSpanOp(span) === 'navigation' && span.is_segment)!;
25+
const clsSpan = spans.find(span => getSpanOp(span) === 'ui.webvital.cls')!;
26+
27+
expect(navigationSpan.name).toBe('/users/:id/');
28+
29+
const softNavigationId = navigationSpan.attributes['browser.navigation.id']?.value;
30+
expect(softNavigationId).toEqual(expect.any(Number));
31+
32+
// Both sides of the correlation carry the id, so the navigation and its vitals are joinable.
33+
expect(clsSpan.attributes).toMatchObject({
34+
'browser.navigation.id': { value: softNavigationId, type: 'integer' },
35+
'browser.navigation.type': { value: 'soft-navigation', type: 'string' },
36+
});
37+
expect(clsSpan.parent_span_id).toBe(navigationSpan.span_id);
38+
});

‎dev-packages/e2e-tests/test-applications/react-17-static/tests/transactions.test.ts‎

Lines changed: 2 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { expect, test } from '@playwright/test';
2-
import { getSpanOp, waitForStreamedSpan, waitForTransaction } from '@sentry-internal/test-utils';
2+
import { getSpanOp, hidePage, waitForStreamedSpan, waitForTransaction } from '@sentry-internal/test-utils';
33

44
test('sends a pageload transaction with a parameterized URL', async ({ page }) => {
55
const transactionPromise = waitForTransaction('react-17-static', async transactionEvent => {
@@ -76,17 +76,8 @@ test('sends an INP span', async ({ page }) => {
7676

7777
await page.click('#exception-button');
7878

79-
// web-vitals defers processing the interaction's event entries to a
80-
// `requestIdleCallback(..., { timeout: 1000 })`. Chromium only runs that on the timeout here, so
81-
// hiding the page any earlier forces a report before the interaction has been processed and no
82-
// INP is emitted at all.
83-
await page.waitForTimeout(1500);
84-
8579
// Page hide to trigger INP
86-
await page.evaluate(() => {
87-
Object.defineProperty(document, 'visibilityState', { value: 'hidden', configurable: true });
88-
document.dispatchEvent(new Event('visibilitychange'));
89-
});
80+
await hidePage(page);
9081

9182
const inpSpan = await inpSpanPromise;
9283

‎dev-packages/e2e-tests/test-applications/react-17/tests/spans.test.ts‎

Lines changed: 2 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { expect, test } from '@playwright/test';
2-
import { getSpanOp, waitForStreamedSpan } from '@sentry-internal/test-utils';
2+
import { getSpanOp, hidePage, waitForStreamedSpan } from '@sentry-internal/test-utils';
33

44
test('sends a pageload span with a parameterized URL', async ({ page }) => {
55
const spanPromise = waitForStreamedSpan('react-17', span => {
@@ -58,17 +58,8 @@ test('sends an INP span', async ({ page }) => {
5858

5959
await page.click('#exception-button');
6060

61-
// web-vitals defers processing the interaction's event entries to a
62-
// `requestIdleCallback(..., { timeout: 1000 })`. Chromium only runs that on the timeout here, so
63-
// hiding the page any earlier forces a report before the interaction has been processed and no
64-
// INP is emitted at all.
65-
await page.waitForTimeout(1500);
66-
6761
// Page hide to trigger INP
68-
await page.evaluate(() => {
69-
Object.defineProperty(document, 'visibilityState', { value: 'hidden', configurable: true });
70-
document.dispatchEvent(new Event('visibilitychange'));
71-
});
62+
await hidePage(page);
7263

7364
const inpSpan = await inpSpanPromise;
7465

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import { expect, test } from '@playwright/test';
2+
import { collectStreamedSpans, getSpanOp, hidePage } from '@sentry-internal/test-utils';
3+
4+
// The correlation between a soft navigation and the SDK's navigation span hangs off the interaction
5+
// that triggered it, so it only holds while the navigation span is started before the interaction's
6+
// Event Timing entry is delivered. `reactRouterV6BrowserTracingIntegration` on a data router defers the navigation span until the
7+
// router's navigation state goes idle, i.e. after loaders resolve, which is the latest any of our
8+
// instrumentations starts one.
9+
test('attributes soft navigation web vitals to the navigation span they were measured on', async ({ page }) => {
10+
const spansPromise = collectStreamedSpans(
11+
'react-create-browser-router',
12+
spansOfTrace =>
13+
spansOfTrace.some(span => getSpanOp(span) === 'navigation' && span.is_segment) &&
14+
spansOfTrace.some(span => getSpanOp(span) === 'ui.webvital.cls'),
15+
);
16+
17+
await page.goto('/');
18+
await page.locator('#navigation').click();
19+
20+
// A soft navigation's vitals are finalized at the next soft navigation or on pagehide, so nothing
21+
// is reported for it until the page goes away.
22+
await hidePage(page);
23+
24+
const spans = await spansPromise;
25+
26+
const navigationSpan = spans.find(span => getSpanOp(span) === 'navigation' && span.is_segment)!;
27+
const clsSpan = spans.find(span => getSpanOp(span) === 'ui.webvital.cls')!;
28+
29+
expect(navigationSpan.name).toBe('/user/:id');
30+
31+
const softNavigationId = navigationSpan.attributes['browser.navigation.id']?.value;
32+
expect(softNavigationId).toEqual(expect.any(Number));
33+
34+
// Both sides of the correlation carry the id, so the navigation and its vitals are joinable.
35+
expect(clsSpan.attributes).toMatchObject({
36+
'browser.navigation.id': { value: softNavigationId, type: 'integer' },
37+
'browser.navigation.type': { value: 'soft-navigation', type: 'string' },
38+
});
39+
expect(clsSpan.parent_span_id).toBe(navigationSpan.span_id);
40+
});
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import { expect, test } from '@playwright/test';
2+
import { collectStreamedSpans, getSpanOp, hidePage } from '@sentry-internal/test-utils';
3+
4+
// The correlation between a soft navigation and the SDK's navigation span hangs off the interaction
5+
// that triggered it, so it only holds while the navigation span is started before the interaction's
6+
// Event Timing entry is delivered. `reactRouterV6BrowserTracingIntegration` starts it from a layout
7+
// effect rather than from the history change, which is what this exercises.
8+
test('attributes soft navigation web vitals to the navigation span they were measured on', async ({ page }) => {
9+
const spansPromise = collectStreamedSpans(
10+
'react-router-6',
11+
spansOfTrace =>
12+
spansOfTrace.some(span => getSpanOp(span) === 'navigation' && span.is_segment) &&
13+
spansOfTrace.some(span => getSpanOp(span) === 'ui.webvital.cls'),
14+
);
15+
16+
await page.goto('/');
17+
await page.click('#navigation');
18+
19+
// A soft navigation's vitals are finalized at the next soft navigation or on pagehide, so nothing
20+
// is reported for it until the page goes away.
21+
await hidePage(page);
22+
23+
const spans = await spansPromise;
24+
25+
const navigationSpan = spans.find(span => getSpanOp(span) === 'navigation' && span.is_segment)!;
26+
const clsSpan = spans.find(span => getSpanOp(span) === 'ui.webvital.cls')!;
27+
28+
expect(navigationSpan.name).toBe('/user/:id');
29+
30+
const softNavigationId = navigationSpan.attributes['browser.navigation.id']?.value;
31+
expect(softNavigationId).toEqual(expect.any(Number));
32+
33+
// Both sides of the correlation carry the id, so the navigation and its vitals are joinable.
34+
expect(clsSpan.attributes).toMatchObject({
35+
'browser.navigation.id': { value: softNavigationId, type: 'integer' },
36+
'browser.navigation.type': { value: 'soft-navigation', type: 'string' },
37+
});
38+
expect(clsSpan.parent_span_id).toBe(navigationSpan.span_id);
39+
expect(clsSpan.attributes['sentry.pageload.span_id']).toBeUndefined();
40+
});

‎dev-packages/e2e-tests/test-applications/react-router-6/tests/spans.test.ts‎

Lines changed: 2 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { expect, test } from '@playwright/test';
2-
import { getSpanOp, waitForStreamedSpan } from '@sentry-internal/test-utils';
2+
import { getSpanOp, hidePage, waitForStreamedSpan } from '@sentry-internal/test-utils';
33

44
test('sends a pageload span with a parameterized URL', async ({ page }) => {
55
const spanPromise = waitForStreamedSpan('react-router-6', span => {
@@ -58,17 +58,8 @@ test('sends an INP span', async ({ page }) => {
5858

5959
await page.click('#exception-button');
6060

61-
// web-vitals defers processing the interaction's event entries to a
62-
// `requestIdleCallback(..., { timeout: 1000 })`. Chromium only runs that on the timeout here, so
63-
// hiding the page any earlier forces a report before the interaction has been processed and no
64-
// INP is emitted at all.
65-
await page.waitForTimeout(1500);
66-
6761
// Page hide to trigger INP
68-
await page.evaluate(() => {
69-
Object.defineProperty(document, 'visibilityState', { value: 'hidden', configurable: true });
70-
document.dispatchEvent(new Event('visibilitychange'));
71-
});
62+
await hidePage(page);
7263

7364
const inpSpan = await inpSpanPromise;
7465

‎dev-packages/e2e-tests/test-applications/react-router-7-spa-streaming/tests/spans.test.ts‎

Lines changed: 2 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { expect, test } from '@playwright/test';
2-
import { getSpanOp, waitForStreamedSpan } from '@sentry-internal/test-utils';
2+
import { getSpanOp, hidePage, waitForStreamedSpan } from '@sentry-internal/test-utils';
33

44
test('sends a pageload span with a parameterized URL', async ({ page }) => {
55
const spanPromise = waitForStreamedSpan('react-router-7-spa-streaming', span => {
@@ -56,17 +56,8 @@ test('sends an INP span', async ({ page }) => {
5656

5757
await page.click('#exception-button');
5858

59-
// web-vitals defers processing the interaction's event entries to a
60-
// `requestIdleCallback(..., { timeout: 1000 })`. Chromium only runs that on the timeout here, so
61-
// hiding the page any earlier forces a report before the interaction has been processed and no
62-
// INP is emitted at all.
63-
await page.waitForTimeout(1500);
64-
6559
// Page hide to trigger INP
66-
await page.evaluate(() => {
67-
Object.defineProperty(document, 'visibilityState', { value: 'hidden', configurable: true });
68-
document.dispatchEvent(new Event('visibilitychange'));
69-
});
60+
await hidePage(page);
7061

7162
const inpSpan = await inpSpanPromise;
7263

‎dev-packages/e2e-tests/test-applications/react-router-7-spa/tests/spans.test.ts‎

Lines changed: 2 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { expect, test } from '@playwright/test';
2-
import { getSpanOp, waitForStreamedSpan } from '@sentry-internal/test-utils';
2+
import { getSpanOp, hidePage, waitForStreamedSpan } from '@sentry-internal/test-utils';
33

44
test('sends a pageload span with a parameterized URL', async ({ page }) => {
55
const spanPromise = waitForStreamedSpan('react-router-7-spa', span => {
@@ -58,17 +58,8 @@ test('sends an INP span', async ({ page }) => {
5858

5959
await page.click('#exception-button');
6060

61-
// web-vitals defers processing the interaction's event entries to a
62-
// `requestIdleCallback(..., { timeout: 1000 })`. Chromium only runs that on the timeout here, so
63-
// hiding the page any earlier forces a report before the interaction has been processed and no
64-
// INP is emitted at all.
65-
await page.waitForTimeout(1500);
66-
6761
// Page hide to trigger INP
68-
await page.evaluate(() => {
69-
Object.defineProperty(document, 'visibilityState', { value: 'hidden', configurable: true });
70-
document.dispatchEvent(new Event('visibilitychange'));
71-
});
62+
await hidePage(page);
7263

7364
const inpSpan = await inpSpanPromise;
7465

‎dev-packages/e2e-tests/test-applications/react-router-8-spa/tests/spans.test.ts‎

Lines changed: 2 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { expect, test } from '@playwright/test';
2-
import { getSpanOp, waitForStreamedSpan } from '@sentry-internal/test-utils';
2+
import { getSpanOp, hidePage, waitForStreamedSpan } from '@sentry-internal/test-utils';
33

44
test('sends a pageload span with a parameterized URL', async ({ page }) => {
55
const spanPromise = waitForStreamedSpan('react-router-8-spa', span => {
@@ -58,17 +58,8 @@ test('sends an INP span', async ({ page }) => {
5858

5959
await page.click('#exception-button');
6060

61-
// web-vitals defers processing the interaction's event entries to a
62-
// `requestIdleCallback(..., { timeout: 1000 })`. Chromium only runs that on the timeout here, so
63-
// hiding the page any earlier forces a report before the interaction has been processed and no
64-
// INP is emitted at all.
65-
await page.waitForTimeout(1500);
66-
6761
// Page hide to trigger INP
68-
await page.evaluate(() => {
69-
Object.defineProperty(document, 'visibilityState', { value: 'hidden', configurable: true });
70-
document.dispatchEvent(new Event('visibilitychange'));
71-
});
62+
await hidePage(page);
7263

7364
const inpSpan = await inpSpanPromise;
7465

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import { expect, test } from '@playwright/test';
2+
import { collectStreamedSpans, getSpanOp, hidePage } from '@sentry-internal/test-utils';
3+
4+
// The correlation between a soft navigation and the SDK's navigation span hangs off the interaction
5+
// that triggered it, so it only holds while the navigation span is started before the interaction's
6+
// Event Timing entry is delivered. `vueIntegration` starts the navigation span from a `router.beforeEach` guard.
7+
test('attributes soft navigation web vitals to the navigation span they were measured on', async ({ page }) => {
8+
const spansPromise = collectStreamedSpans(
9+
'vue-3',
10+
spansOfTrace =>
11+
spansOfTrace.some(span => getSpanOp(span) === 'navigation' && span.is_segment) &&
12+
spansOfTrace.some(span => getSpanOp(span) === 'ui.webvital.cls'),
13+
);
14+
15+
await page.goto('/');
16+
await page.locator('#navLink').click();
17+
18+
// A soft navigation's vitals are finalized at the next soft navigation or on pagehide, so nothing
19+
// is reported for it until the page goes away.
20+
await hidePage(page);
21+
22+
const spans = await spansPromise;
23+
24+
const navigationSpan = spans.find(span => getSpanOp(span) === 'navigation' && span.is_segment)!;
25+
const clsSpan = spans.find(span => getSpanOp(span) === 'ui.webvital.cls')!;
26+
27+
expect(navigationSpan.name).toBe('/users/:id');
28+
29+
const softNavigationId = navigationSpan.attributes['browser.navigation.id']?.value;
30+
expect(softNavigationId).toEqual(expect.any(Number));
31+
32+
// Both sides of the correlation carry the id, so the navigation and its vitals are joinable.
33+
expect(clsSpan.attributes).toMatchObject({
34+
'browser.navigation.id': { value: softNavigationId, type: 'integer' },
35+
'browser.navigation.type': { value: 'soft-navigation', type: 'string' },
36+
});
37+
expect(clsSpan.parent_span_id).toBe(navigationSpan.span_id);
38+
});

0 commit comments

Comments
 (0)