diff --git a/packages/integration-tests/suites/replay/customEvents/test.ts b/packages/integration-tests/suites/replay/customEvents/test.ts index a317ea281e9b..a0d790a5b62c 100644 --- a/packages/integration-tests/suites/replay/customEvents/test.ts +++ b/packages/integration-tests/suites/replay/customEvents/test.ts @@ -10,6 +10,7 @@ import { expectedNavigationPerformanceSpan, getExpectedReplayEvent, } from '../../../utils/replayEventTemplates'; +import type { PerformanceSpan } from '../../../utils/replayHelpers'; import { getCustomRecordingEvents, getReplayEvent, @@ -68,6 +69,13 @@ sentryTest( expectedMemoryPerformanceSpan, ]), ); + + const lcpSpan = collectedPerformanceSpans.find( + s => s.description === 'largest-contentful-paint', + ) as PerformanceSpan; + + // LCP spans should be point-in-time spans + expect(lcpSpan?.startTimestamp).toBeCloseTo(lcpSpan?.endTimestamp); }, ); diff --git a/packages/integration-tests/utils/replayHelpers.ts b/packages/integration-tests/utils/replayHelpers.ts index 8238e986b0cb..1eac33fd1987 100644 --- a/packages/integration-tests/utils/replayHelpers.ts +++ b/packages/integration-tests/utils/replayHelpers.ts @@ -6,7 +6,7 @@ import type { Page, Request } from 'playwright'; import { envelopeRequestParser } from './helpers'; type CustomRecordingEvent = { tag: string; payload: Record }; -type PerformanceSpan = { +export type PerformanceSpan = { op: string; description: string; startTimestamp: number; diff --git a/packages/replay/src/util/createPerformanceEntries.ts b/packages/replay/src/util/createPerformanceEntries.ts index 333a7fe5d809..304ab817001c 100644 --- a/packages/replay/src/util/createPerformanceEntries.ts +++ b/packages/replay/src/util/createPerformanceEntries.ts @@ -117,16 +117,19 @@ function createLargestContentfulPaint(entry: PerformanceEntry & { size: number; startTimeOrNavigationActivation = (navEntry && navEntry.activationStart) || 0; } - const start = getAbsoluteTime(startTimeOrNavigationActivation); + // value is in ms const value = Math.max(startTime - startTimeOrNavigationActivation, 0); + // LCP doesn't have a "duration", it just happens at a single point in time. + // But the UI expects both, so use end (in seconds) for both timestamps. + const end = getAbsoluteTime(startTimeOrNavigationActivation) + value / 1000; return { type: entryType, name: entryType, - start, - end: start + value, + start: end, + end, data: { - value, + value, // LCP "duration" in ms size, // Not sure why this errors, Node should be correct (Argument of type 'Node' is not assignable to parameter of type 'INode') // eslint-disable-next-line @typescript-eslint/no-explicit-any diff --git a/packages/replay/test/fixtures/performanceEntry/lcp.ts b/packages/replay/test/fixtures/performanceEntry/lcp.ts index 7ad0bf7b4637..4db2eb53f565 100644 --- a/packages/replay/test/fixtures/performanceEntry/lcp.ts +++ b/packages/replay/test/fixtures/performanceEntry/lcp.ts @@ -4,10 +4,10 @@ export function PerformanceEntryLcp(obj?: Partial): Perf const entry = { name: '', entryType: 'largest-contentful-paint', - startTime: 108.299, + startTime: 5108.299, duration: 0, size: 7619, - renderTime: 108.299, + renderTime: 5108.299, loadTime: 0, firstAnimatedFrameTime: 0, id: '', diff --git a/packages/replay/test/fixtures/performanceEntry/navigation.ts b/packages/replay/test/fixtures/performanceEntry/navigation.ts index 7de16e9aaf98..d76ebce86538 100644 --- a/packages/replay/test/fixtures/performanceEntry/navigation.ts +++ b/packages/replay/test/fixtures/performanceEntry/navigation.ts @@ -2,6 +2,7 @@ import type { PerformanceNavigationTiming } from '../../../src/types'; export function PerformanceEntryNavigation(obj?: Partial): PerformanceNavigationTiming { const entry = { + activationStart: 0, name: 'https://sentry.io/organizations/sentry/discover/', entryType: 'navigation', startTime: 0, diff --git a/packages/replay/test/unit/util/createPerformanceEntry.test.ts b/packages/replay/test/unit/util/createPerformanceEntry.test.ts index cd250f7db64c..57cb780742eb 100644 --- a/packages/replay/test/unit/util/createPerformanceEntry.test.ts +++ b/packages/replay/test/unit/util/createPerformanceEntry.test.ts @@ -1,6 +1,32 @@ +jest.useFakeTimers().setSystemTime(new Date('2023-01-01')); + +jest.mock('@sentry/utils', () => ({ + ...jest.requireActual('@sentry/utils'), + browserPerformanceTimeOrigin: Date.now(), +})); + +import { WINDOW } from '../../../src/constants'; import { createPerformanceEntries } from '../../../src/util/createPerformanceEntries'; +import { PerformanceEntryLcp } from '../../fixtures/performanceEntry/lcp'; +import { PerformanceEntryNavigation } from '../../fixtures/performanceEntry/navigation'; describe('Unit | util | createPerformanceEntries', () => { + beforeEach(function () { + if (!WINDOW.performance.getEntriesByType) { + WINDOW.performance.getEntriesByType = jest.fn((type: string) => { + if (type === 'navigation') { + return [PerformanceEntryNavigation()]; + } + throw new Error(`entry ${type} not mocked`); + }); + } + }); + + afterAll(function () { + jest.clearAllMocks(); + jest.useRealTimers(); + }); + it('ignores sdks own requests', function () { const data = { name: 'https://ingest.f00.f00/api/1/envelope/?sentry_key=dsn&sentry_version=7', @@ -31,4 +57,21 @@ describe('Unit | util | createPerformanceEntries', () => { // @ts-ignore Needs a PerformanceEntry mock expect(createPerformanceEntries([data])).toEqual([]); }); + + it('has correct LCP entry when no navigation event', function () { + const result = createPerformanceEntries([PerformanceEntryLcp()]); + expect(result).toEqual([ + { + data: { + nodeId: -1, + size: 7619, + value: 5108.299, + }, + name: 'largest-contentful-paint', + type: 'largest-contentful-paint', + end: 1672531205.108299, + start: 1672531205.108299, + }, + ]); + }); });