Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -348,10 +348,10 @@ sentryTest(
sentryTest('sends CLS of the initial page when soft-navigating to a new page', async ({ getLocalTestUrl, page }) => {
const url = await getLocalTestUrl({ testDir: __dirname });

const eventData = await getFirstSentryEnvelopeRequest<SentryEvent>(page, url);
const pageloadEventData = await getFirstSentryEnvelopeRequest<SentryEvent>(page, url);

expect(eventData.type).toBe('transaction');
expect(eventData.contexts?.trace?.op).toBe('pageload');
expect(pageloadEventData.type).toBe('transaction');
expect(pageloadEventData.contexts?.trace?.op).toBe('pageload');

const spanEnvelopePromise = getMultipleSentryEnvelopeRequests<SpanEnvelope>(
page,
Expand All @@ -364,12 +364,16 @@ sentryTest('sends CLS of the initial page when soft-navigating to a new page', a

await page.goto(`${url}#soft-navigation`);

const pageloadTraceId = pageloadEventData.contexts?.trace?.trace_id;
expect(pageloadTraceId).toMatch(/[a-f0-9]{32}/);

const spanEnvelope = (await spanEnvelopePromise)[0];
const spanEnvelopeItem = spanEnvelope[1][0][1];
// Flakey value dependent on timings -> we check for a range
expect(spanEnvelopeItem.measurements?.cls?.value).toBeGreaterThan(0.05);
expect(spanEnvelopeItem.measurements?.cls?.value).toBeLessThan(0.15);
expect(spanEnvelopeItem.data?.['sentry.pageload.span_id']).toMatch(/[a-f0-9]{16}/);
expect(spanEnvelopeItem.data?.['sentry.pageload.span_id']).toBe(pageloadEventData.contexts?.trace?.span_id);
expect(spanEnvelopeItem.trace_id).toEqual(pageloadTraceId);
});

sentryTest("doesn't send further CLS after the first navigation", async ({ getLocalTestUrl, page }) => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,12 @@ import { expect } from '@playwright/test';
import type { Event as SentryEvent, EventEnvelope, SpanEnvelope } from '@sentry/core';
import { sentryTest } from '../../../../utils/fixtures';
import {
envelopeRequestParser,
getFirstSentryEnvelopeRequest,
getMultipleSentryEnvelopeRequests,
properFullEnvelopeRequestParser,
shouldSkipTracingTest,
waitForTransactionRequest,
} from '../../../../utils/helpers';

sentryTest.beforeEach(async ({ browserName, page }) => {
Expand All @@ -31,6 +33,8 @@ sentryTest('captures LCP vital as a standalone span', async ({ getLocalTestUrl,
properFullEnvelopeRequestParser,
);

const pageloadEnvelopePromise = waitForTransactionRequest(page, e => e.contexts?.trace?.op === 'pageload');

page.route('**', route => route.continue());
page.route('**/my/image.png', async (route: Route) => {
return route.fulfill({
Expand All @@ -47,10 +51,14 @@ sentryTest('captures LCP vital as a standalone span', async ({ getLocalTestUrl,
await hidePage(page);

const spanEnvelope = (await spanEnvelopePromise)[0];
const pageloadTransactionEvent = envelopeRequestParser(await pageloadEnvelopePromise);

const spanEnvelopeHeaders = spanEnvelope[0];
const spanEnvelopeItem = spanEnvelope[1][0][1];

const pageloadTraceId = pageloadTransactionEvent.contexts?.trace?.trace_id;
expect(pageloadTraceId).toMatch(/[a-f0-9]{32}/);

expect(spanEnvelopeItem).toEqual({
data: {
'sentry.exclusive_time': 0,
Expand Down Expand Up @@ -80,7 +88,7 @@ sentryTest('captures LCP vital as a standalone span', async ({ getLocalTestUrl,
segment_id: expect.stringMatching(/[a-f0-9]{16}/),
start_timestamp: expect.any(Number),
timestamp: spanEnvelopeItem.start_timestamp, // LCP is a point-in-time metric
trace_id: expect.stringMatching(/[a-f0-9]{32}/),
trace_id: pageloadTraceId,
});

// LCP value should be greater than 0
Expand All @@ -95,7 +103,6 @@ sentryTest('captures LCP vital as a standalone span', async ({ getLocalTestUrl,
sampled: 'true',
trace_id: spanEnvelopeItem.trace_id,
sample_rand: expect.any(String),
// no transaction, because span source is URL
},
});
});
Expand Down Expand Up @@ -152,10 +159,10 @@ sentryTest('sends LCP of the initial page when soft-navigating to a new page', a

const url = await getLocalTestUrl({ testDir: __dirname });

const eventData = await getFirstSentryEnvelopeRequest<SentryEvent>(page, url);
const pageloadEventData = await getFirstSentryEnvelopeRequest<SentryEvent>(page, url);

expect(eventData.type).toBe('transaction');
expect(eventData.contexts?.trace?.op).toBe('pageload');
expect(pageloadEventData.type).toBe('transaction');
expect(pageloadEventData.contexts?.trace?.op).toBe('pageload');

const spanEnvelopePromise = getMultipleSentryEnvelopeRequests<SpanEnvelope>(
page,
Expand All @@ -173,7 +180,8 @@ sentryTest('sends LCP of the initial page when soft-navigating to a new page', a
const spanEnvelopeItem = spanEnvelope[1][0][1];

expect(spanEnvelopeItem.measurements?.lcp?.value).toBeGreaterThan(0);
expect(spanEnvelopeItem.data?.['sentry.pageload.span_id']).toMatch(/[a-f0-9]{16}/);
expect(spanEnvelopeItem.data?.['sentry.pageload.span_id']).toBe(pageloadEventData.contexts?.trace?.span_id);
expect(spanEnvelopeItem.trace_id).toBe(pageloadEventData.contexts?.trace?.trace_id);
});

sentryTest("doesn't send further LCP after the first navigation", async ({ getLocalTestUrl, page }) => {
Expand Down
34 changes: 20 additions & 14 deletions packages/browser-utils/src/metrics/cls.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,10 @@ import {
} from '@sentry/core';
import { DEBUG_BUILD } from '../debug-build';
import { addClsInstrumentationHandler } from './instrument';
import type { WebVitalReportEvent } from './utils';
import { msToSec, startStandaloneWebVitalSpan } from './utils';
import { onHidden } from './web-vitals/lib/onHidden';
import { runOnce } from './web-vitals/lib/runOnce';

/**
* Starts tracking the Cumulative Layout Shift on the current page and collects the value once
Expand All @@ -37,16 +39,13 @@ export function trackClsAsStandaloneSpan(): void {
return;
}

let sentSpan = false;
function _collectClsOnce() {
if (sentSpan) {
return;
}
sentSpan = true;
if (pageloadSpanId) {
sendStandaloneClsSpan(standaloneCLsValue, standaloneClsEntry, pageloadSpanId);
}
cleanupClsHandler();
function _collectClsOnce(reportEvent: WebVitalReportEvent) {
runOnce(() => {
if (pageloadSpanId) {
sendStandaloneClsSpan(standaloneCLsValue, standaloneClsEntry, pageloadSpanId, reportEvent);
}
cleanupClsHandler();
});
}

const cleanupClsHandler = addClsInstrumentationHandler(({ metric }) => {
Expand All @@ -59,7 +58,7 @@ export function trackClsAsStandaloneSpan(): void {
}, true);

onHidden(() => {
_collectClsOnce();
_collectClsOnce('pagehide');
});

// Since the call chain of this function is synchronous and evaluates before the SDK client is created,
Expand All @@ -72,8 +71,8 @@ export function trackClsAsStandaloneSpan(): void {
return;
}

const unsubscribeStartNavigation = client.on('startNavigationSpan', () => {
_collectClsOnce();
const unsubscribeStartNavigation = client.on('beforeStartNavigationSpan', () => {
_collectClsOnce('navigation');
unsubscribeStartNavigation?.();
});

Expand All @@ -88,7 +87,12 @@ export function trackClsAsStandaloneSpan(): void {
}, 0);
}

function sendStandaloneClsSpan(clsValue: number, entry: LayoutShift | undefined, pageloadSpanId: string) {
function sendStandaloneClsSpan(
clsValue: number,
entry: LayoutShift | undefined,
pageloadSpanId: string,
reportEvent: WebVitalReportEvent,
) {
DEBUG_BUILD && logger.log(`Sending CLS span (${clsValue})`);

const startTime = msToSec((browserPerformanceTimeOrigin() || 0) + (entry?.startTime || 0));
Expand All @@ -102,6 +106,8 @@ function sendStandaloneClsSpan(clsValue: number, entry: LayoutShift | undefined,
[SEMANTIC_ATTRIBUTE_EXCLUSIVE_TIME]: entry?.duration || 0,
// attach the pageload span id to the CLS span so that we can link them in the UI
'sentry.pageload.span_id': pageloadSpanId,
// describes what triggered the web vital to be reported
'sentry.report_event': reportEvent,
};

// Add CLS sources as span attributes to help with debugging layout shifts
Expand Down
28 changes: 15 additions & 13 deletions packages/browser-utils/src/metrics/lcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,10 @@ import {
} from '@sentry/core';
import { DEBUG_BUILD } from '../debug-build';
import { addLcpInstrumentationHandler } from './instrument';
import type { WebVitalReportEvent } from './utils';
import { msToSec, startStandaloneWebVitalSpan } from './utils';
import { onHidden } from './web-vitals/lib/onHidden';
import { runOnce } from './web-vitals/lib/runOnce';

/**
* Starts tracking the Largest Contentful Paint on the current page and collects the value once
Expand All @@ -37,16 +39,13 @@ export function trackLcpAsStandaloneSpan(): void {
return;
}

let sentSpan = false;
function _collectLcpOnce() {
if (sentSpan) {
return;
}
sentSpan = true;
if (pageloadSpanId) {
_sendStandaloneLcpSpan(standaloneLcpValue, standaloneLcpEntry, pageloadSpanId);
}
cleanupLcpHandler();
function _collectLcpOnce(reportEvent: WebVitalReportEvent) {
runOnce(() => {
if (pageloadSpanId) {
_sendStandaloneLcpSpan(standaloneLcpValue, standaloneLcpEntry, pageloadSpanId, reportEvent);
}
cleanupLcpHandler();
});
}

const cleanupLcpHandler = addLcpInstrumentationHandler(({ metric }) => {
Expand All @@ -59,7 +58,7 @@ export function trackLcpAsStandaloneSpan(): void {
}, true);

onHidden(() => {
_collectLcpOnce();
_collectLcpOnce('pagehide');
});

// Since the call chain of this function is synchronous and evaluates before the SDK client is created,
Expand All @@ -72,8 +71,8 @@ export function trackLcpAsStandaloneSpan(): void {
return;
}

const unsubscribeStartNavigation = client.on('startNavigationSpan', () => {
_collectLcpOnce();
const unsubscribeStartNavigation = client.on('beforeStartNavigationSpan', () => {
_collectLcpOnce('navigation');
unsubscribeStartNavigation?.();
});

Expand All @@ -95,6 +94,7 @@ export function _sendStandaloneLcpSpan(
lcpValue: number,
entry: LargestContentfulPaint | undefined,
pageloadSpanId: string,
reportEvent: WebVitalReportEvent,
) {
DEBUG_BUILD && logger.log(`Sending LCP span (${lcpValue})`);

Expand All @@ -109,6 +109,8 @@ export function _sendStandaloneLcpSpan(
[SEMANTIC_ATTRIBUTE_EXCLUSIVE_TIME]: 0, // LCP is a point-in-time metric
// attach the pageload span id to the LCP span so that we can link them in the UI
'sentry.pageload.span_id': pageloadSpanId,
// describes what triggered the web vital to be reported
'sentry.report_event': reportEvent,
};

if (entry) {
Expand Down
5 changes: 5 additions & 0 deletions packages/browser-utils/src/metrics/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import type { Integration, SentrySpan, Span, SpanAttributes, SpanTimeInput, Star
import { getClient, getCurrentScope, spanToJSON, startInactiveSpan, withActiveSpan } from '@sentry/core';
import { WINDOW } from '../types';

export type WebVitalReportEvent = 'pagehide' | 'navigation';

/**
* Checks if a given value is a valid measurement value.
*/
Expand Down Expand Up @@ -168,3 +170,6 @@ export function extractNetworkProtocol(nextHopProtocol: string): { name: string;
}
return { name, version };
}

type ReportEvent = 'pagehide' | 'navigation';
function createReportOnceHandler();
1 change: 1 addition & 0 deletions packages/browser/src/tracing/browserTracingIntegration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -609,6 +609,7 @@ 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 {
client.emit('beforeStartNavigationSpan', spanOptions);
client.emit('startNavigationSpan', spanOptions);

getCurrentScope().setTransactionName(spanOptions.name);
Expand Down
18 changes: 18 additions & 0 deletions packages/browser/test/tracing/browserTracingIntegration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -788,6 +788,24 @@ describe('browserTracingIntegration', () => {
},
});
});

it('triggers beforeStartNavigationSpan hook listeners', () => {
const client = new BrowserClient(
getDefaultBrowserClientOptions({
tracesSampleRate: 1,
integrations: [browserTracingIntegration()],
}),
);
setCurrentClient(client);

const mockBeforeStartNavigationSpanCallback = vi.fn((options: StartSpanOptions) => options);

client.on('beforeStartNavigationSpan', mockBeforeStartNavigationSpanCallback);

startBrowserTracingNavigationSpan(client, { name: 'test span', op: 'navigation' });

expect(mockBeforeStartNavigationSpanCallback).toHaveBeenCalledWith({ name: 'test span', op: 'navigation' });
});
});

describe('using the <meta> tag data', () => {
Expand Down
11 changes: 11 additions & 0 deletions packages/core/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -603,6 +603,12 @@ export abstract class Client<O extends ClientOptions = ClientOptions> {
) => void,
): () => void;

/**
* A hook for triggering right before a navigation span is started.
* @returns {() => void} A function that, when executed, removes the registered callback.
*/
public on(hook: 'beforeStartNavigationSpan', callback: (options: StartSpanOptions) => void): () => void;

/**
* A hook for browser tracing integrations to trigger a span for a navigation.
* @returns {() => void} A function that, when executed, removes the registered callback.
Expand Down Expand Up @@ -779,6 +785,11 @@ export abstract class Client<O extends ClientOptions = ClientOptions> {
traceOptions?: { sentryTrace?: string | undefined; baggage?: string | undefined },
): void;

/**
* Emit a hook event for triggering right before a navigation span is started.
*/
public emit(hook: 'beforeStartNavigationSpan', options: StartSpanOptions): void;

/**
* Emit a hook event for browser tracing integrations to trigger a span for a navigation.
*/
Expand Down
23 changes: 11 additions & 12 deletions packages/nextjs/test/performance/pagesRouterInstrumentation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -321,18 +321,17 @@ describe('pagesRouterInstrumentNavigation', () => {

Router.events.emit('routeChangeStart', targetLocation);

expect(emit).toHaveBeenCalledTimes(1);
expect(emit).toHaveBeenCalledWith(
'startNavigationSpan',
expect.objectContaining({
name: expectedTransactionName,
attributes: {
'sentry.op': 'navigation',
'sentry.origin': 'auto.navigation.nextjs.pages_router_instrumentation',
'sentry.source': expectedTransactionSource,
},
}),
);
expect(emit).toHaveBeenCalledTimes(2);
const expectedSpanOptions = {
name: expectedTransactionName,
attributes: {
'sentry.op': 'navigation',
'sentry.origin': 'auto.navigation.nextjs.pages_router_instrumentation',
'sentry.source': expectedTransactionSource,
},
};
expect(emit).toHaveBeenCalledWith('beforeStartNavigationSpan', expect.objectContaining(expectedSpanOptions));
expect(emit).toHaveBeenCalledWith('startNavigationSpan', expect.objectContaining(expectedSpanOptions));
},
);
});
Loading