// SPEC: PJ2026-01060505 Workbench Performance draft-2026-06-19-p0 // Keeps browser-reported performance metrics low-cardinality before Prometheus export. import { decorateErrorPayloadForHttp, logErrorEnvelope } from "./error-envelope.ts"; 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 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", "navigation_load", "lcp", "inp", "fid", "api_request", "long_task" ]); const WORKBENCH_SCHEMA_VERSION = "hwlab-web-performance-v2"; const WORKBENCH_JOURNEYS = new Set([ "submit_to_first_visible", "submit_to_failure", "backend_event_to_visible", "session_switch_first_visible", "session_switch_full_load", "workbench_open_first_visible", "workbench_open_full_load" ]); const WORKBENCH_EVENT_PHASES = new Set([ "created_to_append", "append_to_sse", "sse_to_receive", "receive_to_project", "project_to_paint", "user_submit_to_api_accepted", "api_accepted_to_backend_event" ]); const WORKBENCH_EVENT_TYPES = new Set(["assistant", "tool_call", "backend", "terminal", "error", "status", "request", "result", "unknown"]); const WORKBENCH_TRANSPORTS = new Set(["sse", "rest_gap", "poll", "none", "unknown"]); const WORKBENCH_OUTCOMES = new Set(["ok", "timeout", "error", "dropped", "stale", "partial", "empty", "network", "denied", "unknown"]); const WORKBENCH_ENTRIES = new Set(["new", "existing", "steer", "retry", "unknown"]); const WORKBENCH_VISIBILITY = new Set(["foreground", "background", "hidden", "unknown"]); 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 = { "5m": { id: "5m", label: "最近 5 分钟", milliseconds: 5 * 60 * 1000 }, "15m": { id: "15m", label: "最近 15 分钟", milliseconds: 15 * 60 * 1000 }, "1h": { id: "1h", label: "最近 1 小时", milliseconds: 60 * 60 * 1000 }, "6h": { id: "6h", label: "最近 6 小时", milliseconds: 6 * 60 * 60 * 1000 }, "24h": { id: "24h", label: "最近 24 小时", milliseconds: 24 * 60 * 60 * 1000 }, all: { id: "all", label: "全部累计", milliseconds: null } } as const; interface WebPerformanceStoreOptions { env?: Record; maxSeries?: number; maxSamples?: number; now?: () => number; } interface WebPerformancePayload { schemaVersion?: unknown; serviceId?: unknown; page?: unknown; events?: unknown; } interface WebPerformanceEvent { kind?: unknown; metric?: unknown; journey?: unknown; phase?: unknown; eventType?: unknown; backend?: unknown; transport?: unknown; entry?: unknown; visibility?: unknown; cache?: unknown; targetState?: unknown; source?: unknown; authState?: unknown; route?: unknown; valueMs?: unknown; value?: unknown; method?: unknown; 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; timeoutName?: unknown; activityWaitingFor?: unknown; activityLastEventLabel?: unknown; activityIdleMs?: unknown; } interface NormalizedPerformanceEvent { series: "web_duration" | "layout_shift" | "workbench_journey" | "workbench_event_phase" | "workbench_backend_event_visible" | "workbench_ui_event"; metric: string; value: number; labels: Record; counterLabels?: Record; } interface RecordedPerformanceSample extends NormalizedPerformanceEvent { atMs: number; } interface PerformanceSummaryWindow { id: keyof typeof PERFORMANCE_SUMMARY_WINDOWS; label: string; milliseconds: number | null; } interface PerformanceAggregate { durationSeries: Map; clsSeries: Map; sampleSeries: Map; journeyDurationSeries: Map; journeyTotalSeries: Map; eventPhaseSeries: Map; backendEventVisibleSeries: Map; workbenchUiEventSeries: Map; workbenchUiEventTotalSeries: Map; } interface HistogramSeries { labels: Record; buckets: number[]; sum: number; count: number; } interface CounterSeries { labels: Record; value: number; } interface PerformanceSummaryRow { kind: string; metric: string; route: string; method: string; statusClass: string; outcome: string; count: number; average: number; p50: number; p75: number; p95: number; unit: "seconds" | "score"; sourceFamily?: string; windowFrom?: string | null; windowTo?: string; generatedAt?: string; sampleCount?: number; freshness?: "fresh" | "low-sample" | "empty"; aggregationKind?: "exact" | "histogram"; approximation?: "exact" | "bucketed"; bucketBoundaries?: number[]; tone: "ok" | "warn" | "blocked" | "source"; problem: string; lowSample?: boolean; sampleState?: "ok" | "low-sample" | "empty"; backend?: string; transport?: string; targetState?: string; cache?: string; source?: string; entry?: string; authState?: string; eventType?: string; phase?: string; backendEvidence?: PerformanceRowBackendEvidence; } interface BackendPerformanceEvidenceRow { route?: unknown; method?: unknown; statusClass?: unknown; outcome?: unknown; count?: unknown; average?: unknown; p50?: unknown; p75?: unknown; p95?: unknown; firstObservedAt?: unknown; lastObservedAt?: unknown; responseBytes?: { count?: unknown; average?: unknown; p50?: unknown; p75?: unknown; p95?: unknown } | null; phases?: Array<{ phase?: unknown; outcome?: unknown; count?: unknown; average?: unknown; p50?: unknown; p75?: unknown; p95?: unknown }>; } interface BackendPerformanceEvidenceSnapshot { schemaVersion?: unknown; source?: unknown; observedAt?: unknown; sampleCount?: unknown; retainedSampleCount?: unknown; routeCount?: unknown; rows?: BackendPerformanceEvidenceRow[]; } interface PerformanceRowBackendEvidence { status: "matched" | "missing" | "unavailable"; statusLabel: string; diagnostic: string; diagnosticLabel: string; detail: string; route: string; method: string; count: number; p50?: number; p75?: number; p95?: number; responseBytesP95?: number | null; lastObservedAt?: string | null; phases?: Array<{ phase: string; outcome: string; count: number; p95: number }>; } interface PerformanceDashboardPoint { label: string; detail: string; value: number; p50: number; p75: number; p95: number; count: number; tone: string; sampleState: string; } interface PerformanceDashboardTopRow { label: string; detail: string; value: number; unit: string; count: number; tone: string; } interface PerformanceCollectionHealthDiagnostic { code: string; label: string; tone: "ok" | "warn" | "blocked" | "source"; detail: string; } interface PerformanceCollectionHealth { schemaVersion: "hwlab-web-performance-collection-health-v1"; status: "fresh" | "low-sample" | "stale" | "degraded" | "waiting"; statusLabel: string; reason: string; reasonLabel: string; sampleCount: number; retainedSampleCount: number; sampleSeries: number; lowSampleThreshold: number; firstSampleAt: string | null; lastSampleAt: string | null; lastRetainedSampleAt: string | null; secondsSinceLastSample: number | null; windowCoverageSeconds: number | null; sourceFamilyCounts: Array<{ sourceFamily: string; rowCount: number; sampleCount: number }>; unknownAttribution: { workbenchRows: number; attributableRows: number; rowsWithUnknown: number; samplesWithUnknown: number; backendUnknownRows: number; transportUnknownRows: number; }; diagnostics: PerformanceCollectionHealthDiagnostic[]; } export function createWebPerformanceStore(options: WebPerformanceStoreOptions = {}) { const env = options.env ?? process.env; const maxSeries = parsePositiveInteger(env.HWLAB_WEB_PERFORMANCE_MAX_SERIES, options.maxSeries ?? 240); const maxSamples = parsePositiveInteger(env.HWLAB_WEB_PERFORMANCE_MAX_SAMPLES, options.maxSamples ?? 5000); const now = typeof options.now === "function" ? options.now : () => Date.now(); const durationSeries = new Map(); const clsSeries = new Map(); const sampleSeries = new Map(); const droppedSeries = new Map(); const journeyDurationSeries = new Map(); 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", namespace: sanitizeLabelValue(env.HWLAB_METRICS_NAMESPACE ?? env.POD_NAMESPACE ?? "hwlab-v02"), gitops_target: sanitizeLabelValue(env.HWLAB_GITOPS_TARGET ?? env.HWLAB_GITOPS_PROFILE ?? "v02") }; function record(payload: WebPerformancePayload) { const events = Array.isArray(payload.events) ? payload.events.slice(0, 80) : []; let accepted = 0; let dropped = 0; for (const rawEvent of events) { const event = normalizeEvent(rawEvent, payload); if (!event) { incrementDropped("invalid_event"); dropped += 1; continue; } if (!recordNormalizedEvent(event)) { incrementDropped("series_limit"); dropped += 1; continue; } rememberSample(event); accepted += 1; } return { accepted, dropped, received: events.length }; } function normalizeEvent(rawEvent: unknown, payload: WebPerformancePayload): NormalizedPerformanceEvent | null { if (!rawEvent || typeof rawEvent !== "object" || Array.isArray(rawEvent)) return null; const input = rawEvent as WebPerformanceEvent; const kind = sanitizeMetricName(input.kind, "unknown"); if (!ALLOWED_KINDS.has(kind)) return null; 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; const rawValue = isCls ? finiteNumber(input.value, NaN) : finiteNumber(input.valueMs, NaN) / 1000; if (!Number.isFinite(rawValue) || rawValue < 0) return null; const value = isCls ? Math.min(rawValue, 10) : Math.min(rawValue, 120); const route = webPerformanceRouteTemplate(input.route ?? payload.page ?? "/workspace"); const pageRoute = webPerformanceRouteTemplate(payload.page ?? "/workspace"); if (isObservabilityNoise({ kind, metric, route, pageRoute })) return null; const labels = { ...baseLabels, kind, metric, route, method: normalizeMethod(input.method), status_class: normalizeStatusClass(input.statusClass ?? (input.status === undefined || input.status === null ? "unknown" : statusClass(input.status))), outcome: sanitizeLabelValue(input.outcome ?? "ok", "ok") }; return { series: isCls ? "layout_shift" : "web_duration", metric, value, labels, counterLabels: sampleLabels(labels) }; } function normalizeWorkbenchJourneyEvent(input: WebPerformanceEvent, payload: WebPerformancePayload): NormalizedPerformanceEvent | null { if (String(payload.schemaVersion ?? "") !== WORKBENCH_SCHEMA_VERSION) return null; const journey = requiredEnum(input.journey ?? input.metric, WORKBENCH_JOURNEYS); if (!journey) return null; 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_journey", metric: journey, route, pageRoute })) return null; const labels = { ...baseLabels, journey, route, entry: optionalEnum(input.entry, WORKBENCH_ENTRIES, "unknown"), source: optionalEnum(input.source, WORKBENCH_SOURCES, "unknown"), target_state: optionalEnum(input.targetState ?? field(input, "target_state"), WORKBENCH_TARGET_STATES, "unknown"), cache: optionalEnum(input.cache, WORKBENCH_CACHE_STATES, "unknown"), auth_state: optionalEnum(input.authState ?? field(input, "auth_state"), WORKBENCH_AUTH_STATES, "unknown"), backend: normalizeBackendLabel(input.backend), transport: optionalEnum(input.transport, WORKBENCH_TRANSPORTS, "unknown"), visibility: optionalEnum(input.visibility, WORKBENCH_VISIBILITY, "unknown"), outcome: optionalEnum(input.outcome, WORKBENCH_OUTCOMES, "ok") }; return { series: "workbench_journey", metric: journey, value, labels, counterLabels: labels }; } function normalizeWorkbenchEventPhase(input: WebPerformanceEvent, payload: WebPerformancePayload): NormalizedPerformanceEvent | null { if (String(payload.schemaVersion ?? "") !== WORKBENCH_SCHEMA_VERSION) return null; const phase = requiredEnum(input.phase ?? input.metric, WORKBENCH_EVENT_PHASES); if (!phase) return null; const value = durationValueSeconds(input.valueMs, 30); if (value === null) return null; const labels = { ...baseLabels, phase, event_type: normalizeEventType(input.eventType ?? field(input, "event_type")), backend: normalizeBackendLabel(input.backend), transport: optionalEnum(input.transport, WORKBENCH_TRANSPORTS, "unknown"), outcome: optionalEnum(input.outcome, WORKBENCH_OUTCOMES, "ok") }; return { series: "workbench_event_phase", metric: phase, value, labels }; } function normalizeWorkbenchBackendEventVisible(input: WebPerformanceEvent, payload: WebPerformancePayload): NormalizedPerformanceEvent | null { if (String(payload.schemaVersion ?? "") !== WORKBENCH_SCHEMA_VERSION) return null; const value = durationValueSeconds(input.valueMs, 120); if (value === null) return null; const labels = { ...baseLabels, event_type: normalizeEventType(input.eventType ?? field(input, "event_type")), backend: normalizeBackendLabel(input.backend), transport: optionalEnum(input.transport, WORKBENCH_TRANSPORTS, "unknown"), outcome: optionalEnum(input.outcome, WORKBENCH_OUTCOMES, "ok") }; 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); 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) { samples.push({ ...event, atMs: now(), labels: { ...event.labels }, counterLabels: event.counterLabels ? { ...event.counterLabels } : undefined }); if (samples.length > maxSamples) samples.splice(0, samples.length - maxSamples); } function recordHistogramWithCounter(histograms: Map, counters: Map, histogramLabels: Record, counterLabels: Record, value: number, buckets: number[]) { if (!canRecordSeries(histograms, histogramLabels, maxSeries) || !canRecordSeries(counters, counterLabels, maxSeries)) return false; recordHistogram(histograms, histogramLabels, value, buckets, maxSeries); incrementCounter(counters, counterLabels, maxSeries); return true; } function incrementDropped(reason: string) { incrementCounter(droppedSeries, { ...baseLabels, reason }, Math.max(maxSeries, 16)); } function metricsText() { const lines = [ "# HELP hwlab_webui_performance_sample_total Browser-observed HWLAB Cloud Web performance samples accepted by cloud-api.", "# TYPE hwlab_webui_performance_sample_total counter", ...renderCounters("hwlab_webui_performance_sample_total", sampleSeries), "# HELP hwlab_webui_performance_duration_seconds Browser-observed HWLAB Cloud Web duration metrics in seconds.", "# TYPE hwlab_webui_performance_duration_seconds histogram", ...renderHistogram("hwlab_webui_performance_duration_seconds", durationSeries, DURATION_BUCKETS_SECONDS), "# HELP hwlab_webui_layout_shift_score Browser-observed cumulative layout shift score.", "# TYPE hwlab_webui_layout_shift_score histogram", ...renderHistogram("hwlab_webui_layout_shift_score", clsSeries, CLS_BUCKETS), "# HELP hwlab_webui_performance_dropped_total Browser performance samples dropped before Prometheus export.", "# TYPE hwlab_webui_performance_dropped_total counter", ...renderCounters("hwlab_webui_performance_dropped_total", droppedSeries), "# HELP hwlab_workbench_journey_total Browser-observed HWLAB Workbench journey samples accepted by cloud-api.", "# TYPE hwlab_workbench_journey_total counter", ...renderCounters("hwlab_workbench_journey_total", journeyTotalSeries), "# HELP hwlab_workbench_journey_duration_seconds Browser-observed HWLAB Workbench user-visible journey durations in seconds.", "# TYPE hwlab_workbench_journey_duration_seconds histogram", ...renderHistogram("hwlab_workbench_journey_duration_seconds", journeyDurationSeries, WORKBENCH_JOURNEY_BUCKETS_SECONDS), "# HELP hwlab_workbench_event_phase_duration_seconds Browser/backend phase durations for HWLAB Workbench events in seconds.", "# TYPE hwlab_workbench_event_phase_duration_seconds histogram", ...renderHistogram("hwlab_workbench_event_phase_duration_seconds", eventPhaseSeries, WORKBENCH_EVENT_PHASE_BUCKETS_SECONDS), "# 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"); } function summary(input: { window?: unknown; backendEvidence?: BackendPerformanceEvidenceSnapshot | null } = {}) { const sampleWindow = resolvePerformanceSummaryWindow(input.window); const generatedAtMs = now(); const observedAt = new Date(generatedAtMs).toISOString(); const windowBounds = performanceSummaryWindowBounds(sampleWindow, generatedAtMs, observedAt); const aggregate = aggregateForWindow(sampleWindow, generatedAtMs); const windowSamples = samplesForWindow(sampleWindow, generatedAtMs, samples); const exactStats = exactStatsForSamples(windowSamples); const backendEvidence = normalizeBackendEvidence(input.backendEvidence); const durationRows = attachBackendEvidenceToRows(applySummaryContract(histogramSummaryRows(aggregate.durationSeries, DURATION_BUCKETS_SECONDS, "seconds"), exactStats, windowBounds), backendEvidence); const clsRows = applySummaryContract(histogramSummaryRows(aggregate.clsSeries, CLS_BUCKETS, "score"), exactStats, windowBounds); const rows = [...durationRows, ...clsRows]; const workbenchJourneys = applySummaryContract(workbenchJourneySummaryRows(aggregate.journeyDurationSeries, WORKBENCH_JOURNEY_BUCKETS_SECONDS), exactStats, windowBounds) .sort(sortByProblemThenP95) .slice(0, 24); const workbenchEventPhases = applySummaryContract(workbenchEventPhaseSummaryRows(aggregate.eventPhaseSeries, WORKBENCH_EVENT_PHASE_BUCKETS_SECONDS), exactStats, windowBounds) .sort(sortByProblemThenP95) .slice(0, 24); const workbenchBackendEvents = applySummaryContract(workbenchBackendEventVisibleSummaryRows(aggregate.backendEventVisibleSeries, WORKBENCH_JOURNEY_BUCKETS_SECONDS), exactStats, windowBounds) .sort(sortByProblemThenP95) .slice(0, 24); const workbenchRows = [...workbenchJourneys, ...workbenchEventPhases, ...workbenchBackendEvents]; const apiRoutes = durationRows .filter((row) => row.metric === "api_request") .sort(sortByProblemThenP95) .slice(0, 12); const webVitals = rows .filter((row) => row.kind === "web_vital" || row.metric.startsWith("navigation_")) .sort(sortByProblemThenP95) .slice(0, 12); const longTasks = durationRows .filter((row) => row.metric === "long_task") .sort(sortByProblemThenP95) .slice(0, 12); const problems = [...rows, ...workbenchRows] .filter(isPerformanceProblemRow) .sort(sortByProblemThenP95) .slice(0, 16); 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) + [...aggregate.workbenchUiEventTotalSeries.values()].reduce((sum, entry) => sum + entry.value, 0); const totalSampleCount = sampleCount + workbenchSampleCount; const summaryInfo = { sampleCount: totalSampleCount, sampleSeries: aggregate.sampleSeries.size, durationSeries: aggregate.durationSeries.size, clsSeries: aggregate.clsSeries.size, droppedSeries: droppedSeries.size, 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", lowSampleThreshold: LOW_SAMPLE_THRESHOLD, window: sampleWindow.id, windowLabel: sampleWindow.label, windowSeconds: sampleWindow.milliseconds === null ? null : Math.round(sampleWindow.milliseconds / 1000), windowFrom: windowBounds.windowFrom, windowTo: windowBounds.windowTo, generatedAt: observedAt, aggregationKind: "exact", approximation: "exact" }; const collectionHealth = buildPerformanceCollectionHealth({ observedAt, generatedAtMs, sampleWindow, summary: summaryInfo, windowSamples, retainedSamples: samples, rows: [...rows, ...workbenchRows] }); const dashboard = buildPerformanceDashboard({ observedAt, source: "cloud-api-rum-store", namespace: baseLabels.namespace, gitopsTarget: baseLabels.gitops_target, sampleWindow, summary: summaryInfo, collectionHealth, apiRoutes, webVitals, longTasks, problems, workbenchJourneys, workbenchEventPhases, workbenchBackendEvents, rows: rows.sort(sortByProblemThenP95).slice(0, 40), workbenchRows: workbenchRows.sort(sortByProblemThenP95).slice(0, 40) }); return { serviceId: "hwlab-cloud-api", route: "/v1/web-performance/summary", source: "cloud-api-rum-store", observedAt, namespace: baseLabels.namespace, gitopsTarget: baseLabels.gitops_target, sampleWindow: { id: sampleWindow.id, label: sampleWindow.label, seconds: sampleWindow.milliseconds === null ? null : Math.round(sampleWindow.milliseconds / 1000), windowFrom: windowBounds.windowFrom, windowTo: windowBounds.windowTo, generatedAt: observedAt, aggregationKind: "exact", approximation: "exact" }, summary: summaryInfo, backendEvidence, collectionHealth, dashboard, apiRoutes, webVitals, longTasks, problems, workbenchJourneys, workbenchEventPhases, workbenchBackendEvents, workbenchRows: dashboard.source.workbenchRows, rows: dashboard.source.rows }; } function snapshot() { return { sampleSeries: sampleSeries.size, durationSeries: durationSeries.size, clsSeries: clsSeries.size, droppedSeries: droppedSeries.size, 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) + [...workbenchUiEventTotalSeries.values()].reduce((sum, entry) => sum + entry.value, 0), retainedSamples: samples.length, maxSamples }; } function aggregateForWindow(sampleWindow: PerformanceSummaryWindow, generatedAtMs = now()): PerformanceAggregate { const aggregate = createPerformanceAggregate(); for (const sample of samplesForWindow(sampleWindow, generatedAtMs, samples)) recordSampleIntoAggregate(aggregate, sample); return aggregate; } return { record, metricsText, snapshot, summary }; } function createPerformanceAggregate(): PerformanceAggregate { return { durationSeries: new Map(), clsSeries: new Map(), sampleSeries: new Map(), journeyDurationSeries: new Map(), journeyTotalSeries: new Map(), eventPhaseSeries: new Map(), backendEventVisibleSeries: new Map(), workbenchUiEventSeries: new Map(), workbenchUiEventTotalSeries: new Map() }; } function recordSampleIntoAggregate(aggregate: PerformanceAggregate, sample: RecordedPerformanceSample) { if (sample.series === "layout_shift") { recordHistogram(aggregate.clsSeries, sample.labels, sample.value, CLS_BUCKETS, Number.MAX_SAFE_INTEGER); incrementCounter(aggregate.sampleSeries, sample.counterLabels ?? sampleLabels(sample.labels), Number.MAX_SAFE_INTEGER); return; } if (sample.series === "web_duration") { recordHistogram(aggregate.durationSeries, sample.labels, sample.value, DURATION_BUCKETS_SECONDS, Number.MAX_SAFE_INTEGER); incrementCounter(aggregate.sampleSeries, sample.counterLabels ?? sampleLabels(sample.labels), Number.MAX_SAFE_INTEGER); return; } if (sample.series === "workbench_journey") { recordHistogram(aggregate.journeyDurationSeries, sample.labels, sample.value, WORKBENCH_JOURNEY_BUCKETS_SECONDS, Number.MAX_SAFE_INTEGER); incrementCounter(aggregate.journeyTotalSeries, sample.counterLabels ?? sample.labels, Number.MAX_SAFE_INTEGER); return; } if (sample.series === "workbench_event_phase") { 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); } function samplesForWindow(samplesWindow: PerformanceSummaryWindow, generatedAtMs: number, retainedSamples: RecordedPerformanceSample[] = []) { const source = retainedSamples.length ? retainedSamples : []; if (samplesWindow.milliseconds === null) return source.slice(); const cutoff = generatedAtMs - samplesWindow.milliseconds; return source.filter((sample) => sample.atMs >= cutoff && sample.atMs <= generatedAtMs); } function resolvePerformanceSummaryWindow(input: unknown): PerformanceSummaryWindow { const key = String(input ?? DEFAULT_SUMMARY_WINDOW).trim().toLowerCase(); if (Object.prototype.hasOwnProperty.call(PERFORMANCE_SUMMARY_WINDOWS, key)) return PERFORMANCE_SUMMARY_WINDOWS[key as keyof typeof PERFORMANCE_SUMMARY_WINDOWS]; return PERFORMANCE_SUMMARY_WINDOWS[DEFAULT_SUMMARY_WINDOW]; } function performanceSummaryWindowBounds(sampleWindow: PerformanceSummaryWindow, generatedAtMs: number, generatedAt: string) { const windowTo = generatedAt; const windowFrom = sampleWindow.milliseconds === null ? null : new Date(Math.max(0, generatedAtMs - sampleWindow.milliseconds)).toISOString(); return { windowFrom, windowTo, generatedAt }; } export async function handleWebPerformanceIngestHttp(request, response, options = {}) { const body = await readBody(request, options.bodyLimitBytes ?? DEFAULT_WEB_PERFORMANCE_BODY_LIMIT_BYTES); let payload; try { payload = body ? JSON.parse(body) : {}; } catch (error) { sendJson(response, 400, { accepted: false, error: { code: "parse_error", message: "Invalid web performance JSON body", reason: error.message } }); return; } if (!payload || typeof payload !== "object" || Array.isArray(payload)) { sendJson(response, 400, { accepted: false, error: { code: "invalid_params", message: "web performance body must be a JSON object" } }); 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); const timeoutName = sanitizeOtelAttribute(input.timeoutName, 80); const activityWaitingFor = sanitizeOtelAttribute(input.activityWaitingFor, 80); const activityLastEventLabel = sanitizeOtelAttribute(input.activityLastEventLabel, 120); const activityIdleMs = Math.min(Math.max(finiteNumber(input.activityIdleMs, NaN), 0), 3_600_000); 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.timeout_name": timeoutName, "workbench.ui.activity_waiting_for": activityWaitingFor, "workbench.ui.activity_last_event_label": activityLastEventLabel, ...(Number.isFinite(activityIdleMs) ? { "workbench.ui.activity_idle_ms": Math.trunc(activityIdleMs) } : {}), "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" } }); return; } const primary = String(options.store.metricsText() ?? "").trimEnd(); const extra = typeof options.extraMetricsText === "function" ? String(options.extraMetricsText() ?? "").trimEnd() : String(options.extraMetricsText ?? "").trimEnd(); const payload = [primary, extra].filter(Boolean).join("\n") + "\n"; response.writeHead(200, { "content-type": "text/plain; version=0.0.4; charset=utf-8", "content-length": Buffer.byteLength(payload) }); response.end(payload); } export function handleWebPerformanceSummaryHttp(request, response, options = {}) { const url = requestUrl(request); const backendEvidence = typeof options.backendPerformanceStore?.evidence === "function" ? options.backendPerformanceStore.evidence({ window: url.searchParams.get("window") }) : null; sendJson(response, 200, options.store.summary({ window: url.searchParams.get("window"), backendEvidence })); } export function webPerformanceRouteTemplate(input: unknown): string { const raw = String(input ?? "").trim() || "/"; const normalizedRaw = raw.startsWith("#") ? raw.slice(1) || "/" : raw; const pathOnly = normalizedRaw.split(/[?#]/u, 1)[0] || "/"; let pathname = pathOnly; try { pathname = new URL(normalizedRaw, "http://hwlab.local").pathname || pathOnly; } catch { // Keep pathOnly. } const parts = pathname.split("/").filter(Boolean).map((part) => routeSegmentTemplate(safeDecode(part))); const path = `/${parts.join("/")}`; return path.length > 120 ? `${path.slice(0, 117)}...` : path; } function sampleLabels(labels: Record) { return { service: labels.service, namespace: labels.namespace, gitops_target: labels.gitops_target, kind: labels.kind, metric: labels.metric, route: labels.route, method: labels.method, status_class: labels.status_class, outcome: labels.outcome }; } function requestUrl(request) { try { return new URL(String(request.url ?? "/"), `http://${String(request.headers?.host ?? "hwlab.local")}`); } catch { return new URL("/", "http://hwlab.local"); } } function isObservabilityNoise(event: { kind: string; metric: string; route: string; pageRoute: string }) { if (event.pageRoute === "/performance") return true; if (event.route === "/performance" && (event.kind === "navigation" || event.kind === "web_vital" || event.kind === "long_task")) return true; return event.route === "/v1/web-performance" || event.route.startsWith("/v1/web-performance/"); } function canRecordSeries(series: Map, labels: Record, maxSeries: number) { const key = stableLabelKey(labels); return series.has(key) || series.size < maxSeries; } function recordHistogram(series: Map, labels: Record, value: number, buckets: number[], maxSeries: number) { const key = stableLabelKey(labels); let entry = series.get(key); if (!entry) { if (series.size >= maxSeries) return false; entry = { labels, buckets: buckets.map(() => 0), sum: 0, count: 0 }; series.set(key, entry); } for (let index = 0; index < buckets.length; index += 1) { if (value <= buckets[index]) entry.buckets[index] += 1; } entry.sum += value; entry.count += 1; return true; } function incrementCounter(series: Map, labels: Record, maxSeries: number) { const key = stableLabelKey(labels); let entry = series.get(key); if (!entry) { if (series.size >= maxSeries) return false; entry = { labels, value: 0 }; series.set(key, entry); } entry.value += 1; return true; } function histogramSampleCount(series: Map) { return [...series.values()].reduce((sum, entry) => sum + entry.count, 0); } function renderCounters(metricName: string, series: Map) { return [...series.values()].map((entry) => `${metricName}{${renderLabels(entry.labels)}} ${entry.value}`); } function renderHistogram(metricName: string, series: Map, buckets: number[]) { const lines: string[] = []; for (const entry of series.values()) { for (let index = 0; index < buckets.length; index += 1) { lines.push(`${metricName}_bucket{${renderLabels({ ...entry.labels, le: String(buckets[index]) })}} ${entry.buckets[index]}`); } lines.push(`${metricName}_bucket{${renderLabels({ ...entry.labels, le: "+Inf" })}} ${entry.count}`); lines.push(`${metricName}_sum{${renderLabels(entry.labels)}} ${entry.sum.toFixed(6)}`); lines.push(`${metricName}_count{${renderLabels(entry.labels)}} ${entry.count}`); } return lines; } function histogramSummaryRows(series: Map, buckets: number[], unit: "seconds" | "score"): PerformanceSummaryRow[] { return [...series.values()].map((entry) => { const labels = entry.labels; const count = entry.count; const average = count > 0 ? entry.sum / count : 0; const p50 = histogramQuantile(entry, buckets, 0.5); const p75 = histogramQuantile(entry, buckets, 0.75); const p95 = histogramQuantile(entry, buckets, 0.95); const tone = performanceTone(labels, p95, count, unit); return { kind: labels.kind, metric: labels.metric, route: labels.route, method: labels.method, statusClass: labels.status_class, outcome: labels.outcome, count, average: roundMetric(average), p50: roundMetric(p50), p75: roundMetric(p75), p95: roundMetric(p95), unit, tone, problem: performanceProblem(labels, p95, count, unit, tone), lowSample: count > 0 && count < LOW_SAMPLE_THRESHOLD, sampleState: sampleState(count) }; }); } function exactStatsForSamples(windowSamples: RecordedPerformanceSample[]) { const grouped = new Map(); for (const sample of windowSamples) { const identity = summaryRowIdentityForSample(sample); if (!identity) continue; const key = summaryRowKey(identity); let entry = grouped.get(key); if (!entry) { entry = { values: [] }; grouped.set(key, entry); } entry.values.push(sample.value); } const stats = new Map(); for (const [key, entry] of grouped.entries()) stats.set(key, exactStats(entry.values)); return stats; } function exactStats(values: number[]) { const sorted = values.filter(Number.isFinite).sort((left, right) => left - right); const count = sorted.length; const average = count > 0 ? sorted.reduce((sum, value) => sum + value, 0) / count : 0; return { count, average: roundMetric(average), p50: roundMetric(exactQuantile(sorted, 0.5)), p75: roundMetric(exactQuantile(sorted, 0.75)), p95: roundMetric(exactQuantile(sorted, 0.95)) }; } function exactQuantile(sortedValues: number[], quantile: number) { if (sortedValues.length <= 0) return 0; const index = Math.min(sortedValues.length - 1, Math.max(0, Math.ceil(sortedValues.length * quantile) - 1)); return sortedValues[index] ?? 0; } function applySummaryContract(rows: PerformanceSummaryRow[], exactStatsByRow: Map, windowBounds: { windowFrom: string | null; windowTo: string; generatedAt: string }) { return rows.map((row) => { const exact = exactStatsByRow.get(summaryRowKey(row)); const count = exact?.count ?? row.count; const p95 = exact?.p95 ?? row.p95; const nextRow = { ...row, ...(exact ? { count, average: exact.average, p50: exact.p50, p75: exact.p75, p95 } : {}), sampleCount: count, sourceFamily: sourceFamilyForRow(row), windowFrom: windowBounds.windowFrom, windowTo: windowBounds.windowTo, generatedAt: windowBounds.generatedAt, freshness: sampleFreshness(count), aggregationKind: exact ? "exact" : "histogram", approximation: exact ? "exact" : "bucketed", bucketBoundaries: exact ? undefined : bucketBoundariesForRow(row), lowSample: count > 0 && count < LOW_SAMPLE_THRESHOLD, sampleState: sampleState(count) }; const tone = summaryToneForRow(nextRow, p95, count); return { ...nextRow, tone, problem: summaryProblemForRow(nextRow, p95, count, tone) }; }); } function normalizeBackendEvidence(input?: BackendPerformanceEvidenceSnapshot | null) { const rows = Array.isArray(input?.rows) ? input.rows.map(normalizeBackendEvidenceRow).filter(Boolean) as BackendPerformanceEvidenceRow[] : []; return { schemaVersion: "hwlab-backend-performance-evidence-v1", source: sanitizeLabelValue(input?.source ?? "cloud-api-backend-performance-store"), observedAt: isoOrNull(input?.observedAt), sampleCount: finiteNonNegativeInteger(input?.sampleCount), retainedSampleCount: finiteNonNegativeInteger(input?.retainedSampleCount), routeCount: finiteNonNegativeInteger(input?.routeCount ?? new Set(rows.map((row) => row.route)).size), rows: rows.slice(0, 80), available: Boolean(input && String(input.schemaVersion ?? "") === "hwlab-backend-performance-evidence-v1"), valuesRedacted: true }; } function normalizeBackendEvidenceRow(input: BackendPerformanceEvidenceRow) { const route = webPerformanceRouteTemplate(input?.route ?? ""); if (!route || route === "/") return null; const responseBytes = input.responseBytes && typeof input.responseBytes === "object" ? { count: finiteNonNegativeInteger(input.responseBytes.count), average: finiteNonNegativeInteger(input.responseBytes.average), p50: finiteNonNegativeInteger(input.responseBytes.p50), p75: finiteNonNegativeInteger(input.responseBytes.p75), p95: finiteNonNegativeInteger(input.responseBytes.p95) } : null; return { route, method: normalizeMethod(input.method), statusClass: normalizeStatusClass(input.statusClass), outcome: sanitizeLabelValue(input.outcome ?? "unknown", "unknown"), count: finiteNonNegativeInteger(input.count), average: finiteNonNegativeMetric(input.average), p50: finiteNonNegativeMetric(input.p50), p75: finiteNonNegativeMetric(input.p75), p95: finiteNonNegativeMetric(input.p95), firstObservedAt: isoOrNull(input.firstObservedAt), lastObservedAt: isoOrNull(input.lastObservedAt), responseBytes, phases: Array.isArray(input.phases) ? input.phases.map(normalizeBackendEvidencePhase).filter(Boolean).slice(0, 8) : [] }; } function normalizeBackendEvidencePhase(input: { phase?: unknown; outcome?: unknown; count?: unknown; average?: unknown; p50?: unknown; p75?: unknown; p95?: unknown }) { const phase = sanitizeMetricName(input?.phase, "unknown").slice(0, 64) || "unknown"; return { phase, outcome: sanitizeLabelValue(input?.outcome ?? "unknown", "unknown"), count: finiteNonNegativeInteger(input?.count), average: finiteNonNegativeMetric(input?.average), p50: finiteNonNegativeMetric(input?.p50), p75: finiteNonNegativeMetric(input?.p75), p95: finiteNonNegativeMetric(input?.p95) }; } function attachBackendEvidenceToRows(rows: PerformanceSummaryRow[], evidence: ReturnType) { return rows.map((row) => { if (row.kind !== "api" || row.metric !== "api_request") return row; return { ...row, backendEvidence: backendEvidenceForApiRow(row, evidence) }; }); } function backendEvidenceForApiRow(row: PerformanceSummaryRow, evidence: ReturnType): PerformanceRowBackendEvidence { const route = webPerformanceRouteTemplate(row.route); const method = normalizeMethod(row.method); if (!evidence.available) { return { status: "unavailable", statusLabel: "后端证据未接入", diagnostic: "backend_evidence_unavailable", diagnosticLabel: "后端证据未接入", detail: "当前 summary 没有收到 backend evidence snapshot,无法把 RUM 慢样本与服务端 request 对齐。", route, method, count: 0, lastObservedAt: null, phases: [] }; } const candidates = evidence.rows.filter((item) => item.route === route && item.method === method); const matched = candidates.find((item) => item.statusClass === row.statusClass && item.outcome === row.outcome) ?? candidates[0] ?? null; if (!matched) { return { status: "missing", statusLabel: "后端无匹配样本", diagnostic: "no_backend_sample_window", diagnosticLabel: "窗口内无后端样本", detail: `当前窗口没有匹配 ${method} ${route} 的后端 request 样本;优先检查浏览器采集窗口、边缘/缓存路径或后端 evidence 覆盖范围。`, route, method, count: 0, lastObservedAt: null, phases: [] }; } const count = finiteNonNegativeInteger(matched.count); const backendP95 = finiteNonNegativeMetric(matched.p95); const rumP95 = finiteNonNegativeMetric(row.p95); const diagnostic = backendEvidenceDiagnostic({ count, backendP95, rumP95 }); return { status: "matched", statusLabel: "已匹配后端", diagnostic, diagnosticLabel: backendEvidenceDiagnosticLabel(diagnostic), detail: backendEvidenceDetail({ diagnostic, count, backendP95, rumP95, phases: matched.phases ?? [] }), route: matched.route, method: matched.method, count, p50: finiteNonNegativeMetric(matched.p50), p75: finiteNonNegativeMetric(matched.p75), p95: backendP95, responseBytesP95: matched.responseBytes?.p95 ?? null, lastObservedAt: isoOrNull(matched.lastObservedAt), phases: (matched.phases ?? []).slice(0, 5).map((phase) => ({ phase: String(phase.phase ?? "unknown"), outcome: String(phase.outcome ?? "unknown"), count: finiteNonNegativeInteger(phase.count), p95: finiteNonNegativeMetric(phase.p95) })) }; } function backendEvidenceDiagnostic(input: { count: number; backendP95: number; rumP95: number }) { if (input.count > 0 && input.count < LOW_SAMPLE_THRESHOLD) return "backend_low_sample"; if (input.rumP95 >= 1 && input.backendP95 > 0 && input.backendP95 <= Math.max(0.25, input.rumP95 * 0.5)) return "rum_slower_than_backend"; if (input.backendP95 >= 1 && input.backendP95 >= input.rumP95 * 0.75) return "backend_slow_aligned"; return "backend_matched"; } function backendEvidenceDiagnosticLabel(value: string) { const labels: Record = { backend_low_sample: "后端低样本", backend_slow_aligned: "后端慢已对齐", rum_slower_than_backend: "RUM 慢于后端", backend_matched: "后端样本已匹配" }; return labels[value] ?? value; } function backendEvidenceDetail(input: { diagnostic: string; count: number; backendP95: number; rumP95: number; phases: Array<{ phase?: unknown; p95?: unknown }> }) { const topPhase = input.phases?.[0]; const phaseText = topPhase ? `;最慢阶段 ${String(topPhase.phase ?? "unknown")} P95 ${formatBackendSeconds(finiteNonNegativeMetric(topPhase.p95))}` : ""; if (input.diagnostic === "rum_slower_than_backend") return `RUM P95 ${formatBackendSeconds(input.rumP95)},后端 P95 ${formatBackendSeconds(input.backendP95)},差值更可能在浏览器网络、排队、边缘代理或采集端${phaseText}。`; if (input.diagnostic === "backend_slow_aligned") return `RUM P95 ${formatBackendSeconds(input.rumP95)} 与后端 P95 ${formatBackendSeconds(input.backendP95)} 同向,优先看后端阶段${phaseText}。`; if (input.diagnostic === "backend_low_sample") return `仅 ${input.count} 个后端样本,已匹配但不足以稳定判断${phaseText}。`; return `${input.count} 个后端样本已匹配,后端 P95 ${formatBackendSeconds(input.backendP95)}${phaseText}。`; } function formatBackendSeconds(value: number) { if (!Number.isFinite(value)) return "未知"; if (value < 1) return `${Math.round(value * 1000)}ms`; return `${roundMetric(value)}s`; } function finiteNonNegativeMetric(value: unknown) { const number = Number(value); return Number.isFinite(number) && number >= 0 ? roundMetric(number) : 0; } function finiteNonNegativeInteger(value: unknown) { const number = Number(value); return Number.isFinite(number) && number >= 0 ? Math.trunc(number) : 0; } function isoOrNull(value: unknown): string | null { const parsed = Date.parse(String(value ?? "")); return Number.isFinite(parsed) ? new Date(parsed).toISOString() : null; } function summaryRowIdentityForSample(sample: RecordedPerformanceSample): Record | null { const labels = sample.labels; if (sample.series === "web_duration" || sample.series === "layout_shift") { return { kind: labels.kind, metric: labels.metric, route: labels.route, method: labels.method, statusClass: labels.status_class, outcome: labels.outcome }; } if (sample.series === "workbench_journey") { return { kind: "workbench_journey", metric: labels.journey, route: labels.route, method: "-", statusClass: "-", outcome: labels.outcome, backend: labels.backend, transport: labels.transport, targetState: labels.target_state, cache: labels.cache, source: labels.source, entry: labels.entry, authState: labels.auth_state }; } if (sample.series === "workbench_event_phase") { return { kind: "workbench_event_phase", metric: labels.phase, route: "workbench-events", method: "-", statusClass: "-", outcome: labels.outcome, backend: labels.backend, transport: labels.transport, eventType: labels.event_type, phase: labels.phase }; } if (sample.series === "workbench_backend_event_visible") { return { kind: "workbench_backend_event_visible", metric: "backend_event_to_visible", route: "workbench-events", method: "-", statusClass: "-", outcome: labels.outcome, backend: labels.backend, transport: labels.transport, eventType: labels.event_type }; } return null; } function summaryRowKey(row: Record) { const fields = ["kind", "metric", "route", "method", "statusClass", "outcome", "backend", "transport", "targetState", "cache", "source", "entry", "authState", "eventType", "phase"]; const labels: Record = {}; for (const field of fields) labels[field] = String(row[field] ?? ""); return stableLabelKey(labels); } function summaryToneForRow(row: PerformanceSummaryRow, p95: number, count: number): "ok" | "warn" | "blocked" | "source" { if (count <= 0 || count < LOW_SAMPLE_THRESHOLD) return "source"; if (row.kind === "workbench_journey") { const threshold = workbenchJourneyThreshold(row.metric); return workbenchTone(row.outcome, p95, count, threshold.warn, threshold.blocked); } if (row.kind === "workbench_event_phase") { const threshold = workbenchEventPhaseThreshold(row.metric); return workbenchTone(row.outcome, p95, count, threshold.warn, threshold.blocked); } if (row.kind === "workbench_backend_event_visible") { const threshold = workbenchJourneyThreshold("backend_event_to_visible"); return workbenchTone(row.outcome, p95, count, threshold.warn, threshold.blocked); } return performanceTone({ metric: row.metric, outcome: row.outcome, status_class: row.statusClass }, p95, count, row.unit === "score" ? "score" : "seconds"); } function summaryProblemForRow(row: PerformanceSummaryRow, p95: number, count: number, tone: string) { if (count <= 0) return "no-sample"; if (row.kind === "workbench_journey" || row.kind === "workbench_event_phase" || row.kind === "workbench_backend_event_visible") return workbenchProblem(row.outcome, row.metric, count, tone); return performanceProblem({ metric: row.metric, outcome: row.outcome, status_class: row.statusClass }, p95, count, row.unit === "score" ? "score" : "seconds", tone); } function isPerformanceProblemRow(row: PerformanceSummaryRow) { if ((row.kind === "workbench_journey" || row.kind === "workbench_event_phase" || row.kind === "workbench_backend_event_visible") && row.sampleState !== "ok") return false; return row.tone === "blocked" || row.tone === "warn" || row.outcome !== "ok" || row.statusClass === "network" || row.statusClass === "5xx" || row.statusClass === "4xx"; } function sourceFamilyForRow(row: PerformanceSummaryRow) { if (row.kind === "api") return "rum_api_timing"; if (row.kind === "web_vital") return "rum_web_vitals"; if (row.kind === "navigation") return "rum_navigation"; if (row.kind === "long_task") return "rum_long_task"; if (row.kind === "workbench_journey") return "workbench_journey"; if (row.kind === "workbench_event_phase") return "workbench_event_phase"; if (row.kind === "workbench_backend_event_visible") return "workbench_backend_event_visible"; return "unknown"; } function sampleFreshness(count: number): "fresh" | "low-sample" | "empty" { if (count <= 0) return "empty"; return count < LOW_SAMPLE_THRESHOLD ? "low-sample" : "fresh"; } function bucketBoundariesForRow(row: PerformanceSummaryRow) { if (row.unit === "score") return CLS_BUCKETS; if (row.kind === "workbench_event_phase") return WORKBENCH_EVENT_PHASE_BUCKETS_SECONDS; if (row.kind === "workbench_journey" || row.kind === "workbench_backend_event_visible") return WORKBENCH_JOURNEY_BUCKETS_SECONDS; return DURATION_BUCKETS_SECONDS; } function workbenchJourneySummaryRows(series: Map, buckets: number[]): PerformanceSummaryRow[] { return [...series.values()].map((entry) => { const labels = entry.labels; const metric = labels.journey; const row = workbenchSummaryRow(entry, buckets, { kind: "workbench_journey", metric, route: labels.route, outcome: labels.outcome, backend: labels.backend, transport: labels.transport, targetState: labels.target_state, cache: labels.cache, source: labels.source, entry: labels.entry, authState: labels.auth_state, warn: workbenchJourneyThreshold(metric).warn, blocked: workbenchJourneyThreshold(metric).blocked }); return row; }); } function workbenchEventPhaseSummaryRows(series: Map, buckets: number[]): PerformanceSummaryRow[] { return [...series.values()].map((entry) => { const labels = entry.labels; const metric = labels.phase; const threshold = workbenchEventPhaseThreshold(metric); return workbenchSummaryRow(entry, buckets, { kind: "workbench_event_phase", metric, route: "workbench-events", outcome: labels.outcome, backend: labels.backend, transport: labels.transport, eventType: labels.event_type, phase: labels.phase, warn: threshold.warn, blocked: threshold.blocked }); }); } function workbenchBackendEventVisibleSummaryRows(series: Map, buckets: number[]): PerformanceSummaryRow[] { return [...series.values()].map((entry) => { const labels = entry.labels; const threshold = workbenchJourneyThreshold("backend_event_to_visible"); return workbenchSummaryRow(entry, buckets, { kind: "workbench_backend_event_visible", metric: "backend_event_to_visible", route: "workbench-events", outcome: labels.outcome, backend: labels.backend, transport: labels.transport, eventType: labels.event_type, warn: threshold.warn, blocked: threshold.blocked }); }); } function workbenchSummaryRow(entry: HistogramSeries, buckets: number[], input: { kind: string; metric: string; route: string; outcome: string; warn: number; blocked: number; backend?: string; transport?: string; targetState?: string; cache?: string; source?: string; entry?: string; authState?: string; eventType?: string; phase?: string; }): PerformanceSummaryRow { const count = entry.count; const average = count > 0 ? entry.sum / count : 0; const p50 = histogramQuantile(entry, buckets, 0.5); const p75 = histogramQuantile(entry, buckets, 0.75); const p95 = histogramQuantile(entry, buckets, 0.95); const lowSample = count > 0 && count < LOW_SAMPLE_THRESHOLD; const tone = workbenchTone(input.outcome, p95, count, input.warn, input.blocked); return { kind: input.kind, metric: input.metric, route: input.route, method: "-", statusClass: "-", outcome: input.outcome, count, average: roundMetric(average), p50: roundMetric(p50), p75: roundMetric(p75), p95: roundMetric(p95), unit: "seconds", tone, problem: workbenchProblem(input.outcome, input.metric, count, tone), lowSample, sampleState: sampleState(count), backend: input.backend, transport: input.transport, targetState: input.targetState, cache: input.cache, source: input.source, entry: input.entry, authState: input.authState, eventType: input.eventType, phase: input.phase }; } function histogramQuantile(entry: HistogramSeries, buckets: number[], quantile: number) { if (entry.count <= 0) return 0; const rank = Math.max(1, Math.ceil(entry.count * quantile)); for (let index = 0; index < entry.buckets.length; index += 1) { if (entry.buckets[index] >= rank) return buckets[index]; } return buckets[buckets.length - 1] ?? 0; } function performanceTone(labels: Record, p95: number, count: number, unit: "seconds" | "score") { if (labels.outcome !== "ok" || labels.status_class === "network" || labels.status_class === "5xx") return "blocked"; if (labels.status_class === "4xx") return "warn"; if (count <= 0) return "source"; const metric = labels.metric; if (unit === "score") { if (p95 >= 0.25) return "blocked"; if (p95 >= 0.1) return "warn"; return "ok"; } if (metric === "lcp") return thresholdTone(p95, 2.5, 4); if (metric === "inp" || metric === "fid") return thresholdTone(p95, 0.2, 0.5); if (metric === "api_request") return thresholdTone(p95, 1, 2.5); if (metric === "long_task") return thresholdTone(p95, 0.2, 0.5); if (metric === "navigation_dom_content_loaded") return thresholdTone(p95, 2, 4); if (metric === "navigation_load") return thresholdTone(p95, 3, 6); if (metric === "navigation_ttfb") return thresholdTone(p95, 0.8, 1.8); return p95 > 1 ? "warn" : "ok"; } function workbenchTone(outcome: string, p95: number, count: number, warn: number, blocked: number) { if (outcome !== "ok") return "blocked"; if (count <= 0 || count < LOW_SAMPLE_THRESHOLD) return "source"; return thresholdTone(p95, warn, blocked); } function workbenchJourneyThreshold(metric: string) { if (metric === "session_switch_first_visible") return { warn: 1, blocked: 3 }; if (metric === "session_switch_full_load") return { warn: 2.5, blocked: 8 }; if (metric === "workbench_open_first_visible") return { warn: 2.5, blocked: 5 }; if (metric === "workbench_open_full_load") return { warn: 5, blocked: 13 }; if (metric === "backend_event_to_visible") return { warn: 3, blocked: 10 }; return { warn: 5, blocked: 15 }; } function workbenchEventPhaseThreshold(metric: string) { if (metric === "append_to_sse") return { warn: 0.25, blocked: 1 }; if (metric === "created_to_append" || metric === "receive_to_project" || metric === "project_to_paint") return { warn: 0.5, blocked: 2 }; if (metric === "sse_to_receive") return { warn: 1, blocked: 3 }; if (metric === "user_submit_to_api_accepted") return { warn: 1, blocked: 3 }; return { warn: 5, blocked: 15 }; } function thresholdTone(value: number, warn: number, blocked: number) { if (value >= blocked) return "blocked"; if (value >= warn) return "warn"; return "ok"; } function performanceProblem(labels: Record, p95: number, count: number, unit: "seconds" | "score", tone: string) { if (count <= 0) return "no-sample"; if (labels.outcome !== "ok") return labels.outcome; if (labels.status_class === "network") return "network"; if (labels.status_class === "5xx") return "server-error"; if (labels.status_class === "4xx") return "client-error"; if (tone === "blocked" || tone === "warn") return unit === "score" ? "layout-shift" : `slow-${labels.metric}`; return "ok"; } function workbenchProblem(outcome: string, metric: string, count: number, tone: string) { if (count <= 0) return "no-sample"; if (count < LOW_SAMPLE_THRESHOLD) return "low-sample"; if (outcome !== "ok") return outcome; if (tone === "blocked" || tone === "warn") return `slow-${metric}`; return "ok"; } function sampleState(count: number): "ok" | "low-sample" | "empty" { if (count <= 0) return "empty"; return count < LOW_SAMPLE_THRESHOLD ? "low-sample" : "ok"; } function sortByProblemThenP95(left: PerformanceSummaryRow, right: PerformanceSummaryRow) { return toneWeight(right.tone) - toneWeight(left.tone) || right.p95 - left.p95 || right.count - left.count; } function toneWeight(tone: string) { if (tone === "blocked") return 4; if (tone === "warn") return 3; if (tone === "ok") return 2; return 1; } function buildPerformanceCollectionHealth(input: { observedAt: string; generatedAtMs: number; sampleWindow: PerformanceSummaryWindow; summary: Record; windowSamples: RecordedPerformanceSample[]; retainedSamples: RecordedPerformanceSample[]; rows: PerformanceSummaryRow[]; }): PerformanceCollectionHealth { const sampleCount = numberField(input.summary.sampleCount); const sampleSeries = numberField(input.summary.sampleSeries) + numberField(input.summary.workbenchJourneySeries) + numberField(input.summary.workbenchEventPhaseSeries) + numberField(input.summary.workbenchBackendEventVisibleSeries) + numberField(input.summary.workbenchUiEventSeries); const firstSampleMs = minSampleAt(input.windowSamples); const lastSampleMs = maxSampleAt(input.windowSamples); const lastRetainedSampleMs = maxSampleAt(input.retainedSamples); const sourceFamilyCounts = collectionSourceFamilyCounts(input.rows); const unknownAttribution = collectionUnknownAttribution(input.rows); const diagnostics: PerformanceCollectionHealthDiagnostic[] = []; let status: PerformanceCollectionHealth["status"] = "fresh"; let reason = "window_has_samples"; let reasonLabel = "当前窗口采集正常"; if (input.retainedSamples.length <= 0) { status = "waiting"; reason = "no_samples_retained"; reasonLabel = "采集器尚未上报样本"; diagnostics.push({ code: reason, label: "暂无采集样本", tone: "source", detail: "Cloud API RUM store 当前没有保留任何浏览器性能样本。" }); } else if (sampleCount <= 0) { status = "stale"; reason = "no_samples_in_window"; reasonLabel = `${input.sampleWindow.label}无样本`; diagnostics.push({ code: reason, label: "当前窗口无样本", tone: "source", detail: "存在历史保留样本,但当前所选时间窗口没有样本;请切换更大窗口或等待新上报。" }); } else if (sampleCount < LOW_SAMPLE_THRESHOLD) { status = "low-sample"; reason = "low_sample_window"; reasonLabel = "当前窗口低样本"; diagnostics.push({ code: reason, label: "低样本窗口", tone: "warn", detail: `当前窗口只有 ${sampleCount} 个样本,低于 ${LOW_SAMPLE_THRESHOLD} 个样本阈值。` }); } if (unknownAttribution.rowsWithUnknown > 0) { if (status === "fresh") { status = "degraded"; reason = "unknown_attribution"; reasonLabel = "归因字段不完整"; } diagnostics.push({ code: "unknown_attribution", label: "工作台归因不完整", tone: "warn", detail: `${unknownAttribution.rowsWithUnknown} 条工作台序列存在 backend 或 transport unknown,影响慢路径归因。` }); } if (sourceFamilyCounts.length > 1) { diagnostics.push({ code: "mixed_source_family", label: "多来源样本", tone: "ok", detail: `当前窗口包含 ${sourceFamilyCounts.length} 类低基数来源。` }); } if (!diagnostics.length) diagnostics.push({ code: "ok", label: "采集健康", tone: "ok", detail: "当前窗口有足够样本,且没有发现低样本、缺样或 unknown 归因问题。" }); return { schemaVersion: "hwlab-web-performance-collection-health-v1", status, statusLabel: collectionStatusLabel(status), reason, reasonLabel, sampleCount, retainedSampleCount: input.retainedSamples.length, sampleSeries, lowSampleThreshold: LOW_SAMPLE_THRESHOLD, firstSampleAt: isoFromMs(firstSampleMs), lastSampleAt: isoFromMs(lastSampleMs), lastRetainedSampleAt: isoFromMs(lastRetainedSampleMs), secondsSinceLastSample: lastRetainedSampleMs === null ? null : Math.max(0, Math.round((input.generatedAtMs - lastRetainedSampleMs) / 1000)), windowCoverageSeconds: firstSampleMs === null || lastSampleMs === null ? null : Math.max(0, Math.round((lastSampleMs - firstSampleMs) / 1000)), sourceFamilyCounts, unknownAttribution, diagnostics }; } function collectionSourceFamilyCounts(rows: PerformanceSummaryRow[]) { const counts = new Map(); for (const row of rows) { const sourceFamily = String(row.sourceFamily ?? sourceFamilyForRow(row) ?? "unknown"); const entry = counts.get(sourceFamily) ?? { sourceFamily, rowCount: 0, sampleCount: 0 }; entry.rowCount += 1; entry.sampleCount += Math.max(0, Number(row.count) || 0); counts.set(sourceFamily, entry); } return [...counts.values()].sort((left, right) => right.sampleCount - left.sampleCount || left.sourceFamily.localeCompare(right.sourceFamily)); } function collectionUnknownAttribution(rows: PerformanceSummaryRow[]) { const workbenchRows = rows.filter((row) => row.kind === "workbench_journey" || row.kind === "workbench_event_phase" || row.kind === "workbench_backend_event_visible"); const attributionRows = workbenchRows.filter(rowNeedsWorkbenchAttribution); const backendUnknownRows = attributionRows.filter((row) => unknownAttributionValue(row.backend)).length; const transportUnknownRows = attributionRows.filter((row) => unknownAttributionValue(row.transport)).length; const rowsWithUnknown = attributionRows.filter((row) => unknownAttributionValue(row.backend) || unknownAttributionValue(row.transport)).length; const samplesWithUnknown = attributionRows.reduce((sum, row) => sum + ((unknownAttributionValue(row.backend) || unknownAttributionValue(row.transport)) ? Math.max(0, Number(row.count) || 0) : 0), 0); return { workbenchRows: workbenchRows.length, attributableRows: attributionRows.length, rowsWithUnknown, samplesWithUnknown, backendUnknownRows, transportUnknownRows }; } function rowNeedsWorkbenchAttribution(row: PerformanceSummaryRow) { if (row.kind === "workbench_event_phase" || row.kind === "workbench_backend_event_visible") return true; if (row.kind !== "workbench_journey") return false; return row.metric === "submit_to_first_visible" || row.metric === "submit_to_failure" || row.metric === "backend_event_to_visible"; } function unknownAttributionValue(value: unknown) { const text = String(value ?? "").trim().toLowerCase(); return !text || text === "-" || text === "unknown"; } function minSampleAt(samples: RecordedPerformanceSample[]) { const values = samples.map((sample) => sample.atMs).filter(Number.isFinite); return values.length ? Math.min(...values) : null; } function maxSampleAt(samples: RecordedPerformanceSample[]) { const values = samples.map((sample) => sample.atMs).filter(Number.isFinite); return values.length ? Math.max(...values) : null; } function isoFromMs(value: number | null) { return value === null ? null : new Date(value).toISOString(); } function collectionStatusLabel(status: PerformanceCollectionHealth["status"]) { if (status === "fresh") return "采集正常"; if (status === "low-sample") return "低样本"; if (status === "stale") return "当前窗口无样本"; if (status === "degraded") return "归因不完整"; return "等待数据"; } function buildPerformanceDashboard(input: { observedAt: string; source: string; namespace: string; gitopsTarget: string; sampleWindow: PerformanceSummaryWindow; summary: Record; collectionHealth: PerformanceCollectionHealth; apiRoutes: PerformanceSummaryRow[]; webVitals: PerformanceSummaryRow[]; longTasks: PerformanceSummaryRow[]; problems: PerformanceSummaryRow[]; workbenchJourneys: PerformanceSummaryRow[]; workbenchEventPhases: PerformanceSummaryRow[]; workbenchBackendEvents: PerformanceSummaryRow[]; rows: PerformanceSummaryRow[]; workbenchRows: PerformanceSummaryRow[]; }) { const allRows = [...input.rows, ...input.workbenchRows]; const sampleCount = numberField(input.summary.sampleCount); const problemCount = numberField(input.summary.problemCount); const status = String(input.summary.status ?? "waiting"); const collectionHealth = input.collectionHealth; const trends = [ dashboardTrend("workbench-experience", "工作台体感趋势", "首屏、会话切换和提交首个可见结果的 P95", "seconds", input.workbenchJourneys), dashboardTrend("backend-visible", "后端事件可见延迟", "AgentRun/backend 事件到浏览器可见的 P95", "seconds", input.workbenchBackendEvents), dashboardTrend("api-timing", "同源 API 耗时", "Cloud Web 同源 API 请求 P95", "seconds", input.apiRoutes), dashboardTrend("web-vitals", "浏览器核心指标", "LCP/INP/CLS/FCP/TTFB 当前窗口", "mixed", input.webVitals) ].filter((trend) => trend.points.length > 0); return { schemaVersion: "hwlab-web-performance-dashboard-v1", generatedAt: input.observedAt, sampleWindow: { id: input.sampleWindow.id, label: input.sampleWindow.label, seconds: input.sampleWindow.milliseconds === null ? null : Math.round(input.sampleWindow.milliseconds / 1000), windowFrom: input.summary.windowFrom ?? null, windowTo: input.summary.windowTo ?? input.observedAt, generatedAt: input.summary.generatedAt ?? input.observedAt, aggregationKind: input.summary.aggregationKind ?? "exact", approximation: input.summary.approximation ?? "exact", buckets: [{ id: input.sampleWindow.id, label: input.sampleWindow.label, observedAt: input.observedAt, sampleCount }] }, freshness: { observedAt: input.observedAt, source: input.source, namespace: input.namespace, gitopsTarget: input.gitopsTarget, status: collectionHealth.status, statusLabel: collectionHealth.statusLabel, reason: collectionHealth.reason, reasonLabel: collectionHealth.reasonLabel, lowSampleThreshold: numberField(input.summary.lowSampleThreshold), generatedAt: input.summary.generatedAt ?? input.observedAt, windowFrom: input.summary.windowFrom ?? null, windowTo: input.summary.windowTo ?? input.observedAt, sampleCount, sourceFamily: "mixed", lastSampleAt: collectionHealth.lastSampleAt, lastRetainedSampleAt: collectionHealth.lastRetainedSampleAt, secondsSinceLastSample: collectionHealth.secondsSinceLastSample }, collectionHealth, cards: [ { id: "health", label: "采集状态", value: collectionHealth.statusLabel, unit: "状态", tone: dashboardTone(collectionHealth.status), detail: collectionHealth.reasonLabel }, { id: "samples", label: "样本数", value: sampleCount, unit: "样本", tone: sampleCount === 0 ? "source" : "ok", detail: `${input.sampleWindow.label} · ${numberField(input.summary.sampleSeries)} 个序列` }, { id: "workbench", label: "工作台序列", value: numberField(input.summary.workbenchJourneySeries) + numberField(input.summary.workbenchEventPhaseSeries) + numberField(input.summary.workbenchBackendEventVisibleSeries), unit: "序列", tone: "ok", detail: `${input.namespace} / ${input.gitopsTarget}` }, { id: "problems", label: "问题行", value: problemCount, unit: "条", tone: problemCount > 0 ? "warn" : "ok", detail: "预警、阻塞或非正常结果" } ], trends, distributions: [ dashboardDistribution("status", "状态分布", "按健康状态聚合当前窗口行数", countRowsBy(allRows, (row) => toneLabel(row.tone))), dashboardDistribution("problems", "问题分布", "按问题类型聚合预警和阻塞行", countRowsBy(input.problems, (row) => problemLabel(row.problem || row.outcome))), dashboardDistribution("samples", "样本状态", "正常、低样本和暂无数据分布", countRowsBy(allRows, (row) => sampleStateLabel(row.sampleState))) ].filter((item) => item.buckets.length > 0), topN: [ dashboardTopN("top-api", "慢 API TopN", "同源 API P95 最慢路由", "seconds", input.apiRoutes), dashboardTopN("top-workbench", "工作台慢路径 TopN", "工作台 journey 和事件可见延迟", "seconds", [...input.workbenchJourneys, ...input.workbenchBackendEvents]), dashboardTopN("top-long-task", "长任务 TopN", "主线程长任务当前窗口", "seconds", input.longTasks) ].filter((item) => item.rows.length > 0), source: { rows: input.rows, workbenchRows: input.workbenchRows } }; } function dashboardTrend(id: string, title: string, subtitle: string, unit: string, rows: PerformanceSummaryRow[]) { return { id, title, subtitle, unit, points: rows.slice(0, 8).map(dashboardPoint) }; } function dashboardPoint(row: PerformanceSummaryRow): PerformanceDashboardPoint { return { label: metricLabel(row), detail: rowDetail(row), value: row.p95, p50: row.p50, p75: row.p75, p95: row.p95, count: row.count, tone: row.tone, sampleState: row.sampleState ?? "ok" }; } function dashboardDistribution(id: string, title: string, subtitle: string, counts: Map) { const buckets = [...counts.entries()] .map(([label, count]) => ({ label, count, tone: distributionTone(label) })) .sort((left, right) => right.count - left.count || left.label.localeCompare(right.label, "zh-Hans-CN")); return { id, title, subtitle, unit: "count", buckets }; } function dashboardTopN(id: string, title: string, subtitle: string, unit: string, rows: PerformanceSummaryRow[]) { const topRows: PerformanceDashboardTopRow[] = rows.slice(0, 8).map((row) => ({ label: metricLabel(row), detail: rowDetail(row), value: row.p95, unit: row.unit === "score" ? "score" : unit, count: row.count, tone: row.tone })); return { id, title, subtitle, unit, rows: topRows }; } function countRowsBy(rows: PerformanceSummaryRow[], getLabel: (row: PerformanceSummaryRow) => string) { const counts = new Map(); for (const row of rows) counts.set(getLabel(row), (counts.get(getLabel(row)) ?? 0) + Math.max(1, row.count)); return counts; } function metricLabel(row: PerformanceSummaryRow) { const metric = row.metric || row.phase || row.kind || "unknown"; const labels: Record = { submit_to_first_visible: "提交到首个可见结果", submit_to_failure: "提交失败可见", backend_event_to_visible: "后端事件到可见", session_switch_first_visible: "切换会话首个可见", session_switch_full_load: "切换会话完整加载", workbench_open_first_visible: "打开工作台首个可见", workbench_open_full_load: "打开工作台完整加载", created_to_append: "创建到写入 Trace", append_to_sse: "Trace 写入到 SSE", sse_to_receive: "SSE 到浏览器接收", receive_to_project: "接收到状态投影", project_to_paint: "投影到画面可见", user_submit_to_api_accepted: "提交到 API 接收", api_accepted_to_backend_event: "API 接收到后端事件", api_request: "同源 API 请求", long_task: "主线程长任务", lcp: "最大内容绘制", inp: "交互响应", fid: "首次输入延迟", cls: "布局偏移", navigation_ttfb: "首字节时间", navigation_dom_content_loaded: "DOM 内容加载", navigation_load: "页面加载完成" }; return labels[metric] ?? metric.replace(/_/gu, " "); } function rowDetail(row: PerformanceSummaryRow) { const route = dashboardRouteLabel(row.route); const parts = [ route, row.eventType ? `事件:${eventTypeLabel(row.eventType)}` : "", row.phase ? `阶段:${metricLabel({ ...row, metric: row.phase })}` : "", row.backend ? `后端:${row.backend}` : "", row.transport ? `传输:${transportLabel(row.transport)}` : "", row.outcome ? `结果:${outcomeLabel(row.outcome)}` : "" ].filter(Boolean); return parts.join(" / ") || "当前窗口"; } function dashboardRouteLabel(route: string | undefined) { const value = String(route ?? "").trim(); if (!value || value === "-") return ""; return value .replace(/:sessionId/gu, ":会话") .replace(/:traceId/gu, ":轨迹") .replace(/:runId/gu, ":运行") .replace(/:threadId/gu, ":线程") .replace(/:turnId/gu, ":轮次"); } function toneLabel(tone: string | undefined) { if (tone === "ok") return "正常"; if (tone === "warn") return "预警"; if (tone === "blocked") return "阻塞"; return "等待数据"; } function sampleStateLabel(state: string | undefined) { if (state === "ok") return "正常样本"; if (state === "low-sample") return "低样本"; return "暂无数据"; } function dashboardStatusLabel(status: string) { if (status === "fresh") return "采集正常"; if (status === "low-sample") return "低样本"; if (status === "stale") return "当前窗口无样本"; if (status === "degraded") return "归因不完整"; if (status === "ok") return "正常"; if (status === "watch") return "预警"; if (status === "warming") return "低样本"; return "等待数据"; } function dashboardTone(status: string) { if (status === "ok" || status === "fresh") return "ok"; if (status === "watch" || status === "degraded" || status === "low-sample") return "warn"; return "source"; } function distributionTone(label: string) { if (label.includes("阻塞")) return "blocked"; if (label.includes("预警") || label.includes("低样本")) return "warn"; return "ok"; } function problemLabel(problem: string | undefined) { const text = String(problem ?? "ok"); if (text === "ok") return "正常"; if (text === "low-sample") return "低样本"; if (text === "timeout") return "超时"; if (text === "network") return "网络失败"; if (text === "server-error") return "服务端错误"; if (text === "client-error") return "客户端错误"; if (text === "layout-shift") return "布局偏移"; if (text.startsWith("slow-")) return `慢路径:${metricLabel({ metric: text.slice(5), kind: "", route: "", method: "", statusClass: "", outcome: "", count: 0, average: 0, p50: 0, p75: 0, p95: 0, unit: "seconds", tone: "ok", problem: "" })}`; return text.replace(/[_-]/gu, " "); } function eventTypeLabel(value: string) { const labels: Record = { assistant: "助手消息", tool_call: "工具调用", backend: "后端事件", terminal: "终态结果", error: "错误", status: "状态", request: "请求", result: "结果", unknown: "未知" }; return labels[value] ?? value; } function transportLabel(value: string) { const labels: Record = { sse: "实时流", rest_gap: "REST 补洞", poll: "轮询", none: "无", unknown: "未知" }; return labels[value] ?? value; } function outcomeLabel(value: string) { const labels: Record = { ok: "正常", timeout: "超时", error: "错误", dropped: "丢弃", stale: "滞后", partial: "部分", empty: "空结果", network: "网络", denied: "拒绝", unknown: "未知" }; return labels[value] ?? value; } function numberField(value: unknown) { return Number.isFinite(Number(value)) ? Number(value) : 0; } function roundMetric(value: number) { if (!Number.isFinite(value)) return 0; return Math.round(value * 10000) / 10000; } function renderLabels(labels: Record) { return Object.entries(labels) .map(([key, value]) => `${key}="${escapeLabelValue(value)}"`) .join(","); } function stableLabelKey(labels: Record) { return Object.entries(labels).map(([key, value]) => `${key}=${value}`).join("\u0001"); } function isLoopbackMetricsRequest(request) { const remoteAddress = String(request.socket?.remoteAddress ?? ""); const host = String(request.headers?.host ?? "").trim().toLowerCase(); const loopbackRemote = remoteAddress === "127.0.0.1" || remoteAddress === "::1" || remoteAddress === "::ffff:127.0.0.1"; const loopbackHost = /^(?:127\.0\.0\.1|localhost)(?::\d+)?$/u.test(host); return loopbackRemote && loopbackHost; } function routeSegmentTemplate(segment: string): string { if (/^(?:trc|trace|thread|run|cmd|job|ses|cnv|conv|gws|box|res|pod|dp|usr)[_-]/iu.test(segment)) return ":id"; if (/^[0-9a-f]{8,}(?:-[0-9a-f]{4,}){2,}$/iu.test(segment)) return ":uuid"; if (/^[A-Za-z0-9_-]{20,}$/u.test(segment)) return ":id"; if (/^\d{5,}$/u.test(segment)) return ":id"; return sanitizeLabelValue(segment, "_"); } function sanitizeMetricName(value: unknown, fallback: string) { const text = String(value ?? "").trim().toLowerCase().replace(/[^a-z0-9_:-]/gu, "_").slice(0, 64); return text || fallback; } function sanitizeLabelValue(value: unknown, fallback = "unknown") { const text = String(value ?? "").trim(); const sanitized = text.replace(/[^A-Za-z0-9_.:/-]/gu, "_").slice(0, 120); return sanitized || fallback; } function requiredEnum(value: unknown, allowed: Set) { const text = sanitizeMetricName(value, ""); return allowed.has(text) ? text : null; } function optionalEnum(value: unknown, allowed: Set, fallback: string) { const text = sanitizeMetricName(value, fallback); return allowed.has(text) ? text : fallback; } function normalizeEventType(value: unknown) { const text = sanitizeMetricName(value, "unknown"); const aliased = text === "assistant_message" ? "assistant" : text === "tool" ? "tool_call" : text; return WORKBENCH_EVENT_TYPES.has(aliased) ? aliased : "unknown"; } function normalizeBackendLabel(value: unknown) { const sanitized = sanitizeLabelValue(String(value ?? "unknown").trim().toLowerCase(), "unknown").slice(0, 64); if (!sanitized || looksHighCardinalityLabel(sanitized)) return "unknown"; return sanitized; } function looksHighCardinalityLabel(value: string) { return /(?:^|[^a-z0-9])(?:trc|trace|thread|run|cmd|command|job|ses|session|cnv|conv|conversation|gws|box|res|pod|dp|usr|user)[_-][a-z0-9_-]{6,}/iu.test(value) || /^[0-9a-f]{8,}(?:-[0-9a-f]{4,}){2,}$/iu.test(value) || /^[A-Za-z0-9_-]{24,}$/u.test(value); } function durationValueSeconds(valueMs: unknown, maxSeconds: number) { const seconds = finiteNumber(valueMs, NaN) / 1000; if (!Number.isFinite(seconds) || seconds < 0) return null; return Math.min(seconds, maxSeconds); } function field(input: unknown, key: string) { if (!input || typeof input !== "object" || Array.isArray(input)) return undefined; return (input as Record)[key]; } function escapeLabelValue(value: unknown) { return String(value ?? "").replace(/\\/gu, "\\\\").replace(/\n/gu, "\\n").replace(/"/gu, "\\\""); } function normalizeMethod(value: unknown) { const method = String(value ?? "NONE").trim().toUpperCase(); return /^(?:GET|POST|PUT|PATCH|DELETE|HEAD|OPTIONS)$/u.test(method) ? method : "NONE"; } function normalizeStatusClass(value: unknown) { const text = String(value ?? "unknown").trim().toLowerCase(); if (/^[1-5]xx$/u.test(text)) return text; if (text === "network" || text === "unknown") return text; return "unknown"; } function statusClass(value: unknown) { const status = finiteNumber(value, 0); if (status >= 100 && status < 600) return `${Math.floor(status / 100)}xx`; if (status === 0) return "network"; return "unknown"; } function finiteNumber(value: unknown, fallback: number) { const number = typeof value === "number" ? value : Number(value); 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; } function safeDecode(value: string) { try { return decodeURIComponent(value); } catch { return value; } } function readBody(request, limitBytes) { const limit = Number.isInteger(limitBytes) && limitBytes > 0 ? limitBytes : DEFAULT_WEB_PERFORMANCE_BODY_LIMIT_BYTES; return new Promise((resolve, reject) => { let body = ""; request.setEncoding("utf8"); request.on("data", (chunk) => { body += chunk; if (Buffer.byteLength(body, "utf8") > limit) { request.destroy(new Error(`request body exceeds ${limit} bytes`)); } }); request.on("end", () => resolve(body)); request.on("error", reject); }); } function sendJson(response, statusCode, body) { const payload = decorateErrorPayloadForHttp(body, { response, statusCode, layer: "api", category: "web_performance" }); logErrorEnvelope(payload, { statusCode }); response.writeHead(statusCode, { "content-type": "application/json; charset=utf-8", "cache-control": "no-store" }); response.end(`${JSON.stringify(payload)}\n`); }