From 15b08c1c275ab9020c2c49399836ac83b4002ac0 Mon Sep 17 00:00:00 2001 From: Paul D'Ambra Date: Tue, 14 Nov 2023 22:23:33 +0000 Subject: [PATCH] show prettier timing --- .../components/ItemPerformanceEvent.tsx | 22 +- .../Timing/NetworkRequestTiming.stories.tsx | 57 ++++ .../Timing/NetworkRequestTiming.tsx | 280 ++++++++++++++++++ frontend/src/styles/utilities.scss | 20 ++ 4 files changed, 376 insertions(+), 3 deletions(-) create mode 100644 frontend/src/scenes/session-recordings/player/inspector/components/Timing/NetworkRequestTiming.stories.tsx create mode 100644 frontend/src/scenes/session-recordings/player/inspector/components/Timing/NetworkRequestTiming.tsx diff --git a/frontend/src/scenes/session-recordings/player/inspector/components/ItemPerformanceEvent.tsx b/frontend/src/scenes/session-recordings/player/inspector/components/ItemPerformanceEvent.tsx index 60968747b34de..cb1493077b010 100644 --- a/frontend/src/scenes/session-recordings/player/inspector/components/ItemPerformanceEvent.tsx +++ b/frontend/src/scenes/session-recordings/player/inspector/components/ItemPerformanceEvent.tsx @@ -9,6 +9,7 @@ import { Fragment, useState } from 'react' import { CodeSnippet, Language } from 'lib/components/CodeSnippet' import { FlaggedFeature } from 'lib/components/FlaggedFeature' import { FEATURE_FLAGS } from 'lib/constants' +import { NetworkRequestTiming } from 'scenes/session-recordings/player/inspector/components/Timing/NetworkRequestTiming' const friendlyHttpStatus = { '0': 'Request not sent', @@ -179,6 +180,10 @@ export function ItemPerformanceEvent({ return acc } + if (key.includes('time') || key.includes('end') || key.includes('start')) { + return acc + } + return { ...acc, [key]: typeof value === 'number' ? Math.round(value) : value, @@ -334,7 +339,6 @@ export function ItemPerformanceEvent({

)} - {['fetch', 'xmlhttprequest'].includes(item.initiator_type || '') ? ( <> @@ -346,7 +350,13 @@ export function ItemPerformanceEvent({ { key: 'timings', label: 'timings', - content: , + content: ( + <> + + + + + ), }, { key: 'headers', @@ -387,10 +397,16 @@ export function ItemPerformanceEvent({ + + ) : ( - + <> + + + + )} )} diff --git a/frontend/src/scenes/session-recordings/player/inspector/components/Timing/NetworkRequestTiming.stories.tsx b/frontend/src/scenes/session-recordings/player/inspector/components/Timing/NetworkRequestTiming.stories.tsx new file mode 100644 index 0000000000000..a02e9bf3dce03 --- /dev/null +++ b/frontend/src/scenes/session-recordings/player/inspector/components/Timing/NetworkRequestTiming.stories.tsx @@ -0,0 +1,57 @@ +import { mswDecorator } from '~/mocks/browser' +import { Meta } from '@storybook/react' +import { PerformanceEvent } from '~/types' +import { NetworkRequestTiming } from 'scenes/session-recordings/player/inspector/components/Timing/NetworkRequestTiming' + +const meta: Meta = { + title: 'Components/NetworkRequestTiming', + component: NetworkRequestTiming, + decorators: [ + mswDecorator({ + get: {}, + }), + ], +} +export default meta + +export function Basic(): JSX.Element { + return ( + + ) +} diff --git a/frontend/src/scenes/session-recordings/player/inspector/components/Timing/NetworkRequestTiming.tsx b/frontend/src/scenes/session-recordings/player/inspector/components/Timing/NetworkRequestTiming.tsx new file mode 100644 index 0000000000000..d9b97dac0f26f --- /dev/null +++ b/frontend/src/scenes/session-recordings/player/inspector/components/Timing/NetworkRequestTiming.tsx @@ -0,0 +1,280 @@ +import { PerformanceEvent } from '~/types' +import { getSeriesColor } from 'lib/colors' +import { humanFriendlyMilliseconds } from 'lib/utils' +import { Tooltip } from 'lib/lemon-ui/Tooltip' + +function colorForEntry(entryType: string | undefined): string { + switch (entryType) { + case 'domComplete': + return getSeriesColor(1) + case 'domInteractive': + return getSeriesColor(2) + case 'pageLoaded': + return getSeriesColor(3) + case 'first-contentful-paint': + return getSeriesColor(4) + case 'css': + return getSeriesColor(6) + case 'xmlhttprequest': + return getSeriesColor(7) + case 'fetch': + return getSeriesColor(8) + case 'other': + return getSeriesColor(9) + case 'script': + return getSeriesColor(10) + case 'link': + return getSeriesColor(11) + case 'first-paint': + return getSeriesColor(11) + default: + return getSeriesColor(13) + } +} + +export interface EventPerformanceMeasure { + start: number + end: number + color: string + reducedHeight?: boolean +} + +const perfSections = [ + 'redirect', + 'app cache', + 'dns lookup', + 'connection time', + 'tls time', + 'waiting for first byte (TTFB)', + 'receiving response', + 'document processing', +] as const + +const perfDescriptions: Record<(typeof perfSections)[number], string> = { + redirect: + 'The time it took to fetch any previous resources that redirected to this one. If either redirect_start or redirect_end timestamp is 0, there were no redirects, or one of the redirects wasn’t from the same origin as this resource.', + 'app cache': 'The time taken to check the application cache or fetch the resource from the application cache.', + 'dns lookup': 'The time taken to complete any DNS lookup for the resource.', + 'connection time': 'The time taken to establish a connection to the server to retrieve the resource.', + 'tls time': 'The time taken for the SSL/TLS handshake.', + 'waiting for first byte (TTFB)': 'The time taken waiting for the server to start returning a response.', + 'receiving response': 'The time taken to receive the response from the server.', + 'document processing': + 'The time taken to process the document after the response from the server has been received.', +} + +function colorForSection(section: (typeof perfSections)[number]): string { + switch (section) { + case 'redirect': + return getSeriesColor(1) + case 'app cache': + return getSeriesColor(2) + case 'dns lookup': + return getSeriesColor(3) + case 'connection time': + return getSeriesColor(4) + case 'tls time': + return getSeriesColor(6) + case 'waiting for first byte (TTFB)': + return getSeriesColor(7) + case 'receiving response': + return getSeriesColor(8) + case 'document processing': + return getSeriesColor(9) + default: + return getSeriesColor(10) + } +} + +/** + * There are defined sections to performance measurement. We may have data for some or all of them + * + * 1) Redirect + * - from startTime which would also be redirectStart + * - until redirect_end + * + * 2) App Cache + * - from fetch_start + * - until domain_lookup_start + * + * 3) DNS + * - from domain_lookup_start + * - until domain_lookup_end + * + * 4) TCP + * - from connect_start + * - until connect_end + * + * this contains any time to negotiate SSL/TLS + * - from secure_connection_start + * - until connect_end + * + * 5) Request + * - from request_start + * - until response_start + * + * 6) Response + * - from response_start + * - until response_end + * + * 7) Document Processing + * - from response_end + * - until load_event_end + * + * see https://nicj.net/resourcetiming-in-practice/ + * + * @param perfEntry + * @param maxTime + */ +function calculatePerformanceParts(perfEntry: PerformanceEvent): Record { + const performanceParts: Record = {} + + if (perfEntry.redirect_start && perfEntry.redirect_end) { + performanceParts['redirect'] = { + start: perfEntry.redirect_start, + end: perfEntry.redirect_end, + color: colorForEntry(perfEntry.initiator_type), + } + } + + if (perfEntry.fetch_start && perfEntry.domain_lookup_start) { + performanceParts['app cache'] = { + start: perfEntry.fetch_start, + end: perfEntry.domain_lookup_start, + color: colorForEntry(perfEntry.initiator_type), + } + } + + if (perfEntry.domain_lookup_end && perfEntry.domain_lookup_start) { + performanceParts['dns lookup'] = { + start: perfEntry.domain_lookup_start, + end: perfEntry.domain_lookup_end, + color: colorForEntry(perfEntry.initiator_type), + } + } + + if (perfEntry.connect_end && perfEntry.connect_start) { + performanceParts['connection time'] = { + start: perfEntry.connect_start, + end: perfEntry.connect_end, + color: colorForEntry(perfEntry.initiator_type), + } + + if (perfEntry.secure_connection_start) { + performanceParts['tls time'] = { + start: perfEntry.secure_connection_start, + end: perfEntry.connect_end, + color: colorForEntry(perfEntry.initiator_type), + reducedHeight: true, + } + } + } + + if (perfEntry.response_start && perfEntry.request_start) { + performanceParts['waiting for first byte (TTFB)'] = { + start: perfEntry.request_start, + end: perfEntry.response_start, + color: colorForEntry(perfEntry.initiator_type), + } + } + + if (perfEntry.response_start && perfEntry.response_end) { + performanceParts['receiving response'] = { + start: perfEntry.response_start, + end: perfEntry.response_end, + color: colorForEntry(perfEntry.initiator_type), + } + } + + if (perfEntry.response_end && perfEntry.load_event_end) { + performanceParts['document processing'] = { + start: perfEntry.response_end, + end: perfEntry.load_event_end, + color: colorForEntry(perfEntry.initiator_type), + } + } + + return performanceParts +} + +function percentagesWithinEventRange({ + partStart, + partEnd, + rangeEnd, + rangeStart, +}: { + partStart: number + partEnd: number + rangeStart: number + rangeEnd: number +}): { startPercentage: string; widthPercentage: string } { + const totalDuration = rangeEnd - rangeStart + const partStartRelativeToTimeline = partStart - rangeStart + const partDuration = partEnd - partStart + + const partPercentage = (partDuration / totalDuration) * 100 + const partStartPercentage = (partStartRelativeToTimeline / totalDuration) * 100 + return { startPercentage: `${partStartPercentage}%`, widthPercentage: `${partPercentage}%` } +} + +export const NetworkRequestTiming = ({ + performanceEvent, +}: { + performanceEvent: PerformanceEvent +}): JSX.Element | null => { + const rangeStart = performanceEvent.start_time + const rangeEnd = performanceEvent.response_end + if (typeof rangeStart === 'number' && typeof rangeEnd === 'number') { + const performanceParts = calculatePerformanceParts(performanceEvent) + return ( +
+ {perfSections.map((section) => { + const matchedSection = performanceParts[section] + const start = matchedSection?.start + const end = matchedSection?.end + const partDuration = end - start + let formattedDuration: string | undefined + let startPercentage = null + let widthPercentage = null + + if (isNaN(partDuration) || partDuration === 0) { + formattedDuration = '' + } else { + formattedDuration = humanFriendlyMilliseconds(partDuration) + const percentages = percentagesWithinEventRange({ + rangeStart, + rangeEnd, + partStart: start, + partEnd: end, + }) + startPercentage = percentages.startPercentage + widthPercentage = percentages.widthPercentage + } + + return ( + <> +
+
+ {section} +
+
+
+
+
{formattedDuration || ''}
+
+ + ) + })} +
+ ) + } + return null +} diff --git a/frontend/src/styles/utilities.scss b/frontend/src/styles/utilities.scss index 8e27162b8c7e0..0f26bf65d4b11 100644 --- a/frontend/src/styles/utilities.scss +++ b/frontend/src/styles/utilities.scss @@ -503,6 +503,26 @@ $decorations: underline, overline, line-through, no-underline; .#{$variant}#{$char}-4\/5 { #{$variant}#{$kind}: 80%; } + + .#{$variant}#{$char}-1\/6 { + #{$variant}#{$kind}: 16.66667%; + } + + .#{$variant}#{$char}-2\/6 { + #{$variant}#{$kind}: 33.33333%; + } + + .#{$variant}#{$char}-3\/6 { + #{$variant}#{$kind}: 50%; + } + + .#{$variant}#{$char}-4\/6 { + #{$variant}#{$kind}: 66.66667%; + } + + .#{$variant}#{$char}-5\/6 { + #{$variant}#{$kind}: 83.33333%; + } } }