diff --git a/frontend/__snapshots__/components-networkrequest-navigationitem--all-slow--dark.png b/frontend/__snapshots__/components-networkrequest-navigationitem--all-slow--dark.png index 3c683404aaa08..f8d9ebf34b596 100644 Binary files a/frontend/__snapshots__/components-networkrequest-navigationitem--all-slow--dark.png and b/frontend/__snapshots__/components-networkrequest-navigationitem--all-slow--dark.png differ diff --git a/frontend/__snapshots__/components-networkrequest-navigationitem--all-slow--light.png b/frontend/__snapshots__/components-networkrequest-navigationitem--all-slow--light.png index 5e1ecd3ade756..9391040853b10 100644 Binary files a/frontend/__snapshots__/components-networkrequest-navigationitem--all-slow--light.png and b/frontend/__snapshots__/components-networkrequest-navigationitem--all-slow--light.png differ diff --git a/frontend/__snapshots__/components-networkrequest-navigationitem--default--dark.png b/frontend/__snapshots__/components-networkrequest-navigationitem--default--dark.png index 365aee3e010cb..3d58b4fea9f81 100644 Binary files a/frontend/__snapshots__/components-networkrequest-navigationitem--default--dark.png and b/frontend/__snapshots__/components-networkrequest-navigationitem--default--dark.png differ diff --git a/frontend/__snapshots__/components-networkrequest-navigationitem--default--light.png b/frontend/__snapshots__/components-networkrequest-navigationitem--default--light.png index c9eda9ea22ff7..5232ed893b2dc 100644 Binary files a/frontend/__snapshots__/components-networkrequest-navigationitem--default--light.png and b/frontend/__snapshots__/components-networkrequest-navigationitem--default--light.png differ diff --git a/frontend/__snapshots__/components-networkrequest-navigationitem--expanded--dark.png b/frontend/__snapshots__/components-networkrequest-navigationitem--expanded--dark.png index 365aee3e010cb..3d58b4fea9f81 100644 Binary files a/frontend/__snapshots__/components-networkrequest-navigationitem--expanded--dark.png and b/frontend/__snapshots__/components-networkrequest-navigationitem--expanded--dark.png differ diff --git a/frontend/__snapshots__/components-networkrequest-navigationitem--expanded--light.png b/frontend/__snapshots__/components-networkrequest-navigationitem--expanded--light.png index c9eda9ea22ff7..5232ed893b2dc 100644 Binary files a/frontend/__snapshots__/components-networkrequest-navigationitem--expanded--light.png and b/frontend/__snapshots__/components-networkrequest-navigationitem--expanded--light.png differ diff --git a/frontend/__snapshots__/components-networkrequest-navigationitem--really-slow-dom-interactive--dark.png b/frontend/__snapshots__/components-networkrequest-navigationitem--really-slow-dom-interactive--dark.png index c0d848ec2933f..69f229638345c 100644 Binary files a/frontend/__snapshots__/components-networkrequest-navigationitem--really-slow-dom-interactive--dark.png and b/frontend/__snapshots__/components-networkrequest-navigationitem--really-slow-dom-interactive--dark.png differ diff --git a/frontend/__snapshots__/components-networkrequest-navigationitem--really-slow-dom-interactive--light.png b/frontend/__snapshots__/components-networkrequest-navigationitem--really-slow-dom-interactive--light.png index 402a521b246b3..67f440939f1cb 100644 Binary files a/frontend/__snapshots__/components-networkrequest-navigationitem--really-slow-dom-interactive--light.png and b/frontend/__snapshots__/components-networkrequest-navigationitem--really-slow-dom-interactive--light.png differ diff --git a/frontend/__snapshots__/components-networkrequest-navigationitem--really-slow-fcp--dark.png b/frontend/__snapshots__/components-networkrequest-navigationitem--really-slow-fcp--dark.png index 65ad534220f53..6ab711ef05d0a 100644 Binary files a/frontend/__snapshots__/components-networkrequest-navigationitem--really-slow-fcp--dark.png and b/frontend/__snapshots__/components-networkrequest-navigationitem--really-slow-fcp--dark.png differ diff --git a/frontend/__snapshots__/components-networkrequest-navigationitem--really-slow-fcp--light.png b/frontend/__snapshots__/components-networkrequest-navigationitem--really-slow-fcp--light.png index 4c2b8804e1362..99766bf651e0a 100644 Binary files a/frontend/__snapshots__/components-networkrequest-navigationitem--really-slow-fcp--light.png and b/frontend/__snapshots__/components-networkrequest-navigationitem--really-slow-fcp--light.png differ diff --git a/frontend/__snapshots__/components-networkrequest-navigationitem--really-slow-load-event--dark.png b/frontend/__snapshots__/components-networkrequest-navigationitem--really-slow-load-event--dark.png index c0d04bac886ca..6da90315d9faa 100644 Binary files a/frontend/__snapshots__/components-networkrequest-navigationitem--really-slow-load-event--dark.png and b/frontend/__snapshots__/components-networkrequest-navigationitem--really-slow-load-event--dark.png differ diff --git a/frontend/__snapshots__/components-networkrequest-navigationitem--really-slow-load-event--light.png b/frontend/__snapshots__/components-networkrequest-navigationitem--really-slow-load-event--light.png index 3f419e266e06e..5bd2d952043d1 100644 Binary files a/frontend/__snapshots__/components-networkrequest-navigationitem--really-slow-load-event--light.png and b/frontend/__snapshots__/components-networkrequest-navigationitem--really-slow-load-event--light.png differ diff --git a/frontend/__snapshots__/components-networkrequest-navigationitem--slow-dom-interactive--dark.png b/frontend/__snapshots__/components-networkrequest-navigationitem--slow-dom-interactive--dark.png index 4b6a3f1a3021c..4d270ab840bcf 100644 Binary files a/frontend/__snapshots__/components-networkrequest-navigationitem--slow-dom-interactive--dark.png and b/frontend/__snapshots__/components-networkrequest-navigationitem--slow-dom-interactive--dark.png differ diff --git a/frontend/__snapshots__/components-networkrequest-navigationitem--slow-dom-interactive--light.png b/frontend/__snapshots__/components-networkrequest-navigationitem--slow-dom-interactive--light.png index 9b2e8044a4dd1..50cae88b6dc3f 100644 Binary files a/frontend/__snapshots__/components-networkrequest-navigationitem--slow-dom-interactive--light.png and b/frontend/__snapshots__/components-networkrequest-navigationitem--slow-dom-interactive--light.png differ diff --git a/frontend/__snapshots__/components-networkrequest-navigationitem--slow-fcp--dark.png b/frontend/__snapshots__/components-networkrequest-navigationitem--slow-fcp--dark.png index 18269729036eb..8cea1a608ddfe 100644 Binary files a/frontend/__snapshots__/components-networkrequest-navigationitem--slow-fcp--dark.png and b/frontend/__snapshots__/components-networkrequest-navigationitem--slow-fcp--dark.png differ diff --git a/frontend/__snapshots__/components-networkrequest-navigationitem--slow-fcp--light.png b/frontend/__snapshots__/components-networkrequest-navigationitem--slow-fcp--light.png index 1df2983b4e8d7..af3e1a0be1258 100644 Binary files a/frontend/__snapshots__/components-networkrequest-navigationitem--slow-fcp--light.png and b/frontend/__snapshots__/components-networkrequest-navigationitem--slow-fcp--light.png differ diff --git a/frontend/__snapshots__/components-networkrequest-navigationitem--slow-load-event--dark.png b/frontend/__snapshots__/components-networkrequest-navigationitem--slow-load-event--dark.png index c52430b5d44d2..6e2680e95f4e7 100644 Binary files a/frontend/__snapshots__/components-networkrequest-navigationitem--slow-load-event--dark.png and b/frontend/__snapshots__/components-networkrequest-navigationitem--slow-load-event--dark.png differ diff --git a/frontend/__snapshots__/components-networkrequest-navigationitem--slow-load-event--light.png b/frontend/__snapshots__/components-networkrequest-navigationitem--slow-load-event--light.png index 178f9aabba2ae..89f3daec08d84 100644 Binary files a/frontend/__snapshots__/components-networkrequest-navigationitem--slow-load-event--light.png and b/frontend/__snapshots__/components-networkrequest-navigationitem--slow-load-event--light.png differ diff --git a/frontend/__snapshots__/components-playerinspector-itemevent--page-view-with-current-url--dark.png b/frontend/__snapshots__/components-playerinspector-itemevent--page-view-with-current-url--dark.png index 19d73bc7ffd6f..4a5d7b827fa26 100644 Binary files a/frontend/__snapshots__/components-playerinspector-itemevent--page-view-with-current-url--dark.png and b/frontend/__snapshots__/components-playerinspector-itemevent--page-view-with-current-url--dark.png differ diff --git a/frontend/__snapshots__/components-playerinspector-itemevent--page-view-with-current-url--light.png b/frontend/__snapshots__/components-playerinspector-itemevent--page-view-with-current-url--light.png index 55895c53e01d8..54925db899a2c 100644 Binary files a/frontend/__snapshots__/components-playerinspector-itemevent--page-view-with-current-url--light.png and b/frontend/__snapshots__/components-playerinspector-itemevent--page-view-with-current-url--light.png differ diff --git a/frontend/__snapshots__/components-playerinspector-itemevent--page-view-with-path--dark.png b/frontend/__snapshots__/components-playerinspector-itemevent--page-view-with-path--dark.png index cb2809ec33e2d..950725713e7a0 100644 Binary files a/frontend/__snapshots__/components-playerinspector-itemevent--page-view-with-path--dark.png and b/frontend/__snapshots__/components-playerinspector-itemevent--page-view-with-path--dark.png differ diff --git a/frontend/__snapshots__/components-playerinspector-itemevent--page-view-with-path--light.png b/frontend/__snapshots__/components-playerinspector-itemevent--page-view-with-path--light.png index 09bd6c91f512e..853ad7a6329d1 100644 Binary files a/frontend/__snapshots__/components-playerinspector-itemevent--page-view-with-path--light.png and b/frontend/__snapshots__/components-playerinspector-itemevent--page-view-with-path--light.png differ diff --git a/frontend/__snapshots__/components-playerinspector-itemevent--web-vitals-event--dark.png b/frontend/__snapshots__/components-playerinspector-itemevent--web-vitals-event--dark.png index a764aca1b0182..38a99e796fa43 100644 Binary files a/frontend/__snapshots__/components-playerinspector-itemevent--web-vitals-event--dark.png and b/frontend/__snapshots__/components-playerinspector-itemevent--web-vitals-event--dark.png differ diff --git a/frontend/__snapshots__/components-playerinspector-itemevent--web-vitals-event--light.png b/frontend/__snapshots__/components-playerinspector-itemevent--web-vitals-event--light.png index 5d451dac3493a..c39cb56172b54 100644 Binary files a/frontend/__snapshots__/components-playerinspector-itemevent--web-vitals-event--light.png and b/frontend/__snapshots__/components-playerinspector-itemevent--web-vitals-event--light.png differ diff --git a/frontend/src/lib/components/TitledSnack.tsx b/frontend/src/lib/components/TitledSnack.tsx index 5c5cbacc15be2..c6843cab426cc 100644 --- a/frontend/src/lib/components/TitledSnack.tsx +++ b/frontend/src/lib/components/TitledSnack.tsx @@ -4,8 +4,10 @@ export function TitledSnack({ title, value, type = 'default', + titleSuffix = ':', }: { title: string + titleSuffix?: string value: string | JSX.Element type?: 'default' | 'success' }): JSX.Element { @@ -20,7 +22,10 @@ export function TitledSnack({ type === 'success' ? 'bg-success-highlight' : 'bg-primary-highlight' )} > - {title}: + + {title} + {titleSuffix} + - The First Contentful Paint (FCP) metric measures the time from when the page starts loading to when any - part of the page's content is rendered on the screen.{' '} - - Read more on developer.mozilla.org - - - ), - key: 'first_contentful_paint', - scoreBenchmarks: [1800, 3000], - }, - { - label: 'DOM Interactive', - description: ( -
- The document has finished loading and the document has been parsed but sub-resources such as scripts, - images, stylesheets and frames are still loading.{' '} - - Read more on developer.mozilla.org - -
- ), - key: 'dom_interactive', - scoreBenchmarks: [3800, 7300], - }, - { - label: 'Page Loaded', - description: ( -
- The load event is fired when the whole page has loaded, including all dependent resources such as - stylesheets and images. This is in contrast to DOMContentLoaded, which is fired as soon as the page DOM - has been loaded, without waiting for resources to finish loading.{' '} - - Read more on developer.mozilla.org - -
- ), - key: 'load_event_end', - scoreBenchmarks: [3800, 7300], - }, -] + +import { PerformanceEvent, RecordingEventType } from '~/types' + +interface SummaryCardData { + label: string + description: JSX.Element + scoreBenchmarks: number[] +} + +const fcpSummary: SummaryCardData = { + label: 'First Contentful Paint', + description: ( +
+ The First Contentful Paint (FCP) metric measures the time from when the page starts loading to when any part + of the page's content is rendered on the screen.{' '} + + Read more on developer.mozilla.org + +
+ ), + scoreBenchmarks: [1800, 3000], +} + +const domInteractiveSummary: SummaryCardData = { + label: 'DOM Interactive', + description: ( +
+ The document has finished loading and the document has been parsed but sub-resources such as scripts, + images, stylesheets and frames are still loading.{' '} + + Read more on developer.mozilla.org + +
+ ), + scoreBenchmarks: [3800, 7300], +} + +const pageLoadedSummary: SummaryCardData = { + label: 'Page Loaded', + description: ( +
+ The load event is fired when the whole page has loaded, including all dependent resources such as + stylesheets and images. This is in contrast to DOMContentLoaded, which is fired as soon as the page DOM has + been loaded, without waiting for resources to finish loading.{' '} + + Read more on developer.mozilla.org + +
+ ), + scoreBenchmarks: [3800, 7300], +} + +const clsSummary: SummaryCardData = { + label: 'Cumulative layout shift', + description: ( +
+ Cumulative layout shift measures the extent to which users encounter unexpected layout shifts, in which + elements of the page are moved in an unexpected way: that is, that are not the result of a user action like + pressing a button or part of an animation.{' '} + + Read more on developer.mozilla.org + +
+ ), + + scoreBenchmarks: [0.1, 0.25], +} + +const lcpSummary: SummaryCardData = { + label: 'Largest Contentful Paint', + description: ( +
+ The Largest Contentful Paint (LCP) performance metric provides the render time of the largest image or text + block visible within the viewport, recorded from when the page first begins to load.{' '} + + Read more on developer.mozilla.org + +
+ ), + + scoreBenchmarks: [2500, 4000], +} + +const inpSummary: SummaryCardData = { + label: 'Interaction to next paint', + description: ( +
+ INP is a metric that assesses a page's overall responsiveness to user interactions by observing the latency + of all click, tap, and keyboard interactions that occur throughout the lifespan of a user's visit to a page. + The final INP value is the longest interaction observed, ignoring outliers.{' '} + + Read more on web.dev + +
+ ), + + scoreBenchmarks: [200, 500], +} + +const summaryMapping = { + domInteractive: domInteractiveSummary, + fcp: fcpSummary, + pageLoaded: pageLoadedSummary, + lcp: lcpSummary, + cls: clsSummary, + inp: inpSummary, +} export function PerformanceDuration({ value, - benchmarkKey, + benchmarks, }: { - benchmarkKey: string + benchmarks: number[] value: number | undefined }): JSX.Element { - const scoreBenchmarks = performanceSummaryCards.find(({ key }) => key === benchmarkKey)?.scoreBenchmarks ?? [ - 3000, 6000, - ] return value === undefined ? ( <>- ) : ( = scoreBenchmarks[1], - 'text-warning-dark': value >= scoreBenchmarks[0] && value < scoreBenchmarks[1], - 'text-success-dark': value < scoreBenchmarks[0], + 'text-danger-dark': value >= benchmarks[1], + 'text-warning-dark': value >= benchmarks[0] && value < benchmarks[1], + 'text-success-dark': value < benchmarks[0], })} > {humanFriendlyMilliseconds(value)} @@ -90,37 +148,61 @@ export function PerformanceDuration({ ) } -function PerformanceCard({ - description, - label, - value, - benchmarkKey, -}: { - benchmarkKey: string +function PerformanceCard(props: { + benchmarks: number[] description: JSX.Element label: string value: number | undefined }): JSX.Element { return ( - +
-
{label}
+
{props.label}
- +
) } +function itemToPerformanceValues(item: PerformanceEvent): { + cls?: number + lcp?: number + fcp?: number + inp?: number + domInteractive?: number + pageLoaded?: number +} { + const webVitals: RecordingEventType[] = item.web_vitals ? Array.from(item.web_vitals) : [] + const clsValue = webVitals.find((event) => event.properties.$web_vitals_CLS_value)?.properties.$web_vitals_CLS_value + const lcpValue = webVitals.find((event) => event.properties.$web_vitals_LCP_value)?.properties.$web_vitals_LCP_value + const fcpValue = + item.first_contentful_paint || + webVitals.find((event) => event.properties.$web_vitals_FCP_value)?.properties.$web_vitals_FCP_value + const inpValue = webVitals.find((event) => event.properties.$web_vitals_INP_value)?.properties.$web_vitals_INP_value + return { + cls: clsValue, + lcp: lcpValue, + fcp: fcpValue, + inp: inpValue, + domInteractive: item.dom_interactive, + pageLoaded: item.load_event_end, + } +} + export function PerformanceCardRow({ item }: { item: PerformanceEvent }): JSX.Element { + const performanceValues = itemToPerformanceValues(item) return ( -
- {performanceSummaryCards.map(({ label, description, key }, index) => ( - - {index !== 0 && } - - +
+ {Object.entries(summaryMapping).map(([key, summary]) => ( + ))}
) @@ -133,18 +215,41 @@ export function PerformanceCardDescriptions({ item: PerformanceEvent expanded: boolean }): JSX.Element { + const performanceValues = itemToPerformanceValues(item) return (
- {performanceSummaryCards.map(({ label, description, key }) => ( -
-
- {label} - -
- -

{description}

-
+ {Object.entries(summaryMapping).map(([key, summary]) => ( + ))}
) } + +function PerformanceCardDescription({ + label, + benchmarks, + value, + description, +}: { + benchmarks: number[] + description: JSX.Element + label: string + value: number | undefined +}): JSX.Element { + return ( + <> +
+ {label} + +
+ +

{description}

+ + ) +} diff --git a/frontend/src/scenes/session-recordings/apm/performance-event-utils.test.ts b/frontend/src/scenes/session-recordings/apm/performance-event-utils.test.ts index 10339390c6ee2..d7a77778b1a2c 100644 --- a/frontend/src/scenes/session-recordings/apm/performance-event-utils.test.ts +++ b/frontend/src/scenes/session-recordings/apm/performance-event-utils.test.ts @@ -1,4 +1,4 @@ -import { matchNetworkEvents } from 'scenes/session-recordings/apm/performance-event-utils' +import { getPerformanceEvents } from 'scenes/session-recordings/apm/performance-event-utils' const aSingleSnapshotWithNetworkPayloads = { windowId: '018d5247-079c-7126-8e43-464605576a62', @@ -277,7 +277,7 @@ describe('performance-event-utils', () => { ]) // there are 13 requests in the sample data // only 5 should remain after collapsing server timings - const actual = matchNetworkEvents(someData) + const actual = getPerformanceEvents(someData) // we're collapsing server timings into their parent, so we'll have no top-level server timings expect(actual.map((a) => a.entry_type)).toEqual(['navigation', 'resource', 'resource', 'resource', 'resource']) diff --git a/frontend/src/scenes/session-recordings/apm/performance-event-utils.ts b/frontend/src/scenes/session-recordings/apm/performance-event-utils.ts index 9efaed46c9a5a..2e91c3c583f05 100644 --- a/frontend/src/scenes/session-recordings/apm/performance-event-utils.ts +++ b/frontend/src/scenes/session-recordings/apm/performance-event-utils.ts @@ -222,7 +222,7 @@ export function mapRRWebNetworkRequest( return data as PerformanceEvent } -export function matchNetworkEvents(snapshotsByWindowId: Record): PerformanceEvent[] { +export function getPerformanceEvents(snapshotsByWindowId: Record): PerformanceEvent[] { // we only support rrweb/network@1 events or posthog/network@1 events in any one recording // apart from during testing, where we might have both // if we have both, we only display posthog/network@1 events diff --git a/frontend/src/scenes/session-recordings/apm/performanceEventDataLogic.ts b/frontend/src/scenes/session-recordings/apm/performanceEventDataLogic.ts index f10016fd0cc0a..e6a27e9ed22a6 100644 --- a/frontend/src/scenes/session-recordings/apm/performanceEventDataLogic.ts +++ b/frontend/src/scenes/session-recordings/apm/performanceEventDataLogic.ts @@ -1,8 +1,8 @@ import { connect, kea, key, path, props, selectors } from 'kea' import { + getPerformanceEvents, initiatorToAssetTypeMapping, itemSizeInfo, - matchNetworkEvents, } from 'scenes/session-recordings/apm/performance-event-utils' import { InspectorListItemBase } from 'scenes/session-recordings/player/inspector/playerInspectorLogic' import { playerSettingsLogic } from 'scenes/session-recordings/player/playerSettingsLogic' @@ -11,7 +11,7 @@ import { SessionRecordingDataLogicProps, } from 'scenes/session-recordings/player/sessionRecordingDataLogic' -import { PerformanceEvent, SessionRecordingPlayerTab } from '~/types' +import { PerformanceEvent, RecordingEventType, SessionRecordingPlayerTab } from '~/types' import type { performanceEventDataLogicType } from './performanceEventDataLogicType' @@ -24,14 +24,19 @@ export interface PerformanceEventDataLogicProps extends SessionRecordingDataLogi key?: string } +/** it's pretty quick to sort an already sorted list */ +function sortPerformanceEvents(events: PerformanceEvent[]): PerformanceEvent[] { + return events.sort((a, b) => (a.timestamp.valueOf() > b.timestamp.valueOf() ? 1 : -1)) +} + /** * If we have paint events we should add them to the appropriate navigation event * this makes it easier to draw performance cards for navigation events */ function matchPaintEvents(performanceEvents: PerformanceEvent[]): PerformanceEvent[] { - // KLUDGE: this assumes that the input is sorted by timestamp and relies on the identity of the events to mutate them + // NB: this relies on the input being sorted by timestamp and relies on the identity of the events to mutate them let lastNavigationEvent: PerformanceEvent | null = null - for (const event of performanceEvents) { + for (const event of sortPerformanceEvents(performanceEvents)) { if (event.entry_type === 'navigation') { lastNavigationEvent = event } else if (event.entry_type === 'paint' && event.name === 'first-contentful-paint' && lastNavigationEvent) { @@ -42,6 +47,58 @@ function matchPaintEvents(performanceEvents: PerformanceEvent[]): PerformanceEve return performanceEvents } +function matchWebVitalsEvents( + performanceEvents: PerformanceEvent[], + webVitalsEvents: RecordingEventType[] +): PerformanceEvent[] { + // NB: this relies on the input being sorted by timestamp and relies on the identity of the events to mutate them + + if (!webVitalsEvents.length) { + return performanceEvents + } + + // first we get the timestamps of each navigation event, + // any web vitals events that occur between these timestamps + // can be associated to the navigation event + const navigationTimestamps: number[] = [] + for (const event of performanceEvents) { + if (event.entry_type === 'navigation') { + // TRICKY: this is typed as string|number but it is always number + // TODO: fix this in the types + navigationTimestamps.push(event.timestamp as number) + } + } + + let lastNavigationEvent: PerformanceEvent | null = null + let nextTimestamp: number | null = null + for (const event of sortPerformanceEvents(performanceEvents)) { + if (event.entry_type === 'navigation') { + lastNavigationEvent = event + nextTimestamp = navigationTimestamps.find((t) => t > event.timestamp) ?? null + } else { + if (!lastNavigationEvent) { + continue + } + + for (const webVital of webVitalsEvents) { + if (webVital.properties.$current_url !== lastNavigationEvent.name) { + continue + } + + const webVitalUnixTimestamp = new Date(webVital.timestamp).valueOf() + const isAfterLastNavigation = webVitalUnixTimestamp > lastNavigationEvent.timestamp + const isBeforeNextNavigation = webVitalUnixTimestamp < (nextTimestamp ?? Infinity) + if (isAfterLastNavigation && isBeforeNextNavigation) { + lastNavigationEvent.web_vitals = lastNavigationEvent.web_vitals || new Set() + lastNavigationEvent.web_vitals.add(webVital) + } + } + } + } + + return performanceEvents +} + export const performanceEventDataLogic = kea([ path(['scenes', 'session-recordings', 'apm', 'performanceEventDataLogic']), props({} as PerformanceEventDataLogicProps), @@ -52,32 +109,25 @@ export const performanceEventDataLogic = kea([ playerSettingsLogic, ['showOnlyMatching', 'tab', 'miniFiltersByKey', 'searchQuery'], sessionRecordingDataLogic(props), - [ - 'sessionPlayerData', - 'sessionPlayerMetaDataLoading', - 'snapshotsLoading', - 'sessionEventsData', - 'sessionEventsDataLoading', - 'windowIds', - 'start', - 'end', - 'durationMs', - ], + ['sessionPlayerData', 'webVitalsEvents'], ], })), selectors(() => ({ allPerformanceEvents: [ - (s) => [s.sessionPlayerData], - (sessionPlayerData): PerformanceEvent[] => { + (s) => [s.sessionPlayerData, s.webVitalsEvents], + (sessionPlayerData, webVitalsEvents): PerformanceEvent[] => { + // TRICKY: we listen to webVitalsEventsLoading to trigger a recompute once all the data is present + // performanceEvents used to come from the API, // but we decided to instead store them in the recording data // we gather more info than rrweb, so we mix the two back together here - return matchPaintEvents( - deduplicatePerformanceEvents( - filterUnwanted(matchNetworkEvents(sessionPlayerData.snapshotsByWindowId)) - ).sort((a, b) => (a.timestamp.valueOf() > b.timestamp.valueOf() ? 1 : -1)) - ) + const performanceEvents = getPerformanceEvents(sessionPlayerData.snapshotsByWindowId) + const filteredPerformanceEvents = filterUnwanted(performanceEvents) + const deduplicatedPerformanceEvents = deduplicatePerformanceEvents(filteredPerformanceEvents) + const sortedEvents = sortPerformanceEvents(deduplicatedPerformanceEvents) + const withMatchedPaintEvents = matchPaintEvents(sortedEvents) + return matchWebVitalsEvents(withMatchedPaintEvents, webVitalsEvents) }, ], sizeBreakdown: [ diff --git a/frontend/src/scenes/session-recordings/player/inspector/components/ItemEvent.tsx b/frontend/src/scenes/session-recordings/player/inspector/components/ItemEvent.tsx index 864b0b8782d38..f5cfe244cb075 100644 --- a/frontend/src/scenes/session-recordings/player/inspector/components/ItemEvent.tsx +++ b/frontend/src/scenes/session-recordings/player/inspector/components/ItemEvent.tsx @@ -5,7 +5,7 @@ import { TaxonomicFilterGroupType } from 'lib/components/TaxonomicFilter/types' import { TitledSnack } from 'lib/components/TitledSnack' import { IconOpenInNew } from 'lib/lemon-ui/icons' import { Spinner } from 'lib/lemon-ui/Spinner' -import { autoCaptureEventToDescription, capitalizeFirstLetter } from 'lib/utils' +import { autoCaptureEventToDescription, capitalizeFirstLetter, isString } from 'lib/utils' import { insightUrlForEvent } from 'scenes/insights/utils' import { InspectorListItemEvent } from '../playerInspectorLogic' @@ -17,13 +17,14 @@ export interface ItemEventProps { setExpanded: (expanded: boolean) => void } -function webVitalEventSummary(event: Record): JSX.Element { +function WebVitalEventSummary({ event }: { event: Record }): JSX.Element { return ( <> {event ? ( {event.rating}: {event.value.toFixed(2)} @@ -35,15 +36,15 @@ function webVitalEventSummary(event: Record): JSX.Element { ) } -function summarizeWebVitals(properties: Record): JSX.Element { +function SummarizeWebVitals({ properties }: { properties: Record }): JSX.Element { const { $web_vitals_FCP_event, $web_vitals_CLS_event, $web_vitals_INP_event, $web_vitals_LCP_event } = properties return (
- {webVitalEventSummary($web_vitals_FCP_event)} - {webVitalEventSummary($web_vitals_CLS_event)} - {webVitalEventSummary($web_vitals_INP_event)} - {webVitalEventSummary($web_vitals_LCP_event)} + + + +
) } @@ -52,13 +53,13 @@ export function ItemEvent({ item, expanded, setExpanded }: ItemEventProps): JSX. const insightUrl = insightUrlForEvent(item.data) const subValue = - item.data.event === '$pageview' - ? item.data.properties.$pathname || item.data.properties.$current_url - : item.data.event === '$screen' - ? item.data.properties.$screen_name - : item.data.event === '$web_vitals' - ? summarizeWebVitals(item.data.properties) - : undefined + item.data.event === '$pageview' ? ( + item.data.properties.$pathname || item.data.properties.$current_url + ) : item.data.event === '$screen' ? ( + item.data.properties.$screen_name + ) : item.data.event === '$web_vitals' ? ( + + ) : undefined let promotedKeys: string[] | undefined = undefined if (item.data.event === '$pageview') { @@ -81,19 +82,23 @@ export function ItemEvent({ item, expanded, setExpanded }: ItemEventProps): JSX. return (
setExpanded(!expanded)} fullWidth> -
- - {item.data.event === '$autocapture' ? (Autocapture) : null} +
+
+ + {item.data.event === '$autocapture' ? ( + (Autocapture) + ) : null} +
{subValue ? ( - +
{subValue} - +
) : null}
diff --git a/frontend/src/scenes/session-recordings/player/sessionRecordingDataLogic.ts b/frontend/src/scenes/session-recordings/player/sessionRecordingDataLogic.ts index d49d5294fc5d2..c72be208bd6cc 100644 --- a/frontend/src/scenes/session-recordings/player/sessionRecordingDataLogic.ts +++ b/frontend/src/scenes/session-recordings/player/sessionRecordingDataLogic.ts @@ -16,6 +16,7 @@ import { selectors, } from 'kea' import { loaders } from 'kea-loaders' +import { subscriptions } from 'kea-subscriptions' import api from 'lib/api' import { FEATURE_FLAGS } from 'lib/constants' import { Dayjs, dayjs } from 'lib/dayjs' @@ -476,6 +477,7 @@ export const sessionRecordingDataLogic = kea([ const { person } = values.sessionPlayerData + let loadedProperties: Record = existingEvent.properties // TODO: Move this to an optimised HogQL query when available... try { const res: any = await api.query({ @@ -492,7 +494,8 @@ export const sessionRecordingDataLogic = kea([ const result = res.results.find((x: any) => x[1] === event.timestamp) if (result) { - existingEvent.properties = JSON.parse(result[0]) + loadedProperties = JSON.parse(result[0]) + existingEvent.properties = loadedProperties existingEvent.fullyLoaded = true } } catch (e) { @@ -501,7 +504,18 @@ export const sessionRecordingDataLogic = kea([ captureException(e) } - return values.sessionEventsData + // here we map the events list because we want the result to be a new instance to trigger downstream recalculation + return !values.sessionEventsData + ? values.sessionEventsData + : values.sessionEventsData.map((x) => { + return x.id === event.id + ? ({ + ...x, + properties: loadedProperties, + fullyLoaded: true, + } as RecordingEventType) + : x + }) }, }, ], @@ -662,6 +676,12 @@ export const sessionRecordingDataLogic = kea([ }, })), selectors(({ cache }) => ({ + webVitalsEvents: [ + (s) => [s.sessionEventsData], + (sessionEventsData): RecordingEventType[] => + (sessionEventsData || []).filter((e) => e.event === '$web_vitals'), + ], + sessionPlayerData: [ (s, p) => [ s.sessionPlayerMetaData, @@ -891,6 +911,16 @@ export const sessionRecordingDataLogic = kea([ }, ], })), + subscriptions(({ actions }) => ({ + webVitalsEvents: (value: RecordingEventType[]) => { + value.forEach((item) => { + // we preload all web vitals data, so it can be used before user interaction + if (!item.fullyLoaded) { + actions.loadFullEventData(item) + } + }) + }, + })), afterMount(({ cache }) => { resetTimingsCache(cache) }), diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 6dd0f02930e38..4fe44d361ef4f 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -1466,6 +1466,7 @@ export interface PerformanceEvent { first_contentful_paint?: number // https://web.dev/fcp/ time_to_interactive?: number // https://web.dev/tti/ total_blocking_time?: number // https://web.dev/tbt/ + web_vitals?: Set // request/response capture - merged in from rrweb/network@1 payloads request_headers?: Record