Skip to content
Open
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
11 changes: 8 additions & 3 deletions packages/replay-internal/src/util/createPerformanceEntries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -220,7 +220,10 @@ export function getCumulativeLayoutShift(metric: Metric): ReplayPerformanceEntry
}
}

return getWebVital(metric, 'cumulative-layout-shift', nodes, layoutShifts);
// The CLS value is a score, not a time, so we place the event at the last layout shift. A CLS of 0 has no layout
// shift, so it goes at the time origin.
const lastEntry = metric.entries[metric.entries.length - 1];
return getWebVital(metric, 'cumulative-layout-shift', nodes, layoutShifts, lastEntry?.startTime ?? 0);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

l: a soft nav with a CLS of 0 lands at page load here.

metric.navigationStartTime ?? 0 would put it on its navigation, like _sendClsSpan does.

Comment thread
cursor[bot] marked this conversation as resolved.
}

/**
Expand All @@ -230,7 +233,8 @@ export function getInteractionToNextPaint(metric: Metric): ReplayPerformanceEntr
// oxlint-disable-next-line typescript/no-unnecessary-type-assertion -- rule false positive: the cast exposes the entry's `target` field; tsc errors without it
const lastEntry = metric.entries[metric.entries.length - 1] as (PerformanceEntry & { target?: Node }) | undefined;
const node = lastEntry?.target ? [lastEntry.target] : undefined;
return getWebVital(metric, 'interaction-to-next-paint', node);
// The INP value is a duration, not a time, so we place the event at the interaction.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: Soft navigation metrics without entries are incorrectly timestamped at page load because the navigationStartTime fallback is missing in the Replay integration.
Severity: MEDIUM

Suggested Fix

Update the timestamp calculation in createPerformanceEntries.ts to use metric.navigationStartTime as a fallback when lastEntry.startTime is not available. The logic should be changed from lastEntry?.startTime ?? 0 to lastEntry?.startTime ?? metric?.navigationStartTime ?? 0 to correctly timestamp soft navigation events.

Prompt for AI Agent
Review the code at the location below. A potential bug has been identified by an AI
agent. Verify if this is a real issue. If it is, propose a fix; if not, explain why it's
not valid.

Location: packages/replay-internal/src/util/createPerformanceEntries.ts#L236

Potential issue: The `getCumulativeLayoutShift` and `getInteractionToNextPaint`
functions in the Replay integration do not use the `navigationStartTime` property
available on soft navigation metrics. When a soft navigation metric (like INP or CLS) is
reported with a value but has no associated `entries`, the code defaults to a timestamp
of `0`, which corresponds to the initial page load. It should instead use
`navigationStartTime` to place the event at the beginning of the soft navigation. This
results in replay breadcrumbs showing incorrect timestamps, attributing events to the
page load instead of the relevant soft navigation.

Also affects:

  • packages/replay-internal/src/util/createPerformanceEntries.ts:252~252

Did we get this right? 👍 / 👎 to inform future reviews.

return getWebVital(metric, 'interaction-to-next-paint', node, undefined, lastEntry?.startTime ?? 0);
}

/**
Expand All @@ -241,11 +245,12 @@ function getWebVital(
name: string,
nodes: Node[] | undefined,
attributions?: WebVitalData['attributions'],
time = metric.value,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

q: Can't we set this inside this function? It seem we set INP and CLS both the "last entry", but they are both setting it to metric.entries[metric.entries.length - 1]?.startTime ?? 0. Couldn't this be potentially:

const time = metric.entries[metric.entries.length - 1] ?? metric.value

It is a different logic than the current implemented, but asking since LCP doesn't make use of the "last entry". If LCP shouldn't make use of it, maybe we should document when this should be overwritten

@logaretm logaretm Oct 5, 2026 •

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

m: +1, LCP's last entry startTime is its render time, so it works there too. metric.value is wrong as a time for soft navs, prerender and bfcache.

For bfcache there are no entries, so we could fall back to the navigation start:

const lastEntry = metric.entries[metric.entries.length - 1];
const time = lastEntry?.startTime ?? (metric.navigationStartTime ?? 0) + metric.value;

This also would put a CLS of 0 on its soft nav which is correct.

): ReplayPerformanceEntry<WebVitalData> {
const value = metric.value;
const rating = metric.rating;

const end = getAbsoluteTime(value);
const end = getAbsoluteTime(time);

return {
type: 'web-vital',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -148,11 +148,14 @@ describe('Unit | util | createPerformanceEntries', () => {
});

describe('getCumulativeLayoutShift', () => {
it('works with a CLS metric', async () => {
it('places the CLS event at the last layout shift', async () => {
const metric = {
value: 5108.299,
rating: 'good' as const,
entries: [],
value: 0.15,
rating: 'needs-improvement' as const,
entries: [
{ entryType: 'layout-shift', startTime: 1000, value: 0.05, sources: [], hadRecentInput: false },
{ entryType: 'layout-shift', startTime: 5108.299, value: 0.1, sources: [], hadRecentInput: false },
] as unknown as PerformanceEntry[],
};

const event = getCumulativeLayoutShift(metric);
Expand All @@ -162,17 +165,56 @@ describe('Unit | util | createPerformanceEntries', () => {
name: 'cumulative-layout-shift',
start: 1672531205.108299,
end: 1672531205.108299,
data: { value: 5108.299, size: 5108.299, rating: 'good', nodeIds: [], attributions: [] },
data: {
value: 0.15,
size: 0.15,
rating: 'needs-improvement',
nodeIds: [],
attributions: [
{ value: 0.05, nodeIds: undefined },
{ value: 0.1, nodeIds: undefined },
],
},
});
});

it('uses the time origin from when the last layout shift happened', () => {
const driftPointMs = 200_000;
const sleepDurationMs = 3_600_000;
vi.mocked(browserPerformanceTimeOrigin).mockImplementation((time = 0) =>
time < driftPointMs ? TIME_ORIGIN : TIME_ORIGIN + sleepDurationMs,
);

const event = getCumulativeLayoutShift({
value: 0.1,
rating: 'good',
entries: [
{ entryType: 'layout-shift', startTime: driftPointMs + 1000, value: 0.1, sources: [], hadRecentInput: false },
] as unknown as PerformanceEntry[],
});

expect(event.start).toBe((TIME_ORIGIN + sleepDurationMs + driftPointMs + 1000) / 1000);
});

it('places a CLS of 0 at the time origin', async () => {
const event = getCumulativeLayoutShift({ value: 0, rating: 'good', entries: [] });

expect(event).toEqual({
type: 'web-vital',
name: 'cumulative-layout-shift',
start: TIME_ORIGIN / 1000,
end: TIME_ORIGIN / 1000,
data: { value: 0, size: 0, rating: 'good', nodeIds: [], attributions: [] },
});
});
});

describe('getInteractionToNextPaint', () => {
it('works with an INP metric', async () => {
it('places the INP event at the interaction', async () => {
const metric = {
value: 5108.299,
value: 120,
rating: 'good' as const,
entries: [],
entries: [{ name: 'click', entryType: 'event', startTime: 5108.299, duration: 120 }] as PerformanceEntry[],
};

const event = getInteractionToNextPaint(metric);
Expand All @@ -182,8 +224,26 @@ describe('Unit | util | createPerformanceEntries', () => {
name: 'interaction-to-next-paint',
start: 1672531205.108299,
end: 1672531205.108299,
data: { value: 5108.299, size: 5108.299, rating: 'good', nodeIds: undefined, attributions: undefined },
data: { value: 120, size: 120, rating: 'good', nodeIds: undefined, attributions: undefined },
});
});

it('uses the time origin from when the interaction happened', () => {
const driftPointMs = 200_000;
const sleepDurationMs = 3_600_000;
vi.mocked(browserPerformanceTimeOrigin).mockImplementation((time = 0) =>
time < driftPointMs ? TIME_ORIGIN : TIME_ORIGIN + sleepDurationMs,
);

const event = getInteractionToNextPaint({
value: 120,
rating: 'good',
entries: [
{ name: 'click', entryType: 'event', startTime: driftPointMs + 1000, duration: 120 },
] as PerformanceEntry[],
});

expect(event.start).toBe((TIME_ORIGIN + sleepDurationMs + driftPointMs + 1000) / 1000);
});
});
});
Loading