From 0817e3d22a1e9d4e44956ba728921d82a5549892 Mon Sep 17 00:00:00 2001 From: Codex Date: Tue, 23 Jun 2026 04:31:37 +0000 Subject: [PATCH] fix: make web-probe observe thresholds yaml-driven --- config/hwlab-node-lanes.yaml | 26 ++ scripts/src/hwlab-node-impl.ts | 43 ++- scripts/src/hwlab-node-lanes.ts | 56 +++ .../hwlab-node-web-observe-runner-source.ts | 335 ++++++++++++++++-- 4 files changed, 431 insertions(+), 29 deletions(-) diff --git a/config/hwlab-node-lanes.yaml b/config/hwlab-node-lanes.yaml index 2bb9b970..eebcc4c4 100644 --- a/config/hwlab-node-lanes.yaml +++ b/config/hwlab-node-lanes.yaml @@ -104,6 +104,19 @@ lanes: - hwlab-agent-skills observability: prometheusOperator: true + webProbe: + observe: + analysisThresholds: + sameOriginApiBudgetMs: 5000 + longLivedStreamOpenBudgetMs: 5000 + longLivedStreamLifetimeBudgetMs: 5000 + recentUpdateSawtoothMinAllowedIncreaseSeconds: 3 + recentUpdateSawtoothSampleSlackSeconds: 2 + uncommandedStateChangeCommandWindowMs: 10000 + scrollJumpCommandWindowMs: 8000 + scrollJumpFromY: 250 + scrollJumpToY: 40 + loadingVisibleBudgetMs: 10000 public: webUrl: http://74.48.78.17:20666 apiUrl: http://74.48.78.17:20667 @@ -321,6 +334,19 @@ lanes: thresholdSeconds: 10 minSamples: 1 for: 10m + webProbe: + observe: + analysisThresholds: + sameOriginApiBudgetMs: 5000 + longLivedStreamOpenBudgetMs: 5000 + longLivedStreamLifetimeBudgetMs: 5000 + recentUpdateSawtoothMinAllowedIncreaseSeconds: 3 + recentUpdateSawtoothSampleSlackSeconds: 2 + uncommandedStateChangeCommandWindowMs: 10000 + scrollJumpCommandWindowMs: 8000 + scrollJumpFromY: 250 + scrollJumpToY: 40 + loadingVisibleBudgetMs: 10000 runtimeImageRewrites: - source: fatedier/frpc:v0.68.1 target: 127.0.0.1:5000/hwlab/frpc:v0.68.1 diff --git a/scripts/src/hwlab-node-impl.ts b/scripts/src/hwlab-node-impl.ts index 87d70e65..19007a54 100644 --- a/scripts/src/hwlab-node-impl.ts +++ b/scripts/src/hwlab-node-impl.ts @@ -6730,6 +6730,21 @@ function runNodeWebProbeObserveCollect(options: NodeWebProbeObserveOptions, spec } function runNodeWebProbeObserveAnalyze(options: NodeWebProbeObserveOptions, spec: HwlabRuntimeLaneSpec): Record { + const analysisThresholds = spec.observability.webProbe?.observe?.analysisThresholds ?? null; + if (analysisThresholds === null) { + return { + ok: false, + status: "blocked", + command: webObserveCommandLabel("analyze", options), + id: webObserveIdFromOptions(options), + node: options.node, + lane: options.lane, + workspace: spec.workspace, + degradedReason: "web-probe-analysis-thresholds-not-declared", + configPath: "observability.webProbe.observe.analysisThresholds", + valuesRedacted: true, + }; + } const analyzerB64 = Buffer.from(nodeWebObserveAnalyzerSource(), "utf8").toString("base64"); const script = [ "set -eu", @@ -6737,7 +6752,7 @@ function runNodeWebProbeObserveAnalyze(options: NodeWebProbeObserveOptions, spec "analyzer=\"$state_dir/observer-analyzer.mjs\"", `node -e "require('fs').writeFileSync(process.argv[1], Buffer.from(process.argv[2], 'base64'))" "$analyzer" ${shellQuote(analyzerB64)}`, "chmod 700 \"$analyzer\"", - "UNIDESK_WEB_OBSERVE_STATE_DIR=\"$state_dir\" node \"$analyzer\"", + `UNIDESK_WEB_OBSERVE_ANALYSIS_THRESHOLDS_JSON=${shellQuote(JSON.stringify(analysisThresholds))} UNIDESK_WEB_OBSERVE_STATE_DIR="$state_dir" node "$analyzer"`, ].join("\n"); const result = runTransWorkspaceStdinScript(options.node, spec.workspace, script, options.commandTimeoutSeconds); const analysis = parseJsonObject(result.stdout); @@ -7113,12 +7128,15 @@ function renderWebObserveAnalyzeResult(result: Record): Record< const sampleMetrics = record(analysis.sampleMetrics); const runtimeAlerts = record(analysis.runtimeAlerts); const pagePerformance = record(analysis.pagePerformance); + const loading = record(analysis.loading); + const thresholds = record(analysis.analysisThresholds); const rounds = webObserveArray(sampleMetrics.rounds).slice(-10).map((item) => record(item)); const turnColumns = webObserveArray(sampleMetrics.turnColumns).slice(-12).map((item) => record(item)); const domDiagnosticGroups = webObserveArray(runtimeAlerts.domDiagnosticsByFingerprint).slice(0, 10).map((item) => record(item)); const domDiagnostics = webObserveArray(runtimeAlerts.domDiagnostics).slice(-12).map((item) => record(item)); const jsonlReadIssues = webObserveArray(analysis.jsonlReadIssues).slice(0, 8).map((item) => record(item)); const slowApis = webObserveArray(analysis.pagePerformanceSlowApi).slice(0, 8).map((item) => record(item)); + const loadingSegments = webObserveArray(loading.overBudgetSegments).slice(0, 8).map((item) => record(item)); const findings = webObserveArray(analysis.findings).slice(0, 8).map((item) => record(item)); const id = result.id ?? "-"; const renderedText = [ @@ -7225,6 +7243,7 @@ function renderWebObserveAnalyzeResult(result: Record): Record< webObserveTable( ["PERF", "VALUE"], [ + ["sameOriginApiBudgetMs", thresholds.sameOriginApiBudgetMs], ["sameOriginApiPaths", pagePerformance.sameOriginApiPathCount], ["slowPathCount", pagePerformance.slowPathCount], ["slowSampleCount", pagePerformance.slowSampleCount], @@ -7235,8 +7254,26 @@ function renderWebObserveAnalyzeResult(result: Record): Record< slowApis.length === 0 ? "SLOW_API\n-" : webObserveTable( - ["SLOW_API", "P50", "P75", "P95", ">5S", "COUNT"], - slowApis.map((item) => [item.path, item.p50Ms, item.p75Ms, item.p95Ms, item.overFiveSecondCount, item.sampleCount]), + ["SLOW_API", "P50", "P75", "P95", "OVER_BUDGET", "COUNT"], + slowApis.map((item) => [item.path, item.p50Ms, item.p75Ms, item.p95Ms, item.overBudgetCount ?? item.overFiveSecondCount, item.sampleCount]), + ), + "", + webObserveTable( + ["LOADING", "VALUE"], + [ + ["visibleBudgetMs", thresholds.loadingVisibleBudgetMs], + ["samplesWithLoading", loading.samplesWithLoading], + ["maxVisibleCount", loading.maxVisibleCount], + ["overBudgetSegments", loading.overBudgetSegmentCount], + ["longestDurationMs", loading.longestDurationMs], + ], + ), + "", + loadingSegments.length === 0 + ? "LOADING_OVER_BUDGET\n-" + : webObserveTable( + ["OWNER", "TEXT", "SAMPLES", "DURATION_MS", "FIRST", "LAST"], + loadingSegments.map((item) => [item.owner, String(item.preview ?? "").replace(/\s+/g, " ").slice(0, 120), item.sampleCount, item.durationMs, item.firstAt, item.lastAt]), ), "", findings.length === 0 diff --git a/scripts/src/hwlab-node-lanes.ts b/scripts/src/hwlab-node-lanes.ts index f3c81f99..ded8c836 100644 --- a/scripts/src/hwlab-node-lanes.ts +++ b/scripts/src/hwlab-node-lanes.ts @@ -100,6 +100,7 @@ export interface HwlabRuntimeObservabilitySpec { readonly traceExplorerUrlTemplate?: string; readonly metricsEndpoint?: HwlabRuntimeObservabilityMetricsEndpointSpec; readonly workbench?: HwlabRuntimeObservabilityWorkbenchSpec; + readonly webProbe?: HwlabRuntimeObservabilityWebProbeSpec; readonly recordingRules: readonly HwlabRuntimeObservabilityRecordingRuleSpec[]; readonly warningAlerts: readonly HwlabRuntimeObservabilityWarningAlertSpec[]; } @@ -124,6 +125,27 @@ export interface HwlabRuntimeObservabilityWorkbenchSpec { readonly maxUnknownEventLines: number; } +export interface HwlabRuntimeObservabilityWebProbeSpec { + readonly observe?: HwlabRuntimeObservabilityWebProbeObserveSpec; +} + +export interface HwlabRuntimeObservabilityWebProbeObserveSpec { + readonly analysisThresholds?: HwlabRuntimeObservabilityWebProbeAnalysisThresholdsSpec; +} + +export interface HwlabRuntimeObservabilityWebProbeAnalysisThresholdsSpec { + readonly sameOriginApiBudgetMs: number; + readonly longLivedStreamOpenBudgetMs: number; + readonly longLivedStreamLifetimeBudgetMs: number; + readonly recentUpdateSawtoothMinAllowedIncreaseSeconds: number; + readonly recentUpdateSawtoothSampleSlackSeconds: number; + readonly uncommandedStateChangeCommandWindowMs: number; + readonly scrollJumpCommandWindowMs: number; + readonly scrollJumpFromY: number; + readonly scrollJumpToY: number; + readonly loadingVisibleBudgetMs: number; +} + export interface HwlabRuntimeObservabilityRecordingRuleSpec { readonly id: string; readonly metric: string; @@ -671,6 +693,7 @@ function observabilityConfig(value: unknown, path: string): HwlabRuntimeObservab traceExplorerUrlTemplate, metricsEndpoint: observabilityMetricsEndpointConfig(raw.metricsEndpoint, `${path}.metricsEndpoint`), workbench: observabilityWorkbenchConfig(raw.workbench, `${path}.workbench`), + webProbe: observabilityWebProbeConfig(raw.webProbe, `${path}.webProbe`), recordingRules, warningAlerts, }; @@ -726,6 +749,39 @@ function observabilityWorkbenchConfig(value: unknown, path: string): HwlabRuntim }; } +function observabilityWebProbeConfig(value: unknown, path: string): HwlabRuntimeObservabilityWebProbeSpec | undefined { + if (value === undefined) return undefined; + const raw = asRecord(value, path); + return { + observe: observabilityWebProbeObserveConfig(raw.observe, `${path}.observe`), + }; +} + +function observabilityWebProbeObserveConfig(value: unknown, path: string): HwlabRuntimeObservabilityWebProbeObserveSpec | undefined { + if (value === undefined) return undefined; + const raw = asRecord(value, path); + return { + analysisThresholds: observabilityWebProbeAnalysisThresholdsConfig(raw.analysisThresholds, `${path}.analysisThresholds`), + }; +} + +function observabilityWebProbeAnalysisThresholdsConfig(value: unknown, path: string): HwlabRuntimeObservabilityWebProbeAnalysisThresholdsSpec | undefined { + if (value === undefined) return undefined; + const raw = asRecord(value, path); + return { + sameOriginApiBudgetMs: numberField(raw, "sameOriginApiBudgetMs", path), + longLivedStreamOpenBudgetMs: numberField(raw, "longLivedStreamOpenBudgetMs", path), + longLivedStreamLifetimeBudgetMs: numberField(raw, "longLivedStreamLifetimeBudgetMs", path), + recentUpdateSawtoothMinAllowedIncreaseSeconds: positiveNumberField(raw, "recentUpdateSawtoothMinAllowedIncreaseSeconds", path), + recentUpdateSawtoothSampleSlackSeconds: positiveNumberField(raw, "recentUpdateSawtoothSampleSlackSeconds", path), + uncommandedStateChangeCommandWindowMs: numberField(raw, "uncommandedStateChangeCommandWindowMs", path), + scrollJumpCommandWindowMs: numberField(raw, "scrollJumpCommandWindowMs", path), + scrollJumpFromY: numberField(raw, "scrollJumpFromY", path), + scrollJumpToY: nonNegativeIntegerField(raw, "scrollJumpToY", path), + loadingVisibleBudgetMs: numberField(raw, "loadingVisibleBudgetMs", path), + }; +} + function observabilityRecordingRulesConfig(value: unknown, path: string): HwlabRuntimeObservabilityRecordingRuleSpec[] { if (value === undefined) return []; if (!Array.isArray(value)) throw new Error(`${path} must be an array`); diff --git a/scripts/src/hwlab-node-web-observe-runner-source.ts b/scripts/src/hwlab-node-web-observe-runner-source.ts index ac266e12..b6caa682 100644 --- a/scripts/src/hwlab-node-web-observe-runner-source.ts +++ b/scripts/src/hwlab-node-web-observe-runner-source.ts @@ -713,6 +713,19 @@ async function samplePage(reason) { const messageSelector = '[data-testid*="message" i], [class*="message" i], article, [role="article"]'; const traceSelector = '[data-testid*="trace" i], [class*="trace" i], [data-trace-id], [data-testid*="event" i]'; const diagnosticSelector = '.api-error-diagnostic, .api-error-diagnostic.message-diagnostic, .api-error-diagnostic.projection-diagnostic, [data-testid="api-error-diagnostic" i], [data-testid="error-diagnostic" i], [role="alert"], .alert, .warning, .error'; + const loadingSelector = '[aria-busy="true"], [role="progressbar"], [data-testid*="loading" i], [data-testid*="spinner" i], [class*="loading" i], [class*="spinner" i]'; + const loadingTextPattern = /(?:^|[\s::])(?:加载中|正在加载|加载数据|Loading|loading|Please wait|请稍候|处理中|等待中)(?:[\s.。::…]|$)/u; + const ownerSummary = (element) => { + const owner = element.closest('article.message-card, .message-card, [data-session-id], [data-testid], main, aside, section, article') || element.parentElement || element; + return { + tag: owner.tagName.toLowerCase(), + testId: owner.getAttribute("data-testid"), + role: owner.getAttribute("role"), + sessionId: owner.getAttribute("data-session-id"), + messageId: owner.getAttribute("data-message-id") || owner.getAttribute("id"), + className: String(owner.className || "").slice(0, 160), + }; + }; const messages = summarize(messageSelector, 20); const traceRows = summarize(traceSelector, 30); const diagnostics = Array.from(document.querySelectorAll(diagnosticSelector)).filter(visible).slice(-40).map((element, index) => { @@ -744,6 +757,37 @@ async function samplePage(reason) { rect: { x: Math.round(rect.x), y: Math.round(rect.y), width: Math.round(rect.width), height: Math.round(rect.height) }, }; }).filter(Boolean); + const loading = []; + const loadingSeen = new Set(); + const addLoadingCandidate = (element, reason) => { + if (!element || !visible(element)) return; + const text = textHashInput(element); + if (reason === "text" && !loadingTextPattern.test(text)) return; + const rect = element.getBoundingClientRect(); + const key = [element.tagName, Math.round(rect.x), Math.round(rect.y), Math.round(rect.width), Math.round(rect.height), text.slice(0, 80)].join("|"); + if (loadingSeen.has(key)) return; + loadingSeen.add(key); + loading.push({ + index: loading.length, + reason, + tag: element.tagName.toLowerCase(), + testId: element.getAttribute("data-testid"), + role: element.getAttribute("role"), + ariaBusy: element.getAttribute("aria-busy"), + className: String(element.className || "").slice(0, 160), + owner: ownerSummary(element), + text, + rect: { x: Math.round(rect.x), y: Math.round(rect.y), width: Math.round(rect.width), height: Math.round(rect.height) }, + }); + }; + for (const element of Array.from(document.querySelectorAll(loadingSelector)).slice(0, 120)) addLoadingCandidate(element, "selector"); + for (const element of Array.from(document.querySelectorAll("body *")).slice(0, 1200)) { + if (!visible(element)) continue; + const text = textHashInput(element); + if (!loadingTextPattern.test(text)) continue; + const childHasLoading = Array.from(element.children).some((child) => visible(child) && loadingTextPattern.test(textHashInput(child))); + if (!childHasLoading) addLoadingCandidate(element, "text"); + } const turns = Array.from(document.querySelectorAll('article.message-card[data-role="agent"], .message-card[data-role="agent"], article[data-role="agent"]')).filter(visible).map((element, index) => { const rect = element.getBoundingClientRect(); const text = textHashInput(element); @@ -776,6 +820,7 @@ async function samplePage(reason) { messages, traceRows, diagnostics, + loading: loading.slice(0, 80), turns, pageProvenance: { url: location.href, @@ -832,10 +877,11 @@ function digestDom(dom) { const messages = Array.isArray(dom.messages) ? dom.messages.map((item) => ({ ...item, textHash: sha256Text(item.text || ""), textPreview: truncate(item.text || "", 160), textBytes: Buffer.byteLength(item.text || "") })) : []; const traceRows = Array.isArray(dom.traceRows) ? dom.traceRows.map((item) => ({ ...item, textHash: sha256Text(item.text || ""), textPreview: truncate(item.text || "", 160), textBytes: Buffer.byteLength(item.text || "") })) : []; const diagnostics = Array.isArray(dom.diagnostics) ? dom.diagnostics.map((item) => ({ ...item, textHash: sha256Text(item.text || ""), textPreview: truncate(item.text || "", 260), textBytes: Buffer.byteLength(item.text || "") })) : []; + const loading = Array.isArray(dom.loading) ? dom.loading.map((item) => ({ ...item, textHash: sha256Text(item.text || ""), textPreview: truncate(item.text || "", 160), textBytes: Buffer.byteLength(item.text || "") })) : []; const turns = Array.isArray(dom.turns) ? dom.turns.map((item) => ({ ...item, textHash: sha256Text(item.text || ""), textPreview: truncate(item.text || "", 200), textBytes: Buffer.byteLength(item.text || "") })) : []; const pageProvenance = normalizePageProvenance(dom.pageProvenance, { reason: "sample", pageLoadSeq: currentPageProvenance?.pageLoadSeq ?? pageLoadSeq }); currentPageProvenance = pageProvenance; - return { ...dom, messages, traceRows, diagnostics, turns, pageProvenance: compactPageProvenance(pageProvenance) }; + return { ...dom, messages, traceRows, diagnostics, loading, turns, pageProvenance: compactPageProvenance(pageProvenance) }; } async function captureScreenshot(reason, imageType = "png") { @@ -1000,15 +1046,17 @@ const errors = await readJsonl(path.join(stateDir, "errors.jsonl")); const artifacts = await readJsonl(path.join(stateDir, "artifacts.jsonl")); const manifest = await readJson(path.join(stateDir, "manifest.json")); const heartbeat = await readJson(path.join(stateDir, "heartbeat.json")); +const analysisThresholds = parseAnalysisThresholds(process.env.UNIDESK_WEB_OBSERVE_ANALYSIS_THRESHOLDS_JSON); await mkdir(analysisDir, { recursive: true, mode: 0o700 }); const transitions = buildTransitions(samples); -const sampleMetrics = buildSampleMetrics(samples, control); +const sampleMetrics = buildSampleMetrics(samples, control, analysisThresholds); const pageProvenance = buildPageProvenanceReport(samples, control, manifest); -const pagePerformance = buildPagePerformanceReport(samples, manifest); +const pagePerformance = buildPagePerformanceReport(samples, manifest, analysisThresholds); +const loading = buildLoadingReport(samples, analysisThresholds); const promptNetwork = buildPromptNetworkReport(control, network); const runtimeAlerts = buildRuntimeAlerts(samples, control, network, consoleEvents, errors); -const findings = buildFindings(samples, control, network, errors, sampleMetrics, promptNetwork, runtimeAlerts, pagePerformance, pageProvenance); +const findings = buildFindings(samples, control, network, errors, sampleMetrics, promptNetwork, runtimeAlerts, pagePerformance, pageProvenance, loading, analysisThresholds); 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 })); const report = { ok: findings.filter((item) => item.severity === "red").length === 0, @@ -1020,10 +1068,12 @@ const report = { counts: { samples: samples.length, control: control.length, network: network.length, console: consoleEvents.length, errors: errors.length, artifacts: artifacts.length }, jsonlReadIssues, commandTimeline, + analysisThresholds, transitions, sampleMetrics, pageProvenance, pagePerformance, + loading, promptNetwork, runtimeAlerts, findings, @@ -1038,7 +1088,7 @@ const compactRuntimeAlerts = { domDiagnostics: runtimeAlerts.domDiagnostics.slice(-80), domDiagnosticsByFingerprint: runtimeAlerts.domDiagnosticsByFingerprint.slice(0, 80), }; -console.log(JSON.stringify({ ok: true, command: "web-probe-observe analyze", stateDir, reportJsonPath, reportJsonSha256: jsonMeta.sha256, reportMdPath, reportMdSha256: mdMeta.sha256, counts: report.counts, jsonlReadIssues: jsonlReadIssues.slice(0, 20), sampleMetrics: { ...sampleMetrics.summary, rounds: sampleMetrics.rounds.slice(-20), turnColumns: sampleMetrics.turnColumns.slice(-50) }, pageProvenance: pageProvenance.summary, pagePerformance: pagePerformance.summary, promptNetwork: promptNetwork.summary, runtimeAlerts: compactRuntimeAlerts, turnTimingRecentUpdateSawtoothJumps: sampleMetrics.turnTimingRecentUpdateSawtoothJumps.slice(0, 20), turnTimingRecentUpdateLargestSteps: sampleMetrics.turnTimingRecentUpdateLargestSteps.slice(0, 20), pagePerformanceSlowApi: pagePerformance.sameOriginApiByPath.filter((item) => item.overFiveSecondCount > 0).slice(0, 20), pagePerformanceLongLivedStreams: pagePerformance.sameOriginApiByPath.filter((item) => item.isLongLivedStream).slice(0, 20), findings: findings.slice(0, 20), valuesRedacted: true }, null, 2)); +console.log(JSON.stringify({ ok: true, command: "web-probe-observe analyze", stateDir, reportJsonPath, reportJsonSha256: jsonMeta.sha256, reportMdPath, reportMdSha256: mdMeta.sha256, counts: report.counts, jsonlReadIssues: jsonlReadIssues.slice(0, 20), analysisThresholds, sampleMetrics: { ...sampleMetrics.summary, rounds: sampleMetrics.rounds.slice(-20), turnColumns: sampleMetrics.turnColumns.slice(-50) }, pageProvenance: pageProvenance.summary, pagePerformance: pagePerformance.summary, loading: { ...loading.summary, overBudgetSegments: loading.overBudgetSegments.slice(0, 20), ownerGroups: loading.ownerGroups.slice(0, 20) }, promptNetwork: promptNetwork.summary, runtimeAlerts: compactRuntimeAlerts, turnTimingRecentUpdateSawtoothJumps: sampleMetrics.turnTimingRecentUpdateSawtoothJumps.slice(0, 20), turnTimingRecentUpdateLargestSteps: sampleMetrics.turnTimingRecentUpdateLargestSteps.slice(0, 20), pagePerformanceSlowApi: pagePerformance.sameOriginApiByPath.filter((item) => item.overBudgetCount > 0).slice(0, 20), pagePerformanceLongLivedStreams: pagePerformance.sameOriginApiByPath.filter((item) => item.isLongLivedStream).slice(0, 20), findings: findings.slice(0, 20), valuesRedacted: true }, null, 2)); async function readJson(file) { try { return JSON.parse(await readFile(file, "utf8")); } catch { return null; } @@ -1065,7 +1115,45 @@ async function readJsonl(file) { return rows; } -function buildFindings(samples, control, network, errors, sampleMetrics, promptNetwork, runtimeAlerts, pagePerformance, pageProvenance) { +function parseAnalysisThresholds(rawJson) { + if (!rawJson) throw new Error("missing UNIDESK_WEB_OBSERVE_ANALYSIS_THRESHOLDS_JSON; configure observability.webProbe.observe.analysisThresholds in YAML"); + let raw; + try { + raw = JSON.parse(rawJson); + } catch (error) { + throw new Error("invalid UNIDESK_WEB_OBSERVE_ANALYSIS_THRESHOLDS_JSON: " + (error?.message || String(error))); + } + const positiveInteger = (key) => { + const value = Number(raw?.[key]); + if (!Number.isInteger(value) || value <= 0) throw new Error("analysis threshold " + key + " must be a positive integer"); + return value; + }; + const nonNegativeInteger = (key) => { + const value = Number(raw?.[key]); + if (!Number.isInteger(value) || value < 0) throw new Error("analysis threshold " + key + " must be a non-negative integer"); + return value; + }; + const positiveNumber = (key) => { + const value = Number(raw?.[key]); + if (!Number.isFinite(value) || value <= 0) throw new Error("analysis threshold " + key + " must be a positive number"); + return value; + }; + return { + sameOriginApiBudgetMs: positiveInteger("sameOriginApiBudgetMs"), + longLivedStreamOpenBudgetMs: positiveInteger("longLivedStreamOpenBudgetMs"), + longLivedStreamLifetimeBudgetMs: positiveInteger("longLivedStreamLifetimeBudgetMs"), + recentUpdateSawtoothMinAllowedIncreaseSeconds: positiveNumber("recentUpdateSawtoothMinAllowedIncreaseSeconds"), + recentUpdateSawtoothSampleSlackSeconds: positiveNumber("recentUpdateSawtoothSampleSlackSeconds"), + uncommandedStateChangeCommandWindowMs: positiveInteger("uncommandedStateChangeCommandWindowMs"), + scrollJumpCommandWindowMs: positiveInteger("scrollJumpCommandWindowMs"), + scrollJumpFromY: positiveInteger("scrollJumpFromY"), + scrollJumpToY: nonNegativeInteger("scrollJumpToY"), + loadingVisibleBudgetMs: positiveInteger("loadingVisibleBudgetMs"), + valuesRedacted: true + }; +} + +function buildFindings(samples, control, network, errors, sampleMetrics, promptNetwork, runtimeAlerts, pagePerformance, pageProvenance, loading, thresholds) { const findings = []; const commandTimes = control.filter((item) => item.phase === "completed" || item.phase === "started").map((item) => Date.parse(item.ts)).filter(Number.isFinite); const routeSessions = new Set(samples.map((item) => item.routeSessionId).filter(Boolean)); @@ -1079,7 +1167,7 @@ function buildFindings(samples, control, network, errors, sampleMetrics, promptN for (let i = 1; i < samples.length; i += 1) { const prev = digestSample(samples[i - 1]); const next = digestSample(samples[i]); - if (prev !== next && !commandedPromptSeqs.has(samples[i]?.seq) && !nearCommand(samples[i], commandTimes, 10000)) uncommandedChanges.push(ref(samples[i])); + if (prev !== next && !commandedPromptSeqs.has(samples[i]?.seq) && !nearCommand(samples[i], commandTimes, thresholds.uncommandedStateChangeCommandWindowMs)) uncommandedChanges.push(ref(samples[i])); } if (uncommandedChanges.length > 0) findings.push({ id: "uncommanded-visible-state-change", severity: "amber", summary: "visible message/trace digest changed without a nearby command", count: uncommandedChanges.length, samples: uncommandedChanges.slice(0, 20) }); const finalFlicker = detectFinalFlicker(samples); @@ -1088,7 +1176,7 @@ function buildFindings(samples, control, network, errors, sampleMetrics, promptN for (let i = 1; i < samples.length; i += 1) { const prevY = Number(samples[i - 1]?.scroll?.y ?? 0); const nextY = Number(samples[i]?.scroll?.y ?? 0); - if (prevY > 250 && nextY < 40 && !nearCommand(samples[i], commandTimes, 8000)) scrollJumps.push({ from: ref(samples[i - 1]), to: ref(samples[i]) }); + if (prevY > thresholds.scrollJumpFromY && nextY < thresholds.scrollJumpToY && !nearCommand(samples[i], commandTimes, thresholds.scrollJumpCommandWindowMs)) scrollJumps.push({ from: ref(samples[i - 1]), to: ref(samples[i]) }); } if (scrollJumps.length > 0) findings.push({ id: "scroll-jump-top", severity: "amber", summary: "scroll position jumped near top without nearby command", count: scrollJumps.length, samples: scrollJumps.slice(0, 10) }); const traceTerminal = samples.some((item) => Array.isArray(item.traceRows) && item.traceRows.some((row) => isTerminalTraceText((row.status || "") + " " + (row.textPreview || "")))); @@ -1107,8 +1195,10 @@ function buildFindings(samples, control, network, errors, sampleMetrics, promptN if ((runtimeAlerts?.summary?.domDiagnosticSampleCount ?? 0) > 0) findings.push({ id: "runtime-dom-diagnostics", severity: "amber", summary: "diagnostic/error/warning-like text was visible in sampled DOM", count: runtimeAlerts.summary.domDiagnosticSampleCount, samples: runtimeAlerts.domDiagnostics.slice(0, 12) }); if ((runtimeAlerts?.summary?.executionErrorCount ?? 0) > 0) findings.push({ id: "runtime-execution-errors", severity: "red", summary: "Workbench rendered execution failure/error rows during observation", count: runtimeAlerts.summary.executionErrorCount, groups: runtimeAlerts.runtimeExecutionErrorsByCode.slice(0, 12) }); if ((runtimeAlerts?.summary?.consoleAlertCount ?? 0) > 0) findings.push({ id: "runtime-console-alerts", severity: "amber", summary: "browser console warning/error entries were captured during observation", count: runtimeAlerts.summary.consoleAlertCount, groups: runtimeAlerts.consoleAlertsByPath.slice(0, 12) }); - const slowApi = Array.isArray(pagePerformance?.sameOriginApiByPath) ? pagePerformance.sameOriginApiByPath.filter((item) => item.overFiveSecondCount > 0) : []; - if (slowApi.length > 0) findings.push({ id: "page-performance-slow-same-origin-api", severity: "red", summary: "same-origin API resource timing exceeded 5s usability budget", count: slowApi.length, groups: slowApi.slice(0, 20) }); + const slowApi = Array.isArray(pagePerformance?.sameOriginApiByPath) ? pagePerformance.sameOriginApiByPath.filter((item) => item.overBudgetCount > 0) : []; + if (slowApi.length > 0) findings.push({ id: "page-performance-slow-same-origin-api", severity: "red", summary: "same-origin API resource timing exceeded YAML-configured usability budget", budgetMs: thresholds.sameOriginApiBudgetMs, count: slowApi.length, groups: slowApi.slice(0, 20) }); + const slowLoading = Array.isArray(loading?.overBudgetSegments) ? loading.overBudgetSegments : []; + if (slowLoading.length > 0) findings.push({ id: "page-loading-visible-too-long", severity: "red", summary: "loading indicators stayed visible longer than YAML-configured budget", budgetMs: thresholds.loadingVisibleBudgetMs, count: slowLoading.length, groups: slowLoading.slice(0, 20) }); const longLivedStreams = Array.isArray(pagePerformance?.sameOriginApiByPath) ? pagePerformance.sameOriginApiByPath.filter((item) => item.isLongLivedStream) : []; 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) }); 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) }); @@ -1171,7 +1261,7 @@ function buildPageProvenanceReport(samples, control, manifest) { }; } -function buildPagePerformanceReport(samples, manifest) { +function buildPagePerformanceReport(samples, manifest, thresholds) { const base = manifest?.baseUrl || "http://invalid.local"; const seen = new Set(); const groups = new Map(); @@ -1212,15 +1302,15 @@ function buildPagePerformanceReport(samples, manifest) { group.sampleCount += 1; group.durationsMs.push(durationMs); if (isLongLivedStream) { - if (durationMs > 5000) group.streamLifetimeOverFiveSecondCount += 1; + if (durationMs > thresholds.longLivedStreamLifetimeBudgetMs) group.streamLifetimeOverFiveSecondCount += 1; if (streamOpenMs !== null) { group.streamOpenDurationsMs.push(streamOpenMs); - if (streamOpenMs > 5000) { + if (streamOpenMs > thresholds.longLivedStreamOpenBudgetMs) { group.streamOpenOverFiveSecondCount += 1; group.overFiveSecondCount += 1; } } - } else if (durationMs > 5000) { + } else if (durationMs > thresholds.sameOriginApiBudgetMs) { group.overFiveSecondCount += 1; } group.lastAt = sample.ts ?? null; @@ -1250,9 +1340,13 @@ function buildPagePerformanceReport(samples, manifest) { streamOpenP75Ms: percentile(streamOpenDurations, 75), streamOpenP95Ms: percentile(streamOpenDurations, 95), streamOpenMaxMs: streamOpenDurations.length > 0 ? streamOpenDurations[streamOpenDurations.length - 1] : null, + streamOpenOverBudgetCount: group.streamOpenOverFiveSecondCount, streamOpenOverFiveSecondCount: group.streamOpenOverFiveSecondCount, + streamLifetimeOverBudgetCount: group.streamLifetimeOverFiveSecondCount, streamLifetimeOverFiveSecondCount: group.streamLifetimeOverFiveSecondCount, + overBudgetCount: group.overFiveSecondCount, overFiveSecondCount: group.overFiveSecondCount, + overBudgetRatio: group.sampleCount > 0 ? Number((group.overFiveSecondCount / group.sampleCount).toFixed(3)) : 0, overFiveSecondRatio: group.sampleCount > 0 ? Number((group.overFiveSecondCount / group.sampleCount).toFixed(3)) : 0, firstAt: group.firstAt, lastAt: group.lastAt, @@ -1264,20 +1358,23 @@ function buildPagePerformanceReport(samples, manifest) { valuesRedacted: true }; }).sort((a, b) => (b.overFiveSecondCount - a.overFiveSecondCount) || (Number(b.p95Ms ?? 0) - Number(a.p95Ms ?? 0)) || a.path.localeCompare(b.path)); - const slow = sameOriginApiByPath.filter((item) => item.overFiveSecondCount > 0); + const slow = sameOriginApiByPath.filter((item) => item.overBudgetCount > 0); const longLivedStreams = sameOriginApiByPath.filter((item) => item.isLongLivedStream); const budgetP95Values = sameOriginApiByPath .map((item) => Number(item.isLongLivedStream ? (item.streamOpenP95Ms ?? 0) : (item.p95Ms ?? 0))) .filter((value) => Number.isFinite(value)); return { summary: { - budgetMs: 5000, + budgetMs: thresholds.sameOriginApiBudgetMs, + sameOriginApiBudgetMs: thresholds.sameOriginApiBudgetMs, + longLivedStreamOpenBudgetMs: thresholds.longLivedStreamOpenBudgetMs, + longLivedStreamLifetimeBudgetMs: thresholds.longLivedStreamLifetimeBudgetMs, sameOriginApiPathCount: sameOriginApiByPath.length, sameOriginApiSampleCount: sameOriginApiByPath.reduce((sum, item) => sum + item.sampleCount, 0), longLivedStreamPathCount: longLivedStreams.length, longLivedStreamSampleCount: longLivedStreams.reduce((sum, item) => sum + item.sampleCount, 0), slowPathCount: slow.length, - slowSampleCount: slow.reduce((sum, item) => sum + item.overFiveSecondCount, 0), + slowSampleCount: slow.reduce((sum, item) => sum + item.overBudgetCount, 0), worstP95Ms: budgetP95Values.length > 0 ? Math.max(...budgetP95Values) : null, valuesRedacted: true }, @@ -1286,6 +1383,165 @@ function buildPagePerformanceReport(samples, manifest) { }; } +function buildLoadingReport(samples, thresholds) { + const timeline = []; + const segments = []; + const active = new Map(); + for (const sample of samples) { + const tsMs = Date.parse(String(sample?.ts ?? "")); + const items = Array.isArray(sample?.loading) ? sample.loading : []; + const current = new Map(); + for (const item of items) { + const key = loadingItemKey(item); + if (!current.has(key)) current.set(key, item); + } + for (const [key, segment] of Array.from(active.entries())) { + if (!current.has(key)) { + segments.push(closeLoadingSegment(segment, thresholds)); + active.delete(key); + } + } + for (const [key, item] of current.entries()) { + const owner = loadingOwnerLabel(item); + const preview = limitText(item?.textPreview || item?.text || "", 160); + const segment = active.get(key); + if (!segment) { + active.set(key, { + keyHash: sha256(key), + owner, + preview, + reason: item?.reason ?? null, + firstSeq: sample?.seq ?? null, + lastSeq: sample?.seq ?? null, + firstAt: sample?.ts ?? null, + lastAt: sample?.ts ?? null, + firstTsMs: Number.isFinite(tsMs) ? tsMs : null, + lastTsMs: Number.isFinite(tsMs) ? tsMs : null, + sampleCount: 1, + routeSessionId: sample?.routeSessionId ?? null, + activeSessionId: sample?.activeSessionId ?? null, + valuesRedacted: true + }); + } else { + segment.lastSeq = sample?.seq ?? null; + segment.lastAt = sample?.ts ?? null; + segment.lastTsMs = Number.isFinite(tsMs) ? tsMs : segment.lastTsMs; + segment.sampleCount += 1; + } + } + timeline.push({ + seq: sample?.seq ?? null, + ts: sample?.ts ?? null, + routeSessionId: sample?.routeSessionId ?? null, + activeSessionId: sample?.activeSessionId ?? null, + count: current.size, + owners: Array.from(current.values()).map(loadingOwnerLabel).slice(0, 20) + }); + } + for (const segment of active.values()) segments.push(closeLoadingSegment(segment, thresholds)); + const sortedSegments = segments.sort((a, b) => Number(b.durationMs ?? 0) - Number(a.durationMs ?? 0)); + const overBudgetSegments = sortedSegments.filter((item) => item.overBudget); + const ownerGroups = groupLoadingOwners(sortedSegments); + return { + summary: { + visibleBudgetMs: thresholds.loadingVisibleBudgetMs, + sampleCount: samples.length, + samplesWithLoading: timeline.filter((item) => item.count > 0).length, + maxVisibleCount: timeline.reduce((max, item) => Math.max(max, item.count), 0), + segmentCount: sortedSegments.length, + overBudgetSegmentCount: overBudgetSegments.length, + longestDurationMs: sortedSegments.length > 0 ? sortedSegments[0].durationMs : 0, + ownerGroupCount: ownerGroups.length, + valuesRedacted: true + }, + timeline: timeline.slice(0, 500), + segments: sortedSegments.slice(0, 200), + overBudgetSegments: overBudgetSegments.slice(0, 80), + ownerGroups, + valuesRedacted: true + }; +} + +function closeLoadingSegment(segment, thresholds) { + const durationMs = segment.firstTsMs !== null && segment.lastTsMs !== null ? Math.max(0, segment.lastTsMs - segment.firstTsMs) : null; + return { + keyHash: segment.keyHash, + owner: segment.owner, + preview: segment.preview, + reason: segment.reason, + firstSeq: segment.firstSeq, + lastSeq: segment.lastSeq, + firstAt: segment.firstAt, + lastAt: segment.lastAt, + sampleCount: segment.sampleCount, + durationMs, + budgetMs: thresholds.loadingVisibleBudgetMs, + overBudget: durationMs !== null && durationMs > thresholds.loadingVisibleBudgetMs, + routeSessionId: segment.routeSessionId, + activeSessionId: segment.activeSessionId, + valuesRedacted: true + }; +} + +function loadingItemKey(item) { + const owner = loadingOwnerLabel(item); + const rect = item?.rect || {}; + return [ + owner, + item?.tag ?? "", + item?.testId ?? "", + item?.role ?? "", + item?.ariaBusy ?? "", + item?.textHash ?? sha256(String(item?.textPreview || item?.text || "")), + Math.round(Number(rect.x ?? 0) / 20), + Math.round(Number(rect.y ?? 0) / 20), + Math.round(Number(rect.width ?? 0) / 20), + Math.round(Number(rect.height ?? 0) / 20) + ].join("|"); +} + +function loadingOwnerLabel(item) { + const owner = item?.owner || {}; + const parts = [ + owner.testId ? "testId=" + owner.testId : "", + owner.sessionId ? "session=" + owner.sessionId : "", + owner.messageId ? "message=" + owner.messageId : "", + owner.role ? "role=" + owner.role : "", + owner.tag ? "tag=" + owner.tag : "", + owner.className ? "class=" + String(owner.className).replace(/\s+/g, ".").slice(0, 80) : "" + ].filter(Boolean); + return parts.length > 0 ? parts.join(" ") : "unknown"; +} + +function groupLoadingOwners(segments) { + const groups = new Map(); + for (const segment of segments) { + const key = segment.owner || "unknown"; + const group = groups.get(key) || { + owner: key, + segmentCount: 0, + overBudgetSegmentCount: 0, + sampleCount: 0, + longestDurationMs: 0, + previews: [], + firstAt: segment.firstAt, + lastAt: segment.lastAt, + valuesRedacted: true + }; + group.segmentCount += 1; + if (segment.overBudget) group.overBudgetSegmentCount += 1; + group.sampleCount += Number(segment.sampleCount ?? 0); + group.longestDurationMs = Math.max(group.longestDurationMs, Number(segment.durationMs ?? 0)); + if (segment.preview && !group.previews.includes(segment.preview)) group.previews.push(segment.preview); + group.lastAt = segment.lastAt; + groups.set(key, group); + } + return Array.from(groups.values()) + .map((item) => ({ ...item, previews: item.previews.slice(0, 5) })) + .sort((a, b) => (b.overBudgetSegmentCount - a.overBudgetSegmentCount) || (b.longestDurationMs - a.longestDurationMs) || a.owner.localeCompare(b.owner)) + .slice(0, 80); +} + function classifyApiPerformanceRoute(normalizedPath, entry = {}) { if (normalizedPath === "/v1/workbench/events") return "same-origin-api-stream"; if (String(entry?.initiatorType ?? "").toLowerCase() === "eventsource") return "same-origin-api-stream"; @@ -1835,7 +2091,7 @@ function isFinalResultText(text) { return /已完成第\d+轮|final response|sealed final response|最终结果|已完成[::]/iu.test(String(text || "")); } -function buildSampleMetrics(samples, control) { +function buildSampleMetrics(samples, control, thresholds) { const promptCommands = control .filter((item) => item.type === "sendPrompt" && item.phase === "completed") .map((item) => ({ ts: item.ts, tsMs: Date.parse(item.ts), commandId: item.commandId ?? null, textHash: item.input?.textHash ?? null, textBytes: item.input?.textBytes ?? null })) @@ -1868,7 +2124,7 @@ function buildSampleMetrics(samples, control) { textDigest: digestSample(sample) }; }); - const turnTiming = buildTurnTimingTable(samples, timeline); + const turnTiming = buildTurnTimingTable(samples, timeline, thresholds); const turnCells = turnTiming.rows.flatMap((row) => Object.values(row.cells || {})); const turnTimingNonMonotonic = Array.isArray(turnTiming.nonMonotonic) ? turnTiming.nonMonotonic : []; const turnTimingRecentUpdateSawtoothJumps = turnTimingNonMonotonic.filter((item) => item.metric === "recentUpdateSeconds" && item.anomaly === "jump"); @@ -1930,7 +2186,7 @@ function buildSampleMetrics(samples, control) { }; } -function buildTurnTimingTable(samples, timeline) { +function buildTurnTimingTable(samples, timeline, thresholds) { const columns = []; const registry = new Map(); const rows = []; @@ -1985,7 +2241,7 @@ function buildTurnTimingTable(samples, timeline) { cells }); } - const timingEvents = detectTurnTimingNonMonotonic(columns, rows); + const timingEvents = detectTurnTimingNonMonotonic(columns, rows, thresholds); return { columns, rows, @@ -1995,7 +2251,7 @@ function buildTurnTimingTable(samples, timeline) { }; } -function detectTurnTimingNonMonotonic(columns, rows) { +function detectTurnTimingNonMonotonic(columns, rows, thresholds) { const anomalies = []; const recentUpdateResets = []; const recentUpdateSteps = []; @@ -2033,7 +2289,9 @@ function detectTurnTimingNonMonotonic(columns, rows) { const elapsedMs = Date.parse(String(row.ts ?? "")) - Date.parse(String(previous.ts ?? "")); const elapsedSeconds = Number.isFinite(elapsedMs) && elapsedMs >= 0 ? elapsedMs / 1000 : null; const increase = current - previous.value; - const allowedIncrease = elapsedSeconds === null ? 3 : Math.max(3, elapsedSeconds + 2); + const allowedIncrease = elapsedSeconds === null + ? thresholds.recentUpdateSawtoothMinAllowedIncreaseSeconds + : Math.max(thresholds.recentUpdateSawtoothMinAllowedIncreaseSeconds, elapsedSeconds + thresholds.recentUpdateSawtoothSampleSlackSeconds); const excessiveIncrease = increase > allowedIncrease ? increase - allowedIncrease : 0; recentUpdateSteps.push({ columnId: column.id, @@ -2336,7 +2594,8 @@ function digestSample(sample) { const messages = Array.isArray(sample.messages) ? sample.messages.map((item) => item.textHash || item.textPreview || "").join("|") : ""; const trace = Array.isArray(sample.traceRows) ? sample.traceRows.map((item) => (item.status || "") + ":" + (item.textHash || item.textPreview || "")).join("|") : ""; const diagnostics = Array.isArray(sample.diagnostics) ? sample.diagnostics.map((item) => (item.className || "") + ":" + (item.textHash || item.textPreview || "")).join("|") : ""; - return sha256((sample.routeSessionId || "") + "|" + (sample.activeSessionId || "") + "|" + messages + "|" + trace + "|" + diagnostics); + const loading = Array.isArray(sample.loading) ? sample.loading.map((item) => (item.owner ? loadingOwnerLabel(item) : "") + ":" + (item.textHash || item.textPreview || "")).join("|") : ""; + return sha256((sample.routeSessionId || "") + "|" + (sample.activeSessionId || "") + "|" + messages + "|" + trace + "|" + diagnostics + "|" + loading); } function nearCommand(sample, commandTimes, windowMs) { @@ -2436,8 +2695,14 @@ function renderMarkdown(report) { const findingLines = report.findings.length === 0 ? "- 无红灯项。" : report.findings.map((item) => "- " + item.severity + ": " + item.id + " - " + item.summary).join("\n"); const commandLines = report.commandTimeline.length === 0 ? "- 无控制命令。" : report.commandTimeline.map((item) => "- " + item.ts + " " + item.phase + " " + item.type + " " + item.commandId + " " + (item.afterUrl || "")).join("\n"); const transitionLines = report.transitions.length === 0 ? "- 无状态变化。" : report.transitions.slice(0, 80).map((item) => "- #" + item.seq + " " + item.ts + " messages=" + item.messageCount + " traceRows=" + item.traceRowCount + " route=" + (item.routeSessionId || "-") + " active=" + (item.activeSessionId || "-")).join("\n"); + const thresholds = report.analysisThresholds || {}; const metricSummary = report.sampleMetrics?.summary || {}; const alertSummary = report.runtimeAlerts?.summary || {}; + const loadingSummary = report.loading?.summary || {}; + const thresholdLines = Object.entries(thresholds) + .filter(([key]) => key !== "valuesRedacted") + .map(([key, value]) => "- " + key + ": " + value) + .join("\n") || "- 未记录阈值。"; const httpAlertLines = Array.isArray(report.runtimeAlerts?.networkHttpErrorsByPath) && report.runtimeAlerts.networkHttpErrorsByPath.length > 0 ? report.runtimeAlerts.networkHttpErrorsByPath.slice(0, 40).map((item) => "- HTTP " + (item.status ?? "-") + " " + item.method + " " + item.urlPath + " count=" + item.count + " prompts=" + (item.promptIndexes?.join(",") || "-") + " first=" + (item.firstAt || "-") + " last=" + (item.lastAt || "-")).join("\n") : "- 无 HTTP 错误。"; @@ -2463,8 +2728,14 @@ function renderMarkdown(report) { ? report.pageProvenance.segments.slice(0, 40).map((item) => "- fingerprint=" + (item.assetFingerprint || "-") + " samples=" + item.sampleCount + " seq=" + (item.firstSeq ?? "-") + ".." + (item.lastSeq ?? "-") + " ts=" + (item.firstAt || "-") + ".." + (item.lastAt || "-") + " scripts=" + (item.scriptCount ?? "-") + " styles=" + (item.stylesheetCount ?? "-") + " urlPaths=" + (Array.isArray(item.urlPaths) ? item.urlPaths.slice(0, 4).join(",") : "-")).join("\n") : "- 无页面 provenance segment。"; const performanceLines = Array.isArray(report.pagePerformance?.sameOriginApiByPath) && report.pagePerformance.sameOriginApiByPath.length > 0 - ? report.pagePerformance.sameOriginApiByPath.slice(0, 80).map((item) => "- " + item.path + " kind=" + (item.routeKind || "same-origin-api") + " budgetMetric=" + (item.budgetMetric || "durationMs") + " samples=" + item.sampleCount + " p50=" + (item.p50Ms ?? "-") + "ms p75=" + (item.p75Ms ?? "-") + "ms p95=" + (item.p95Ms ?? "-") + "ms max=" + (item.maxMs ?? "-") + "ms >5s=" + (item.overFiveSecondCount ?? 0) + " streamOpenP95=" + (item.streamOpenP95Ms ?? "-") + "ms streamLifetime>5s=" + (item.streamLifetimeOverFiveSecondCount ?? 0) + " window=" + (item.firstAt || "-") + ".." + (item.lastAt || "-")).join("\n") + ? report.pagePerformance.sameOriginApiByPath.slice(0, 80).map((item) => "- " + item.path + " kind=" + (item.routeKind || "same-origin-api") + " budgetMetric=" + (item.budgetMetric || "durationMs") + " samples=" + item.sampleCount + " p50=" + (item.p50Ms ?? "-") + "ms p75=" + (item.p75Ms ?? "-") + "ms p95=" + (item.p95Ms ?? "-") + "ms max=" + (item.maxMs ?? "-") + "ms overBudget=" + (item.overBudgetCount ?? item.overFiveSecondCount ?? 0) + " streamOpenP95=" + (item.streamOpenP95Ms ?? "-") + "ms streamLifetimeOverBudget=" + (item.streamLifetimeOverFiveSecondCount ?? 0) + " window=" + (item.firstAt || "-") + ".." + (item.lastAt || "-")).join("\n") : "- 无同源 API Resource Timing 样本。"; + const loadingLines = Array.isArray(report.loading?.segments) && report.loading.segments.length > 0 + ? report.loading.segments.slice(0, 80).map((item) => "- owner=" + escapeMarkdownCell(item.owner || "-") + " samples=" + item.sampleCount + " durationMs=" + (item.durationMs ?? "-") + " overBudget=" + String(item.overBudget) + " seq=" + (item.firstSeq ?? "-") + ".." + (item.lastSeq ?? "-") + " ts=" + (item.firstAt || "-") + ".." + (item.lastAt || "-") + " preview=" + escapeMarkdownCell(item.preview || "")).join("\n") + : "- 无 loading 可见段。"; + const loadingGroupLines = Array.isArray(report.loading?.ownerGroups) && report.loading.ownerGroups.length > 0 + ? report.loading.ownerGroups.slice(0, 80).map((item) => "- owner=" + escapeMarkdownCell(item.owner || "-") + " segments=" + item.segmentCount + " overBudget=" + item.overBudgetSegmentCount + " samples=" + item.sampleCount + " longestMs=" + item.longestDurationMs + " previews=" + escapeMarkdownCell(Array.isArray(item.previews) ? item.previews.join(" / ") : "")).join("\n") + : "- 无 loading owner 分组。"; const metricLines = Array.isArray(report.sampleMetrics?.timeline) && report.sampleMetrics.timeline.length > 0 ? report.sampleMetrics.timeline.slice(0, 120).map((item) => "- #" + item.seq + " " + item.ts + " prompt=" + item.promptIndex + " totalElapsedSeconds=" + (item.totalElapsedSeconds ?? "-") + " recentUpdateSeconds=" + (item.recentUpdateSeconds ?? "-") + " terminal=" + item.terminalSeen + " finalText=" + item.finalResultTextSeen + " diagnostic=" + item.diagnosticSeen).join("\n") : "- 无采样指标。"; @@ -2477,6 +2748,7 @@ function renderMarkdown(report) { + "- network: " + report.counts.network + "\n" + "- console: " + (report.counts.console ?? 0) + "\n" + "- errors: " + report.counts.errors + "\n\n" + + "## Analysis thresholds\n\n" + thresholdLines + "\n\n" + "## Findings\n\n" + findingLines + "\n\n" + "## Sample metrics\n\n" + "- sampleCount: " + (metricSummary.sampleCount ?? 0) + "\n" @@ -2500,7 +2772,9 @@ function renderMarkdown(report) { + "- controlSegmentCount: " + (report.pageProvenance?.summary?.controlSegmentCount ?? 0) + "\n\n" + provenanceLines + "\n\n" + "### Page performance: same-origin API Resource Timing\n\n" - + "- budgetMs: " + (report.pagePerformance?.summary?.budgetMs ?? 5000) + "\n" + + "- budgetMs: " + (report.pagePerformance?.summary?.budgetMs ?? thresholds.sameOriginApiBudgetMs ?? "-") + "\n" + + "- longLivedStreamOpenBudgetMs: " + (report.pagePerformance?.summary?.longLivedStreamOpenBudgetMs ?? thresholds.longLivedStreamOpenBudgetMs ?? "-") + "\n" + + "- longLivedStreamLifetimeBudgetMs: " + (report.pagePerformance?.summary?.longLivedStreamLifetimeBudgetMs ?? thresholds.longLivedStreamLifetimeBudgetMs ?? "-") + "\n" + "- sameOriginApiPathCount: " + (report.pagePerformance?.summary?.sameOriginApiPathCount ?? 0) + "\n" + "- sameOriginApiSampleCount: " + (report.pagePerformance?.summary?.sameOriginApiSampleCount ?? 0) + "\n" + "- longLivedStreamPathCount: " + (report.pagePerformance?.summary?.longLivedStreamPathCount ?? 0) + "\n" @@ -2509,6 +2783,15 @@ function renderMarkdown(report) { + "- slowSampleCount: " + (report.pagePerformance?.summary?.slowSampleCount ?? 0) + "\n" + "- worstP95Ms: " + (report.pagePerformance?.summary?.worstP95Ms ?? "-") + "\n\n" + performanceLines + "\n\n" + + "### Loading indicators\n\n" + + "- visibleBudgetMs: " + (loadingSummary.visibleBudgetMs ?? thresholds.loadingVisibleBudgetMs ?? "-") + "\n" + + "- samplesWithLoading: " + (loadingSummary.samplesWithLoading ?? 0) + "\n" + + "- maxVisibleCount: " + (loadingSummary.maxVisibleCount ?? 0) + "\n" + + "- segmentCount: " + (loadingSummary.segmentCount ?? 0) + "\n" + + "- overBudgetSegmentCount: " + (loadingSummary.overBudgetSegmentCount ?? 0) + "\n" + + "- longestDurationMs: " + (loadingSummary.longestDurationMs ?? 0) + "\n\n" + + "#### Loading owner groups\n\n" + loadingGroupLines + "\n\n" + + "#### Loading segments\n\n" + loadingLines + "\n\n" + "### Prompt network\n\n" + promptNetworkLines + "\n\n" + "### Runtime alerts\n\n" + "- httpErrorCount: " + (alertSummary.httpErrorCount ?? 0) + "\n"