Skip to content
Draft
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
10 changes: 6 additions & 4 deletions packages/browser-utils/src/performance/entries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
browserPerformanceTimeOrigin,
getActiveSpan,
parseUrl,
performanceTimeToSeconds,
RESOURCE_SPAN_NAME_FALLBACK,
SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN,
setMeasurement,
Expand Down Expand Up @@ -108,7 +109,7 @@ export function startTrackingLongTasks(): void {
const { attributes: parentAttributes, start_timestamp: parentStartTimestamp } = spanToJSON(parent);

for (const entry of entries) {
const startTime = msToSec((browserPerformanceTimeOrigin() as number) + entry.startTime);
const startTime = performanceTimeToSeconds(entry.startTime) as number;
const duration = msToSec(entry.duration);

if (parentAttributes[SENTRY_OP] === 'navigation' && parentStartTimestamp && startTime < parentStartTimestamp) {
Expand Down Expand Up @@ -147,7 +148,7 @@ export function startTrackingLongAnimationFrames(): void {
continue;
}

const startTime = msToSec((browserPerformanceTimeOrigin() as number) + entry.startTime);
const startTime = performanceTimeToSeconds(entry.startTime) as number;

const {
start_timestamp: parentStartTimestamp,
Expand Down Expand Up @@ -217,13 +218,14 @@ export function addPerformanceEntries(span: Span, options: AddPerformanceEntries

const { spanStreamingEnabled, ignoreResourceSpans } = options;

const timeOrigin = msToSec(origin);

const performanceEntries = performance.getEntries();

const { attributes, start_timestamp: transactionStartTime } = spanToJSON(span);

performanceEntries.slice(_performanceCursor).forEach(entry => {
// Navigations can happen long after page load, and after a clock drift correction. All timings of an entry share
// the origin in effect when it started, so the entry keeps its duration.
const timeOrigin = msToSec(browserPerformanceTimeOrigin(entry.startTime) as number);
const startTime = msToSec(entry.startTime);
const duration = msToSec(
// Inexplicably, Chrome sometimes emits a negative duration. We need to work around this.
Expand Down
4 changes: 2 additions & 2 deletions packages/browser-utils/src/performance/interactions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,13 @@ import {
import { UI_ACTION_CLICK, UI_INTERACTION_CLICK } from '@sentry/conventions/op';
import type { Client, IntegrationFn, Span, StartSpanOptions, TransactionSource } from '@sentry/core';
import {
browserPerformanceTimeOrigin,
debug,
defineIntegration,
filterCollectedUrl,
getActiveSpan,
getRootSpan,
hasSpanStreamingEnabled,
performanceTimeToSeconds,
spanToJSON,
UI_ACTION_CLICK_SPAN_NAME_FALLBACK,
UI_INTERACTION_CLICK_SPAN_NAME_FALLBACK,
Expand Down Expand Up @@ -242,7 +242,7 @@ function trackInteractionsAsSpans(client: Client): void {
}
for (const entry of entries) {
if (entry.name === 'click') {
const startTime = msToSec((browserPerformanceTimeOrigin() as number) + entry.startTime);
const startTime = performanceTimeToSeconds(entry.startTime) as number;
const duration = msToSec(entry.duration);

const selector = htmlTreeAsString(entry.target);
Expand Down
32 changes: 17 additions & 15 deletions packages/browser-utils/src/performance/resourceTiming.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,10 @@ import type { SpanAttributes } from '@sentry/core';
import { browserPerformanceTimeOrigin } from '@sentry/core';
import { extractNetworkProtocol, getBrowserPerformanceAPI } from './utils';

function getAbsoluteTime(time: number | undefined): number | undefined {
function getAbsoluteTime(time: number | undefined, timeOrigin: number): number | undefined {
// falsy values should be preserved so that we can later on drop undefined values and
// preserve 0 vals for cross-origin resources without proper `Timing-Allow-Origin` header.
return time ? ((browserPerformanceTimeOrigin() || performance.timeOrigin) + time) / 1000 : time;
return time ? (timeOrigin + time) / 1000 : time;
}

/**
Expand All @@ -28,31 +28,33 @@ export function resourceTimingToSpanAttributes(resourceTiming: PerformanceResour
timingSpanData['network.protocol.name'] = name;
}

if (!(browserPerformanceTimeOrigin() || getBrowserPerformanceAPI()?.timeOrigin)) {
// All timings share the origin in effect when the request started, so the phases between them stay intact.
const timeOrigin = browserPerformanceTimeOrigin(resourceTiming.startTime) || getBrowserPerformanceAPI()?.timeOrigin;
if (!timeOrigin) {
return timingSpanData;
}

return dropUndefinedKeysFromObject({
...timingSpanData,

'http.request.redirect_start': getAbsoluteTime(resourceTiming.redirectStart),
'http.request.redirect_end': getAbsoluteTime(resourceTiming.redirectEnd),
'http.request.redirect_start': getAbsoluteTime(resourceTiming.redirectStart, timeOrigin),
'http.request.redirect_end': getAbsoluteTime(resourceTiming.redirectEnd, timeOrigin),

'http.request.worker_start': getAbsoluteTime(resourceTiming.workerStart),
'http.request.worker_start': getAbsoluteTime(resourceTiming.workerStart, timeOrigin),

'http.request.fetch_start': getAbsoluteTime(resourceTiming.fetchStart),
'http.request.fetch_start': getAbsoluteTime(resourceTiming.fetchStart, timeOrigin),

'http.request.domain_lookup_start': getAbsoluteTime(resourceTiming.domainLookupStart),
'http.request.domain_lookup_end': getAbsoluteTime(resourceTiming.domainLookupEnd),
'http.request.domain_lookup_start': getAbsoluteTime(resourceTiming.domainLookupStart, timeOrigin),
'http.request.domain_lookup_end': getAbsoluteTime(resourceTiming.domainLookupEnd, timeOrigin),

'http.request.connect_start': getAbsoluteTime(resourceTiming.connectStart),
'http.request.secure_connection_start': getAbsoluteTime(resourceTiming.secureConnectionStart),
'http.request.connection_end': getAbsoluteTime(resourceTiming.connectEnd),
'http.request.connect_start': getAbsoluteTime(resourceTiming.connectStart, timeOrigin),
'http.request.secure_connection_start': getAbsoluteTime(resourceTiming.secureConnectionStart, timeOrigin),
'http.request.connection_end': getAbsoluteTime(resourceTiming.connectEnd, timeOrigin),

'http.request.request_start': getAbsoluteTime(resourceTiming.requestStart),
'http.request.request_start': getAbsoluteTime(resourceTiming.requestStart, timeOrigin),

'http.request.response_start': getAbsoluteTime(resourceTiming.responseStart),
'http.request.response_end': getAbsoluteTime(resourceTiming.responseEnd),
'http.request.response_start': getAbsoluteTime(resourceTiming.responseStart, timeOrigin),
'http.request.response_end': getAbsoluteTime(resourceTiming.responseEnd, timeOrigin),

// For TTFB we actually want the relative time from timeOrigin to responseStart
// This way, TTFB always measures the "first page load" experience.
Expand Down
6 changes: 3 additions & 3 deletions packages/browser-utils/src/performance/userTiming.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,11 +26,9 @@ const _userTimingIntegration = ((options: UserTimingOptions = {}) => {
name: INTEGRATION_NAME,
setup(client) {
const performance = getBrowserPerformanceAPI();
const timeOrigin = browserPerformanceTimeOrigin();
if (!performance?.getEntries || !timeOrigin) {
if (!performance?.getEntries || !browserPerformanceTimeOrigin()) {
return;
}
const timeOriginInSeconds = msToSec(timeOrigin);
let performanceCursor = 0;

client.on('beforeIdleSpanEnd', idleSpan => {
Expand All @@ -49,6 +47,8 @@ const _userTimingIntegration = ((options: UserTimingOptions = {}) => {
continue;
}

// Navigations can happen long after page load, and after a clock drift correction.
const timeOriginInSeconds = msToSec(browserPerformanceTimeOrigin(entry.startTime) as number);
const startTime = msToSec(entry.startTime);
const absoluteStartTime = timeOriginInSeconds + startTime;

Expand Down
17 changes: 10 additions & 7 deletions packages/browser-utils/src/web-vitals/spans.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
getClient,
getRootSpan,
hasSpanStreamingEnabled,
performanceTimeToSeconds,
SEMANTIC_ATTRIBUTE_EXCLUSIVE_TIME,
SEMANTIC_ATTRIBUTE_SENTRY_OP,
spanToJSON,
Expand Down Expand Up @@ -193,10 +194,11 @@ export function _sendLcpSpan(

DEBUG_BUILD && debug.log(`Sending LCP span (${lcpValue})`);

const performanceTimeOrigin = browserPerformanceTimeOrigin() || 0;
// A soft navigation's LCP is measured from the triggering interaction, not the document time
// origin. Starting the span there too keeps it inside the navigation span it is parented to and
// keeps its duration equal to the value it reports.
// keeps its duration equal to the value it reports. The span's end shares that origin for the
// same reason, even if the clock drift was corrected in between.
const performanceTimeOrigin = browserPerformanceTimeOrigin(navigationStartTime || 0) || 0;
const startTime = msToSec(performanceTimeOrigin + (navigationStartTime || 0));
// Without an entry there is no render time to end at, so the span lasts the value it reports,
// like an entry-less INP does. Ending at the time origin instead would invert the span.
Expand Down Expand Up @@ -297,12 +299,13 @@ export function _sendClsSpan(
): void {
DEBUG_BUILD && debug.log(`Sending CLS span (${clsValue})`);

const performanceTimeOrigin = browserPerformanceTimeOrigin();
Comment thread
cursor[bot] marked this conversation as resolved.
// A CLS of 0 has no shift to place the span at. It is reported when the navigation it was
// measured on is already over - the next soft navigation, or pagehide - so the current time would
// land it outside that navigation, on the route that follows it.
const offset = entry?.startTime ?? navigationStartTime ?? 0;
const startTime = performanceTimeOrigin ? msToSec(performanceTimeOrigin + offset) : timestampInSeconds();
// Layout shifts can happen at any point in the page's life, but are only reported on pagehide, so the entry is
// converted against the time origin that was in effect when the shift happened.
const startTime = performanceTimeToSeconds(offset) ?? timestampInSeconds();
const firstSourceNode = entry?.sources[0]?.node;
const selector = entry ? htmlTreeAsString(firstSourceNode) : undefined;
const componentName = firstSourceNode ? getComponentName(firstSourceNode) : null;
Expand Down Expand Up @@ -409,9 +412,9 @@ export function _sendInpSpan(
// A web vital span carries the metric, not a real interaction timing, so an INP without an entry
// is still worth reporting. It just has no element or interaction type to describe, and is placed
// at the start of the navigation it belongs to rather than at the interaction.
const startTime = msToSec(
(browserPerformanceTimeOrigin() as number) + (entry?.startTime ?? metric?.navigationStartTime ?? 0),
);
// INP reports on pagehide, potentially long after the interaction itself, so the entry is converted against the time
// origin that was in effect when it happened rather than the one in effect now.
const startTime = performanceTimeToSeconds(entry?.startTime ?? metric?.navigationStartTime ?? 0) as number;
const duration = msToSec(inpValue);
// An INP without an entry has no interaction type to report. It still has to land inside the
// `ui.interaction.*` family, because falling outside it would hide exactly the fast navigations
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import type { Span } from '@sentry/core';
import * as SentryCore from '@sentry/core';
import { getClient, getMainCarrier, SentrySpan, setCurrentClient, spanToJSON } from '@sentry/core';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { addPerformanceEntries } from '../../src/performance/entries';
import { WINDOW } from '../../src/types';
import { getDefaultClientOptions, TestClient } from '../utils/TestClient';

vi.mock('@sentry/core', async () => {
const actual = await vi.importActual('@sentry/core');
return {
...actual,
browserPerformanceTimeOrigin: vi.fn(),
};
});

const pageloadOriginMs = 1_000_000;
const sleepDurationMs = 3_600_000;
// The monotonic time from which the SDK applies the origin it re-derived after the device slept.
const correctionFromMs = 10_000;

describe('addPerformanceEntries', () => {
beforeEach(() => {
getMainCarrier().__SENTRY__ = undefined;
const client = new TestClient(getDefaultClientOptions({ tracesSampleRate: 1 }));
setCurrentClient(client);
client.init();

vi.mocked(SentryCore.browserPerformanceTimeOrigin).mockImplementation((monotonicTimeInMs = 0) =>
monotonicTimeInMs < correctionFromMs ? pageloadOriginMs : pageloadOriginMs + sleepDurationMs,
);
});

afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
});

it('keeps the resource spans of a navigation that happens after a clock drift correction', () => {
const resourceStartTime = 12_000;
vi.stubGlobal('addEventListener', vi.fn());
vi.stubGlobal('location', { origin: 'https://example.com' });
vi.spyOn(WINDOW.performance, 'getEntries').mockReturnValue([
{
entryType: 'resource',
name: 'https://example.com/app.js',
initiatorType: 'script',
startTime: resourceStartTime,
duration: 100,
responseEnd: resourceStartTime + 100,
toJSON: () => ({}),
} as PerformanceResourceTiming,
]);

const navigationStartTimestamp = (pageloadOriginMs + sleepDurationMs + resourceStartTime - 50) / 1000;
const span = new SentrySpan({
op: 'navigation',
name: '/next',
sampled: true,
startTimestamp: navigationStartTimestamp,
});

const endedSpans: Span[] = [];
getClient()?.on('spanEnd', endedSpan => void endedSpans.push(endedSpan));

addPerformanceEntries(span, { ignoreResourceSpans: [] });

expect(endedSpans.map(spanToJSON)).toEqual([
expect.objectContaining({
start_timestamp: (pageloadOriginMs + sleepDurationMs + resourceStartTime) / 1000,
end_timestamp: (pageloadOriginMs + sleepDurationMs + resourceStartTime + 100) / 1000,
attributes: expect.objectContaining({
'sentry.op': 'resource.script',
'http.request.response_end': (pageloadOriginMs + sleepDurationMs + resourceStartTime + 100) / 1000,
}),
}),
]);
});
});
45 changes: 45 additions & 0 deletions packages/browser-utils/test/web-vitals/spans.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ vi.mock('@sentry/core', async () => {
return {
...actual,
browserPerformanceTimeOrigin: vi.fn(),
performanceTimeToSeconds: vi.fn(),
timestampInSeconds: vi.fn(),
getCurrentScope: vi.fn(),
getClient: vi.fn(),
Expand Down Expand Up @@ -475,6 +476,22 @@ describe('_sendLcpSpan', () => {
expect(mockSpan.end).toHaveBeenCalledWith(3.25);
});

it('times a soft navigation LCP against the origin in effect when the navigation started', () => {
// The soft navigation happens after the SDK corrected its time origin for a clock drift.
const sleepDurationMs = 3_600_000;
vi.mocked(SentryCore.browserPerformanceTimeOrigin).mockImplementation((monotonicTimeInMs = 0) =>
monotonicTimeInMs < 1500 ? 1000 : 1000 + sleepDurationMs,
);
const entry = { element: { tagName: 'img' } as Element, startTime: 2250 } as LargestContentfulPaint;

_sendLcpSpan(250, entry, undefined, 2, 'soft-navigation', 2000);

expect(SentryCoreBrowser.startInactiveSpan).toHaveBeenCalledWith(
expect.objectContaining({ startTime: (1000 + sleepDurationMs + 2000) / 1000 }),
);
expect(mockSpan.end).toHaveBeenCalledWith((1000 + sleepDurationMs + 2250) / 1000);
});

it('drops implausible LCP values', () => {
_sendLcpSpan(0, undefined);
_sendLcpSpan(MAX_PLAUSIBLE_LCP_DURATION + 1, undefined);
Expand All @@ -497,6 +514,7 @@ describe('_sendClsSpan', () => {
beforeEach(() => {
vi.mocked(SentryCore.getCurrentScope).mockReturnValue(mockScope as any);
vi.mocked(SentryCore.browserPerformanceTimeOrigin).mockReturnValue(1000);
vi.mocked(SentryCore.performanceTimeToSeconds).mockImplementation(time => (1000 + time) / 1000);
vi.mocked(SentryCore.timestampInSeconds).mockReturnValue(1.5);
vi.mocked(htmlTreeAsString).mockImplementation((node: any) => `<${node?.tagName || 'div'}>`);
vi.mocked(SentryCoreBrowser.startInactiveSpan).mockReturnValue(mockSpan as any);
Expand Down Expand Up @@ -590,6 +608,7 @@ describe('_sendClsSpan', () => {

it('falls back to the current time when there is no performance time origin', () => {
vi.mocked(SentryCore.browserPerformanceTimeOrigin).mockReturnValue(undefined);
vi.mocked(SentryCore.performanceTimeToSeconds).mockReturnValue(undefined);

_sendClsSpan(0, undefined);

Expand All @@ -612,6 +631,7 @@ describe('_sendInpSpan', () => {
beforeEach(() => {
vi.mocked(SentryCore.getCurrentScope).mockReturnValue(mockScope as any);
vi.mocked(SentryCore.browserPerformanceTimeOrigin).mockReturnValue(1000);
vi.mocked(SentryCore.performanceTimeToSeconds).mockImplementation(time => (1000 + time) / 1000);
vi.mocked(htmlTreeAsString).mockReturnValue('<button>');
vi.mocked(SentryCoreBrowser.startInactiveSpan).mockReturnValue(mockSpan as any);
vi.mocked(SentryCore.getActiveSpan).mockReturnValue(undefined);
Expand Down Expand Up @@ -667,6 +687,29 @@ describe('_sendInpSpan', () => {
expect(mockSpan.end).toHaveBeenCalledWith(1.62);
});

it('times the span against the origin the interaction happened under, not the one in effect at report time', () => {
vi.spyOn(inpModule, 'getCachedInteractionContext').mockReturnValue(undefined);

// INP reports on pagehide. If the device slept in between, the SDK has since re-derived its time origin, but the
// interaction itself still belongs to the timeline it happened on.
const sleepDurationMs = 3_600_000;
vi.mocked(SentryCore.browserPerformanceTimeOrigin).mockReturnValue(1000 + sleepDurationMs);
vi.mocked(SentryCore.performanceTimeToSeconds).mockImplementation(time =>
time < 500 ? (1000 + sleepDurationMs + time) / 1000 : (1000 + time) / 1000,
);

_sendInpSpan(120, {
name: 'pointerdown',
startTime: 500,
duration: 120,
interactionId: 1,
target: { tagName: 'button' },
} as any);

expect(SentryCoreBrowser.startInactiveSpan).toHaveBeenCalledWith(expect.objectContaining({ startTime: 1.5 }));
expect(mockSpan.end).toHaveBeenCalledWith(1.62);
});

it('sends a streamed INP span for a keypress interaction', () => {
vi.spyOn(inpModule, 'getCachedInteractionContext').mockReturnValue(undefined);

Expand Down Expand Up @@ -792,6 +835,7 @@ describe('trackInpAsSpan', () => {

beforeEach(() => {
vi.mocked(SentryCore.browserPerformanceTimeOrigin).mockReturnValue(1000);
vi.mocked(SentryCore.performanceTimeToSeconds).mockImplementation(time => (1000 + time) / 1000);
vi.mocked(SentryCore.getCurrentScope).mockReturnValue(mockScope as any);
vi.mocked(SentryCore.getActiveSpan).mockReturnValue(undefined);
vi.mocked(SentryCoreBrowser.startInactiveSpan).mockReturnValue({ end: vi.fn() } as any);
Expand Down Expand Up @@ -890,6 +934,7 @@ describe('soft navigation web vitals', () => {
supportedEntryTypes: ['largest-contentful-paint', 'layout-shift', 'soft-navigation'],
});
vi.mocked(SentryCore.browserPerformanceTimeOrigin).mockReturnValue(1000);
vi.mocked(SentryCore.performanceTimeToSeconds).mockImplementation(time => (1000 + time) / 1000);
vi.mocked(SentryCore.getCurrentScope).mockReturnValue(mockScope as any);
vi.mocked(SentryCoreBrowser.startInactiveSpan).mockReturnValue({ end: vi.fn() } as any);
vi.mocked(SentryCore.spanToJSON).mockImplementation(
Expand Down
Loading
Loading