From f7a0b0672c83fa4aedbfa307b1d2e15ddc0b715f Mon Sep 17 00:00:00 2001 From: Codex Date: Thu, 2 Jul 2026 04:51:18 +0000 Subject: [PATCH] feat: add web sentinel request-rate curves --- config/hwlab-node-lanes.yaml | 12 + .../check-catalog.yaml | 24 ++ .../report-views.auth-session-switch.yaml | 6 + .../report-views.multi-sentinel.yaml | 6 + .../report-views.yaml | 6 + .../monitor-web.css | 56 ++- .../monitor-web.js | 156 ++++++++- scripts/src/hwlab-node-lanes.ts | 8 + .../hwlab-node-web-observe-analyzer-source.ts | 326 +++++++++++++++++- .../hwlab-node-web-observe-runner-source.ts | 4 + .../src/hwlab-node-web-sentinel-service.ts | 197 +++++++++++ scripts/src/hwlab-node/web-observe-render.ts | 45 +++ scripts/src/hwlab-node/web-probe-observe.ts | 87 ++++- 13 files changed, 921 insertions(+), 12 deletions(-) diff --git a/config/hwlab-node-lanes.yaml b/config/hwlab-node-lanes.yaml index 98571101..2943982d 100644 --- a/config/hwlab-node-lanes.yaml +++ b/config/hwlab-node-lanes.yaml @@ -221,6 +221,10 @@ lanes: screenshotTimeoutRedCount: 2 pageErrorRedCount: 2 browserProcessSampleIntervalMs: 1000 + requestRateBucketMs: 10000 + requestRateTotalRedPerMinute: 300 + requestRatePageRedPerMinute: 240 + requestRateApiPathRedPerMinute: 120 browserTotalRssRedMb: 800 browserProcessRssRedMb: 600 browserRssGrowthRedMb: 300 @@ -646,6 +650,10 @@ lanes: screenshotTimeoutRedCount: 2 pageErrorRedCount: 2 browserProcessSampleIntervalMs: 1000 + requestRateBucketMs: 10000 + requestRateTotalRedPerMinute: 300 + requestRatePageRedPerMinute: 240 + requestRateApiPathRedPerMinute: 120 browserTotalRssRedMb: 800 browserProcessRssRedMb: 600 browserRssGrowthRedMb: 300 @@ -1001,6 +1009,10 @@ lanes: screenshotTimeoutRedCount: 2 pageErrorRedCount: 2 browserProcessSampleIntervalMs: 1000 + requestRateBucketMs: 10000 + requestRateTotalRedPerMinute: 300 + requestRatePageRedPerMinute: 240 + requestRateApiPathRedPerMinute: 120 browserTotalRssRedMb: 800 browserProcessRssRedMb: 600 browserRssGrowthRedMb: 300 diff --git a/config/hwlab-web-probe-sentinel/check-catalog.yaml b/config/hwlab-web-probe-sentinel/check-catalog.yaml index a64f5888..29f21f16 100644 --- a/config/hwlab-web-probe-sentinel/check-catalog.yaml +++ b/config/hwlab-web-probe-sentinel/check-catalog.yaml @@ -803,3 +803,27 @@ sentinel: actionZh: 检查 stateDir/analysis/report.json 读取路径和回填流程。 blocking: true order: 980 + - code: WBC-099 + id: request-rate-total-peak + level: error + titleZh: API 请求总频率过高 + summaryZh: 同源 API 请求总量在 YAML 配置窗口内超过峰值阈值,可能存在请求风暴。 + actionZh: 对照请求频率曲线和页面内存曲线,查看峰值时间点和 top API path。 + blocking: true + order: 990 + - code: WBC-100 + id: request-rate-page-peak + level: error + titleZh: 页面 API 请求频率过高 + summaryZh: 单个页面在 YAML 配置窗口内的 API 请求频率超过阈值,可能存在页面级轮询风暴。 + actionZh: 查看对应页面曲线、峰值时间点和同窗口内的内存变化。 + blocking: true + order: 1000 + - code: WBC-101 + id: request-rate-api-path-peak + level: error + titleZh: API path 请求频率过高 + summaryZh: 单个 API path 在 YAML 配置窗口内的请求频率超过阈值,可能存在接口级请求风暴。 + actionZh: 查看对应 API path 曲线、调用页面和峰值时间点。 + blocking: true + order: 1010 diff --git a/config/hwlab-web-probe-sentinel/report-views.auth-session-switch.yaml b/config/hwlab-web-probe-sentinel/report-views.auth-session-switch.yaml index f9c2a98b..83b56c7b 100644 --- a/config/hwlab-web-probe-sentinel/report-views.auth-session-switch.yaml +++ b/config/hwlab-web-probe-sentinel/report-views.auth-session-switch.yaml @@ -19,6 +19,12 @@ sentinel: detailMemory: maxPages: 8 maxSamplesPerPage: 360 + detailRequestRate: + maxTotalBuckets: 120 + maxPageSeries: 6 + maxApiPathSeries: 8 + maxBucketsPerSeries: 120 + maxPeaks: 12 redaction: prompt: hash-and-byte-count assistantFinal: summary-and-hash diff --git a/config/hwlab-web-probe-sentinel/report-views.multi-sentinel.yaml b/config/hwlab-web-probe-sentinel/report-views.multi-sentinel.yaml index c071703b..364bb769 100644 --- a/config/hwlab-web-probe-sentinel/report-views.multi-sentinel.yaml +++ b/config/hwlab-web-probe-sentinel/report-views.multi-sentinel.yaml @@ -20,6 +20,12 @@ sentinel: detailMemory: maxPages: 8 maxSamplesPerPage: 360 + detailRequestRate: + maxTotalBuckets: 120 + maxPageSeries: 6 + maxApiPathSeries: 8 + maxBucketsPerSeries: 120 + maxPeaks: 12 redaction: prompt: hash-and-byte-count assistantFinal: summary-and-hash diff --git a/config/hwlab-web-probe-sentinel/report-views.yaml b/config/hwlab-web-probe-sentinel/report-views.yaml index 4c076051..70073c35 100644 --- a/config/hwlab-web-probe-sentinel/report-views.yaml +++ b/config/hwlab-web-probe-sentinel/report-views.yaml @@ -19,6 +19,12 @@ sentinel: detailMemory: maxPages: 8 maxSamplesPerPage: 360 + detailRequestRate: + maxTotalBuckets: 120 + maxPageSeries: 6 + maxApiPathSeries: 8 + maxBucketsPerSeries: 120 + maxPeaks: 12 redaction: prompt: hash-and-byte-count assistantFinal: summary-and-hash diff --git a/scripts/assets/web-probe-sentinel-monitor-web/monitor-web.css b/scripts/assets/web-probe-sentinel-monitor-web/monitor-web.css index 9091b0ed..18d3daa2 100644 --- a/scripts/assets/web-probe-sentinel-monitor-web/monitor-web.css +++ b/scripts/assets/web-probe-sentinel-monitor-web/monitor-web.css @@ -391,7 +391,8 @@ select { .trend-red, .trend-warning, -.memory-line { +.memory-line, +.request-rate-line { fill: none; stroke-width: 2.4; stroke-linecap: round; @@ -545,6 +546,42 @@ select { background: #327b89; } +.legend-swatch.request-rate.request-rate-line-1, +.request-rate-line-1 { + stroke: #c2410c; + background: #c2410c; +} + +.legend-swatch.request-rate.request-rate-line-2, +.request-rate-line-2 { + stroke: #a16207; + background: #a16207; +} + +.legend-swatch.request-rate.request-rate-line-3, +.request-rate-line-3 { + stroke: #7c3aed; + background: #7c3aed; +} + +.legend-swatch.request-rate.request-rate-line-4, +.request-rate-line-4 { + stroke: #be185d; + background: #be185d; +} + +.legend-swatch.request-rate.request-rate-line-5, +.request-rate-line-5 { + stroke: #0f766e; + background: #0f766e; +} + +.legend-swatch.request-rate.request-rate-line-6, +.request-rate-line-6 { + stroke: #4338ca; + background: #4338ca; +} + .timeline-panel { display: flex; min-height: 0; @@ -1012,7 +1049,14 @@ select { gap: 8px; } -.memory-chart { +.request-rate-chart-wrap { + display: grid; + gap: 8px; + margin-bottom: 12px; +} + +.memory-chart, +.request-rate-chart { display: block; width: 100%; height: 192px; @@ -1021,6 +1065,10 @@ select { background: #ffffff; } +.request-rate-chart { + background: #fffdfb; +} + .memory-legend { display: flex; min-width: 0; @@ -1030,6 +1078,10 @@ select { font-size: 12px; } +.request-rate-empty { + margin-bottom: 12px; +} + .memory-grid-line { stroke: #e5ecea; stroke-width: 1; diff --git a/scripts/assets/web-probe-sentinel-monitor-web/monitor-web.js b/scripts/assets/web-probe-sentinel-monitor-web/monitor-web.js index 14c66057..4190d31c 100644 --- a/scripts/assets/web-probe-sentinel-monitor-web/monitor-web.js +++ b/scripts/assets/web-probe-sentinel-monitor-web/monitor-web.js @@ -121,6 +121,19 @@ createApp({ return Array.isArray(rows) ? rows : []; }); const detailMemory = computed(() => selectedDetail.value?.memory && typeof selectedDetail.value.memory === "object" ? selectedDetail.value.memory : {}); + const detailRequestRate = computed(() => selectedDetail.value?.requestRate && typeof selectedDetail.value.requestRate === "object" ? selectedDetail.value.requestRate : {}); + const detailRequestRateSeries = computed(() => { + const rate = detailRequestRate.value; + const rows = [ + ...tagRequestRateSeries(Array.isArray(rate.totalSeries) ? rate.totalSeries : [], "total"), + ...tagRequestRateSeries(Array.isArray(rate.pageSeries) ? rate.pageSeries : [], "page"), + ...tagRequestRateSeries(Array.isArray(rate.apiPathSeries) ? rate.apiPathSeries : [], "apiPath"), + ]; + return rows.map((series) => ({ + ...series, + points: Array.isArray(series.points) ? series.points.filter((point) => Number.isFinite(Number(point?.requestPerMinute))) : [], + })).filter((series) => series.points.length > 0); + }); const detailMemorySeries = computed(() => { const rows = Array.isArray(detailMemory.value.pageSeries) ? detailMemory.value.pageSeries : []; return rows.map((series) => ({ @@ -129,7 +142,40 @@ createApp({ })).filter((series) => series.points.length > 0); }); const detailMemoryMax = computed(() => Math.max(1, ...detailMemorySeries.value.flatMap((series) => series.points.map((point) => number(point.memoryMb))))); - const detailMemoryDurationMax = computed(() => Math.max(1, ...detailMemorySeries.value.flatMap((series) => series.points.map((point) => number(point.elapsedMinutes))))); + const detailRequestRateMax = computed(() => Math.max( + 1, + number(detailRequestRate.value.totalPeakPerMinute), + number(detailRequestRate.value.pagePeakPerMinute), + number(detailRequestRate.value.apiPathPeakPerMinute), + ...detailRequestRateSeries.value.flatMap((series) => series.points.map((point) => number(point.requestPerMinute))), + )); + const detailTimelineStartMs = computed(() => { + const values = [ + ...detailMemorySeries.value.flatMap((series) => series.points.map(pointTimeMs)), + ...detailRequestRateSeries.value.flatMap((series) => series.points.map(pointTimeMs)), + ].filter((value) => value !== null); + return values.length === 0 ? null : Math.min(...values); + }); + const detailTimelineDurationMax = computed(() => { + const startMs = detailTimelineStartMs.value; + const values = [ + ...detailMemorySeries.value.flatMap((series) => series.points.map((point) => pointElapsedMinutes(point, startMs))), + ...detailRequestRateSeries.value.flatMap((series) => series.points.map((point) => pointElapsedMinutes(point, startMs))), + ].filter((value) => Number.isFinite(value)); + return Math.max(1, ...values); + }); + const detailMemoryDurationMax = computed(() => detailTimelineDurationMax.value); + const detailRequestRateYAxisTicks = computed(() => memoryTickValues(detailRequestRateMax.value).map((value) => ({ + key: `request-rate-y-${value}`, + value, + y: requestRateYValue(value), + label: formatRpm(value), + }))); + const detailRequestRatePeak = computed(() => { + const peak = optionalNumber(detailRequestRate.value.totalPeakPerMinute); + if (peak !== null) return peak; + return detailRequestRateSeries.value.length > 0 ? detailRequestRateMax.value : null; + }); const detailMemoryYAxisTicks = computed(() => memoryTickValues(detailMemoryMax.value).map((value) => ({ key: `memory-y-${value}`, value, @@ -409,9 +455,14 @@ createApp({ return points.map((point) => `${memoryX(point)},${memoryY(point)}`).join(" "); } + function requestRatePolyline(series) { + const points = Array.isArray(series?.points) ? series.points : []; + if (points.length < 2) return ""; + return points.map((point) => `${memoryX(point)},${requestRateY(point)}`).join(" "); + } + function memoryX(point) { - const elapsed = number(point?.elapsedMinutes); - return memoryXValue(elapsed); + return memoryXValue(pointElapsedMinutes(point, detailTimelineStartMs.value)); } function memoryY(point) { @@ -419,6 +470,10 @@ createApp({ return memoryYValue(memory); } + function requestRateY(point) { + return requestRateYValue(number(point?.requestPerMinute)); + } + function memoryXValue(elapsedMinutes) { const ratio = clamp(number(elapsedMinutes) / detailMemoryDurationMax.value, 0, 1); return Math.round(memoryChartFrame.left + ratio * (memoryChartFrame.right - memoryChartFrame.left)); @@ -429,16 +484,31 @@ createApp({ return Math.round(memoryChartFrame.bottom - ratio * (memoryChartFrame.bottom - memoryChartFrame.top)); } + function requestRateYValue(requestPerMinute) { + const ratio = clamp(number(requestPerMinute) / detailRequestRateMax.value, 0, 1); + return Math.round(memoryChartFrame.bottom - ratio * (memoryChartFrame.bottom - memoryChartFrame.top)); + } + function memoryLineClass(index) { return `memory-line-${(index % 6) + 1}`; } + function requestRateLineClass(index) { + return `request-rate-line-${(index % 6) + 1}`; + } + function memorySeriesLabel(series) { const sampleCount = Number.isFinite(Number(series?.sampleCount)) ? Number(series.sampleCount) : Array.isArray(series?.points) ? series.points.length : 0; const latest = Array.isArray(series?.points) && series.points.length > 0 ? series.points[series.points.length - 1] : null; return `${series?.label || series?.pageRole || series?.pageId || "page"} · 最新 ${formatMb(latest?.memoryMb)} · 样本 ${sampleCount}`; } + function requestRateSeriesLabel(series) { + const sampleCount = Number.isFinite(Number(series?.bucketCount)) ? Number(series.bucketCount) : Array.isArray(series?.points) ? series.points.length : 0; + const latest = Array.isArray(series?.points) && series.points.length > 0 ? series.points[series.points.length - 1] : null; + return `${series?.label || series?.path || series?.key || "request"} · 最新 ${formatRpm(latest?.requestPerMinute)} · 峰值 ${formatRpm(series?.peakRequestPerMinute)} · 桶 ${sampleCount}`; + } + function showTrendTooltip(dot) { hoveredTrendDot.value = dot; } @@ -506,6 +576,11 @@ createApp({ timelineRuns, historicalCheckFindings, runDetailCheckFindings, + detailRequestRate, + detailRequestRateSeries, + detailRequestRateMax, + detailRequestRatePeak, + detailRequestRateYAxisTicks, detailMemory, detailMemorySeries, detailMemoryMax, @@ -546,6 +621,9 @@ createApp({ trendErrorCount, trendWarningCount, trendDurationMinutes, + requestRatePolyline, + requestRateLineClass, + requestRateSeriesLabel, memoryPolyline, memoryLineClass, memorySeriesLabel, @@ -557,6 +635,7 @@ createApp({ formatDuration, formatMinutes, formatMb, + formatRpm, shortId, rootCauseText, findingTitle, @@ -867,11 +946,50 @@ createApp({ :data-memory-page-count="detailMemorySeries.length" :data-memory-sample-count="detailMemory.sampleCount || 0" :data-memory-source="detailMemory.source || ''" + :data-request-rate-series-count="detailRequestRateSeries.length" + :data-request-rate-source="detailRequestRate.source || ''" >
-

页面内存曲线

- 每个页面一条线 · 峰值 {{ formatMb(detailMemory.maxMemoryMb || detailMemoryMax) }} +

请求频率 / 页面内存曲线

+ 请求峰值 {{ formatRpm(detailRequestRatePeak) }} · 内存峰值 {{ formatMb(detailMemory.maxMemoryMb || detailMemoryMax) }}
+
+ + + + + {{ tick.label }} + + + 请求 rpm + + + + + + {{ tick.label }} + + 运行分钟 + + + +
+ + {{ requestRateSeriesLabel(series) }} + +
+
+
暂无 API 请求频率样本
@@ -1280,11 +1398,39 @@ function formatMb(value) { return `${text} MB`; } +function formatRpm(value) { + const numberValue = optionalNumber(value); + if (numberValue === null) return "-"; + const rounded = Math.round(numberValue * 10) / 10; + const text = Number.isInteger(rounded) ? String(rounded) : rounded.toFixed(1).replace(/\.0$/u, ""); + return `${text} rpm`; +} + function memoryTickValues(maxValue) { const max = Math.max(1, number(maxValue)); return [0, max / 2, max]; } +function tagRequestRateSeries(rows, scope) { + return rows.map((series) => ({ + ...series, + scope: series?.scope || scope, + })); +} + +function pointTimeMs(point) { + const raw = point?.ts || point?.startAt; + if (typeof raw !== "string" || raw.length === 0) return null; + const parsed = Date.parse(raw); + return Number.isFinite(parsed) ? parsed : null; +} + +function pointElapsedMinutes(point, timelineStartMs) { + const tsMs = pointTimeMs(point); + if (tsMs !== null && Number.isFinite(Number(timelineStartMs))) return Math.max(0, (tsMs - Number(timelineStartMs)) / 60000); + return number(point?.elapsedMinutes); +} + function runDurationText(run) { const timing = run?.timing || {}; return formatMinutes(optionalNumber(run?.runDurationMinutes, run?.durationMinutes, timing.runDurationMinutes, timing.durationMinutes)); diff --git a/scripts/src/hwlab-node-lanes.ts b/scripts/src/hwlab-node-lanes.ts index c54acf10..6283d856 100644 --- a/scripts/src/hwlab-node-lanes.ts +++ b/scripts/src/hwlab-node-lanes.ts @@ -220,6 +220,10 @@ export interface HwlabRuntimeWebProbeAlertThresholdsSpec { readonly screenshotTimeoutRedCount: number; readonly pageErrorRedCount: number; readonly browserProcessSampleIntervalMs: number; + readonly requestRateBucketMs: number; + readonly requestRateTotalRedPerMinute: number; + readonly requestRatePageRedPerMinute: number; + readonly requestRateApiPathRedPerMinute: number; readonly browserTotalRssRedMb: number; readonly browserProcessRssRedMb: number; readonly browserRssGrowthRedMb: number; @@ -1332,6 +1336,10 @@ function webProbeAlertThresholdsConfig(value: unknown, path: string): HwlabRunti screenshotTimeoutRedCount: positiveNumberField(raw, "screenshotTimeoutRedCount", path), pageErrorRedCount: positiveNumberField(raw, "pageErrorRedCount", path), browserProcessSampleIntervalMs: positiveNumberField(raw, "browserProcessSampleIntervalMs", path), + requestRateBucketMs: positiveNumberField(raw, "requestRateBucketMs", path), + requestRateTotalRedPerMinute: positiveNumberField(raw, "requestRateTotalRedPerMinute", path), + requestRatePageRedPerMinute: positiveNumberField(raw, "requestRatePageRedPerMinute", path), + requestRateApiPathRedPerMinute: positiveNumberField(raw, "requestRateApiPathRedPerMinute", path), browserTotalRssRedMb: positiveNumberField(raw, "browserTotalRssRedMb", path), browserProcessRssRedMb: positiveNumberField(raw, "browserProcessRssRedMb", path), browserRssGrowthRedMb: positiveNumberField(raw, "browserRssGrowthRedMb", path), diff --git a/scripts/src/hwlab-node-web-observe-analyzer-source.ts b/scripts/src/hwlab-node-web-observe-analyzer-source.ts index 58baf930..26df3165 100644 --- a/scripts/src/hwlab-node-web-observe-analyzer-source.ts +++ b/scripts/src/hwlab-node-web-observe-analyzer-source.ts @@ -54,6 +54,7 @@ const transitions = buildTransitions(samples); const sampleMetrics = buildSampleMetrics(samples, control); const pageProvenance = buildPageProvenanceReport(samples, control, manifest); const pagePerformance = buildPagePerformanceReport(samples, manifest); +const requestRate = buildRequestRateReport(network); const promptNetwork = buildPromptNetworkReport(control, promptNetworkRows); const runtimeAlerts = buildRuntimeAlerts(samples, control, network, consoleEvents, errors); const apiDomLag = buildApiDomLagReport(samples, network); @@ -108,7 +109,7 @@ const runnerErrors = errors.slice(-8).map((item) => { }); const commandFailures = summarizeCommandFailures(control); const toolFindings = buildToolFindings({ manifest, heartbeat, commandState }); -const findings = [...toolFindings, ...buildProjectManagementFindings(projectManagement), ...buildFindings(samples, control, network, errors, sampleMetrics, promptNetwork, runtimeAlerts, pagePerformance, pageProvenance, commandFailures, manifest, apiDomLag, browserProcess)]; +const findings = [...toolFindings, ...buildProjectManagementFindings(projectManagement), ...buildFindings(samples, control, network, errors, sampleMetrics, promptNetwork, runtimeAlerts, pagePerformance, requestRate, pageProvenance, commandFailures, manifest, apiDomLag, browserProcess)]; if (jsonlReadIssues.length > 0) findings.unshift({ id: "jsonl-read-issues", severity: "red", summary: "observer analyzer hit JSONL read/parse issues", count: jsonlReadIssues.length, issues: jsonlReadIssues.slice(0, 20) }); const recentWindow = buildRecentAnalysisWindow({ samples, control, network, consoleEvents, errors, browserProcessRows, manifest }); const commandTimeline = control.filter((item) => item.phase === "completed" || item.phase === "failed").map((item) => ({ ts: item.ts, phase: item.phase, commandId: item.commandId, type: item.type, input: item.input, afterUrl: item.afterUrl })); @@ -128,6 +129,7 @@ const report = { sampleMetrics, pageProvenance, pagePerformance, + requestRate, projectManagement, promptNetwork, runtimeAlerts, @@ -159,6 +161,7 @@ console.log(JSON.stringify({ archiveSummary: { sampleMetrics: sampleMetrics.summary, pagePerformance: pagePerformance.summary, + requestRate: requestRate.summary, projectManagement: projectManagement.summary, runtimeAlerts: runtimeAlerts.summary, apiDomLag: apiDomLag.summary, @@ -180,6 +183,7 @@ console.log(JSON.stringify({ }, pageProvenance: recentWindow.pageProvenance.summary, pagePerformance: recentWindow.pagePerformance.summary, + requestRate: recentWindow.requestRate.summary, projectManagement: compactProjectManagementForOutput(projectManagement), promptNetwork: recentWindow.promptNetwork.summary, runtimeAlerts: recentWindow.runtimeAlerts.summary, @@ -309,6 +313,8 @@ console.log(JSON.stringify({ timingSourceOfTruth: item.timingSourceOfTruth ?? null, timingStatus: item.timingStatus ?? null, })), + requestRateCurve: compactRequestRateForOutput(recentWindow.requestRate), + requestRatePeaks: recentWindow.requestRate.peaks.slice(0, 8), pagePerformanceSlowApi: recentWindow.pagePerformance.sameOriginApiByPath.filter((item) => item.isLongLivedStream !== true && Number(item.overBudgetCount ?? item.overFiveSecondCount ?? 0) > 0).slice(0, 5).map((item) => ({ path: item.path, route: item.route, sampleCount: item.sampleCount, p95Ms: item.p95Ms, maxMs: item.maxMs, overBudgetCount: item.overBudgetCount, budgetMs: item.budgetMs, overFiveSecondCount: item.overFiveSecondCount, slowSamples: item.slowSamples })), archivePagePerformanceSlowApi: pagePerformance.sameOriginApiByPath.filter((item) => item.isLongLivedStream !== true && Number(item.overBudgetCount ?? item.overFiveSecondCount ?? 0) > 0).slice(0, 8).map((item) => ({ path: item.path, route: item.route, sampleCount: item.sampleCount, p95Ms: item.p95Ms, maxMs: item.maxMs, overBudgetCount: item.overBudgetCount, budgetMs: item.budgetMs, overFiveSecondCount: item.overFiveSecondCount, slowSamples: item.slowSamples })), pagePerformancePartialApi: recentWindow.pagePerformance.sameOriginApiByPath.filter((item) => item.isLongLivedStream !== true && Number(item.partialOverBudgetCount ?? item.partialOverFiveSecondCount ?? 0) > 0).slice(0, 5).map((item) => ({ path: item.path, route: item.route, sampleCount: item.sampleCount, completeTimingSampleCount: item.completeTimingSampleCount, partialTimingSampleCount: item.partialTimingSampleCount, partialOverBudgetCount: item.partialOverBudgetCount, budgetMs: item.partialBudgetMs, partialOverFiveSecondCount: item.partialOverFiveSecondCount, partialSamples: item.partialSamples })), @@ -570,6 +576,10 @@ function parseAlertThresholds(value) { screenshotTimeoutRedCount: requiredPositiveThreshold(raw, "screenshotTimeoutRedCount"), pageErrorRedCount: requiredPositiveThreshold(raw, "pageErrorRedCount"), browserProcessSampleIntervalMs: requiredPositiveThreshold(raw, "browserProcessSampleIntervalMs"), + requestRateBucketMs: requiredPositiveThreshold(raw, "requestRateBucketMs"), + requestRateTotalRedPerMinute: requiredPositiveThreshold(raw, "requestRateTotalRedPerMinute"), + requestRatePageRedPerMinute: requiredPositiveThreshold(raw, "requestRatePageRedPerMinute"), + requestRateApiPathRedPerMinute: requiredPositiveThreshold(raw, "requestRateApiPathRedPerMinute"), browserTotalRssRedMb: requiredPositiveThreshold(raw, "browserTotalRssRedMb"), browserProcessRssRedMb: requiredPositiveThreshold(raw, "browserProcessRssRedMb"), browserRssGrowthRedMb: requiredPositiveThreshold(raw, "browserRssGrowthRedMb"), @@ -3259,7 +3269,7 @@ function promptCommandHasAuthoritativeSubmitSideEffect(control, promptRound) { || Number(sideEffect.messageCountDelta ?? 0) > 0; } -function buildFindings(samples, control, network, errors, sampleMetrics, promptNetwork, runtimeAlerts, pagePerformance, pageProvenance, commandFailures = [], manifest = {}, apiDomLag = null, browserProcess = null) { +function buildFindings(samples, control, network, errors, sampleMetrics, promptNetwork, runtimeAlerts, pagePerformance, requestRate, pageProvenance, commandFailures = [], manifest = {}, apiDomLag = null, browserProcess = null) { const findings = []; const effectiveApiDomLag = apiDomLag || buildApiDomLagReport(samples, network); if (commandFailures.length > 0) findings.push({ id: "observer-command-failed", severity: "red", summary: "observer control commands failed; analyze must surface command failure instead of hiding it in command artifacts", count: commandFailures.length, commands: commandFailures.slice(0, 20) }); @@ -3590,6 +3600,40 @@ function buildFindings(samples, control, network, errors, sampleMetrics, promptN const slowStreamOpen = longLivedStreams.filter((item) => Number(item.streamOpenOverBudgetCount ?? item.streamOpenOverFiveSecondCount ?? 0) > 0); if (slowStreamOpen.length > 0) findings.push({ id: "page-performance-slow-long-lived-stream-open", severity: "red", summary: "long-lived stream open latency exceeded configured YAML usability budget; stream lifetime is still reported separately", count: slowStreamOpen.length, budgetMs: alertThresholds.longLivedStreamOpenSlowMs, groups: slowStreamOpen.slice(0, 20) }); if (longLivedStreams.length > 0) findings.push({ id: "page-performance-long-lived-streams", severity: "info", summary: "same-origin long-lived streams are reported separately; lifetime is not treated as API load latency", count: longLivedStreams.length, groups: longLivedStreams.slice(0, 20) }); + const requestRatePeaks = Array.isArray(requestRate?.peaks) ? requestRate.peaks : []; + const totalRequestRatePeaks = requestRatePeaks.filter((item) => item.scope === "total" && item.overThreshold === true); + const pageRequestRatePeaks = requestRatePeaks.filter((item) => item.scope === "page" && item.overThreshold === true); + const apiPathRequestRatePeaks = requestRatePeaks.filter((item) => item.scope === "apiPath" && item.overThreshold === true); + if (totalRequestRatePeaks.length > 0) findings.push({ + id: "request-rate-total-peak", + severity: "red", + summary: "same-origin API request rate exceeded the YAML total peak threshold; this is request storm evidence from browser network events", + count: totalRequestRatePeaks.length, + thresholdPerMinute: alertThresholds.requestRateTotalRedPerMinute, + bucketMs: requestRate?.summary?.bucketMs ?? alertThresholds.requestRateBucketMs, + peaks: totalRequestRatePeaks.slice(0, 20), + valuesRedacted: true + }); + if (pageRequestRatePeaks.length > 0) findings.push({ + id: "request-rate-page-peak", + severity: "red", + summary: "a page-level same-origin API request curve exceeded the YAML peak threshold; inspect the page and top API paths before changing probe sampling", + count: pageRequestRatePeaks.length, + thresholdPerMinute: alertThresholds.requestRatePageRedPerMinute, + bucketMs: requestRate?.summary?.bucketMs ?? alertThresholds.requestRateBucketMs, + peaks: pageRequestRatePeaks.slice(0, 20), + valuesRedacted: true + }); + if (apiPathRequestRatePeaks.length > 0) findings.push({ + id: "request-rate-api-path-peak", + severity: "red", + summary: "an API-path request curve exceeded the YAML peak threshold; this pinpoints the route most likely involved in a request storm", + count: apiPathRequestRatePeaks.length, + thresholdPerMinute: alertThresholds.requestRateApiPathRedPerMinute, + bucketMs: requestRate?.summary?.bucketMs ?? alertThresholds.requestRateBucketMs, + peaks: apiPathRequestRatePeaks.slice(0, 20), + valuesRedacted: true + }); if ((pageProvenance?.summary?.segmentCount ?? 0) > 1) findings.push({ id: "page-provenance-segments", severity: "info", summary: "observer crossed page asset provenance segments; interpret runtime findings by segment", segmentCount: pageProvenance.summary.segmentCount, segments: pageProvenance.segments.slice(0, 20) }); const naturalApi = network.filter((item) => item.observerInitiated === false && item.type === "response" && /\/v1\/|\/auth\//u.test(String(item.url || ""))); const apiDomLagSummary = effectiveApiDomLag?.summary || {}; @@ -4395,11 +4439,12 @@ function buildRecentAnalysisWindow({ samples, control, network, consoleEvents, e const sampleMetrics = buildSampleMetrics(windowSamples, control); const pageProvenance = buildPageProvenanceReport(windowSamples, windowControl, manifest); const pagePerformance = buildPagePerformanceReport(windowSamples, manifest); + const requestRate = buildRequestRateReport(windowNetwork); const promptNetwork = buildPromptNetworkReport(windowControl, windowNetwork); const runtimeAlerts = buildRuntimeAlerts(windowSamples, control, windowNetwork, windowConsole, windowErrors); const apiDomLag = buildApiDomLagReport(windowSamples, windowNetwork); const browserProcess = buildBrowserProcessReport(windowBrowserProcessRows); - const findings = buildFindings(windowSamples, control, windowNetwork, windowErrors, sampleMetrics, promptNetwork, runtimeAlerts, pagePerformance, pageProvenance, [], {}, apiDomLag, browserProcess); + const findings = buildFindings(windowSamples, control, windowNetwork, windowErrors, sampleMetrics, promptNetwork, runtimeAlerts, pagePerformance, requestRate, pageProvenance, [], {}, apiDomLag, browserProcess); return { summary: { name: "recent-5m", @@ -4417,6 +4462,7 @@ function buildRecentAnalysisWindow({ samples, control, network, consoleEvents, e sampleMetrics, pageProvenance, pagePerformance, + requestRate, promptNetwork, runtimeAlerts, apiDomLag, @@ -4822,6 +4868,280 @@ function percentile(sortedValues, percentileValue) { return Math.round(sortedValues[lower] * (1 - weight) + sortedValues[upper] * weight); } +function buildRequestRateReport(network) { + const bucketMs = Math.max(1000, Number(alertThresholds.requestRateBucketMs || 0)); + const naturalApiRequests = (Array.isArray(network) ? network : []) + .filter((item) => item?.observerInitiated !== true && item?.type === "request") + .map((item) => requestRateEvent(item)) + .filter((item) => item !== null) + .sort((a, b) => a.tsMs - b.tsMs); + if (naturalApiRequests.length === 0) { + return { + summary: { + bucketMs, + bucketSeconds: Number((bucketMs / 1000).toFixed(3)), + requestCount: 0, + bucketCount: 0, + pageCount: 0, + apiPathCount: 0, + totalRedPerMinute: alertThresholds.requestRateTotalRedPerMinute, + pageRedPerMinute: alertThresholds.requestRatePageRedPerMinute, + apiPathRedPerMinute: alertThresholds.requestRateApiPathRedPerMinute, + totalPeakPerMinute: 0, + pagePeakPerMinute: 0, + apiPathPeakPerMinute: 0, + overThresholdPeakCount: 0, + valuesRedacted: true, + }, + buckets: [], + pageCurves: [], + apiPathCurves: [], + peaks: [], + valuesRedacted: true, + }; + } + const firstBucketMs = Math.floor(naturalApiRequests[0].tsMs / bucketMs) * bucketMs; + const lastBucketMs = Math.floor(naturalApiRequests[naturalApiRequests.length - 1].tsMs / bucketMs) * bucketMs; + const makeBucket = (bucketStartMs, extra = {}) => ({ + bucketStartMs, + bucketEndMs: bucketStartMs + bucketMs, + startAt: new Date(bucketStartMs).toISOString(), + endAt: new Date(bucketStartMs + bucketMs).toISOString(), + count: 0, + requestPerMinute: 0, + ...extra, + valuesRedacted: true, + }); + const totalBuckets = new Map(); + const pageBuckets = new Map(); + const apiPathBuckets = new Map(); + const pageTotals = new Map(); + const apiPathTotals = new Map(); + const allBucketStarts = []; + for (let bucketStartMs = firstBucketMs; bucketStartMs <= lastBucketMs; bucketStartMs += bucketMs) { + totalBuckets.set(bucketStartMs, makeBucket(bucketStartMs)); + allBucketStarts.push(bucketStartMs); + } + for (const item of naturalApiRequests) { + const bucketStartMs = Math.floor(item.tsMs / bucketMs) * bucketMs; + const totalBucket = totalBuckets.get(bucketStartMs) || makeBucket(bucketStartMs); + totalBucket.count += 1; + totalBuckets.set(bucketStartMs, totalBucket); + + const pageKey = item.pageKey; + const pageMapKey = pageKey + "|" + bucketStartMs; + const pageBucket = pageBuckets.get(pageMapKey) || makeBucket(bucketStartMs, { + pageKey, + pageRole: item.pageRole, + pageId: item.pageId, + pageEpoch: item.pageEpoch, + path: item.framePath, + }); + pageBucket.count += 1; + pageBuckets.set(pageMapKey, pageBucket); + const pageTotal = pageTotals.get(pageKey) || { pageKey, pageRole: item.pageRole, pageId: item.pageId, pageEpoch: item.pageEpoch, path: item.framePath, count: 0 }; + pageTotal.count += 1; + pageTotals.set(pageKey, pageTotal); + + const apiKey = item.method + " " + item.apiPath; + const apiMapKey = apiKey + "|" + bucketStartMs; + const apiBucket = apiPathBuckets.get(apiMapKey) || makeBucket(bucketStartMs, { + apiKey, + method: item.method, + path: item.apiPath, + pageKey, + pageRole: item.pageRole, + pageId: item.pageId, + pageEpoch: item.pageEpoch, + }); + apiBucket.count += 1; + apiPathBuckets.set(apiMapKey, apiBucket); + const apiTotal = apiPathTotals.get(apiKey) || { apiKey, method: item.method, path: item.apiPath, count: 0 }; + apiTotal.count += 1; + apiPathTotals.set(apiKey, apiTotal); + } + const finalizeBucket = (bucket) => ({ + ...bucket, + requestPerMinute: Number((Number(bucket.count || 0) * 60000 / bucketMs).toFixed(2)), + }); + const buckets = Array.from(totalBuckets.values()).map(finalizeBucket); + const pageBucketRows = Array.from(pageBuckets.values()).map(finalizeBucket); + const apiPathBucketRows = Array.from(apiPathBuckets.values()).map(finalizeBucket); + const peakForRows = (rows, thresholdPerMinute, scope, extra = {}) => rows + .filter((row) => Number(row.requestPerMinute ?? 0) >= Number(thresholdPerMinute || Infinity)) + .map((row) => ({ + scope, + thresholdPerMinute, + overThreshold: true, + bucketMs, + bucketStartMs: row.bucketStartMs, + bucketEndMs: row.bucketEndMs, + startAt: row.startAt, + endAt: row.endAt, + count: row.count, + requestPerMinute: row.requestPerMinute, + ...extra(row), + valuesRedacted: true, + })); + const totalPeaks = peakForRows(buckets, alertThresholds.requestRateTotalRedPerMinute, "total", () => ({})); + const pagePeaks = peakForRows(pageBucketRows, alertThresholds.requestRatePageRedPerMinute, "page", (row) => ({ + pageKey: row.pageKey, + pageRole: row.pageRole, + pageId: row.pageId, + pageEpoch: row.pageEpoch, + path: row.path, + })); + const apiPathPeaks = peakForRows(apiPathBucketRows, alertThresholds.requestRateApiPathRedPerMinute, "apiPath", (row) => ({ + apiKey: row.apiKey, + method: row.method, + path: row.path, + pageKey: row.pageKey, + pageRole: row.pageRole, + pageId: row.pageId, + pageEpoch: row.pageEpoch, + })); + const pageCurves = Array.from(pageTotals.values()) + .map((page) => { + const rows = pageBucketRows + .filter((row) => row.pageKey === page.pageKey) + .sort((a, b) => a.bucketStartMs - b.bucketStartMs); + const peak = maxByNumber(rows, (row) => row.requestPerMinute); + return { + ...page, + bucketCount: rows.length, + peakRequestPerMinute: peak?.requestPerMinute ?? 0, + peakBucket: peak ?? null, + buckets: rows.slice(-60), + valuesRedacted: true, + }; + }) + .sort((a, b) => Number(b.peakRequestPerMinute ?? 0) - Number(a.peakRequestPerMinute ?? 0) || Number(b.count ?? 0) - Number(a.count ?? 0)); + const apiPathCurves = Array.from(apiPathTotals.values()) + .map((api) => { + const rows = apiPathBucketRows + .filter((row) => row.apiKey === api.apiKey) + .sort((a, b) => a.bucketStartMs - b.bucketStartMs); + const peak = maxByNumber(rows, (row) => row.requestPerMinute); + return { + ...api, + bucketCount: rows.length, + peakRequestPerMinute: peak?.requestPerMinute ?? 0, + peakBucket: peak ?? null, + buckets: rows.slice(-60), + valuesRedacted: true, + }; + }) + .sort((a, b) => Number(b.peakRequestPerMinute ?? 0) - Number(a.peakRequestPerMinute ?? 0) || Number(b.count ?? 0) - Number(a.count ?? 0)); + const totalPeak = maxByNumber(buckets, (row) => row.requestPerMinute); + const pagePeak = pageCurves[0] ?? null; + const apiPathPeak = apiPathCurves[0] ?? null; + const peaks = [...totalPeaks, ...pagePeaks, ...apiPathPeaks] + .sort((a, b) => Number(b.requestPerMinute ?? 0) - Number(a.requestPerMinute ?? 0) || String(a.scope).localeCompare(String(b.scope))); + return { + summary: { + bucketMs, + bucketSeconds: Number((bucketMs / 1000).toFixed(3)), + requestCount: naturalApiRequests.length, + bucketCount: buckets.length, + pageCount: pageCurves.length, + apiPathCount: apiPathCurves.length, + firstAt: new Date(firstBucketMs).toISOString(), + lastAt: new Date(lastBucketMs + bucketMs).toISOString(), + totalRedPerMinute: alertThresholds.requestRateTotalRedPerMinute, + pageRedPerMinute: alertThresholds.requestRatePageRedPerMinute, + apiPathRedPerMinute: alertThresholds.requestRateApiPathRedPerMinute, + totalPeakPerMinute: totalPeak?.requestPerMinute ?? 0, + totalPeakCount: totalPeak?.count ?? 0, + totalPeakAt: totalPeak?.startAt ?? null, + pagePeakPerMinute: pagePeak?.peakRequestPerMinute ?? 0, + pagePeakKey: pagePeak?.pageKey ?? null, + pagePeakPath: pagePeak?.path ?? null, + apiPathPeakPerMinute: apiPathPeak?.peakRequestPerMinute ?? 0, + apiPathPeakKey: apiPathPeak?.apiKey ?? null, + overThresholdPeakCount: peaks.length, + valuesRedacted: true, + }, + buckets: buckets.slice(-120), + pageCurves: pageCurves.slice(0, 20), + apiPathCurves: apiPathCurves.slice(0, 40), + peaks: peaks.slice(0, 80), + valuesRedacted: true, + }; +} + +function requestRateEvent(item) { + const tsMs = Date.parse(String(item?.ts || "")); + const apiPath = normalizeApiRatePath(urlPath(item?.url)); + if (!Number.isFinite(tsMs) || !isApiLikePath(apiPath)) return null; + const framePath = normalizeRoutePath(urlPath(item?.frameUrl || item?.url)); + const pageRole = String(item?.pageRole || "unknown"); + const pageId = String(item?.pageId || "unknown"); + const pageEpoch = Number.isFinite(Number(item?.pageEpoch)) ? Number(item.pageEpoch) : null; + return { + ts: item.ts, + tsMs, + method: String(item?.method || "GET").toUpperCase(), + apiPath, + framePath, + pageRole, + pageId, + pageEpoch, + pageKey: [pageRole, pageId, pageEpoch ?? "-", framePath || "-"].join("|"), + valuesRedacted: true, + }; +} + +function normalizeApiRatePath(value) { + return normalizeRoutePath(value) + .replace(/\/sessions\/[^/?#]+/gu, "/sessions/:id") + .replace(/\/turns\/[^/?#]+/gu, "/turns/:id") + .replace(/\/traces\/[^/?#]+/gu, "/traces/:id") + .replace(/\/messages\/[^/?#]+/gu, "/messages/:id") + .replace(/\/files\/[^/?#]+/gu, "/files/:id") + .replace(/\/tasks\/[^/?#]+/gu, "/tasks/:id") + .replace(/\/runs\/[^/?#]+/gu, "/runs/:id") + .replace(/\/[0-9a-f]{8,}(?=\/|$)/giu, "/:id") + .replace(/\/[A-Za-z0-9_-]{24,}(?=\/|$)/gu, "/:id"); +} + +function normalizeRoutePath(value) { + const raw = String(value || ""); + if (!raw || raw === "-") return "-"; + return raw.replace(/\/+/gu, "/").replace(/\/$/u, "") || "/"; +} + +function compactRequestRateForOutput(report) { + if (!report || typeof report !== "object") return null; + return { + summary: report.summary ?? null, + buckets: Array.isArray(report.buckets) ? report.buckets.slice(-12) : [], + pageCurves: Array.isArray(report.pageCurves) ? report.pageCurves.slice(0, 8).map((item) => ({ + pageKey: item.pageKey ?? null, + pageRole: item.pageRole ?? null, + pageId: item.pageId ?? null, + pageEpoch: item.pageEpoch ?? null, + path: item.path ?? null, + count: item.count ?? null, + peakRequestPerMinute: item.peakRequestPerMinute ?? null, + peakBucket: item.peakBucket ?? null, + buckets: Array.isArray(item.buckets) ? item.buckets.slice(-12) : [], + valuesRedacted: true, + })) : [], + apiPathCurves: Array.isArray(report.apiPathCurves) ? report.apiPathCurves.slice(0, 12).map((item) => ({ + apiKey: item.apiKey ?? null, + method: item.method ?? null, + path: item.path ?? null, + count: item.count ?? null, + peakRequestPerMinute: item.peakRequestPerMinute ?? null, + peakBucket: item.peakBucket ?? null, + buckets: Array.isArray(item.buckets) ? item.buckets.slice(-12) : [], + valuesRedacted: true, + })) : [], + peaks: Array.isArray(report.peaks) ? report.peaks.slice(0, 12) : [], + valuesRedacted: true, + }; +} + function buildPromptNetworkReport(control, network) { const promptsById = new Map(); for (const item of control) { diff --git a/scripts/src/hwlab-node-web-observe-runner-source.ts b/scripts/src/hwlab-node-web-observe-runner-source.ts index cec218a4..bd47c1f3 100644 --- a/scripts/src/hwlab-node-web-observe-runner-source.ts +++ b/scripts/src/hwlab-node-web-observe-runner-source.ts @@ -5616,6 +5616,10 @@ function parseAlertThresholds(value) { screenshotTimeoutRedCount: requiredPositiveThreshold(raw, "screenshotTimeoutRedCount"), pageErrorRedCount: requiredPositiveThreshold(raw, "pageErrorRedCount"), browserProcessSampleIntervalMs: requiredPositiveThreshold(raw, "browserProcessSampleIntervalMs"), + requestRateBucketMs: requiredPositiveThreshold(raw, "requestRateBucketMs"), + requestRateTotalRedPerMinute: requiredPositiveThreshold(raw, "requestRateTotalRedPerMinute"), + requestRatePageRedPerMinute: requiredPositiveThreshold(raw, "requestRatePageRedPerMinute"), + requestRateApiPathRedPerMinute: requiredPositiveThreshold(raw, "requestRateApiPathRedPerMinute"), browserTotalRssRedMb: requiredPositiveThreshold(raw, "browserTotalRssRedMb"), browserProcessRssRedMb: requiredPositiveThreshold(raw, "browserProcessRssRedMb"), browserRssGrowthRedMb: requiredPositiveThreshold(raw, "browserRssGrowthRedMb"), diff --git a/scripts/src/hwlab-node-web-sentinel-service.ts b/scripts/src/hwlab-node-web-sentinel-service.ts index dc185ba6..8f3d4507 100644 --- a/scripts/src/hwlab-node-web-sentinel-service.ts +++ b/scripts/src/hwlab-node-web-sentinel-service.ts @@ -951,6 +951,7 @@ function dashboardRunDetail(config: WebProbeSentinelServiceConfig, db: Database, run: dashboardRunSummary(config, db, row), summary: record(stored.summary), memory: dashboardRunMemoryDetail(config, row, stored), + requestRate: dashboardRunRequestRateDetail(config, row, stored), findings, viewsAvailable: Object.keys(views), artifacts: { @@ -1099,6 +1100,37 @@ function dashboardRunMemoryDetail(config: WebProbeSentinelServiceConfig, row: Re }; } +function dashboardRunRequestRateDetail(config: WebProbeSentinelServiceConfig, row: Record, stored: Record): Record { + const options = dashboardDetailRequestRateOptions(config); + const storedRequestRate = compactDashboardRequestRate(record(record(record(stored.summary).analysis).requestRate), options, "recorded-analysis-summary"); + if (storedRequestRate.ok === true && hasDashboardRequestRateSeries(storedRequestRate)) return storedRequestRate; + const fromArtifacts = readDashboardRequestRateFromAnalysisReport(config, row, options); + if (fromArtifacts.ok === true) return fromArtifacts; + if (storedRequestRate.ok === true) return storedRequestRate; + return { + ok: false, + source: "unavailable", + unit: "rpm", + metric: "same-origin-api-request-rate", + requestCount: 0, + bucketCount: 0, + pageCount: 0, + apiPathCount: 0, + totalSeries: [], + pageSeries: [], + apiPathSeries: [], + peaks: [], + reason: fromArtifacts.reason ?? storedRequestRate.reason ?? "request-rate-samples-missing", + valuesRedacted: true, + }; +} + +function hasDashboardRequestRateSeries(value: Record): boolean { + return arrayRecords(value.totalSeries).length > 0 + || arrayRecords(value.pageSeries).length > 0 + || arrayRecords(value.apiPathSeries).length > 0; +} + function dashboardDetailMemoryOptions(config: WebProbeSentinelServiceConfig): Record { const detailMemory = recordTarget(valueAtPath(config.reportViews, "detailMemory")); return { @@ -1107,6 +1139,17 @@ function dashboardDetailMemoryOptions(config: WebProbeSentinelServiceConfig): Re }; } +function dashboardDetailRequestRateOptions(config: WebProbeSentinelServiceConfig): Record { + const detailRequestRate = recordTarget(valueAtPath(config.reportViews, "detailRequestRate")); + return { + maxTotalBuckets: Math.max(1, Math.floor(numberAt(detailRequestRate, "maxTotalBuckets"))), + maxPageSeries: Math.max(1, Math.floor(numberAt(detailRequestRate, "maxPageSeries"))), + maxApiPathSeries: Math.max(1, Math.floor(numberAt(detailRequestRate, "maxApiPathSeries"))), + maxBucketsPerSeries: Math.max(1, Math.floor(numberAt(detailRequestRate, "maxBucketsPerSeries"))), + maxPeaks: Math.max(1, Math.floor(numberAt(detailRequestRate, "maxPeaks"))), + }; +} + function readDashboardMemoryFromStateDir(config: WebProbeSentinelServiceConfig, row: Record, options: Record): Record { const stateDir = stringOrNull(row.state_dir) ?? stringOrNull(row.stateDir); if (stateDir === null) return { ok: false, source: "artifact-browser-process-jsonl", reason: "state-dir-missing", pageSeries: [], valuesRedacted: true }; @@ -1141,6 +1184,160 @@ function compactDashboardMemory(value: Record, options: Record< return { ok: false, source, reason: "recorded-page-series-missing", pageSeries: [], valuesRedacted: true }; } +function readDashboardRequestRateFromAnalysisReport(config: WebProbeSentinelServiceConfig, row: Record, options: Record): Record { + const stateDir = stringOrNull(row.state_dir) ?? stringOrNull(row.stateDir); + if (stateDir === null) return { ok: false, source: "analysis-report-json", reason: "state-dir-missing", totalSeries: [], pageSeries: [], apiPathSeries: [], valuesRedacted: true }; + if (!isSafeDashboardStateDir(stateDir)) return { ok: false, source: "analysis-report-json", reason: "unsafe-state-dir", totalSeries: [], pageSeries: [], apiPathSeries: [], valuesRedacted: true }; + const reportJsonPath = join(config.stateRoot, stateDir, "analysis", "report.json"); + const jsonBuffer = readFileBuffer(reportJsonPath); + if (jsonBuffer === null) return { ok: false, source: "analysis-report-json", reason: "analysis-report-json-missing", totalSeries: [], pageSeries: [], apiPathSeries: [], valuesRedacted: true }; + try { + const report = record(JSON.parse(jsonBuffer.toString("utf8")) as unknown); + return compactDashboardRequestRate(record(report.requestRate), options, "analysis-report-json"); + } catch (error) { + return { + ok: false, + source: "analysis-report-json", + reason: "analysis-report-json-invalid", + error: error instanceof Error ? error.message : String(error), + totalSeries: [], + pageSeries: [], + apiPathSeries: [], + valuesRedacted: true, + }; + } +} + +function compactDashboardRequestRate(value: Record, options: Record, source: string): Record { + const summary = requestRateSummary(record(value.summary).requestCount === undefined ? value : record(value.summary)); + const totalSeries = finalizeDashboardRequestRateSeries([{ + key: "total", + label: "全部 API 请求", + count: summary.requestCount, + peakRequestPerMinute: summary.totalPeakPerMinute, + buckets: arrayRecords(value.buckets), + }], numberOr(options.maxTotalBuckets, 1), 1, "total"); + const pageSeries = finalizeDashboardRequestRateSeries(arrayRecords(value.pageCurves), numberOr(options.maxBucketsPerSeries, 1), numberOr(options.maxPageSeries, 1), "page"); + const apiPathSeries = finalizeDashboardRequestRateSeries(arrayRecords(value.apiPathCurves), numberOr(options.maxBucketsPerSeries, 1), numberOr(options.maxApiPathSeries, 1), "apiPath"); + const peaks = compactDashboardRequestRatePeaks(arrayRecords(value.peaks), numberOr(options.maxPeaks, 1)); + const hasRequestRate = summary.requestCount !== null || totalSeries.length > 0 || pageSeries.length > 0 || apiPathSeries.length > 0 || peaks.length > 0; + if (!hasRequestRate) { + return { ok: false, source, reason: "request-rate-report-missing", totalSeries: [], pageSeries: [], apiPathSeries: [], peaks: [], valuesRedacted: true }; + } + return { + ok: true, + source, + unit: "rpm", + metric: "same-origin-api-request-rate", + ...summary, + totalSeries, + pageSeries, + apiPathSeries, + peaks, + valuesRedacted: true, + }; +} + +function requestRateSummary(value: Record): Record { + return { + bucketMs: numberOrNull(value.bucketMs), + bucketSeconds: numberOrNull(value.bucketSeconds), + requestCount: numberOrNull(value.requestCount), + bucketCount: numberOrNull(value.bucketCount), + pageCount: numberOrNull(value.pageCount), + apiPathCount: numberOrNull(value.apiPathCount), + firstAt: stringOrNull(value.firstAt), + lastAt: stringOrNull(value.lastAt), + totalRedPerMinute: numberOrNull(value.totalRedPerMinute), + pageRedPerMinute: numberOrNull(value.pageRedPerMinute), + apiPathRedPerMinute: numberOrNull(value.apiPathRedPerMinute), + totalPeakPerMinute: numberOrNull(value.totalPeakPerMinute), + totalPeakCount: numberOrNull(value.totalPeakCount), + totalPeakAt: stringOrNull(value.totalPeakAt), + pagePeakPerMinute: numberOrNull(value.pagePeakPerMinute), + pagePeakKey: stringOrNull(value.pagePeakKey), + pagePeakPath: stringOrNull(value.pagePeakPath), + apiPathPeakPerMinute: numberOrNull(value.apiPathPeakPerMinute), + apiPathPeakKey: stringOrNull(value.apiPathPeakKey), + overThresholdPeakCount: numberOrNull(value.overThresholdPeakCount), + }; +} + +function finalizeDashboardRequestRateSeries(rawSeries: readonly Record[], maxBucketsPerSeries: number, maxSeries: number, scope: string): Record[] { + return rawSeries + .map((item) => { + const points = arrayRecords(item.buckets) + .map((bucket) => ({ + ts: stringOrNull(bucket.startAt), + startAt: stringOrNull(bucket.startAt), + endAt: stringOrNull(bucket.endAt), + bucketStartMs: numberOrNull(bucket.bucketStartMs), + bucketEndMs: numberOrNull(bucket.bucketEndMs), + count: numberOrNull(bucket.count), + requestPerMinute: numberOrNull(bucket.requestPerMinute), + valuesRedacted: true, + })) + .filter((point) => point.ts !== null && point.requestPerMinute !== null); + const thinned = thinDashboardPoints(points, maxBucketsPerSeries); + const peakRequestPerMinute = numberOrNull(item.peakRequestPerMinute) ?? maxNumber(thinned.map((point) => numberOrNull(point.requestPerMinute))) ?? numberOrNull(item.totalPeakPerMinute) ?? 0; + const count = numberOrNull(item.count) ?? numberOrNull(item.requestCount) ?? thinned.reduce((sum, point) => sum + numberOr(point.count, 0), 0); + return { + key: requestRateSeriesKey(item, scope), + scope, + label: requestRateSeriesLabel(item, scope), + method: stringOrNull(item.method), + path: stringOrNull(item.path), + pageRole: stringOrNull(item.pageRole), + pageId: stringOrNull(item.pageId), + pageEpoch: numberOrNull(item.pageEpoch), + count, + bucketCount: numberOrNull(item.bucketCount) ?? thinned.length, + peakRequestPerMinute, + peakAt: stringOrNull(record(item.peakBucket).startAt), + points: thinned, + valuesRedacted: true, + }; + }) + .filter((item) => arrayRecords(item.points).length > 0) + .sort((a, b) => numberOr(b.peakRequestPerMinute, 0) - numberOr(a.peakRequestPerMinute, 0) || numberOr(b.count, 0) - numberOr(a.count, 0)) + .slice(0, maxSeries); +} + +function compactDashboardRequestRatePeaks(rawPeaks: readonly Record[], maxPeaks: number): Record[] { + return rawPeaks.slice(0, maxPeaks).map((item) => ({ + scope: stringOrNull(item.scope), + thresholdPerMinute: numberOrNull(item.thresholdPerMinute), + overThreshold: item.overThreshold === true, + bucketMs: numberOrNull(item.bucketMs), + startAt: stringOrNull(item.startAt), + endAt: stringOrNull(item.endAt), + count: numberOrNull(item.count), + requestPerMinute: numberOrNull(item.requestPerMinute), + method: stringOrNull(item.method), + path: stringOrNull(item.path), + apiKey: stringOrNull(item.apiKey), + pageKey: stringOrNull(item.pageKey), + pageRole: stringOrNull(item.pageRole), + pageId: stringOrNull(item.pageId), + pageEpoch: numberOrNull(item.pageEpoch), + valuesRedacted: true, + })); +} + +function requestRateSeriesKey(item: Record, scope: string): string { + const fallback = [stringOrNull(item.method), stringOrNull(item.path)].filter((part) => part !== null).join(" "); + return stringOrNull(item.key) + ?? stringOrNull(item.apiKey) + ?? stringOrNull(item.pageKey) + ?? (fallback.length > 0 ? fallback : scope); +} + +function requestRateSeriesLabel(item: Record, scope: string): string { + if (scope === "total") return "全部 API 请求"; + if (scope === "apiPath") return (stringOrNull(item.apiKey) ?? [stringOrNull(item.method), stringOrNull(item.path)].filter((part) => part !== null).join(" ")) || "API"; + return stringOrNull(item.path) ?? stringOrNull(item.pageKey) ?? stringOrNull(item.pageRole) ?? "page"; +} + function buildDashboardMemoryDetail(rows: readonly Record[], options: Record, source: string): Record { const seriesByKey = new Map>(); let firstTsMs: number | null = null; diff --git a/scripts/src/hwlab-node/web-observe-render.ts b/scripts/src/hwlab-node/web-observe-render.ts index d252664c..f6f52b85 100644 --- a/scripts/src/hwlab-node/web-observe-render.ts +++ b/scripts/src/hwlab-node/web-observe-render.ts @@ -116,6 +116,16 @@ export function renderWebObserveAnalyzeTable(value: Record): st const runtimeAlerts = record(analysis?.runtimeAlerts); const pagePerformance = record(analysis?.pagePerformance); const pagePerformanceSummary = record(pagePerformance?.summary); + const nonEmptyRecord = (item: unknown): Record | null => { + const valueRecord = record(item); + return Object.keys(valueRecord).length > 0 ? valueRecord : null; + }; + const requestRateSummarySource = nonEmptyRecord(analysis?.requestRate); + const requestRateCurve = nonEmptyRecord(analysis?.requestRateCurve) ?? (requestRateSummarySource?.summary ? requestRateSummarySource : null); + const requestRateSummary = nonEmptyRecord(requestRateCurve?.summary) ?? requestRateSummarySource; + const requestRatePeaks = webObserveArray(analysis?.requestRatePeaks ?? requestRateCurve?.peaks ?? requestRateSummarySource?.peaks).map(record).filter((item): item is Record => item !== null).slice(0, 8); + const requestRatePageCurves = webObserveArray(requestRateCurve?.pageCurves ?? requestRateSummarySource?.pageCurves).map(record).filter((item): item is Record => item !== null).slice(0, 8); + const requestRateApiPathCurves = webObserveArray(requestRateCurve?.apiPathCurves ?? requestRateSummarySource?.apiPathCurves).map(record).filter((item): item is Record => item !== null).slice(0, 8); const projectManagement = record(analysis?.projectManagement) ?? record(value.projectManagement); const projectManagementSummary = record(projectManagement?.summary) ?? projectManagement; const projectManagementCommandsSource = Array.isArray(projectManagement?.launchCommands) && projectManagement.launchCommands.length > 0 @@ -558,6 +568,41 @@ export function renderWebObserveAnalyzeTable(value: Record): st webObserveText(promptNetwork?.promptSegments), ]]), "", + "Request rate:", + webObserveTable(["BUCKET", "REQUESTS", "TOTAL_PEAK", "PAGE_PEAK", "API_PEAK", "OVER_PEAKS", "THRESHOLDS"], [[ + requestRateSummary?.bucketSeconds ? `${webObserveText(requestRateSummary.bucketSeconds)}s` : webObserveText(requestRateSummary?.bucketMs), + webObserveText(requestRateSummary?.requestCount), + webObserveText(requestRateSummary?.totalPeakPerMinute), + webObserveText(requestRateSummary?.pagePeakPerMinute), + webObserveText(requestRateSummary?.apiPathPeakPerMinute), + webObserveText(requestRateSummary?.overThresholdPeakCount), + `total=${webObserveText(requestRateSummary?.totalRedPerMinute)} page=${webObserveText(requestRateSummary?.pageRedPerMinute)} api=${webObserveText(requestRateSummary?.apiPathRedPerMinute)}`, + ]]), + requestRatePeaks.length === 0 + ? "Request rate peaks:\n-" + : webObserveTable(["SCOPE", "RPM", "COUNT", "START", "PAGE", "API"], requestRatePeaks.map((item) => [ + webObserveText(item.scope), + webObserveText(item.requestPerMinute), + webObserveText(item.count), + webObserveShort(webObserveText(item.startAt), 24), + webObserveShort(webObserveText(item.path ?? item.pageKey), 48), + webObserveShort(webObserveText(item.apiKey), 64), + ])), + "Request page curves:", + webObserveTable(["PAGE", "COUNT", "PEAK_RPM", "PEAK_AT"], requestRatePageCurves.length > 0 ? requestRatePageCurves.map((item) => [ + webObserveShort(webObserveText(item.path ?? item.pageKey), 56), + webObserveText(item.count), + webObserveText(item.peakRequestPerMinute), + webObserveShort(webObserveText(record(item.peakBucket)?.startAt), 24), + ]) : [["-", "-", "-", "-"]]), + "Request API curves:", + webObserveTable(["API", "COUNT", "PEAK_RPM", "PEAK_AT"], requestRateApiPathCurves.length > 0 ? requestRateApiPathCurves.map((item) => [ + webObserveShort(webObserveText(item.apiKey ?? item.path), 72), + webObserveText(item.count), + webObserveText(item.peakRequestPerMinute), + webObserveShort(webObserveText(record(item.peakBucket)?.startAt), 24), + ]) : [["-", "-", "-", "-"]]), + "", "Runner errors:", webObserveTable(["TS", "TYPE", "RETRY", "EXH", "ATTEMPTS", "LAST_KIND", "READY", "MESSAGE"], runnerErrors.length > 0 ? runnerErrors.map((item) => [ webObserveShort(webObserveText(item.ts), 24), diff --git a/scripts/src/hwlab-node/web-probe-observe.ts b/scripts/src/hwlab-node/web-probe-observe.ts index 2db76f57..84d3cf6e 100644 --- a/scripts/src/hwlab-node/web-probe-observe.ts +++ b/scripts/src/hwlab-node/web-probe-observe.ts @@ -2252,6 +2252,10 @@ export function runNodeWebProbeObserveAnalyze(options: NodeWebProbeObserveOption "const slimProjectManagement = (value) => { const v = objectOrNull(value); if (!v) return null; const s = objectOrNull(v.summary) || v; return { summary: { enabled: s.enabled === true, projectSampleCount: s.projectSampleCount ?? null, mdtodoSampleCount: s.mdtodoSampleCount ?? null, latestPageKind: clip(s.latestPageKind, 48), latestPath: clip(s.latestPath, 96), latestSourceCount: s.latestSourceCount ?? null, latestFileCount: s.latestFileCount ?? null, latestTaskCount: s.latestTaskCount ?? null, latestSelectedTaskRefHash: clip(s.latestSelectedTaskRefHash, 80), launchCommandCount: s.launchCommandCount ?? null, launchSuccessCount: s.launchSuccessCount ?? null, launchFailureCount: s.launchFailureCount ?? null, launchWithOtelTraceHeaderCount: s.launchWithOtelTraceHeaderCount ?? null, projectApiResponseCount: s.projectApiResponseCount ?? null, projectApiFailureCount: s.projectApiFailureCount ?? null, projectApiSlowPathCount: s.projectApiSlowPathCount ?? null, slowApiBudgetMs: s.slowApiBudgetMs ?? null }, commands: takeTail(v.commands, 8).map((item) => { const row = objectOrNull(item) || {}; return { ts: row.ts ?? null, phase: clip(row.phase, 16), type: clip(row.type, 32), commandId: clip(row.commandId, 80), launchStatus: row.launchStatus ?? null, sessionId: clip(row.sessionId, 80), workbenchUrl: clip(row.workbenchUrl, 120), otelTraceId: clip(row.otelTraceId, 32), selectedTaskRefHash: clip(row.selectedTaskRefHash, 80) }; }), samples: takeTail(v.samples, 8).map((item) => { const row = objectOrNull(item) || {}; return { seq: row.seq ?? null, ts: row.ts ?? null, pageRole: clip(row.pageRole, 24), path: clip(row.path, 96), pageKind: clip(row.pageKind, 48), sourceCount: row.sourceCount ?? null, fileCount: row.fileCount ?? null, taskCount: row.taskCount ?? null, selectedTaskRefHash: clip(row.selectedTaskRefHash, 80), launchButtonEnabled: row.launchButtonEnabled === true, workbenchLinkCount: row.workbenchLinkCount ?? null }; }), projectApiByPath: takeHead(v.projectApiByPath, 8).map(slimNetworkGroup), valuesRedacted: true }; };", "const slimDomGroup = (item) => { const v = objectOrNull(item) || {}; return { count: v.count ?? null, firstAt: v.firstAt ?? null, lastAt: v.lastAt ?? null, text: clip(v.text ?? v.preview, 180) }; };", "const slimNetworkGroup = (item) => { const v = objectOrNull(item) || {}; return { count: v.count ?? null, method: clip(v.method, 12), status: v.status ?? null, path: clip(v.path ?? v.urlPath, 96), firstAt: v.firstAt ?? null, lastAt: v.lastAt ?? null, promptIndexes: Array.isArray(v.promptIndexes) ? v.promptIndexes.slice(0, 6) : [], failureKinds: Array.isArray(v.failureKinds) ? v.failureKinds.slice(0, 4).map((x) => clip(x, 48)) : [] }; };", + "const slimRequestBucket = (item) => { const v = objectOrNull(item) || {}; return { startAt: v.startAt ?? null, endAt: v.endAt ?? null, count: v.count ?? null, requestPerMinute: v.requestPerMinute ?? null, pageKey: clip(v.pageKey, 96), path: clip(v.path, 96), apiKey: clip(v.apiKey, 120), valuesRedacted: true }; };", + "const slimRequestCurve = (item, isApi = false) => { const v = objectOrNull(item) || {}; return { pageKey: clip(v.pageKey, 96), pageRole: clip(v.pageRole, 24), pageId: clip(v.pageId, 32), pageEpoch: v.pageEpoch ?? null, apiKey: clip(v.apiKey, 120), method: clip(v.method, 12), path: clip(v.path, isApi ? 120 : 96), count: v.count ?? null, bucketCount: v.bucketCount ?? null, peakRequestPerMinute: v.peakRequestPerMinute ?? null, peakBucket: slimRequestBucket(v.peakBucket), buckets: takeTail(v.buckets, 12).map(slimRequestBucket), valuesRedacted: true }; };", + "const slimRequestPeak = (item) => { const v = objectOrNull(item) || {}; return { scope: clip(v.scope, 16), thresholdPerMinute: v.thresholdPerMinute ?? null, bucketMs: v.bucketMs ?? null, startAt: v.startAt ?? null, endAt: v.endAt ?? null, count: v.count ?? null, requestPerMinute: v.requestPerMinute ?? null, pageKey: clip(v.pageKey, 96), path: clip(v.path, 96), apiKey: clip(v.apiKey, 120), valuesRedacted: true }; };", + "const slimRequestRateCurve = (value) => { const v = objectOrNull(value); if (!v) return null; return { summary: objectOrNull(v.summary) || v, buckets: takeTail(v.buckets, 12).map(slimRequestBucket), pageCurves: takeHead(v.pageCurves, 8).map((item) => slimRequestCurve(item, false)), apiPathCurves: takeHead(v.apiPathCurves, 12).map((item) => slimRequestCurve(item, true)), peaks: takeHead(v.peaks, 12).map(slimRequestPeak), valuesRedacted: true }; };", "const slimDomSample = (item) => { const v = objectOrNull(item) || {}; return { seq: v.seq ?? null, ts: v.ts ?? null, source: clip(v.source, 32), diagnosticCode: clip(v.diagnosticCode, 48), traceId: clip(v.traceId, 64), httpStatus: v.httpStatus ?? null, idleSeconds: v.idleSeconds ?? null, waitingFor: clip(v.waitingFor, 48), lastEventLabel: clip(v.lastEventLabel, 80), text: clip(v.text ?? v.preview, 180) }; };", "const slimConsoleGroup = (item) => { const v = objectOrNull(item) || {}; return { count: v.count ?? null, type: clip(v.type, 24), status: v.status ?? null, path: clip(v.path ?? v.urlPath, 96), lastAt: v.lastAt ?? v.firstAt ?? null, firstAt: v.firstAt ?? null, traceIds: Array.isArray(v.traceIds) ? v.traceIds.slice(0, 3).map((x) => clip(x, 64)) : [] }; };", "const slimConsoleSample = (item) => { const v = objectOrNull(item) || {}; return { ts: v.ts ?? null, type: clip(v.type, 24), status: v.status ?? null, path: clip(v.path ?? v.urlPath, 96), traceId: clip(v.traceId, 64), text: clip(v.text ?? v.preview, 180) }; };", @@ -2319,6 +2323,10 @@ export function runNodeWebProbeObserveAnalyze(options: NodeWebProbeObserveOption "const srcPromptNetwork = objectOrNull(source?.promptNetwork);", "const promptNetwork = srcPromptNetwork ? { promptSegments: srcPromptNetwork.promptSegments ?? null } : null;", "const projectManagement = slimProjectManagement(source?.projectManagement || fullSource?.projectManagement);", + "const sourceRequestRate = objectOrNull(source?.requestRate);", + "const requestRateCurve = slimRequestRateCurve(source?.requestRateCurve) || slimRequestRateCurve(sourceRequestRate) || slimRequestRateCurve(fullRecentWindow?.requestRate) || slimRequestRateCurve(fullSource?.requestRate);", + "const requestRateSummary = objectOrNull(requestRateCurve?.summary) || objectOrNull(sourceRequestRate?.summary) || sourceRequestRate || null;", + "const requestRatePeaks = takeHead(firstNonEmptyArray(source?.requestRatePeaks, requestRateCurve?.peaks, sourceRequestRate?.peaks, fullRecentWindow?.requestRate?.peaks, fullSource?.requestRate?.peaks), 12).map(slimRequestPeak);", "const runnerErrorsFromJsonl = readJsonlTail(reportJsonPath.replace(/\\/analysis\\/report\\.json$/u, '/errors.jsonl'), 8).filter((item) => item?.type === 'runner-error').map(slimRunnerErrorFromJsonl);", "const compact = source ? {", " ok: analyzerExit === 0,", @@ -2331,6 +2339,9 @@ export function runNodeWebProbeObserveAnalyze(options: NodeWebProbeObserveOption " pagePerformance,", " projectManagement,", " promptNetwork,", + " requestRate: requestRateSummary,", + " requestRateCurve,", + " requestRatePeaks,", " pagePerformanceSlowApi: takeHead(sourceSlowApi, 4).map(slimSlowApi),", " archivePagePerformanceSlowApi: takeHead(archiveSlowApi, 8).map(slimSlowApi),", " pagePerformanceSseStreams: takeHead(sourceSseStreams, 4).map((item) => ({ path: item?.path ?? item?.route ?? null, route: item?.route ?? null, sampleCount: item?.sampleCount ?? null, streamOpenSampleCount: item?.streamOpenSampleCount ?? null, streamOpenP95Ms: item?.streamOpenP95Ms ?? null, streamOpenMaxMs: item?.streamOpenMaxMs ?? null, streamOpenBudgetMs: item?.streamOpenBudgetMs ?? null, streamOpenOverBudgetCount: item?.streamOpenOverBudgetCount ?? null, streamOpenOverFiveSecondCount: item?.streamOpenOverFiveSecondCount ?? null, streamLifetimeOverFiveSecondCount: item?.streamLifetimeOverFiveSecondCount ?? null })),", @@ -2418,6 +2429,9 @@ export function runNodeWebProbeObserveAnalyze(options: NodeWebProbeObserveOption " } : null,", " runtimeAlerts: compact.runtimeAlerts ?? null,", " pagePerformance: compact.pagePerformance ?? null,", + " requestRate: compact.requestRate ?? null,", + " requestRateCurve: compact.requestRateCurve ? { summary: compact.requestRateCurve.summary ?? null, buckets: Array.isArray(compact.requestRateCurve.buckets) ? compact.requestRateCurve.buckets.slice(-6) : [], pageCurves: Array.isArray(compact.requestRateCurve.pageCurves) ? compact.requestRateCurve.pageCurves.slice(0, 4).map((item) => ({ pageKey: item.pageKey ?? null, path: item.path ?? null, count: item.count ?? null, peakRequestPerMinute: item.peakRequestPerMinute ?? null, peakBucket: item.peakBucket ?? null })) : [], apiPathCurves: Array.isArray(compact.requestRateCurve.apiPathCurves) ? compact.requestRateCurve.apiPathCurves.slice(0, 6).map((item) => ({ apiKey: item.apiKey ?? null, path: item.path ?? null, count: item.count ?? null, peakRequestPerMinute: item.peakRequestPerMinute ?? null, peakBucket: item.peakBucket ?? null })) : [], valuesRedacted: true } : null,", + " requestRatePeaks: Array.isArray(compact.requestRatePeaks) ? compact.requestRatePeaks.slice(0, 6) : [],", " projectManagement: compact.projectManagement ?? null,", " promptNetwork: compact.promptNetwork ?? null,", " toolFindings: Array.isArray(compact.toolFindings) ? compact.toolFindings.slice(0, 8) : [],", @@ -2482,6 +2496,9 @@ export function runNodeWebProbeObserveAnalyze(options: NodeWebProbeObserveOption " } : null,", " runtimeAlerts: compact.runtimeAlerts ?? null,", " pagePerformance: compact.pagePerformance ?? null,", + " requestRate: compact.requestRate ?? null,", + " requestRateCurve: compact.requestRateCurve ? { summary: compact.requestRateCurve.summary ?? null, pageCurves: Array.isArray(compact.requestRateCurve.pageCurves) ? compact.requestRateCurve.pageCurves.slice(0, 3).map((item) => ({ pageKey: item.pageKey ?? null, path: item.path ?? null, count: item.count ?? null, peakRequestPerMinute: item.peakRequestPerMinute ?? null })) : [], apiPathCurves: Array.isArray(compact.requestRateCurve.apiPathCurves) ? compact.requestRateCurve.apiPathCurves.slice(0, 4).map((item) => ({ apiKey: item.apiKey ?? null, path: item.path ?? null, count: item.count ?? null, peakRequestPerMinute: item.peakRequestPerMinute ?? null })) : [], valuesRedacted: true } : null,", + " requestRatePeaks: Array.isArray(compact.requestRatePeaks) ? compact.requestRatePeaks.slice(0, 4) : [],", " projectManagement: compact.projectManagement ? { summary: compact.projectManagement.summary ?? compact.projectManagement, samples: Array.isArray(compact.projectManagement.samples) ? compact.projectManagement.samples.slice(-4) : [], commands: Array.isArray(compact.projectManagement.commands) ? compact.projectManagement.commands.slice(-4) : [], launchCommands: Array.isArray(compact.projectManagement.launchCommands) ? compact.projectManagement.launchCommands.slice(-4) : [], projectApiByPath: Array.isArray(compact.projectManagement.projectApiByPath) ? compact.projectManagement.projectApiByPath.slice(0, 4) : [], slowProjectApiPerformance: Array.isArray(compact.projectManagement.slowProjectApiPerformance) ? compact.projectManagement.slowProjectApiPerformance.slice(0, 4) : [], valuesRedacted: true } : null,", " toolFindings: Array.isArray(compact.toolFindings) ? compact.toolFindings.slice(0, 8) : [],", " commandState: compact.commandState ?? null,", @@ -2523,6 +2540,8 @@ export function runNodeWebProbeObserveAnalyze(options: NodeWebProbeObserveOption " } : null,", " runtimeAlerts: compact.runtimeAlerts ?? null,", " pagePerformance: compact.pagePerformance ?? null,", + " requestRate: compact.requestRate ?? null,", + " requestRatePeaks: Array.isArray(compact.requestRatePeaks) ? compact.requestRatePeaks.slice(0, 3) : [],", " projectManagement: compact.projectManagement ? { summary: compact.projectManagement.summary ?? compact.projectManagement, samples: Array.isArray(compact.projectManagement.samples) ? compact.projectManagement.samples.slice(-3) : [], commands: Array.isArray(compact.projectManagement.commands) ? compact.projectManagement.commands.slice(-3) : [], launchCommands: Array.isArray(compact.projectManagement.launchCommands) ? compact.projectManagement.launchCommands.slice(-3) : [], projectApiByPath: Array.isArray(compact.projectManagement.projectApiByPath) ? compact.projectManagement.projectApiByPath.slice(0, 3) : [], slowProjectApiPerformance: Array.isArray(compact.projectManagement.slowProjectApiPerformance) ? compact.projectManagement.slowProjectApiPerformance.slice(0, 2) : [], valuesRedacted: true } : null,", " toolFindings: Array.isArray(compact.toolFindings) ? compact.toolFindings.slice(0, 8) : [],", " commandState: compact.commandState ?? null,", @@ -2547,7 +2566,7 @@ export function runNodeWebProbeObserveAnalyze(options: NodeWebProbeObserveOption " output = compactOutput(ultratiny);", " }", " if (Buffer.byteLength(output, 'utf8') > compactStdoutLimitBytes) {", - " output = compactOutput({ ok: compact.ok, counts: compact.counts ?? null, jsonlScope: compact.jsonlScope ?? null, analysisWindow: compact.analysisWindow ?? null, archiveSummary: compact.archiveSummary ? { redFindingCount: compact.archiveSummary.redFindingCount ?? null, findingCount: compact.archiveSummary.findingCount ?? null, sampleCount: compact.archiveSummary.sampleMetrics?.sampleCount ?? null, slowPathCount: compact.archiveSummary.pagePerformance?.slowPathCount ?? null } : null, projectManagement: compact.projectManagement ? { summary: compact.projectManagement.summary ?? compact.projectManagement, samples: Array.isArray(compact.projectManagement.samples) ? compact.projectManagement.samples.slice(-2) : [], commands: Array.isArray(compact.projectManagement.commands) ? compact.projectManagement.commands.slice(-2) : [], launchCommands: Array.isArray(compact.projectManagement.launchCommands) ? compact.projectManagement.launchCommands.slice(-2) : [], projectApiByPath: Array.isArray(compact.projectManagement.projectApiByPath) ? compact.projectManagement.projectApiByPath.slice(0, 2) : [], valuesRedacted: true } : null, toolFindings: Array.isArray(compact.toolFindings) ? compact.toolFindings.slice(0, 8) : [], commandState: compact.commandState ? { pendingCount: compact.commandState.pendingCount ?? null, processingCount: compact.commandState.processingCount ?? null, abandonedCount: compact.commandState.abandonedCount ?? null, failedCount: compact.commandState.failedCount ?? null } : null, findings: Array.isArray(compact.findings) ? compact.findings.slice(0, 4).map((item) => ({ kind: item.kind ?? item.code ?? null, severity: item.severity ?? item.level ?? null, count: item.count ?? item.sampleCount ?? null, timingSourceOfTruth: item.timingSourceOfTruth ?? null, timingStatus: item.timingStatus ?? null, timingAlert: item.timingAlert === true, summary: clip(item.summary ?? item.message, 120) })) : [], archiveRedFindings: Array.isArray(compact.archiveRedFindings) ? compact.archiveRedFindings.slice(0, 4).map((item) => ({ kind: item.kind ?? item.code ?? null, severity: item.severity ?? item.level ?? null, count: item.count ?? item.sampleCount ?? null, timingSourceOfTruth: item.timingSourceOfTruth ?? null, timingStatus: item.timingStatus ?? null, timingAlert: item.timingAlert === true, summary: clip(item.summary ?? item.message, 120) })) : [], commandFailures: Array.isArray(compact.commandFailures) ? compact.commandFailures.slice(-3).map((item) => ({ ts: item.ts ?? null, type: item.type ?? null, commandId: item.commandId ?? null, durationMs: item.durationMs ?? null, sampleSeq: item.sampleSeq ?? null, beforePath: item.beforePath ?? null, afterPath: item.afterPath ?? null, message: clip(item.message ?? item.failureKind ?? item.name, 120) })) : [], archivePagePerformanceSlowApi: Array.isArray(compact.archivePagePerformanceSlowApi) ? compact.archivePagePerformanceSlowApi.slice(0, 4).map((item) => ({ path: item.path ?? item.route ?? null, sampleCount: item.sampleCount ?? null, p95Ms: item.p95Ms ?? null, maxMs: item.maxMs ?? null, overFiveSecondCount: item.overFiveSecondCount ?? null })) : [], reportJsonPath: compact.reportJsonPath ?? reportJsonPath, reportJsonSha256: compact.reportJsonSha256 ?? sha256(reportJsonPath), reportMdPath: compact.reportMdPath ?? reportMdPath, reportMdSha256: compact.reportMdSha256 ?? sha256(reportMdPath), analyzer: { ...(compact.analyzer ?? {}), compactStdoutLimited: true, ultratiny: true, hardFallback: true, compactStdoutLimitBytes }, valuesRedacted: true });", + " output = compactOutput({ ok: compact.ok, counts: compact.counts ?? null, jsonlScope: compact.jsonlScope ?? null, analysisWindow: compact.analysisWindow ?? null, archiveSummary: compact.archiveSummary ? { redFindingCount: compact.archiveSummary.redFindingCount ?? null, findingCount: compact.archiveSummary.findingCount ?? null, sampleCount: compact.archiveSummary.sampleMetrics?.sampleCount ?? null, slowPathCount: compact.archiveSummary.pagePerformance?.slowPathCount ?? null } : null, requestRate: compact.requestRate ?? null, requestRateCurve: compact.requestRateCurve ? { summary: compact.requestRateCurve.summary ?? null, pageCurves: Array.isArray(compact.requestRateCurve.pageCurves) ? compact.requestRateCurve.pageCurves.slice(0, 2).map((item) => ({ path: item.path ?? item.pageKey ?? null, count: item.count ?? null, peakRequestPerMinute: item.peakRequestPerMinute ?? null, peakBucket: item.peakBucket ?? null })) : [], apiPathCurves: Array.isArray(compact.requestRateCurve.apiPathCurves) ? compact.requestRateCurve.apiPathCurves.slice(0, 3).map((item) => ({ apiKey: item.apiKey ?? item.path ?? null, count: item.count ?? null, peakRequestPerMinute: item.peakRequestPerMinute ?? null, peakBucket: item.peakBucket ?? null })) : [], valuesRedacted: true } : null, requestRatePeaks: Array.isArray(compact.requestRatePeaks) ? compact.requestRatePeaks.slice(0, 3) : [], projectManagement: compact.projectManagement ? { summary: compact.projectManagement.summary ?? compact.projectManagement, samples: Array.isArray(compact.projectManagement.samples) ? compact.projectManagement.samples.slice(-2) : [], commands: Array.isArray(compact.projectManagement.commands) ? compact.projectManagement.commands.slice(-2) : [], launchCommands: Array.isArray(compact.projectManagement.launchCommands) ? compact.projectManagement.launchCommands.slice(-2) : [], projectApiByPath: Array.isArray(compact.projectManagement.projectApiByPath) ? compact.projectManagement.projectApiByPath.slice(0, 2) : [], valuesRedacted: true } : null, toolFindings: Array.isArray(compact.toolFindings) ? compact.toolFindings.slice(0, 8) : [], commandState: compact.commandState ? { pendingCount: compact.commandState.pendingCount ?? null, processingCount: compact.commandState.processingCount ?? null, abandonedCount: compact.commandState.abandonedCount ?? null, failedCount: compact.commandState.failedCount ?? null } : null, findings: Array.isArray(compact.findings) ? compact.findings.slice(0, 4).map((item) => ({ kind: item.kind ?? item.code ?? null, severity: item.severity ?? item.level ?? null, count: item.count ?? item.sampleCount ?? null, timingSourceOfTruth: item.timingSourceOfTruth ?? null, timingStatus: item.timingStatus ?? null, timingAlert: item.timingAlert === true, summary: clip(item.summary ?? item.message, 120) })) : [], archiveRedFindings: Array.isArray(compact.archiveRedFindings) ? compact.archiveRedFindings.slice(0, 4).map((item) => ({ kind: item.kind ?? item.code ?? null, severity: item.severity ?? item.level ?? null, count: item.count ?? item.sampleCount ?? null, timingSourceOfTruth: item.timingSourceOfTruth ?? null, timingStatus: item.timingStatus ?? null, timingAlert: item.timingAlert === true, summary: clip(item.summary ?? item.message, 120) })) : [], commandFailures: Array.isArray(compact.commandFailures) ? compact.commandFailures.slice(-3).map((item) => ({ ts: item.ts ?? null, type: item.type ?? null, commandId: item.commandId ?? null, durationMs: item.durationMs ?? null, sampleSeq: item.sampleSeq ?? null, beforePath: item.beforePath ?? null, afterPath: item.afterPath ?? null, message: clip(item.message ?? item.failureKind ?? item.name, 120) })) : [], archivePagePerformanceSlowApi: Array.isArray(compact.archivePagePerformanceSlowApi) ? compact.archivePagePerformanceSlowApi.slice(0, 4).map((item) => ({ path: item.path ?? item.route ?? null, sampleCount: item.sampleCount ?? null, p95Ms: item.p95Ms ?? null, maxMs: item.maxMs ?? null, overFiveSecondCount: item.overFiveSecondCount ?? null })) : [], reportJsonPath: compact.reportJsonPath ?? reportJsonPath, reportJsonSha256: compact.reportJsonSha256 ?? sha256(reportJsonPath), reportMdPath: compact.reportMdPath ?? reportMdPath, reportMdSha256: compact.reportMdSha256 ?? sha256(reportMdPath), analyzer: { ...(compact.analyzer ?? {}), compactStdoutLimited: true, ultratiny: true, hardFallback: true, compactStdoutLimitBytes }, valuesRedacted: true });", " }", " }", "}", @@ -2654,6 +2673,8 @@ function compactWebObserveAnalyzePayloadForRaw(payload: Record, function compactWebObserveAnalyzeAnalysisForRaw(analysis: Record): Record { const counts = recordValue(analysis.counts); const archiveSummary = recordValue(analysis.archiveSummary); + const requestRate = recordValue(analysis.requestRate); + const requestRateCurve = recordValue(analysis.requestRateCurve); const findings = arrayRecordsValue(analysis.findings).slice(0, 16).map(compactWebObserveAnalyzeFindingForRaw); const archiveRedFindings = arrayRecordsValue(analysis.archiveRedFindings ?? archiveSummary.redFindings).slice(0, 16).map(compactWebObserveAnalyzeFindingForRaw); return { @@ -2671,6 +2692,9 @@ function compactWebObserveAnalyzeAnalysisForRaw(analysis: Record 0 ? requestRate : recordValue(requestRateCurve.summary), + requestRateCurve: compactWebObserveAnalyzeRequestRateCurveForRaw(requestRateCurve), + requestRatePeaks: arrayRecordsValue(analysis.requestRatePeaks ?? requestRateCurve.peaks ?? requestRate.peaks).slice(0, 8), pagePerformanceSlowApi: arrayRecordsValue(analysis.pagePerformanceSlowApi ?? analysis.archivePagePerformanceSlowApi).slice(0, 8).map((item) => ({ path: stringOrNullValue(item.path ?? item.route), route: stringOrNullValue(item.route ?? item.path), @@ -2693,6 +2717,58 @@ function compactWebObserveAnalyzeAnalysisForRaw(analysis: Record): Record | null { + const summary = recordValue(value.summary); + const hasCurve = Object.keys(summary).length > 0 + || arrayRecordsValue(value.buckets).length > 0 + || arrayRecordsValue(value.pageCurves).length > 0 + || arrayRecordsValue(value.apiPathCurves).length > 0 + || arrayRecordsValue(value.peaks).length > 0; + if (!hasCurve) return null; + return { + buckets: arrayRecordsValue(value.buckets).slice(-4).map(compactWebObserveAnalyzeRequestRateBucketForRaw), + pageCurves: arrayRecordsValue(value.pageCurves).slice(0, 8).map(compactWebObserveAnalyzeRequestRateSeriesForRaw), + apiPathCurves: arrayRecordsValue(value.apiPathCurves).slice(0, 12).map(compactWebObserveAnalyzeRequestRateSeriesForRaw), + peaks: arrayRecordsValue(value.peaks).slice(0, 8), + valuesRedacted: true, + }; +} + +function compactWebObserveAnalyzeRequestRateBucketForRaw(value: Record): Record { + return { + startAt: stringOrNullValue(value.startAt), + endAt: stringOrNullValue(value.endAt), + count: numberOrNullValue(value.count), + requestPerMinute: numberOrNullValue(value.requestPerMinute), + valuesRedacted: true, + }; +} + +function compactWebObserveAnalyzeRequestRateSeriesForRaw(value: Record): Record { + const out: Record = { + count: numberOrNullValue(value.count), + bucketCount: numberOrNullValue(value.bucketCount), + peakRequestPerMinute: numberOrNullValue(value.peakRequestPerMinute), + peakBucket: compactWebObserveAnalyzeRequestRateBucketForRaw(recordValue(value.peakBucket)), + valuesRedacted: true, + }; + const pageKey = stringOrNullValue(value.pageKey); + const apiKey = stringOrNullValue(value.apiKey); + const path = stringOrNullValue(value.path); + const method = stringOrNullValue(value.method); + const pageRole = stringOrNullValue(value.pageRole); + const pageId = stringOrNullValue(value.pageId); + const pageEpoch = numberOrNullValue(value.pageEpoch); + if (pageKey) out.pageKey = pageKey; + if (pageRole) out.pageRole = pageRole; + if (pageId) out.pageId = pageId; + if (pageEpoch !== null) out.pageEpoch = pageEpoch; + if (apiKey) out.apiKey = apiKey; + if (method) out.method = method; + if (path) out.path = path; + return out; +} + function compactWebObserveAnalyzeFailureForRaw(value: unknown): Record | null { const failure = recordValue(value); if (Object.keys(failure).length === 0) return null; @@ -2818,6 +2894,10 @@ export function recoverWebObserveAnalyzeFromArtifacts(options: NodeWebProbeObser "const slimGroup = (item) => { const v = objectOrNull(item) || {}; return { count: v.count ?? null, method: clip(v.method, 12), status: v.status ?? null, path: clip(v.path ?? v.urlPath ?? v.route, 96), firstAt: v.firstAt ?? null, lastAt: v.lastAt ?? null, text: clip(v.text ?? v.preview, 180), failureKinds: arr(v.failureKinds).slice(0, 4).map((x) => clip(x, 48)), traceIds: arr(v.traceIds).slice(0, 3).map((x) => clip(x, 64)) }; };", "const slimSlowSample = (item) => { const v = objectOrNull(item) || {}; return { ts: v.ts ?? null, seq: v.seq ?? null, path: clip(v.path ?? v.rawPath, 96), initiatorType: clip(v.initiatorType, 24), durationMs: v.durationMs ?? null, requestToResponseStartMs: v.requestToResponseStartMs ?? v.streamOpenMs ?? null, responseTransferMs: v.responseTransferMs ?? null, timingStatus: clip(v.timingStatus, 16), nextHopProtocol: clip(v.nextHopProtocol, 24), serverTimingNames: arr(v.serverTimingNames).slice(0, 4).map((x) => clip(x, 32)), otelTraceId: clip(v.otelTraceId, 32) }; };", "const slimSlowApi = (item) => { const v = objectOrNull(item) || {}; return { path: clip(v.path ?? v.route, 96), route: clip(v.route ?? v.path, 96), sampleCount: v.sampleCount ?? null, p95Ms: v.p95Ms ?? v.p95 ?? null, maxMs: v.maxMs ?? v.max ?? null, budgetMs: v.budgetMs ?? null, overBudgetCount: v.overBudgetCount ?? null, overFiveSecondCount: v.overFiveSecondCount ?? null, slowSamples: arr(v.slowSamples).slice(0, 3).map(slimSlowSample) }; };", + "const slimRequestBucket = (item) => { const v = objectOrNull(item) || {}; return { startAt: v.startAt ?? null, endAt: v.endAt ?? null, count: v.count ?? null, requestPerMinute: v.requestPerMinute ?? null, pageKey: clip(v.pageKey, 96), path: clip(v.path, 96), apiKey: clip(v.apiKey, 120), valuesRedacted: true }; };", + "const slimRequestCurve = (item, isApi = false) => { const v = objectOrNull(item) || {}; return { pageKey: clip(v.pageKey, 96), pageRole: clip(v.pageRole, 24), pageId: clip(v.pageId, 32), pageEpoch: v.pageEpoch ?? null, apiKey: clip(v.apiKey, 120), method: clip(v.method, 12), path: clip(v.path, isApi ? 120 : 96), count: v.count ?? null, bucketCount: v.bucketCount ?? null, peakRequestPerMinute: v.peakRequestPerMinute ?? null, peakBucket: slimRequestBucket(v.peakBucket), buckets: arr(v.buckets).slice(-12).map(slimRequestBucket), valuesRedacted: true }; };", + "const slimRequestPeak = (item) => { const v = objectOrNull(item) || {}; return { scope: clip(v.scope, 16), thresholdPerMinute: v.thresholdPerMinute ?? null, bucketMs: v.bucketMs ?? null, startAt: v.startAt ?? null, endAt: v.endAt ?? null, count: v.count ?? null, requestPerMinute: v.requestPerMinute ?? null, pageKey: clip(v.pageKey, 96), path: clip(v.path, 96), apiKey: clip(v.apiKey, 120), valuesRedacted: true }; };", + "const slimRequestRateCurve = (value) => { const v = objectOrNull(value); if (!v) return null; return { summary: objectOrNull(v.summary) || v, buckets: arr(v.buckets).slice(-12).map(slimRequestBucket), pageCurves: arr(v.pageCurves).slice(0, 8).map((item) => slimRequestCurve(item, false)), apiPathCurves: arr(v.apiPathCurves).slice(0, 12).map((item) => slimRequestCurve(item, true)), peaks: arr(v.peaks).slice(0, 12).map(slimRequestPeak), valuesRedacted: true }; };", "const compactLoading = (value) => { const v = objectOrNull(value); if (!v) return null; const s = objectOrNull(v.summary) || {}; return { summary: s, longestSegments: arr(v.longestSegments ?? v.segments).slice(0, 8), owners: arr(v.owners).slice(0, 8), timeline: arr(v.timeline).slice(-12) }; };", "const compactSessionRailTitles = (value) => { const v = objectOrNull(value); if (!v) return null; return { summary: objectOrNull(v.summary) || {}, samples: arr(v.samples).slice(0, 8), examples: arr(v.examples).slice(0, 8) }; };", "const compactTraceOrder = (value) => { const v = objectOrNull(value); if (!v) return null; const s = objectOrNull(v.summary) || {}; return { summary: { sampleCount: v.sampleCount ?? s.sampleCount ?? null, traceRowCount: v.traceRowCount ?? s.traceRowCount ?? null, orderAnomalyCount: v.orderAnomalyCount ?? s.orderAnomalyCount ?? arr(v.orderAnomalies).length, completionNotLastCount: v.completionNotLastCount ?? s.completionNotLastCount ?? arr(v.completionNotLast).length }, orderAnomalies: arr(v.orderAnomalies).slice(0, 8) }; };", @@ -2832,10 +2912,13 @@ export function recoverWebObserveAnalyzeFromArtifacts(options: NodeWebProbeObser "const runtimeAlerts = objectOrNull(source.runtimeAlerts) || objectOrNull(recent.runtimeAlerts) || {};", "const browserProcess = objectOrNull(source.browserProcess) || objectOrNull(recent.browserProcess) || {};", "const promptNetwork = objectOrNull(source.promptNetwork) || objectOrNull(recent.promptNetwork) || {};", + "const requestRateCurve = slimRequestRateCurve(source.requestRateCurve) || slimRequestRateCurve(source.requestRate) || slimRequestRateCurve(recent.requestRate);", + "const requestRateSummary = objectOrNull(requestRateCurve?.summary) || objectOrNull(source.requestRate?.summary) || objectOrNull(source.requestRate) || null;", + "const requestRatePeaks = arr(source.requestRatePeaks ?? requestRateCurve?.peaks ?? source.requestRate?.peaks ?? recent.requestRate?.peaks).slice(0, 12).map(slimRequestPeak);", "const archiveSummary = objectOrNull(source.archiveSummary) || {};", "const archiveSampleMetrics = objectOrNull(archiveSummary.sampleMetrics) || objectOrNull(source.sampleMetrics?.summary) || objectOrNull(srcMetrics.summary) || {};", "const slowApis = arr(source.pagePerformanceSlowApi).length > 0 ? arr(source.pagePerformanceSlowApi) : arr(pagePerformance.sameOriginApiByPath).filter((item) => Number(item?.overBudgetCount ?? item?.overFiveSecondCount ?? 0) > 0);", - "const compact = { ok: source.ok === true, command: source.command ?? 'web-probe-observe analyze', stateDir: source.stateDir ?? stateDir, jsonlScope: source.jsonlScope ?? null, alertThresholds: source.alertThresholds ?? null, counts: source.counts ?? null, archiveSummary: { ...archiveSummary, sampleMetrics: archiveSampleMetrics, pagePerformance: objectOrNull(archiveSummary.pagePerformance) || objectOrNull(pagePerformance.summary) || {}, runtimeAlerts: objectOrNull(archiveSummary.runtimeAlerts) || objectOrNull(runtimeAlerts.summary) || {}, browserProcess: objectOrNull(archiveSummary.browserProcess) || objectOrNull(browserProcess.summary) || {}, redFindings: arr(archiveSummary.redFindings).slice(0, 12).map(slimFinding) }, analysisWindow: source.analysisWindow ?? objectOrNull(recent.summary), sampleMetrics: compactMetrics(srcMetrics), pageProvenance: objectOrNull(source.pageProvenance?.summary) || source.pageProvenance ?? null, pagePerformance: objectOrNull(pagePerformance.summary) || pagePerformance, projectManagement: objectOrNull(source.projectManagement) || null, promptNetwork: objectOrNull(promptNetwork.summary) || promptNetwork, runtimeAlerts: objectOrNull(runtimeAlerts.summary) || runtimeAlerts, browserProcess: objectOrNull(browserProcess.summary) || browserProcess, runnerErrors: arr(source.runnerErrors).slice(-8), commandFailures: arr(source.commandFailures).slice(-8), commandState: objectOrNull(source.commandState) || null, toolFindings: arr(source.toolFindings).slice(0, 8).map(slimFinding), httpErrorGroups: arr(source.httpErrorGroups ?? runtimeAlerts.networkHttpErrorsByPath).slice(0, 8).map(slimGroup), requestFailedGroups: arr(source.requestFailedGroups ?? runtimeAlerts.networkRequestFailedByPath).slice(0, 8).map(slimGroup), domDiagnosticGroups: arr(source.domDiagnosticGroups ?? runtimeAlerts.domDiagnosticsByText).slice(0, 5).map(slimGroup), domDiagnosticSamples: arr(source.domDiagnosticSamples ?? runtimeAlerts.domDiagnostics).slice(0, 8).map(slimGroup), consoleAlertGroups: arr(source.consoleAlertGroups ?? runtimeAlerts.consoleAlertsByPath).slice(0, 8).map(slimGroup), consoleAlertSamples: arr(source.consoleAlertSamples ?? runtimeAlerts.consoleAlerts).slice(0, 8).map(slimGroup), turnTimingRecentUpdateJumps: arr(source.turnTimingRecentUpdateJumps ?? srcMetrics.turnTimingRecentUpdateSawtoothJumps).slice(0, 8), turnTimingElapsedZeroResets: arr(source.turnTimingElapsedZeroResets ?? srcMetrics.turnTimingElapsedZeroResets).slice(0, 8), turnTimingTotalElapsedForwardJumps: arr(source.turnTimingTotalElapsedForwardJumps ?? srcMetrics.turnTimingTotalElapsedForwardJumps).slice(0, 8), pagePerformanceSlowApi: slowApis.slice(0, 8).map(slimSlowApi), archivePagePerformanceSlowApi: arr(source.archivePagePerformanceSlowApi).slice(0, 8).map(slimSlowApi), pagePerformancePartialApi: arr(source.pagePerformancePartialApi).slice(0, 8), pagePerformanceSseStreams: arr(source.pagePerformanceSseStreams).slice(0, 8), findings: arr(source.findings).slice(0, 12).map(slimFinding), archiveRedFindings: arr(source.archiveRedFindings ?? archiveSummary.redFindings).slice(0, 12).map(slimFinding), reportJsonPath: source.reportJsonPath ?? reportJsonPath, reportJsonSha256: source.reportJsonSha256 ?? sha256(reportJsonPath), reportMdPath: source.reportMdPath ?? reportMdPath, reportMdSha256: source.reportMdSha256 ?? sha256(reportMdPath), analyzer: { ...(objectOrNull(source.analyzer) || {}), recoveredFrom: 'analysis-artifact-after-transport-timeout', stdoutPath, stderrPath, stdoutBytes: statSize(stdoutPath), stderrBytes: statSize(stderrPath), reportJsonBytes: statSize(reportJsonPath), reportMdBytes: statSize(reportMdPath), transportExitCode: Number(transportExitRaw), transportTimedOut: transportTimedOutRaw === 'true', valuesRedacted: true }, valuesRedacted: true };", + "const compact = { ok: source.ok === true, command: source.command ?? 'web-probe-observe analyze', stateDir: source.stateDir ?? stateDir, jsonlScope: source.jsonlScope ?? null, alertThresholds: source.alertThresholds ?? null, counts: source.counts ?? null, archiveSummary: { ...archiveSummary, sampleMetrics: archiveSampleMetrics, pagePerformance: objectOrNull(archiveSummary.pagePerformance) || objectOrNull(pagePerformance.summary) || {}, runtimeAlerts: objectOrNull(archiveSummary.runtimeAlerts) || objectOrNull(runtimeAlerts.summary) || {}, browserProcess: objectOrNull(archiveSummary.browserProcess) || objectOrNull(browserProcess.summary) || {}, requestRate: objectOrNull(archiveSummary.requestRate) || requestRateSummary || {}, redFindings: arr(archiveSummary.redFindings).slice(0, 12).map(slimFinding) }, analysisWindow: source.analysisWindow ?? objectOrNull(recent.summary), sampleMetrics: compactMetrics(srcMetrics), pageProvenance: objectOrNull(source.pageProvenance?.summary) || source.pageProvenance ?? null, pagePerformance: objectOrNull(pagePerformance.summary) || pagePerformance, projectManagement: objectOrNull(source.projectManagement) || null, promptNetwork: objectOrNull(promptNetwork.summary) || promptNetwork, requestRate: requestRateSummary, requestRateCurve, requestRatePeaks, runtimeAlerts: objectOrNull(runtimeAlerts.summary) || runtimeAlerts, browserProcess: objectOrNull(browserProcess.summary) || browserProcess, runnerErrors: arr(source.runnerErrors).slice(-8), commandFailures: arr(source.commandFailures).slice(-8), commandState: objectOrNull(source.commandState) || null, toolFindings: arr(source.toolFindings).slice(0, 8).map(slimFinding), httpErrorGroups: arr(source.httpErrorGroups ?? runtimeAlerts.networkHttpErrorsByPath).slice(0, 8).map(slimGroup), requestFailedGroups: arr(source.requestFailedGroups ?? runtimeAlerts.networkRequestFailedByPath).slice(0, 8).map(slimGroup), domDiagnosticGroups: arr(source.domDiagnosticGroups ?? runtimeAlerts.domDiagnosticsByText).slice(0, 5).map(slimGroup), domDiagnosticSamples: arr(source.domDiagnosticSamples ?? runtimeAlerts.domDiagnostics).slice(0, 8).map(slimGroup), consoleAlertGroups: arr(source.consoleAlertGroups ?? runtimeAlerts.consoleAlertsByPath).slice(0, 8).map(slimGroup), consoleAlertSamples: arr(source.consoleAlertSamples ?? runtimeAlerts.consoleAlerts).slice(0, 8).map(slimGroup), turnTimingRecentUpdateJumps: arr(source.turnTimingRecentUpdateJumps ?? srcMetrics.turnTimingRecentUpdateSawtoothJumps).slice(0, 8), turnTimingElapsedZeroResets: arr(source.turnTimingElapsedZeroResets ?? srcMetrics.turnTimingElapsedZeroResets).slice(0, 8), turnTimingTotalElapsedForwardJumps: arr(source.turnTimingTotalElapsedForwardJumps ?? srcMetrics.turnTimingTotalElapsedForwardJumps).slice(0, 8), pagePerformanceSlowApi: slowApis.slice(0, 8).map(slimSlowApi), archivePagePerformanceSlowApi: arr(source.archivePagePerformanceSlowApi).slice(0, 8).map(slimSlowApi), pagePerformancePartialApi: arr(source.pagePerformancePartialApi).slice(0, 8), pagePerformanceSseStreams: arr(source.pagePerformanceSseStreams).slice(0, 8), findings: arr(source.findings).slice(0, 12).map(slimFinding), archiveRedFindings: arr(source.archiveRedFindings ?? archiveSummary.redFindings).slice(0, 12).map(slimFinding), reportJsonPath: source.reportJsonPath ?? reportJsonPath, reportJsonSha256: source.reportJsonSha256 ?? sha256(reportJsonPath), reportMdPath: source.reportMdPath ?? reportMdPath, reportMdSha256: source.reportMdSha256 ?? sha256(reportMdPath), analyzer: { ...(objectOrNull(source.analyzer) || {}), recoveredFrom: 'analysis-artifact-after-transport-timeout', stdoutPath, stderrPath, stdoutBytes: statSize(stdoutPath), stderrBytes: statSize(stderrPath), reportJsonBytes: statSize(reportJsonPath), reportMdBytes: statSize(reportMdPath), transportExitCode: Number(transportExitRaw), transportTimedOut: transportTimedOutRaw === 'true', valuesRedacted: true }, valuesRedacted: true };", "console.log(JSON.stringify(compact));", "UNIDESK_WEB_OBSERVE_RECOVER_ANALYZE_ARTIFACT", ].join("\n");