diff --git a/packages/rum-core/src/domain/view/viewMetrics/startInitialViewMetricsTelemetry.spec.ts b/packages/rum-core/src/domain/view/viewMetrics/startInitialViewMetricsTelemetry.spec.ts index 26642b8123..c4d7fb8096 100644 --- a/packages/rum-core/src/domain/view/viewMetrics/startInitialViewMetricsTelemetry.spec.ts +++ b/packages/rum-core/src/domain/view/viewMetrics/startInitialViewMetricsTelemetry.spec.ts @@ -1,4 +1,5 @@ -import type { Telemetry, RelativeTime, Duration, RawTelemetryEvent } from '@datadog/browser-core' +import type { Telemetry, RelativeTime, Duration, RawTelemetryEvent, PageMayExitEvent } from '@datadog/browser-core' +import { PageExitReason } from '@datadog/browser-core' import type { MockTelemetry } from '@datadog/browser-core/test' import { registerCleanupTask, startMockTelemetry } from '@datadog/browser-core/test' import type { RumConfiguration } from '@datadog/browser-rum-core' @@ -39,6 +40,18 @@ const TELEMETRY_FOR_VIEW_METRICS: RawTelemetryEvent = { }, } +const TELEMETRY_FOR_EARLY_PAGE_UNLOAD: RawTelemetryEvent = { + type: 'log', + status: 'debug', + message: 'Initial view metrics', + metrics: { + earlyPageUnload: { + domContentLoaded: jasmine.anything(), + timestamp: jasmine.anything(), + }, + }, +} + describe('startInitialViewMetricsTelemetry', () => { const lifeCycle = new LifeCycle() let telemetry: MockTelemetry @@ -49,6 +62,10 @@ describe('startInitialViewMetricsTelemetry', () => { telemetrySampleRate: 100, } + function generatePageMayExit(reason: PageExitReason) { + lifeCycle.notify(LifeCycleEventType.PAGE_MAY_EXIT, { reason } as PageMayExitEvent) + } + function generateViewUpdateWithInitialViewMetrics(initialViewMetrics: Partial) { lifeCycle.notify(LifeCycleEventType.VIEW_UPDATED, { initialViewMetrics } as ViewEvent) } @@ -72,6 +89,12 @@ describe('startInitialViewMetricsTelemetry', () => { expect(await telemetry.getEvents()).toEqual([jasmine.objectContaining(TELEMETRY_FOR_VIEW_METRICS)]) }) + it('should collect minimal initial view metrics telemetry if page unloads early', async () => { + startInitialViewMetricsTelemetryCollection() + generatePageMayExit(PageExitReason.UNLOADING) + expect(await telemetry.getEvents()).toEqual([jasmine.objectContaining(TELEMETRY_FOR_EARLY_PAGE_UNLOAD)]) + }) + it('should not collect initial view metrics telemetry twice', async () => { startInitialViewMetricsTelemetryCollection() @@ -88,6 +111,36 @@ describe('startInitialViewMetricsTelemetry', () => { expect(await telemetry.hasEvents()).toBe(false) }) + it('should not collect early page unload telemetry if page is not unloading', async () => { + startInitialViewMetricsTelemetryCollection() + generatePageMayExit(PageExitReason.FROZEN) + generatePageMayExit(PageExitReason.HIDDEN) + generatePageMayExit(PageExitReason.PAGEHIDE) + expect(await telemetry.hasEvents()).toBe(false) + }) + + it('should not collect early page unload telemetry if initial view metrics were already collected', async () => { + startInitialViewMetricsTelemetryCollection() + + generateViewUpdateWithInitialViewMetrics(VIEW_METRICS) + expect(await telemetry.getEvents()).toEqual([jasmine.objectContaining(TELEMETRY_FOR_VIEW_METRICS)]) + telemetry.reset() + + generatePageMayExit(PageExitReason.UNLOADING) + expect(await telemetry.hasEvents()).toBe(false) + }) + + it('should collect initial view metrics even if page unload telemetry was already collected', async () => { + startInitialViewMetricsTelemetryCollection() + + generatePageMayExit(PageExitReason.UNLOADING) + expect(await telemetry.getEvents()).toEqual([jasmine.objectContaining(TELEMETRY_FOR_EARLY_PAGE_UNLOAD)]) + telemetry.reset() + + generateViewUpdateWithInitialViewMetrics(VIEW_METRICS) + expect(await telemetry.getEvents()).toEqual([jasmine.objectContaining(TELEMETRY_FOR_VIEW_METRICS)]) + }) + it('should not collect initial view metrics telemetry until LCP is known', async () => { startInitialViewMetricsTelemetryCollection() @@ -123,5 +176,7 @@ describe('startInitialViewMetricsTelemetry', () => { }) generateViewUpdateWithInitialViewMetrics(VIEW_METRICS) expect(await telemetry.hasEvents()).toBe(false) + generatePageMayExit(PageExitReason.UNLOADING) + expect(await telemetry.hasEvents()).toBe(false) }) }) diff --git a/packages/rum-core/src/domain/view/viewMetrics/startInitialViewMetricsTelemetry.ts b/packages/rum-core/src/domain/view/viewMetrics/startInitialViewMetricsTelemetry.ts index f652693694..6eec95fb89 100644 --- a/packages/rum-core/src/domain/view/viewMetrics/startInitialViewMetricsTelemetry.ts +++ b/packages/rum-core/src/domain/view/viewMetrics/startInitialViewMetricsTelemetry.ts @@ -1,5 +1,6 @@ -import type { Context, Telemetry } from '@datadog/browser-core' -import { performDraw, addTelemetryMetrics, noop } from '@datadog/browser-core' +import type { Context, RelativeTime, Telemetry } from '@datadog/browser-core' +import { PageExitReason, performDraw, addTelemetryMetrics, noop, relativeNow } from '@datadog/browser-core' +import { getNavigationEntry } from '../../../browser/performanceUtils' import { LifeCycleEventType } from '../../lifeCycle' import type { LifeCycle } from '../../lifeCycle' import type { RumConfiguration } from '../../configuration' @@ -8,7 +9,7 @@ import type { NavigationTimings } from './trackNavigationTimings' const INITIAL_VIEW_METRICS_TELEMETRY_NAME = 'Initial view metrics' -interface CoreInitialViewMetrics extends Context { +interface AfterPageLoadInitialViewMetrics extends Context { lcp: { value: number } @@ -21,6 +22,13 @@ interface CoreInitialViewMetrics extends Context { } } +interface EarlyPageUnloadInitialViewMetrics extends Context { + earlyPageUnload: { + domContentLoaded: number | undefined + timestamp: number + } +} + export function startInitialViewMetricsTelemetry( configuration: RumConfiguration, lifeCycle: LifeCycle, @@ -32,38 +40,65 @@ export function startInitialViewMetricsTelemetry( return { stop: noop } } - const { unsubscribe } = lifeCycle.subscribe(LifeCycleEventType.VIEW_UPDATED, ({ initialViewMetrics }) => { - if (!initialViewMetrics.largestContentfulPaint || !initialViewMetrics.navigationTimings) { - return + const { unsubscribe: unsubscribePageMayExit } = lifeCycle.subscribe( + LifeCycleEventType.PAGE_MAY_EXIT, + ({ reason }) => { + if (reason !== PageExitReason.UNLOADING) { + return + } + + const navigationEntry = getNavigationEntry() + addTelemetryMetrics(INITIAL_VIEW_METRICS_TELEMETRY_NAME, { + metrics: createEarlyPageUnloadInitialViewMetrics(navigationEntry.domContentLoadedEventEnd, relativeNow()), + }) + + // Only send metrics in response to PAGE_MAY_EXIT once, but keep the subscription to + // VIEW_UPDATED in case the page doesn't actually exit and we do eventually get + // final numbers. + unsubscribePageMayExit() } + ) + + const { unsubscribe: unsubscribeViewUpdated } = lifeCycle.subscribe( + LifeCycleEventType.VIEW_UPDATED, + ({ initialViewMetrics }) => { + if (!initialViewMetrics.largestContentfulPaint || !initialViewMetrics.navigationTimings) { + return + } - // The navigation timings become available shortly after the load event fires, so - // we're snapshotting the LCP value available at that point. However, more LCP values - // can be emitted until the page is scrolled or interacted with, so it's possible that - // the final LCP value may differ. These metrics are intended to help diagnose - // performance issues early in the page load process, and using LCP-at-page-load is a - // good fit for that use case, but it's important to be aware that this is not - // necessarily equivalent to the normal LCP metric. + // The navigation timings become available shortly after the load event fires, so + // we're snapshotting the LCP value available at that point. However, more LCP values + // can be emitted until the page is scrolled or interacted with, so it's possible that + // the final LCP value may differ. These metrics are intended to help diagnose + // performance issues early in the page load process, and using LCP-at-page-load is a + // good fit for that use case, but it's important to be aware that this is not + // necessarily equivalent to the normal LCP metric. - addTelemetryMetrics(INITIAL_VIEW_METRICS_TELEMETRY_NAME, { - metrics: createCoreInitialViewMetrics( - initialViewMetrics.largestContentfulPaint, - initialViewMetrics.navigationTimings - ), - }) + addTelemetryMetrics(INITIAL_VIEW_METRICS_TELEMETRY_NAME, { + metrics: createAfterPageLoadInitialViewMetrics( + initialViewMetrics.largestContentfulPaint, + initialViewMetrics.navigationTimings + ), + }) - unsubscribe() - }) + // Don't send any further metrics. + unsubscribePageMayExit() + unsubscribeViewUpdated() + } + ) return { - stop: unsubscribe, + stop: () => { + unsubscribePageMayExit() + unsubscribeViewUpdated() + }, } } -function createCoreInitialViewMetrics( +function createAfterPageLoadInitialViewMetrics( lcp: LargestContentfulPaint, navigation: NavigationTimings -): CoreInitialViewMetrics { +): AfterPageLoadInitialViewMetrics { return { lcp: { value: lcp.value, @@ -77,3 +112,15 @@ function createCoreInitialViewMetrics( }, } } + +function createEarlyPageUnloadInitialViewMetrics( + domContentLoadedEventEnd: RelativeTime, + timestamp: RelativeTime +): EarlyPageUnloadInitialViewMetrics { + return { + earlyPageUnload: { + domContentLoaded: domContentLoadedEventEnd > 0 ? domContentLoadedEventEnd : undefined, + timestamp, + }, + } +}