ui, tracker: add stall, add ui implementation

This commit is contained in:
nick-delirium 2025-04-14 17:48:19 +02:00 committed by Delirium
parent 3200107d71
commit c71db6c441
7 changed files with 250 additions and 5 deletions

View file

@ -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
}

View file

@ -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 <div>
{resource.timings ? JSON.stringify(resource.timings, null, 2) : 'notihng :('}
</div>;
return <NoContent
title={
<div className="flex flex-col items-center justify-center">
<AnimatedSVG name={ICONS.NO_RESULTS} size={30} />
<div className="mt-6 text-base font-normal">
{t('No timings recorded.')}
<br />
<a
href="https://docs.openreplay.com/en/sdk/network-options"
className="link"
target="_blank"
>
{t('Learn how to get more out of Fetch/XHR requests.')}
</a>
</div>
</div>
}
size="small"
show={noTimings}
>
<FetchTimings timings={resource.timings} />
</NoContent>
}
};
const usedTabs = isXHR ? TABS : RESOURCE_TABS;

View file

@ -0,0 +1,205 @@
import React from 'react';
import { Tooltip } from 'antd';
import { HelpCircle } from 'lucide-react'
function FetchTimings({ timings }: { timings: Record<string, number> }) {
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 (
<div className="w-full bg-white rounded-lg shadow-sm p-4 font-sans">
<div>
<div className="space-y-4">
{timelineData.map((cat, index) => (
<div>
<div>{cat.category}</div>
<div>
{cat.children.map((phase, index) => (
<div
key={index}
className="grid grid-cols-12 items-center gap-2 space-y-2"
>
<div className="col-span-4 text-sm text-gray-dark font-medium flex items-center gap-2">
<Tooltip title={phase.description}>
<HelpCircle size={12} />
</Tooltip>
<span>{phase.name}:</span>
</div>
<div className="col-span-6 relative">
<div className="h-5 bg-gray-lightest rounded overflow-hidden">
{phase.width > 0 && (
<div
className={`absolute top-0 h-full ${phase.color} hover:opacity-80 transition-opacity`}
style={{
left: `${phase.position}%`,
width: `${Math.max(phase.width, 0.5)}%`, // Ensure minimum visibility
}}
title={`${phase.name}: ${formatTime(phase.duration)} (starts at ${formatTime((total * phase.position) / 100)})`}
/>
)}
</div>
</div>
<div className="col-span-2 text-right font-mono text-sm text-gray-dark">
{formatTime(phase.duration)}
</div>
</div>
))}
</div>
</div>
))}
<div className="grid grid-cols-12 items-center gap-2 pt-2 border-t border-t-gray-light mt-2">
<div className="col-span-3 text-sm text-gray-dark font-semibold">
Total:
</div>
<div className="col-span-7"></div>
<div className="col-span-2 text-right font-mono text-sm text-gray-dark font-semibold">
{formatTime(total)}{' '}
{isAdjusted ? (
<span className="ml-1 text-xs text-yellow">
(adjusted from reported value: {formatTime(timings.total)})
</span>
) : null}
</div>
</div>
<div className="mb-2">
<div className="relative h-6 bg-gray-lightest rounded overflow-hidden">
{timelineData
.flatMap((phase) => phase.children)
.filter((phase) => phase.width > 0)
.map((phase, index) => (
<div
key={index}
className={`absolute top-0 h-full ${phase.color} hover:opacity-80 transition-opacity`}
style={{
left: `${phase.position}%`,
width: `${Math.max(phase.width, 0.5)}%`, // Ensure minimum visibility
}}
title={`${phase.name}: ${formatTime(phase.duration)}`}
/>
))}
</div>
<div className="flex justify-between mt-1 text-xs text-gray-medium">
<div>0ms</div>
<div>{formatTime(total)}</div>
</div>
</div>
</div>
</div>
</div>
);
}
export default FetchTimings;

View file

@ -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,
},
});
}

View file

@ -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

View file

@ -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))
}
})

View file

@ -130,6 +130,14 @@ export default function (app: App, opts: Partial<Options>): 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<Options>): 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<Options>): void {
timings.ssl,
timings.contentDownload,
timings.total,
timings.stalled,
),
)
}
@ -180,6 +190,7 @@ export default function (app: App, opts: Partial<Options>): void {
timings.ssl,
timings.contentDownload,
timings.total,
timings.stalled
),
)
}