From c71db6c44112748696d32ba4cd7f6b259c3f01a5 Mon Sep 17 00:00:00 2001 From: nick-delirium Date: Mon, 14 Apr 2025 17:48:19 +0200 Subject: [PATCH] ui, tracker: add stall, add ui implementation --- backend/pkg/messages/read-message.go | 3 + .../components/FetchTabs/FetchTabs.tsx | 28 ++- .../components/FetchTabs/FetchTimings.tsx | 205 ++++++++++++++++++ frontend/app/player/web/types/resource.ts | 3 + mobs/messages.rb | 1 + tracker/tracker/src/main/modules/img.ts | 2 +- tracker/tracker/src/main/modules/timing.ts | 13 +- 7 files changed, 250 insertions(+), 5 deletions(-) create mode 100644 frontend/app/components/shared/FetchDetailsModal/components/FetchTabs/FetchTimings.tsx diff --git a/backend/pkg/messages/read-message.go b/backend/pkg/messages/read-message.go index 4b4d9681f..998d2d20c 100644 --- a/backend/pkg/messages/read-message.go +++ b/backend/pkg/messages/read-message.go @@ -1440,6 +1440,9 @@ func DecodeLongAnimationTask(reader BytesReader) (Message, error) { if msg.Scripts, err = reader.ReadString(); err != nil { return nil, err } + if msg.Stalled, err = reader.ReadUint(); err != nil { + return nil, err + } return msg, err } diff --git a/frontend/app/components/shared/FetchDetailsModal/components/FetchTabs/FetchTabs.tsx b/frontend/app/components/shared/FetchDetailsModal/components/FetchTabs/FetchTabs.tsx index cbf546cc0..e653d5033 100644 --- a/frontend/app/components/shared/FetchDetailsModal/components/FetchTabs/FetchTabs.tsx +++ b/frontend/app/components/shared/FetchDetailsModal/components/FetchTabs/FetchTabs.tsx @@ -5,6 +5,7 @@ import AnimatedSVG, { ICONS } from 'Shared/AnimatedSVG/AnimatedSVG'; import Headers from '../Headers'; import { useTranslation } from 'react-i18next'; import { TFunction } from 'i18next'; +import FetchTimings from './FetchTimings'; const HEADERS = 'HEADERS'; const REQUEST = 'REQUEST'; @@ -120,6 +121,8 @@ function FetchTabs({ resource, isSpot, isXHR }: Props) { ); }, [resource]); + const noTimings = resource.timings ? Object.values(resource.timings).every((v) => v === 0) : true; + const renderActiveTab = () => { switch (activeTab) { case REQUEST: @@ -212,9 +215,28 @@ function FetchTabs({ resource, isSpot, isXHR }: Props) { /> ); case TIMINGS: - return
- {resource.timings ? JSON.stringify(resource.timings, null, 2) : 'notihng :('} -
; + return + +
+ {t('No timings recorded.')} +
+ + {t('Learn how to get more out of Fetch/XHR requests.')} + +
+ + } + size="small" + show={noTimings} + > + +
} }; const usedTabs = isXHR ? TABS : RESOURCE_TABS; diff --git a/frontend/app/components/shared/FetchDetailsModal/components/FetchTabs/FetchTimings.tsx b/frontend/app/components/shared/FetchDetailsModal/components/FetchTabs/FetchTimings.tsx new file mode 100644 index 000000000..73016b3ed --- /dev/null +++ b/frontend/app/components/shared/FetchDetailsModal/components/FetchTabs/FetchTimings.tsx @@ -0,0 +1,205 @@ +import React from 'react'; +import { Tooltip } from 'antd'; +import { HelpCircle } from 'lucide-react' + +function FetchTimings({ timings }: { timings: Record }) { + const formatTime = (time: number) => { + if (time === undefined || time === null) return '—'; + if (time === 0) return '0ms'; + if (time < 1) return `${Math.round(time * 1000)}μs`; + return `${Math.round(time)}ms`; + }; + + const total = React.useMemo(() => { + const sumOfComponents = Object.entries(timings) + .filter(([key]) => key !== 'total') + .reduce((sum, [_, value]) => sum + (value || 0), 0); + + const largestComponent = Math.max( + ...Object.entries(timings) + .filter(([key]) => key !== 'total') + .map(([_, value]) => value || 0), + ); + + return Math.max(timings.total || 0, sumOfComponents, largestComponent); + }, [timings.total]); + const isAdjusted = timings.total !== undefined && total !== timings.total; + + const phases = [ + { + category: 'Resource Scheduling', + children: [ + { + key: 'queueing', + name: 'Queueing', + color: 'bg-[#99a1af]', + description: 'Time spent in browser queue before connection start', + }, + { + key: 'stalled', + name: 'Stalled', + color: 'bg-[#c3c3c3]', + description: 'Time request was stalled after connection start', + }, + ], + }, + { + category: 'Connection Start', + children: [ + { + key: 'dnsLookup', + name: 'DNS Lookup', + color: 'bg-[#00c951]', + description: 'Time spent resolving the DNS', + }, + { + key: 'initialConnection', + name: 'Initial Connection', + color: 'bg-[#efb100]', + description: 'Time establishing connection (TCP handshakes/retries)', + }, + { + key: 'ssl', + name: 'SSL', + color: 'bg-[#ad46ff]', + description: 'Time spent completing SSL/TLS handshake', + }, + ], + }, + { + category: 'Request/Response', + children: [ + { + key: 'ttfb', + name: 'Request & TTFB', + color: 'bg-[#2b7fff]', + description: 'Time waiting for first byte (server response time)', + }, + { + key: 'contentDownload', + name: 'Content Download', + color: 'bg-[#00a63e]', + description: 'Time spent receiving the response data', + }, + ], + }, + ]; + + const calculateTimelines = () => { + let currentPosition = 0; + const results = []; + + for (const phase of phases) { + const parts = []; + for (const child of phase.children) { + const duration = timings[child.key] || 0; + const width = (duration / total) * 100; + + parts.push({ + ...child, + duration, + position: currentPosition, + width, + }); + + currentPosition += width; + } + results.push({ + category: phase.category, + children: parts, + }); + } + + return results; + }; + + const timelineData = React.useMemo(() => calculateTimelines(), [total]); + + return ( +
+
+
+ {timelineData.map((cat, index) => ( +
+
{cat.category}
+
+ {cat.children.map((phase, index) => ( +
+
+ + + + {phase.name}: +
+ +
+
+ {phase.width > 0 && ( +
+ )} +
+
+ +
+ {formatTime(phase.duration)} +
+
+ ))} +
+
+ ))} + +
+
+ Total: +
+
+
+ {formatTime(total)}{' '} + {isAdjusted ? ( + + (adjusted from reported value: {formatTime(timings.total)}) + + ) : null} +
+
+
+
+ {timelineData + .flatMap((phase) => phase.children) + .filter((phase) => phase.width > 0) + .map((phase, index) => ( +
+ ))} +
+ +
+
0ms
+
{formatTime(total)}
+
+
+
+
+
+ ); +} + +export default FetchTimings; diff --git a/frontend/app/player/web/types/resource.ts b/frontend/app/player/web/types/resource.ts index b578b990e..3c499ee76 100644 --- a/frontend/app/player/web/types/resource.ts +++ b/frontend/app/player/web/types/resource.ts @@ -85,6 +85,7 @@ interface IResource { decodedBodySize?: number; responseBodySize?: number; error?: string; + stalled?: number; } export interface IResourceTiming extends IResource { @@ -103,6 +104,7 @@ export interface IResourceTiming extends IResource { ssl: number; ttfb: number; contentDownload: number; + stalled: number; total: number; }; } @@ -175,6 +177,7 @@ export function getResourceFromResourceTiming( ttfb: msg.ttfb, contentDownload: msg.contentDownload, total: msg.total, + stalled: msg.stalled, }, }); } diff --git a/mobs/messages.rb b/mobs/messages.rb index e9fb97d54..2dddad994 100644 --- a/mobs/messages.rb +++ b/mobs/messages.rb @@ -539,6 +539,7 @@ message 85, 'ResourceTiming', :replayer => :devtools do uint 'SSL' uint 'ContentDownload' uint 'Total' + uint 'Stalled' end message 89, 'LongAnimationTask', :replayer => :devtools do diff --git a/tracker/tracker/src/main/modules/img.ts b/tracker/tracker/src/main/modules/img.ts index 2cfc3a988..7b9cd9b41 100644 --- a/tracker/tracker/src/main/modules/img.ts +++ b/tracker/tracker/src/main/modules/img.ts @@ -60,7 +60,7 @@ export default function (app: App): void { const sendImgError = app.safe(function (img: HTMLImageElement): void { const resolvedSrc = resolveURL(img.src || '') // Src type is null sometimes. - is it true? if (isURL(resolvedSrc)) { - app.send(ResourceTiming(app.timestamp(), 0, 0, 0, 0, 0, resolvedSrc, 'img', 0, false, 0, 0, 0, 0, 0, 0)) + app.send(ResourceTiming(app.timestamp(), 0, 0, 0, 0, 0, resolvedSrc, 'img', 0, false, 0, 0, 0, 0, 0, 0, 0)) } }) diff --git a/tracker/tracker/src/main/modules/timing.ts b/tracker/tracker/src/main/modules/timing.ts index fc93938a3..42b7d5038 100644 --- a/tracker/tracker/src/main/modules/timing.ts +++ b/tracker/tracker/src/main/modules/timing.ts @@ -130,6 +130,14 @@ export default function (app: App, opts: Partial): void { // will probably require custom header added to responses for tracked fetch/xhr requests: // Timing-Allow-Origin: * // https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Timing-Allow-Origin + let stalled = 0; + if (entry.connectEnd && entry.connectEnd > entry.domainLookupEnd) { + // Usual case stalled is time between connection establishment and request start + stalled = Math.max(0, entry.requestStart - entry.connectEnd); + } else { + // Connection reuse case - stalled is time between domain lookup and request start + stalled = Math.max(0, entry.requestStart - entry.domainLookupEnd); + } const timings = { queueing: entry.domainLookupStart - entry.startTime, dnsLookup: entry.domainLookupEnd - entry.domainLookupStart, @@ -138,7 +146,8 @@ export default function (app: App, opts: Partial): void { ? entry.connectEnd - entry.secureConnectionStart : 0, ttfb: entry.responseStart - entry.requestStart, contentDownload: entry.responseEnd - entry.responseStart, - total: entry.responseEnd - entry.startTime + total: entry.duration ?? (entry.responseEnd - entry.startTime), + stalled, }; if (failed) { app.send( @@ -159,6 +168,7 @@ export default function (app: App, opts: Partial): void { timings.ssl, timings.contentDownload, timings.total, + timings.stalled, ), ) } @@ -180,6 +190,7 @@ export default function (app: App, opts: Partial): void { timings.ssl, timings.contentDownload, timings.total, + timings.stalled ), ) }