fix(workbench): include resource timing in ui rum spans (#1934)

This commit is contained in:
Lyon
2026-06-23 01:42:29 +08:00
committed by GitHub
parent 6a0b59e499
commit 87c153aa45
2 changed files with 105 additions and 1 deletions
+27
View File
@@ -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),
@@ -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<WorkbenchPerformanceEvent> {
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<WorkbenchPerformanceEvent> = {
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<WorkbenchPerformanceEvent>;
}
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;