fix(workbench): include resource timing in ui rum spans (#1934)
This commit is contained in:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user