From 87c153aa4540cda80952887018f1cc1b3c8152be Mon Sep 17 00:00:00 2001 From: Lyon <88232613+pikasTech@users.noreply.github.com> Date: Tue, 23 Jun 2026 01:42:29 +0800 Subject: [PATCH] fix(workbench): include resource timing in ui rum spans (#1934) --- internal/cloud/web-performance.ts | 27 +++++++ .../src/utils/workbench-performance.ts | 79 ++++++++++++++++++- 2 files changed, 105 insertions(+), 1 deletion(-) diff --git a/internal/cloud/web-performance.ts b/internal/cloud/web-performance.ts index 8cee0ac2..dca9ac0e 100644 --- a/internal/cloud/web-performance.ts +++ b/internal/cloud/web-performance.ts @@ -117,6 +117,15 @@ interface WebPerformanceEvent { activityWaitingFor?: unknown; activityLastEventLabel?: unknown; activityIdleMs?: unknown; + resourceDurationMs?: unknown; + resourceFetchToRequestMs?: unknown; + resourceRequestWaitMs?: unknown; + resourceResponseTransferMs?: unknown; + resourceTransferSize?: unknown; + resourceEncodedBodySize?: unknown; + resourceDecodedBodySize?: unknown; + resourceNextHopProtocol?: unknown; + resourceServerTiming?: unknown; } interface NormalizedPerformanceEvent { @@ -821,6 +830,15 @@ function normalizeWorkbenchUiOtelSpan(rawEvent: unknown, payload: WebPerformance const activityWaitingFor = sanitizeOtelAttribute(input.activityWaitingFor, 80); const activityLastEventLabel = sanitizeOtelAttribute(input.activityLastEventLabel, 120); const activityIdleMs = Math.min(Math.max(finiteNumber(input.activityIdleMs, NaN), 0), 3_600_000); + const resourceDurationMs = Math.min(Math.max(finiteNumber(input.resourceDurationMs, NaN), 0), 120_000); + const resourceFetchToRequestMs = Math.min(Math.max(finiteNumber(input.resourceFetchToRequestMs, NaN), 0), 120_000); + const resourceRequestWaitMs = Math.min(Math.max(finiteNumber(input.resourceRequestWaitMs, NaN), 0), 120_000); + const resourceResponseTransferMs = Math.min(Math.max(finiteNumber(input.resourceResponseTransferMs, NaN), 0), 120_000); + const resourceTransferSize = Math.min(Math.max(finiteNumber(input.resourceTransferSize, NaN), 0), 100 * 1024 * 1024); + const resourceEncodedBodySize = Math.min(Math.max(finiteNumber(input.resourceEncodedBodySize, NaN), 0), 100 * 1024 * 1024); + const resourceDecodedBodySize = Math.min(Math.max(finiteNumber(input.resourceDecodedBodySize, NaN), 0), 100 * 1024 * 1024); + const resourceNextHopProtocol = sanitizeOtelAttribute(input.resourceNextHopProtocol, 40); + const resourceServerTiming = sanitizeOtelAttribute(input.resourceServerTiming, 240); return { name: ["workbench", eventType, scope, state].filter((part) => part && part !== "unknown").join("."), uiTraceId, @@ -847,6 +865,15 @@ function normalizeWorkbenchUiOtelSpan(rawEvent: unknown, payload: WebPerformance "workbench.ui.activity_waiting_for": activityWaitingFor, "workbench.ui.activity_last_event_label": activityLastEventLabel, ...(Number.isFinite(activityIdleMs) ? { "workbench.ui.activity_idle_ms": Math.trunc(activityIdleMs) } : {}), + ...(Number.isFinite(resourceDurationMs) ? { "workbench.ui.resource_duration_ms": Math.trunc(resourceDurationMs) } : {}), + ...(Number.isFinite(resourceFetchToRequestMs) ? { "workbench.ui.resource_fetch_to_request_ms": Math.trunc(resourceFetchToRequestMs) } : {}), + ...(Number.isFinite(resourceRequestWaitMs) ? { "workbench.ui.resource_request_wait_ms": Math.trunc(resourceRequestWaitMs) } : {}), + ...(Number.isFinite(resourceResponseTransferMs) ? { "workbench.ui.resource_response_transfer_ms": Math.trunc(resourceResponseTransferMs) } : {}), + ...(Number.isFinite(resourceTransferSize) ? { "workbench.ui.resource_transfer_size": Math.trunc(resourceTransferSize) } : {}), + ...(Number.isFinite(resourceEncodedBodySize) ? { "workbench.ui.resource_encoded_body_size": Math.trunc(resourceEncodedBodySize) } : {}), + ...(Number.isFinite(resourceDecodedBodySize) ? { "workbench.ui.resource_decoded_body_size": Math.trunc(resourceDecodedBodySize) } : {}), + ...(resourceNextHopProtocol ? { "workbench.ui.resource_next_hop_protocol": resourceNextHopProtocol } : {}), + ...(resourceServerTiming ? { "workbench.ui.resource_server_timing": resourceServerTiming } : {}), "workbench.ui.values_printed": false, "http.route": route, "http.request.method": normalizeMethod(input.method), diff --git a/web/hwlab-cloud-web/src/utils/workbench-performance.ts b/web/hwlab-cloud-web/src/utils/workbench-performance.ts index 52ea2ab3..fecf44dd 100644 --- a/web/hwlab-cloud-web/src/utils/workbench-performance.ts +++ b/web/hwlab-cloud-web/src/utils/workbench-performance.ts @@ -45,6 +45,15 @@ interface WorkbenchPerformanceEvent { activityWaitingFor?: string | null; activityLastEventLabel?: string | null; activityIdleMs?: number; + resourceDurationMs?: number; + resourceFetchToRequestMs?: number; + resourceRequestWaitMs?: number; + resourceResponseTransferMs?: number; + resourceTransferSize?: number; + resourceEncodedBodySize?: number; + resourceDecodedBodySize?: number; + resourceNextHopProtocol?: string; + resourceServerTiming?: string; } interface TraceEventTimingInput { @@ -179,6 +188,7 @@ export function recordWorkbenchApiRequest(input: { route: string; method?: strin if (!route || route.startsWith("/v1/web-performance")) return; ensureInstalled(); const endedAtEpochMs = input.endedAtEpochMs ?? wallNow(); + const resourceTiming = resourceTimingForApiRequest(route, input.startedAtEpochMs, endedAtEpochMs); enqueueWorkbenchUiEvent({ eventType: "api_request", loadingScope: "api", @@ -196,10 +206,77 @@ export function recordWorkbenchApiRequest(input: { route: string; method?: strin activityIdleMs: Number.isFinite(input.activityIdleMs ?? NaN) ? Math.max(0, Math.round(Number(input.activityIdleMs))) : undefined, valueMs: Math.max(0, endedAtEpochMs - input.startedAtEpochMs), startedAtEpochMs: input.startedAtEpochMs, - endedAtEpochMs + endedAtEpochMs, + ...resourceTiming }); } +function resourceTimingForApiRequest(route: string, startedAtEpochMs: number, endedAtEpochMs: number): Partial { + if (typeof window === "undefined" || typeof performance === "undefined" || typeof performance.getEntriesByType !== "function") return {}; + let target: URL; + try { + target = new URL(route, window.location.href); + } catch { + return {}; + } + const timeOrigin = Number.isFinite(performance.timeOrigin) ? performance.timeOrigin : Date.now() - monotonicNow(); + const entries = performance.getEntriesByType("resource") as PerformanceResourceTiming[]; + const lowerBound = startedAtEpochMs - 2000; + const upperBound = endedAtEpochMs + 2000; + const candidates = entries + .filter((entry) => resourceEntryMatches(entry, target, timeOrigin, lowerBound, upperBound)) + .sort((left, right) => Math.abs(timeOrigin + right.startTime - startedAtEpochMs) - Math.abs(timeOrigin + left.startTime - startedAtEpochMs)); + const entry = candidates.at(-1); + if (!entry) return {}; + const result: Partial = { + resourceDurationMs: roundedNonNegative(entry.duration), + resourceFetchToRequestMs: roundedDelta(entry.requestStart, entry.fetchStart), + resourceRequestWaitMs: roundedDelta(entry.responseStart, entry.requestStart), + resourceResponseTransferMs: roundedDelta(entry.responseEnd, entry.responseStart), + resourceTransferSize: roundedNonNegative(entry.transferSize), + resourceEncodedBodySize: roundedNonNegative(entry.encodedBodySize), + resourceDecodedBodySize: roundedNonNegative(entry.decodedBodySize), + resourceNextHopProtocol: safeText(entry.nextHopProtocol).slice(0, 40) || undefined, + resourceServerTiming: serverTimingSummary(entry.serverTiming) + }; + return Object.fromEntries(Object.entries(result).filter(([, value]) => value !== undefined)) as Partial; +} + +function resourceEntryMatches(entry: PerformanceResourceTiming, target: URL, timeOrigin: number, lowerBound: number, upperBound: number): boolean { + if (!entry || entry.initiatorType !== "fetch") return false; + let url: URL; + try { + url = new URL(entry.name); + } catch { + return false; + } + if (url.origin !== target.origin) return false; + if (url.pathname !== target.pathname) return false; + if (target.search && url.search !== target.search) return false; + const started = timeOrigin + entry.startTime; + return started >= lowerBound && started <= upperBound; +} + +function roundedDelta(end: number, start: number): number | undefined { + if (!Number.isFinite(end) || !Number.isFinite(start) || end < start) return undefined; + return Math.round(end - start); +} + +function roundedNonNegative(value: number): number | undefined { + if (!Number.isFinite(value) || value < 0) return undefined; + return Math.round(value); +} + +function serverTimingSummary(entries: readonly PerformanceServerTiming[] | undefined): string | undefined { + if (!Array.isArray(entries) || entries.length === 0) return undefined; + const summary = entries.slice(0, 8).map((entry) => { + const name = safeText(entry.name).replace(/[^A-Za-z0-9_.-]/gu, "_").slice(0, 40) || "metric"; + const duration = Number.isFinite(entry.duration) ? Math.round(entry.duration) : null; + return duration === null ? name : `${name}:${duration}`; + }).join(","); + return summary.slice(0, 240) || undefined; +} + export function recordWorkbenchSseLifecycle(input: { state: "connect" | "open" | "error" | "close"; route: string; sessionId?: string | null; traceId?: string | null; errorName?: string | null }): void { ensureInstalled(); const state = input.state;