Merge pull request #1636 from pikasTech/fix/1633-workbench-loading-otel

Workbench loading 状态接入 OTel trace
This commit is contained in:
Lyon
2026-06-20 00:49:05 +08:00
committed by GitHub
9 changed files with 507 additions and 19 deletions
+79
View File
@@ -33,6 +33,21 @@ export function codeAgentOtelTraceContext(traceId) {
};
}
export function workbenchUiOtelTraceContext(input = {}) {
const businessTraceId = String(input.uiTraceId ?? input.traceId ?? "").trim() || "ui_unassigned";
const explicitTraceId = normalizeOtelTraceId(input.otelTraceId);
const otelTraceId = explicitTraceId ?? nonZeroHex(createHash("sha256").update(`hwlab-workbench-ui:${businessTraceId}`).digest("hex").slice(0, 32), ZERO_TRACE_ID);
const parentSpanId = normalizeOtelSpanId(input.parentSpanId)
?? nonZeroHex(createHash("sha256").update(`hwlab-workbench-ui-parent:${businessTraceId}:${otelTraceId}`).digest("hex").slice(0, 16), ZERO_SPAN_ID);
return {
businessTraceId,
traceId: otelTraceId,
parentSpanId,
traceparent: `00-${otelTraceId}-${parentSpanId}-01`,
valuesPrinted: false
};
}
export async function emitCodeAgentOtelSpan(name, traceId, env = process.env, options = {}) {
const endpoint = resolveOtlpTracesEndpoint(env);
if (!endpoint || typeof fetch !== "function") return { ok: false, skipped: true, reason: "otlp-endpoint-missing", valuesPrinted: false };
@@ -87,6 +102,60 @@ export async function emitCodeAgentOtelSpan(name, traceId, env = process.env, op
}
}
export async function emitWorkbenchUiOtelSpan(name, uiTraceId, env = process.env, options = {}) {
const endpoint = resolveOtlpTracesEndpoint(env);
if (!endpoint || typeof fetch !== "function") return { ok: false, skipped: true, reason: "otlp-endpoint-missing", valuesPrinted: false };
const context = workbenchUiOtelTraceContext({ uiTraceId, otelTraceId: options.otelTraceId, parentSpanId: options.parentSpanId });
const now = Date.now();
const startedAtMs = epochUnixMs(options.startTimeMs, now);
const endedAtMs = epochUnixMs(options.endTimeMs, startedAtMs);
const spanId = normalizeOtelSpanId(options.spanId) ?? nonZeroHex(randomBytes(8).toString("hex"), ZERO_SPAN_ID);
const parentSpanId = normalizeOtelSpanId(options.parentSpanId) ?? context.parentSpanId;
const statusCode = options.status === "error" || options.error ? 2 : 1;
const body = {
resourceSpans: [{
resource: { attributes: attributesFromRecord(resourceAttributes(env)) },
scopeSpans: [{
scope: { name: "hwlab.workbench-ui", version: "1" },
spans: [{
traceId: context.traceId,
spanId,
parentSpanId,
name,
kind: Number(options.kind ?? 1),
startTimeUnixNano: unixNano(startedAtMs),
endTimeUnixNano: unixNano(Math.max(startedAtMs, endedAtMs)),
attributes: attributesFromRecord({
"ui.trace_id": context.businessTraceId,
"otel.trace_id": context.traceId,
"workbench.ui.stage": name,
...options.attributes
}),
status: {
code: statusCode,
...(options.error ? { message: String(options.error?.message ?? options.error).slice(0, 300) } : {})
}
}]
}]
}]
};
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), OTLP_TIMEOUT_MS);
try {
const response = await fetch(endpoint, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
signal: controller.signal
});
return { ok: response.ok, status: response.status, traceId: context.traceId, valuesPrinted: false };
} catch (error) {
return { ok: false, error: error?.name === "AbortError" ? "otlp-timeout" : "otlp-send-failed", traceId: context.traceId, valuesPrinted: false };
} finally {
clearTimeout(timeout);
}
}
function resolveOtlpTracesEndpoint(env = process.env) {
const explicit = firstNonEmpty(env.HWLAB_OTEL_EXPORTER_OTLP_TRACES_ENDPOINT, env.OTEL_EXPORTER_OTLP_TRACES_ENDPOINT);
if (explicit) return explicit.replace(/\/+$/u, "");
@@ -138,3 +207,13 @@ function firstNonEmpty(...values) {
function nonZeroHex(value, zero) {
return /^[0-9a-f]+$/u.test(value) && value !== zero ? value : zero.replace(/0$/u, "1");
}
function normalizeOtelTraceId(value) {
const text = String(value ?? "").trim().toLowerCase();
return /^[0-9a-f]{32}$/u.test(text) && text !== ZERO_TRACE_ID ? text : null;
}
function normalizeOtelSpanId(value) {
const text = String(value ?? "").trim().toLowerCase();
return /^[0-9a-f]{16}$/u.test(text) && text !== ZERO_SPAN_ID ? text : null;
}
+1 -1
View File
@@ -329,7 +329,7 @@ async function handleRpcHttpRequest(request, response, options) {
async function handleRestAdapter(request, response, url, options) {
if (url.pathname === "/v1/web-performance" && request.method === "POST") {
await handleWebPerformanceIngestHttp(request, response, { store: options.webPerformanceStore });
await handleWebPerformanceIngestHttp(request, response, { store: options.webPerformanceStore, env: options.env });
return;
}
+21
View File
@@ -181,6 +181,27 @@ test("web performance summary exposes Workbench p75 and low-sample diagnostics w
assert.doesNotMatch(json, /trc_secret|ses_secret|run_secret|cmd_secret|traceId|sessionId|runId|commandId|conversationId|prompt|api key/iu);
});
test("web performance store accepts Workbench UI lifecycle events without high-cardinality labels", () => {
const store = createWebPerformanceStore({ env: { HWLAB_METRICS_NAMESPACE: "hwlab-v03", HWLAB_GITOPS_TARGET: "v03" } });
const result = store.record({
schemaVersion: "hwlab-web-performance-v2",
page: "/workbench/sessions/ses_secret",
events: [
{ kind: "workbench_ui_event", eventType: "loading_state", loadingScope: "session_detail", state: "enter", reason: "hydrate", route: "/workbench/sessions/ses_secret", valueMs: 0, uiTraceId: "ui_secret", otelTraceId: "0123456789abcdef0123456789abcdef", sessionHash: "ses_hash", sessionId: "ses_secret" },
{ kind: "workbench_ui_event", eventType: "loading_state", loadingScope: "session_detail", state: "exit", reason: "hydrate", route: "/workbench/sessions/ses_secret", valueMs: 512, uiTraceId: "ui_secret", otelTraceId: "0123456789abcdef0123456789abcdef", sessionHash: "ses_hash", traceId: "trc_secret" },
{ kind: "workbench_ui_event", eventType: "api_request", loadingScope: "api", state: "request", reason: "api_request", route: "/v1/workbench/sessions/ses_secret/messages", method: "GET", status: 200, valueMs: 84, uiTraceId: "ui_secret" },
{ kind: "workbench_ui_event", eventType: "sse_lifecycle", loadingScope: "sse", state: "error", reason: "sse_error", route: "/v1/workbench/events?sessionId=ses_secret", outcome: "network", valueMs: 0, uiTraceId: "ui_secret" }
]
} as Record<string, unknown>);
const text = store.metricsText();
assert.deepEqual(result, { accepted: 4, dropped: 0, received: 4 });
assert.match(text, /hwlab_workbench_ui_event_total\{[^}]*event_type="loading_state"[^}]*scope="session_detail"[^}]*state="enter"[^}]*reason="hydrate"/u);
assert.match(text, /hwlab_workbench_ui_event_duration_seconds_bucket\{[^}]*event_type="api_request"[^}]*route="\/v1\/workbench\/sessions\/:id\/messages"[^}]*le="0\.1"\} 1/u);
assert.match(text, /hwlab_workbench_ui_event_total\{[^}]*event_type="sse_lifecycle"[^}]*state="error"[^}]*outcome="network"/u);
assert.doesNotMatch(text, /ses_secret|trc_secret|ui_secret|0123456789abcdef|sessionId|traceId|otelTraceId|prompt|api key/iu);
});
test("web performance store drops unsupported Workbench labels and counts series limit drops", () => {
const store = createWebPerformanceStore({ env: { HWLAB_METRICS_NAMESPACE: "hwlab-v03", HWLAB_GITOPS_TARGET: "v03" }, maxSeries: 1 });
const first = store.record({
+161 -7
View File
@@ -1,12 +1,15 @@
// SPEC: PJ2026-01060505 Workbench Performance draft-2026-06-17-p0
// Keeps browser-reported performance metrics low-cardinality before Prometheus export.
import { emitWorkbenchUiOtelSpan } from "./otel-trace.ts";
const DEFAULT_WEB_PERFORMANCE_BODY_LIMIT_BYTES = 64 * 1024;
const DURATION_BUCKETS_SECONDS = [0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10, 30];
const CLS_BUCKETS = [0.01, 0.05, 0.1, 0.25, 0.5, 1, 2.5];
const WORKBENCH_JOURNEY_BUCKETS_SECONDS = [0.05, 0.1, 0.25, 0.5, 1, 2, 3, 5, 8, 13, 21, 30, 60, 120];
const WORKBENCH_EVENT_PHASE_BUCKETS_SECONDS = [0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10];
const ALLOWED_KINDS = new Set(["navigation", "web_vital", "api", "long_task", "workbench_journey", "workbench_event_phase", "workbench_backend_event_visible"]);
const WORKBENCH_UI_EVENT_BUCKETS_SECONDS = [0.001, 0.01, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10, 30, 120];
const ALLOWED_KINDS = new Set(["navigation", "web_vital", "api", "long_task", "workbench_journey", "workbench_event_phase", "workbench_backend_event_visible", "workbench_ui_event"]);
const DURATION_METRICS = new Set([
"navigation_ttfb",
"navigation_dom_content_loaded",
@@ -45,6 +48,11 @@ const WORKBENCH_CACHE_STATES = new Set(["warm", "cold", "unknown"]);
const WORKBENCH_TARGET_STATES = new Set(["running", "terminal", "empty", "unknown"]);
const WORKBENCH_SOURCES = new Set(["rail", "deeplink", "history", "direct", "hydrate", "unknown"]);
const WORKBENCH_AUTH_STATES = new Set(["warm", "login_redirect", "unknown"]);
const WORKBENCH_UI_EVENT_TYPES = new Set(["loading_state", "api_request", "sse_lifecycle", "page_lifecycle", "unknown"]);
const WORKBENCH_UI_SCOPES = new Set(["workbench", "session_list", "session_detail", "api", "sse", "page", "unknown"]);
const WORKBENCH_UI_STATES = new Set(["enter", "exit", "request", "connect", "open", "message", "close", "error", "sample", "unknown"]);
const WORKBENCH_UI_REASONS = new Set(["hydrate", "select_session", "create_session", "api_request", "sse_connect", "sse_open", "sse_error", "sse_close", "pagehide", "visibility_hidden", "unknown"]);
const OTEL_MIN_VALID_EPOCH_MS = Date.UTC(2020, 0, 1);
const LOW_SAMPLE_THRESHOLD = 5;
const DEFAULT_SUMMARY_WINDOW = "15m";
const PERFORMANCE_SUMMARY_WINDOWS = {
@@ -91,10 +99,23 @@ interface WebPerformanceEvent {
status?: unknown;
statusClass?: unknown;
outcome?: unknown;
uiTraceId?: unknown;
otelTraceId?: unknown;
spanId?: unknown;
parentSpanId?: unknown;
eventName?: unknown;
loadingScope?: unknown;
state?: unknown;
reason?: unknown;
sessionHash?: unknown;
traceHash?: unknown;
startedAtEpochMs?: unknown;
endedAtEpochMs?: unknown;
errorName?: unknown;
}
interface NormalizedPerformanceEvent {
series: "web_duration" | "layout_shift" | "workbench_journey" | "workbench_event_phase" | "workbench_backend_event_visible";
series: "web_duration" | "layout_shift" | "workbench_journey" | "workbench_event_phase" | "workbench_backend_event_visible" | "workbench_ui_event";
metric: string;
value: number;
labels: Record<string, string>;
@@ -119,6 +140,8 @@ interface PerformanceAggregate {
journeyTotalSeries: Map<string, CounterSeries>;
eventPhaseSeries: Map<string, HistogramSeries>;
backendEventVisibleSeries: Map<string, HistogramSeries>;
workbenchUiEventSeries: Map<string, HistogramSeries>;
workbenchUiEventTotalSeries: Map<string, CounterSeries>;
}
interface HistogramSeries {
@@ -195,6 +218,8 @@ export function createWebPerformanceStore(options: WebPerformanceStoreOptions =
const journeyTotalSeries = new Map<string, CounterSeries>();
const eventPhaseSeries = new Map<string, HistogramSeries>();
const backendEventVisibleSeries = new Map<string, HistogramSeries>();
const workbenchUiEventSeries = new Map<string, HistogramSeries>();
const workbenchUiEventTotalSeries = new Map<string, CounterSeries>();
const samples: RecordedPerformanceSample[] = [];
const baseLabels = {
service: "hwlab-cloud-web",
@@ -232,6 +257,7 @@ export function createWebPerformanceStore(options: WebPerformanceStoreOptions =
if (kind === "workbench_journey") return normalizeWorkbenchJourneyEvent(input, payload);
if (kind === "workbench_event_phase") return normalizeWorkbenchEventPhase(input, payload);
if (kind === "workbench_backend_event_visible") return normalizeWorkbenchBackendEventVisible(input, payload);
if (kind === "workbench_ui_event") return normalizeWorkbenchUiEvent(input, payload);
const metric = sanitizeMetricName(input.metric, "unknown");
const isCls = metric === "cls";
if (!isCls && !DURATION_METRICS.has(metric)) return null;
@@ -310,12 +336,35 @@ export function createWebPerformanceStore(options: WebPerformanceStoreOptions =
return { series: "workbench_backend_event_visible", metric: "backend_event_to_visible", value, labels };
}
function normalizeWorkbenchUiEvent(input: WebPerformanceEvent, payload: WebPerformancePayload): NormalizedPerformanceEvent | null {
if (String(payload.schemaVersion ?? "") !== WORKBENCH_SCHEMA_VERSION) return null;
const eventType = optionalEnum(input.eventType ?? field(input, "event_type"), WORKBENCH_UI_EVENT_TYPES, "unknown");
const value = durationValueSeconds(input.valueMs, 120);
if (value === null) return null;
const route = webPerformanceRouteTemplate(input.route ?? payload.page ?? "/workbench");
const pageRoute = webPerformanceRouteTemplate(payload.page ?? "/workbench");
if (isObservabilityNoise({ kind: "workbench_ui_event", metric: eventType, route, pageRoute })) return null;
const labels = {
...baseLabels,
event_type: eventType,
route,
scope: optionalEnum(input.loadingScope ?? field(input, "loading_scope"), WORKBENCH_UI_SCOPES, "unknown"),
state: optionalEnum(input.state, WORKBENCH_UI_STATES, "unknown"),
reason: optionalEnum(input.reason, WORKBENCH_UI_REASONS, "unknown"),
method: normalizeMethod(input.method),
status_class: normalizeStatusClass(input.statusClass ?? (input.status === undefined || input.status === null ? "unknown" : statusClass(input.status))),
outcome: optionalEnum(input.outcome, WORKBENCH_OUTCOMES, "ok")
};
return { series: "workbench_ui_event", metric: "workbench_ui_event", value, labels, counterLabels: labels };
}
function recordNormalizedEvent(event: NormalizedPerformanceEvent) {
if (event.series === "layout_shift") return recordHistogramWithCounter(clsSeries, sampleSeries, event.labels, event.counterLabels ?? sampleLabels(event.labels), event.value, CLS_BUCKETS);
if (event.series === "web_duration") return recordHistogramWithCounter(durationSeries, sampleSeries, event.labels, event.counterLabels ?? sampleLabels(event.labels), event.value, DURATION_BUCKETS_SECONDS);
if (event.series === "workbench_journey") return recordHistogramWithCounter(journeyDurationSeries, journeyTotalSeries, event.labels, event.counterLabels ?? event.labels, event.value, WORKBENCH_JOURNEY_BUCKETS_SECONDS);
if (event.series === "workbench_event_phase") return recordHistogram(eventPhaseSeries, event.labels, event.value, WORKBENCH_EVENT_PHASE_BUCKETS_SECONDS, maxSeries);
return recordHistogram(backendEventVisibleSeries, event.labels, event.value, WORKBENCH_JOURNEY_BUCKETS_SECONDS, maxSeries);
if (event.series === "workbench_backend_event_visible") return recordHistogram(backendEventVisibleSeries, event.labels, event.value, WORKBENCH_JOURNEY_BUCKETS_SECONDS, maxSeries);
return recordHistogramWithCounter(workbenchUiEventSeries, workbenchUiEventTotalSeries, event.labels, event.counterLabels ?? event.labels, event.value, WORKBENCH_UI_EVENT_BUCKETS_SECONDS);
}
function rememberSample(event: NormalizedPerformanceEvent) {
@@ -365,6 +414,12 @@ export function createWebPerformanceStore(options: WebPerformanceStoreOptions =
"# HELP hwlab_workbench_backend_event_visible_latency_seconds Agent backend event generated to browser-visible latency in seconds.",
"# TYPE hwlab_workbench_backend_event_visible_latency_seconds histogram",
...renderHistogram("hwlab_workbench_backend_event_visible_latency_seconds", backendEventVisibleSeries, WORKBENCH_JOURNEY_BUCKETS_SECONDS),
"# HELP hwlab_workbench_ui_event_total Browser-observed Workbench UI lifecycle events accepted by cloud-api.",
"# TYPE hwlab_workbench_ui_event_total counter",
...renderCounters("hwlab_workbench_ui_event_total", workbenchUiEventTotalSeries),
"# HELP hwlab_workbench_ui_event_duration_seconds Browser-observed Workbench UI lifecycle event durations in seconds.",
"# TYPE hwlab_workbench_ui_event_duration_seconds histogram",
...renderHistogram("hwlab_workbench_ui_event_duration_seconds", workbenchUiEventSeries, WORKBENCH_UI_EVENT_BUCKETS_SECONDS),
""
];
return lines.join("\n");
@@ -405,7 +460,8 @@ export function createWebPerformanceStore(options: WebPerformanceStoreOptions =
const sampleCount = [...aggregate.sampleSeries.values()].reduce((sum, entry) => sum + entry.value, 0);
const workbenchSampleCount = [...aggregate.journeyTotalSeries.values()].reduce((sum, entry) => sum + entry.value, 0)
+ histogramSampleCount(aggregate.eventPhaseSeries)
+ histogramSampleCount(aggregate.backendEventVisibleSeries);
+ histogramSampleCount(aggregate.backendEventVisibleSeries)
+ [...aggregate.workbenchUiEventTotalSeries.values()].reduce((sum, entry) => sum + entry.value, 0);
const totalSampleCount = sampleCount + workbenchSampleCount;
const summaryInfo = {
sampleCount: totalSampleCount,
@@ -416,6 +472,7 @@ export function createWebPerformanceStore(options: WebPerformanceStoreOptions =
workbenchJourneySeries: aggregate.journeyDurationSeries.size,
workbenchEventPhaseSeries: aggregate.eventPhaseSeries.size,
workbenchBackendEventVisibleSeries: aggregate.backendEventVisibleSeries.size,
workbenchUiEventSeries: aggregate.workbenchUiEventSeries.size,
routeCount: new Set([...rows, ...workbenchRows].map((row) => row.route)).size,
problemCount: problems.length,
status: totalSampleCount === 0 ? "waiting" : totalSampleCount < 10 ? "warming" : problems.length > 0 ? "watch" : "ok",
@@ -477,17 +534,19 @@ export function createWebPerformanceStore(options: WebPerformanceStoreOptions =
workbenchJourneySeries: journeyDurationSeries.size,
workbenchEventPhaseSeries: eventPhaseSeries.size,
workbenchBackendEventVisibleSeries: backendEventVisibleSeries.size,
workbenchUiEventSeries: workbenchUiEventSeries.size,
sampleCount: [...sampleSeries.values()].reduce((sum, entry) => sum + entry.value, 0)
+ [...journeyTotalSeries.values()].reduce((sum, entry) => sum + entry.value, 0)
+ histogramSampleCount(eventPhaseSeries)
+ histogramSampleCount(backendEventVisibleSeries),
+ histogramSampleCount(backendEventVisibleSeries)
+ [...workbenchUiEventTotalSeries.values()].reduce((sum, entry) => sum + entry.value, 0),
retainedSamples: samples.length,
maxSamples
};
}
function aggregateForWindow(sampleWindow: PerformanceSummaryWindow): PerformanceAggregate {
if (sampleWindow.milliseconds === null) return { durationSeries, clsSeries, sampleSeries, journeyDurationSeries, journeyTotalSeries, eventPhaseSeries, backendEventVisibleSeries };
if (sampleWindow.milliseconds === null) return { durationSeries, clsSeries, sampleSeries, journeyDurationSeries, journeyTotalSeries, eventPhaseSeries, backendEventVisibleSeries, workbenchUiEventSeries, workbenchUiEventTotalSeries };
const aggregate = createPerformanceAggregate();
const cutoff = now() - sampleWindow.milliseconds;
for (const sample of samples) {
@@ -507,7 +566,9 @@ function createPerformanceAggregate(): PerformanceAggregate {
journeyDurationSeries: new Map(),
journeyTotalSeries: new Map(),
eventPhaseSeries: new Map(),
backendEventVisibleSeries: new Map()
backendEventVisibleSeries: new Map(),
workbenchUiEventSeries: new Map(),
workbenchUiEventTotalSeries: new Map()
};
}
@@ -531,6 +592,11 @@ function recordSampleIntoAggregate(aggregate: PerformanceAggregate, sample: Reco
recordHistogram(aggregate.eventPhaseSeries, sample.labels, sample.value, WORKBENCH_EVENT_PHASE_BUCKETS_SECONDS, Number.MAX_SAFE_INTEGER);
return;
}
if (sample.series === "workbench_ui_event") {
recordHistogram(aggregate.workbenchUiEventSeries, sample.labels, sample.value, WORKBENCH_UI_EVENT_BUCKETS_SECONDS, Number.MAX_SAFE_INTEGER);
incrementCounter(aggregate.workbenchUiEventTotalSeries, sample.counterLabels ?? sample.labels, Number.MAX_SAFE_INTEGER);
return;
}
recordHistogram(aggregate.backendEventVisibleSeries, sample.labels, sample.value, WORKBENCH_JOURNEY_BUCKETS_SECONDS, Number.MAX_SAFE_INTEGER);
}
@@ -554,9 +620,81 @@ export async function handleWebPerformanceIngestHttp(request, response, options
return;
}
const result = options.store.record(payload);
void emitWorkbenchUiOtelSpans(payload, options.env ?? process.env).catch(() => undefined);
sendJson(response, 202, { accepted: true, ...result });
}
export async function emitWorkbenchUiOtelSpans(payload: WebPerformancePayload, env = process.env) {
if (String(payload?.schemaVersion ?? "") !== WORKBENCH_SCHEMA_VERSION) return { submitted: 0, valuesPrinted: false };
const events = Array.isArray(payload.events) ? payload.events.slice(0, 80) : [];
const tasks = events
.map((rawEvent) => normalizeWorkbenchUiOtelSpan(rawEvent, payload))
.filter(Boolean)
.map((event) => emitWorkbenchUiOtelSpan(event.name, event.uiTraceId, env, {
otelTraceId: event.otelTraceId,
spanId: event.spanId,
parentSpanId: event.parentSpanId,
startTimeMs: event.startedAtEpochMs,
endTimeMs: event.endedAtEpochMs,
status: event.status,
attributes: event.attributes
}));
if (!tasks.length) return { submitted: 0, valuesPrinted: false };
await Promise.allSettled(tasks);
return { submitted: tasks.length, valuesPrinted: false };
}
function normalizeWorkbenchUiOtelSpan(rawEvent: unknown, payload: WebPerformancePayload) {
if (!rawEvent || typeof rawEvent !== "object" || Array.isArray(rawEvent)) return null;
const input = rawEvent as WebPerformanceEvent;
if (sanitizeMetricName(input.kind, "unknown") !== "workbench_ui_event") return null;
const uiTraceId = sanitizeOtelAttribute(input.uiTraceId, 96);
if (!uiTraceId) return null;
const eventType = optionalEnum(input.eventType ?? field(input, "event_type"), WORKBENCH_UI_EVENT_TYPES, "unknown");
const scope = optionalEnum(input.loadingScope ?? field(input, "loading_scope"), WORKBENCH_UI_SCOPES, "unknown");
const state = optionalEnum(input.state, WORKBENCH_UI_STATES, "unknown");
const reason = optionalEnum(input.reason, WORKBENCH_UI_REASONS, "unknown");
const route = webPerformanceRouteTemplate(input.route ?? payload.page ?? "/workbench");
const valueMs = Math.min(Math.max(finiteNumber(input.valueMs, 0), 0), 120_000);
const endedAtEpochMs = validEpochMs(input.endedAtEpochMs) ?? Date.now();
const startedAtEpochMs = validEpochMs(input.startedAtEpochMs) ?? Math.max(OTEL_MIN_VALID_EPOCH_MS, endedAtEpochMs - valueMs);
const outcome = optionalEnum(input.outcome, WORKBENCH_OUTCOMES, "ok");
const statusNumber = finiteNumber(input.status, NaN);
const eventName = sanitizeOtelAttribute(input.eventName, 80);
const sessionHash = sanitizeOtelAttribute(input.sessionHash, 80);
const traceHash = sanitizeOtelAttribute(input.traceHash, 80);
const errorName = sanitizeOtelAttribute(input.errorName, 80);
return {
name: ["workbench", eventType, scope, state].filter((part) => part && part !== "unknown").join("."),
uiTraceId,
otelTraceId: sanitizeHex(input.otelTraceId, 32),
spanId: sanitizeHex(input.spanId, 16),
parentSpanId: sanitizeHex(input.parentSpanId, 16),
startedAtEpochMs,
endedAtEpochMs,
status: state === "error" || ["error", "network", "timeout", "denied"].includes(outcome) ? "error" : "ok",
attributes: {
"workbench.ui.event_type": eventType,
"workbench.ui.scope": scope,
"workbench.ui.state": state,
"workbench.ui.reason": reason,
"workbench.ui.route": route,
"workbench.ui.outcome": outcome,
"workbench.ui.value_ms": valueMs,
"workbench.ui.visibility": optionalEnum(input.visibility, WORKBENCH_VISIBILITY, "unknown"),
"workbench.ui.event_name": eventName,
"workbench.ui.session_hash": sessionHash,
"workbench.ui.trace_hash": traceHash,
"workbench.ui.error_name": errorName,
"workbench.ui.values_printed": false,
"http.route": route,
"http.request.method": normalizeMethod(input.method),
...(Number.isFinite(statusNumber) ? { "http.response.status_code": Math.trunc(statusNumber) } : {}),
"http.response.status_class": normalizeStatusClass(input.statusClass ?? (Number.isFinite(statusNumber) ? statusClass(statusNumber) : "unknown"))
}
};
}
export function handleWebPerformanceMetricsHttp(request, response, options = {}) {
if (!isLoopbackMetricsRequest(request)) {
sendJson(response, 404, { error: { code: "not_found", message: "route is not public" } });
@@ -1258,6 +1396,22 @@ function finiteNumber(value: unknown, fallback: number) {
return Number.isFinite(number) ? number : fallback;
}
function sanitizeOtelAttribute(value: unknown, maxLength: number) {
const text = String(value ?? "").trim();
if (!text) return undefined;
return text.replace(/[\u0000-\u001f\u007f]/gu, "_").slice(0, maxLength);
}
function sanitizeHex(value: unknown, length: number) {
const text = String(value ?? "").trim().toLowerCase();
return new RegExp(`^[0-9a-f]{${length}}$`, "u").test(text) && !/^0+$/u.test(text) ? text : undefined;
}
function validEpochMs(value: unknown) {
const number = finiteNumber(value, NaN);
return Number.isFinite(number) && number >= OTEL_MIN_VALID_EPOCH_MS ? number : null;
}
function parsePositiveInteger(value: unknown, fallback: number) {
const number = typeof value === "number" ? value : Number.parseInt(String(value ?? ""), 10);
return Number.isInteger(number) && number > 0 ? number : fallback;
+7 -1
View File
@@ -1,5 +1,6 @@
import type { ApiResult } from "@/types";
import { recordApiTiming } from "@/utils/rum";
import { recordWorkbenchApiRequest } from "@/utils/workbench-performance";
export interface ActivityRef {
lastActivityAt: number;
@@ -70,9 +71,12 @@ export async function fetchJson<T>(path: string, options: ApiRequestOptions = {}
});
const payload = await response.json().catch(() => null) as T | null;
recordApiTiming({ route: path, method: options.method, status: response.status, startedAt, outcome: response.ok ? "ok" : "http_error" });
recordWorkbenchApiRequest({ route: path, method: options.method, status: response.status, startedAtEpochMs: startedAt, endedAtEpochMs: Date.now(), outcome: response.ok ? "ok" : "http_error" });
return { ok: response.ok, status: response.status, data: payload, error: response.ok ? null : errorMessage(payload, `HTTP ${response.status}`) };
} catch (error) {
recordApiTiming({ route: path, method: options.method, status: 0, startedAt, outcome: error instanceof DOMException && error.name === "AbortError" ? "timeout" : "network_error" });
const outcome = error instanceof DOMException && error.name === "AbortError" ? "timeout" : "network_error";
recordApiTiming({ route: path, method: options.method, status: 0, startedAt, outcome });
recordWorkbenchApiRequest({ route: path, method: options.method, status: 0, startedAtEpochMs: startedAt, endedAtEpochMs: Date.now(), outcome });
if (error instanceof DOMException && error.name === "AbortError") {
if (inactivityRef.current) {
const detail = inactivityRef.current;
@@ -92,9 +96,11 @@ export async function fetchText(path: string, options: ApiRequestOptions = {}):
const response = await fetch(path, { ...options, credentials: options.credentials ?? "same-origin" });
const text = await response.text();
recordApiTiming({ route: path, method: options.method, status: response.status, startedAt, outcome: response.ok ? "ok" : "http_error" });
recordWorkbenchApiRequest({ route: path, method: options.method, status: response.status, startedAtEpochMs: startedAt, endedAtEpochMs: Date.now(), outcome: response.ok ? "ok" : "http_error" });
return { ok: response.ok, status: response.status, data: text, error: response.ok ? null : `HTTP ${response.status}` };
} catch (error) {
recordApiTiming({ route: path, method: options.method, status: 0, startedAt, outcome: "network_error" });
recordWorkbenchApiRequest({ route: path, method: options.method, status: 0, startedAtEpochMs: startedAt, endedAtEpochMs: Date.now(), outcome: "network_error" });
return { ok: false, status: 0, data: null, error: error instanceof Error ? error.message : String(error) };
}
}
@@ -2,6 +2,7 @@
// Responsibility: Workbench SSE client. Realtime events accelerate UI projection; REST snapshots remain gap-fill authority.
import type { ProjectionDiagnostic, TraceEvent } from "@/types";
import { recordWorkbenchSseLifecycle } from "@/utils/workbench-performance";
export interface WorkbenchRealtimeTraceSnapshot {
traceId?: string | null;
@@ -63,9 +64,17 @@ export function connectWorkbenchEvents(options: WorkbenchEventStreamOptions): Wo
const params = new URLSearchParams();
appendParam(params, "sessionId", options.sessionId);
appendParam(params, "traceId", options.traceId);
const source = new EventSource(`/v1/workbench/events?${params.toString()}`, { withCredentials: true });
source.onopen = () => options.onOpen?.();
source.onerror = (event) => options.onError?.(event);
const eventRoute = `/v1/workbench/events?${params.toString()}`;
recordWorkbenchSseLifecycle({ state: "connect", route: eventRoute, sessionId: options.sessionId, traceId: options.traceId });
const source = new EventSource(eventRoute, { withCredentials: true });
source.onopen = () => {
recordWorkbenchSseLifecycle({ state: "open", route: eventRoute, sessionId: options.sessionId, traceId: options.traceId });
options.onOpen?.();
};
source.onerror = (event) => {
recordWorkbenchSseLifecycle({ state: "error", route: eventRoute, sessionId: options.sessionId, traceId: options.traceId, errorName: event.type });
options.onError?.(event);
};
const listeners = WORKBENCH_EVENT_NAMES.map((name) => {
const listener = (event: MessageEvent) => {
const payload = parseRealtimeEvent(event.data);
@@ -81,6 +90,7 @@ export function connectWorkbenchEvents(options: WorkbenchEventStreamOptions): Wo
return {
close() {
for (const { name, listener } of listeners) source.removeEventListener(name, listener);
recordWorkbenchSseLifecycle({ state: "close", route: eventRoute, sessionId: options.sessionId, traceId: options.traceId });
source.close();
}
};
+26 -4
View File
@@ -8,7 +8,7 @@ import { connectWorkbenchEvents, type WorkbenchEventStream, type WorkbenchRealti
import { mergeRunnerTrace, snapshotToRunnerTrace, type TraceSnapshot } from "@/composables/useTraceSubscription";
import type { AgentChatResponse, AgentChatResultResponse, AgentRunProvenance, ApiResult, ChatMessage, LiveSurface, ProjectionDiagnostic, ProviderProfile, TraceEvent, WorkbenchSessionRecord } from "@/types";
import { firstNonEmptyString, nextProtocolId, normalizeWorkbenchSessionId, normalizeWorkbenchSessionRouteId } from "@/utils";
import { failWorkbenchSessionSwitch, failWorkbenchSubmitJourney, finishWorkbenchSessionSwitchFullLoad, markWorkbenchSubmitApiAccepted, markWorkbenchTraceEventsReceived, markWorkbenchTraceProjected, startWorkbenchSessionSwitch, startWorkbenchSubmitJourney } from "@/utils/workbench-performance";
import { failWorkbenchSessionSwitch, failWorkbenchSubmitJourney, finishWorkbenchSessionSwitchFullLoad, markWorkbenchSubmitApiAccepted, markWorkbenchTraceEventsReceived, markWorkbenchTraceProjected, recordWorkbenchLoadingState, startWorkbenchSessionSwitch, startWorkbenchSubmitJourney } from "@/utils/workbench-performance";
import { RECENT_DRAFTS_STORAGE_KEY, appendSessionPage, defaultProviderProfileOptions, isArchivedSession, mergeSessionIntoList, normalizeChatMessageStatus, normalizeRecentDrafts, normalizeWorkbenchMessageTitle, providerProfileOptionsFromPayload, recordRecentDraft, resolveCancelableAgentMessage, resolveComposerState, shouldShowSessionListLoading, sortSessionTabs, stableSessionList, type DraftEntry, type ProviderProfileOption, type TurnStatusAuthority } from "./workbench-session";
import { initialWorkbenchSessionIdFromLocation } from "./workbench-projection";
import { createWorkbenchServerState, reduceWorkbenchServerState, selectActiveMessages, selectActiveSession, selectSessionList, selectSessionStatusAuthority, selectTraceAuthorityById, selectTurnStatusAuthority, type WorkbenchServerAction } from "./workbench-server-state";
@@ -93,17 +93,23 @@ export const useWorkbenchStore = defineStore("workbench", () => {
const routeSessionId = normalizeWorkbenchSessionId(options.sessionId);
const requestEpoch = beginSessionSelection();
sessionsReady.value = sessions.value.length > 0;
loading.value = true;
error.value = null;
const includeSessionId = routeSessionId ?? activeSessionId.value;
loading.value = true;
recordWorkbenchLoadingState({ scope: "workbench", active: true, reason: "hydrate", sessionId: includeSessionId });
recordWorkbenchLoadingState({ scope: "session_list", active: true, reason: "hydrate", sessionId: includeSessionId });
error.value = null;
if (routeSessionId) {
sessionDetailLoadingId.value = routeSessionId;
recordWorkbenchLoadingState({ scope: "session_detail", active: true, reason: "hydrate", sessionId: routeSessionId });
setActiveSessionSelection(routeSessionId, "route");
}
const sessionsResult = await api.workbench.sessions({ includeSessionId, limit: SESSION_LIST_PAGE_LIMIT });
if (!sessionsResult.ok) {
clearSessionDetailLoading(includeSessionId);
loading.value = false;
recordWorkbenchLoadingState({ scope: "session_detail", active: false, reason: "hydrate", sessionId: includeSessionId });
recordWorkbenchLoadingState({ scope: "session_list", active: false, reason: "hydrate", sessionId: includeSessionId });
recordWorkbenchLoadingState({ scope: "workbench", active: false, reason: "hydrate", sessionId: includeSessionId });
error.value = sessionsResult.error ?? "session list unavailable";
return;
}
@@ -113,7 +119,10 @@ export const useWorkbenchStore = defineStore("workbench", () => {
if (targetSessionId && (routeSessionId || !routeRequestId)) {
setActiveSessionSelection(targetSessionId, routeSessionId ? "route" : "system");
}
if (targetSessionId) sessionDetailLoadingId.value = targetSessionId;
if (targetSessionId) {
sessionDetailLoadingId.value = targetSessionId;
recordWorkbenchLoadingState({ scope: "session_detail", active: true, reason: "hydrate", sessionId: targetSessionId });
}
const selected = targetSessionId ? await loadWorkbenchSession(targetSessionId, listedSessions.find((item) => item.sessionId === targetSessionId) ?? null) : null;
if (selected && !isArchivedSession(selected) && routeRequestId && !routeSessionId && requestEpoch === selectionEpoch.value) setActiveSessionSelection(selected.sessionId, "route");
const selectedIsCurrent = Boolean(selected && !isArchivedSession(selected) && isCurrentSessionSelection(requestEpoch, selected.sessionId));
@@ -129,6 +138,9 @@ export const useWorkbenchStore = defineStore("workbench", () => {
void refreshSessionStatusAuthority(nextSessions);
clearSessionDetailLoading(targetSessionId);
loading.value = false;
recordWorkbenchLoadingState({ scope: "session_detail", active: false, reason: "hydrate", sessionId: targetSessionId });
recordWorkbenchLoadingState({ scope: "session_list", active: false, reason: "hydrate", sessionId: includeSessionId });
recordWorkbenchLoadingState({ scope: "workbench", active: false, reason: "hydrate", sessionId: targetSessionId ?? includeSessionId });
await refreshProviderOptions();
restartRealtime("hydrate");
}
@@ -148,9 +160,11 @@ export const useWorkbenchStore = defineStore("workbench", () => {
async function createSession(): Promise<void> {
beginSessionSelection();
loading.value = true;
recordWorkbenchLoadingState({ scope: "workbench", active: true, reason: "create_session", sessionId: activeSessionId.value });
error.value = null;
const response = await api.agent.createAgentSession({ providerProfile: providerProfile.value });
loading.value = false;
recordWorkbenchLoadingState({ scope: "workbench", active: false, reason: "create_session", sessionId: activeSessionId.value });
const created = response.ok ? sessionFromWorkbenchSession(response.data?.session) : null;
if (!response.ok || !created) {
error.value = response.error ?? "session create failed";
@@ -180,6 +194,8 @@ export const useWorkbenchStore = defineStore("workbench", () => {
switchingSessionId.value = requestId;
sessionDetailLoadingId.value = requestId;
loading.value = true;
recordWorkbenchLoadingState({ scope: "workbench", active: true, reason: "select_session", sessionId: normalized ?? requestId });
recordWorkbenchLoadingState({ scope: "session_detail", active: true, reason: "select_session", sessionId: normalized ?? requestId });
if (normalized) setActiveSessionSelection(normalized, source);
if (existing) {
rememberSessionList(mergeSessionIntoList(sessions.value, existing));
@@ -190,6 +206,8 @@ export const useWorkbenchStore = defineStore("workbench", () => {
if (!isCurrentSessionRequest(requestEpoch, requestId, selected?.sessionId ?? normalized)) {
clearSessionDetailLoading(requestId);
clearSwitchingSession(requestId);
recordWorkbenchLoadingState({ scope: "session_detail", active: false, reason: "select_session", sessionId: normalized ?? requestId });
recordWorkbenchLoadingState({ scope: "workbench", active: false, reason: "select_session", sessionId: normalized ?? requestId });
return true;
}
if (selected && !isArchivedSession(selected)) {
@@ -198,6 +216,8 @@ export const useWorkbenchStore = defineStore("workbench", () => {
loading.value = false;
clearSessionDetailLoading(requestId);
clearSwitchingSession(requestId);
recordWorkbenchLoadingState({ scope: "session_detail", active: false, reason: "select_session", sessionId: selected.sessionId });
recordWorkbenchLoadingState({ scope: "workbench", active: false, reason: "select_session", sessionId: selected.sessionId });
error.value = null;
await refreshSessions(selected.sessionId);
finishWorkbenchSessionSwitchFullLoad(selected.sessionId, "ok");
@@ -206,6 +226,8 @@ export const useWorkbenchStore = defineStore("workbench", () => {
loading.value = false;
clearSessionDetailLoading(requestId);
clearSwitchingSession(requestId);
recordWorkbenchLoadingState({ scope: "session_detail", active: false, reason: "select_session", sessionId: normalized ?? requestId });
recordWorkbenchLoadingState({ scope: "workbench", active: false, reason: "select_session", sessionId: normalized ?? requestId });
if (selected && isArchivedSession(selected)) applyCanonicalSessionArchive(selected.sessionId, "session archived");
else markSessionReadUnavailable(requestId, "session detail unavailable");
failWorkbenchSessionSwitch(normalized ?? requestId);
+7
View File
@@ -9,6 +9,13 @@ declare global {
};
};
};
__HWLAB_WORKBENCH_UI_TRACE__?: {
uiTraceId: string;
otelTraceId: string;
traceparent: string;
startedAtEpochMs: number;
valuesPrinted: false;
};
}
}
@@ -3,8 +3,11 @@
import type { ChatMessage, TraceEvent } from "@/types";
type WorkbenchEventKind = "workbench_journey" | "workbench_event_phase" | "workbench_backend_event_visible";
type WorkbenchEventKind = "workbench_journey" | "workbench_event_phase" | "workbench_backend_event_visible" | "workbench_ui_event";
type WorkbenchOutcome = "ok" | "timeout" | "error" | "dropped" | "stale" | "partial" | "empty" | "network" | "unknown";
type WorkbenchLoadingScope = "workbench" | "session_list" | "session_detail" | "api" | "sse" | "page";
type WorkbenchUiState = "enter" | "exit" | "request" | "connect" | "open" | "message" | "close" | "error" | "sample";
type WorkbenchUiReason = "hydrate" | "select_session" | "create_session" | "api_request" | "sse_connect" | "sse_open" | "sse_error" | "sse_close" | "pagehide" | "visibility_hidden" | "unknown";
interface WorkbenchPerformanceEvent {
kind: WorkbenchEventKind;
@@ -22,6 +25,22 @@ interface WorkbenchPerformanceEvent {
authState?: string;
visibility?: string;
outcome?: WorkbenchOutcome;
eventName?: string;
uiTraceId?: string;
otelTraceId?: string;
spanId?: string;
parentSpanId?: string;
loadingScope?: WorkbenchLoadingScope;
state?: WorkbenchUiState;
reason?: WorkbenchUiReason;
sessionHash?: string;
traceHash?: string;
method?: string;
status?: number;
statusClass?: string;
startedAtEpochMs?: number;
endedAtEpochMs?: number;
errorName?: string;
}
interface TraceEventTimingInput {
@@ -80,16 +99,116 @@ interface TraceEventState {
visibleReported: boolean;
}
interface WorkbenchUiTrace {
uiTraceId: string;
otelTraceId: string;
traceparent: string;
startedAtEpochMs: number;
valuesPrinted: false;
}
interface LoadingStateRecord {
active: true;
startAt: number;
startEpochMs: number;
reason: WorkbenchUiReason;
route: string;
sessionHash?: string;
traceHash?: string;
}
const SCHEMA_VERSION = "hwlab-web-performance-v2";
const FLUSH_INTERVAL_MS = 2500;
const MAX_QUEUE = 120;
const FLUSH_BATCH_THRESHOLD = 60;
const MAX_QUEUE = 200;
const submitJourneys = new Map<string, SubmitJourneyState>();
const sessionSwitches = new Map<string, SessionSwitchState>();
const traceEvents = new Map<string, TraceEventState>();
const loadingStates = new Map<WorkbenchLoadingScope, LoadingStateRecord>();
let openJourney: OpenJourneyState | null = null;
let queue: WorkbenchPerformanceEvent[] = [];
let installed = false;
let flushTimer: number | null = null;
let uiTrace: WorkbenchUiTrace | null = null;
export function currentWorkbenchUiTrace(): WorkbenchUiTrace | null {
if (typeof window === "undefined") return null;
if (!uiTrace) {
const otelTraceId = randomHex(16);
const parentSpanId = randomHex(8);
uiTrace = {
uiTraceId: `ui_${Date.now().toString(36)}_${randomHex(6)}`,
otelTraceId,
traceparent: `00-${otelTraceId}-${parentSpanId}-01`,
startedAtEpochMs: Date.now(),
valuesPrinted: false
};
}
publishWorkbenchUiTrace(uiTrace);
return uiTrace;
}
export function recordWorkbenchLoadingState(input: { scope: WorkbenchLoadingScope; active: boolean; reason?: WorkbenchUiReason; sessionId?: string | null; traceId?: string | null }): void {
ensureInstalled();
const reason = input.reason ?? "unknown";
const now = monotonicNow();
const nowEpoch = wallNow();
const sessionHash = hashIdentifier(input.sessionId, "ses");
const traceHash = hashIdentifier(input.traceId, "trc");
const previous = loadingStates.get(input.scope);
if (input.active) {
if (previous?.active) return;
const state: LoadingStateRecord = { active: true, startAt: now, startEpochMs: nowEpoch, reason, route: pageRoute(), sessionHash, traceHash };
loadingStates.set(input.scope, state);
enqueueWorkbenchUiEvent({ eventType: "loading_state", loadingScope: input.scope, state: "enter", reason, route: state.route, valueMs: 0, startedAtEpochMs: nowEpoch, endedAtEpochMs: nowEpoch, sessionHash, traceHash, outcome: "ok" });
return;
}
if (!previous?.active) return;
loadingStates.delete(input.scope);
const valueMs = Math.max(0, now - previous.startAt);
enqueueWorkbenchUiEvent({ eventType: "loading_state", loadingScope: input.scope, state: "exit", reason: previous.reason, route: previous.route, valueMs, startedAtEpochMs: previous.startEpochMs, endedAtEpochMs: nowEpoch, sessionHash: previous.sessionHash, traceHash: previous.traceHash, outcome: "ok" });
}
export function recordWorkbenchApiRequest(input: { route: string; method?: string; status?: number; startedAtEpochMs: number; endedAtEpochMs?: number; outcome?: string | null }): void {
if (!isWorkbenchPage()) return;
const route = safeText(input.route);
if (!route || route.startsWith("/v1/web-performance")) return;
ensureInstalled();
const endedAtEpochMs = input.endedAtEpochMs ?? wallNow();
enqueueWorkbenchUiEvent({
eventType: "api_request",
loadingScope: "api",
state: "request",
reason: "api_request",
route,
method: input.method ?? "GET",
status: input.status,
statusClass: statusClass(input.status),
outcome: normalizeUiOutcome(input.outcome),
valueMs: Math.max(0, endedAtEpochMs - input.startedAtEpochMs),
startedAtEpochMs: input.startedAtEpochMs,
endedAtEpochMs
});
}
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;
enqueueWorkbenchUiEvent({
eventType: "sse_lifecycle",
loadingScope: "sse",
state,
reason: state === "connect" ? "sse_connect" : state === "open" ? "sse_open" : state === "close" ? "sse_close" : "sse_error",
route: input.route,
valueMs: 0,
startedAtEpochMs: wallNow(),
endedAtEpochMs: wallNow(),
sessionHash: hashIdentifier(input.sessionId, "ses"),
traceHash: hashIdentifier(input.traceId, "trc"),
outcome: state === "error" ? "network" : "ok",
errorName: input.errorName ? safeText(input.errorName).slice(0, 80) : undefined
});
}
export function startWorkbenchOpenJourney(input: { route?: string; cache?: "warm" | "cold" | "unknown"; authState?: "warm" | "login_redirect" | "unknown" } = {}): void {
ensureInstalled();
@@ -273,8 +392,10 @@ export function resetWorkbenchPerformanceForTest(): void {
submitJourneys.clear();
sessionSwitches.clear();
traceEvents.clear();
loadingStates.clear();
openJourney = null;
queue = [];
uiTrace = null;
if (flushTimer !== null && typeof window !== "undefined") window.clearTimeout(flushTimer);
flushTimer = null;
installed = false;
@@ -286,10 +407,27 @@ export function drainWorkbenchPerformanceEventsForTest(): WorkbenchPerformanceEv
return events;
}
function enqueueWorkbenchUiEvent(event: Omit<WorkbenchPerformanceEvent, "kind" | "uiTraceId" | "otelTraceId" | "spanId">): void {
const trace = currentWorkbenchUiTrace();
if (!trace) return;
enqueue({
...event,
kind: "workbench_ui_event",
uiTraceId: trace.uiTraceId,
otelTraceId: trace.otelTraceId,
spanId: randomHex(8),
visibility: visibilityState()
});
}
function enqueue(event: WorkbenchPerformanceEvent): void {
if (!Number.isFinite(event.valueMs) || event.valueMs < 0) return;
queue.push({ ...event, valueMs: Math.round(event.valueMs * 1000) / 1000 });
if (queue.length > MAX_QUEUE) queue.splice(0, queue.length - MAX_QUEUE);
if (queue.length >= FLUSH_BATCH_THRESHOLD) {
flushWorkbenchPerformance(false);
return;
}
scheduleFlush();
}
@@ -316,8 +454,16 @@ function flushWorkbenchPerformance(useBeacon: boolean): void {
function ensureInstalled(): void {
if (installed || typeof document === "undefined") return;
installed = true;
currentWorkbenchUiTrace();
document.addEventListener("visibilitychange", () => {
if (document.visibilityState === "hidden") flushWorkbenchPerformance(true);
if (document.visibilityState === "hidden") {
enqueueWorkbenchUiEvent({ eventType: "page_lifecycle", loadingScope: "page", state: "close", reason: "visibility_hidden", route: pageRoute(), valueMs: 0, startedAtEpochMs: wallNow(), endedAtEpochMs: wallNow(), outcome: "ok" });
flushWorkbenchPerformance(true);
}
});
window.addEventListener("pagehide", () => {
enqueueWorkbenchUiEvent({ eventType: "page_lifecycle", loadingScope: "page", state: "close", reason: "pagehide", route: pageRoute(), valueMs: 0, startedAtEpochMs: wallNow(), endedAtEpochMs: wallNow(), outcome: "ok" });
flushWorkbenchPerformance(true);
});
}
@@ -447,6 +593,49 @@ function visibilityState(): string {
return document.visibilityState === "hidden" ? "background" : "foreground";
}
function publishWorkbenchUiTrace(trace: WorkbenchUiTrace): void {
if (typeof window === "undefined") return;
window.__HWLAB_WORKBENCH_UI_TRACE__ = { ...trace };
}
function randomHex(bytes: number): string {
const values = new Uint8Array(Math.max(1, bytes));
const cryptoSource = typeof crypto !== "undefined" ? crypto : typeof window !== "undefined" ? window.crypto : null;
if (cryptoSource?.getRandomValues) cryptoSource.getRandomValues(values);
else for (let index = 0; index < values.length; index += 1) values[index] = Math.floor(Math.random() * 256);
return [...values].map((value) => value.toString(16).padStart(2, "0")).join("");
}
function hashIdentifier(value: unknown, prefix: string): string | undefined {
const text = safeText(value);
if (!text) return undefined;
let hash = 0x811c9dc5;
for (let index = 0; index < text.length; index += 1) {
hash ^= text.charCodeAt(index);
hash = Math.imul(hash, 0x01000193) >>> 0;
}
return `${prefix}_${hash.toString(16).padStart(8, "0")}`;
}
function isWorkbenchPage(): boolean {
return pageRoute().startsWith("/workbench");
}
function statusClass(status: unknown): string {
const code = typeof status === "number" ? status : Number(status);
if (Number.isFinite(code) && code >= 100 && code < 600) return `${Math.floor(code / 100)}xx`;
if (code === 0) return "network";
return "unknown";
}
function normalizeUiOutcome(value: unknown): WorkbenchOutcome {
const text = safeText(value).toLowerCase();
if (text === "ok" || text === "timeout" || text === "error" || text === "dropped" || text === "stale" || text === "partial" || text === "empty" || text === "network" || text === "unknown") return text;
if (text === "http_error") return "error";
if (text === "network_error") return "network";
return "unknown";
}
function monotonicNow(): number {
return typeof performance !== "undefined" && typeof performance.now === "function" ? performance.now() : Date.now();
}