From 4eadd2f85a1b3b50c43198a5e220654e73aa36a3 Mon Sep 17 00:00:00 2001 From: lyon Date: Sat, 20 Jun 2026 00:47:57 +0800 Subject: [PATCH] fix: trace workbench loading states with otel --- internal/cloud/otel-trace.ts | 79 +++++++ internal/cloud/server.ts | 2 +- internal/cloud/web-performance.test.ts | 21 ++ internal/cloud/web-performance.ts | 168 ++++++++++++++- web/hwlab-cloud-web/src/api/client.ts | 8 +- .../src/api/workbench-events.ts | 16 +- web/hwlab-cloud-web/src/stores/workbench.ts | 30 ++- web/hwlab-cloud-web/src/types/global.d.ts | 7 + .../src/utils/workbench-performance.ts | 195 +++++++++++++++++- 9 files changed, 507 insertions(+), 19 deletions(-) diff --git a/internal/cloud/otel-trace.ts b/internal/cloud/otel-trace.ts index 413c482f..23690eec 100644 --- a/internal/cloud/otel-trace.ts +++ b/internal/cloud/otel-trace.ts @@ -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; +} diff --git a/internal/cloud/server.ts b/internal/cloud/server.ts index cde71de5..c4a7f3dc 100644 --- a/internal/cloud/server.ts +++ b/internal/cloud/server.ts @@ -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; } diff --git a/internal/cloud/web-performance.test.ts b/internal/cloud/web-performance.test.ts index f4fcb617..494df843 100644 --- a/internal/cloud/web-performance.test.ts +++ b/internal/cloud/web-performance.test.ts @@ -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); + 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({ diff --git a/internal/cloud/web-performance.ts b/internal/cloud/web-performance.ts index ba44aee7..bb17aa88 100644 --- a/internal/cloud/web-performance.ts +++ b/internal/cloud/web-performance.ts @@ -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; @@ -119,6 +140,8 @@ interface PerformanceAggregate { journeyTotalSeries: Map; eventPhaseSeries: Map; backendEventVisibleSeries: Map; + workbenchUiEventSeries: Map; + workbenchUiEventTotalSeries: Map; } interface HistogramSeries { @@ -195,6 +218,8 @@ export function createWebPerformanceStore(options: WebPerformanceStoreOptions = const journeyTotalSeries = new Map(); const eventPhaseSeries = new Map(); const backendEventVisibleSeries = new Map(); + const workbenchUiEventSeries = new Map(); + const workbenchUiEventTotalSeries = new Map(); 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; diff --git a/web/hwlab-cloud-web/src/api/client.ts b/web/hwlab-cloud-web/src/api/client.ts index 674cf4e7..bdc97f9c 100644 --- a/web/hwlab-cloud-web/src/api/client.ts +++ b/web/hwlab-cloud-web/src/api/client.ts @@ -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(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) }; } } diff --git a/web/hwlab-cloud-web/src/api/workbench-events.ts b/web/hwlab-cloud-web/src/api/workbench-events.ts index 08002410..feeadc62 100644 --- a/web/hwlab-cloud-web/src/api/workbench-events.ts +++ b/web/hwlab-cloud-web/src/api/workbench-events.ts @@ -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(); } }; diff --git a/web/hwlab-cloud-web/src/stores/workbench.ts b/web/hwlab-cloud-web/src/stores/workbench.ts index 2b41ca8d..c55dffab 100644 --- a/web/hwlab-cloud-web/src/stores/workbench.ts +++ b/web/hwlab-cloud-web/src/stores/workbench.ts @@ -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 { 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); diff --git a/web/hwlab-cloud-web/src/types/global.d.ts b/web/hwlab-cloud-web/src/types/global.d.ts index 504764c7..aa5ef323 100644 --- a/web/hwlab-cloud-web/src/types/global.d.ts +++ b/web/hwlab-cloud-web/src/types/global.d.ts @@ -9,6 +9,13 @@ declare global { }; }; }; + __HWLAB_WORKBENCH_UI_TRACE__?: { + uiTraceId: string; + otelTraceId: string; + traceparent: string; + startedAtEpochMs: number; + valuesPrinted: false; + }; } } diff --git a/web/hwlab-cloud-web/src/utils/workbench-performance.ts b/web/hwlab-cloud-web/src/utils/workbench-performance.ts index b2c1ee6f..8dab8d8d 100644 --- a/web/hwlab-cloud-web/src/utils/workbench-performance.ts +++ b/web/hwlab-cloud-web/src/utils/workbench-performance.ts @@ -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(); const sessionSwitches = new Map(); const traceEvents = new Map(); +const loadingStates = new Map(); 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): 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(); }