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%;
+ }
}
}