Merge pull request #1636 from pikasTech/fix/1633-workbench-loading-otel
Workbench loading 状态接入 OTel trace
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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
@@ -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();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user