diff --git a/scripts/src/hwlab-node-web-observe-analyzer-source.ts b/scripts/src/hwlab-node-web-observe-analyzer-source.ts index b5ab6d14..1a51810b 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 pageProvenance = buildPageProvenanceReport(samples, control, manifest); const pagePerformance = buildPagePerformanceReport(samples, manifest); const promptNetwork = buildPromptNetworkReport(control, promptNetworkRows); const runtimeAlerts = buildRuntimeAlerts(samples, control, network, consoleEvents, errors); +const apiDomLag = buildApiDomLagReport(samples, network); const projectManagement = buildProjectManagementReport(samples, control, network, pagePerformance, projectManagementConfig); const runnerErrors = errors.slice(-8).map((item) => { const attempts = Array.isArray(item.error?.attempts) ? item.error.attempts : []; @@ -97,7 +98,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)]; +const findings = [...toolFindings, ...buildProjectManagementFindings(projectManagement), ...buildFindings(samples, control, network, errors, sampleMetrics, promptNetwork, runtimeAlerts, pagePerformance, pageProvenance, commandFailures, manifest, apiDomLag)]; 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, 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 })); @@ -119,6 +120,7 @@ const report = { projectManagement, promptNetwork, runtimeAlerts, + apiDomLag, runnerErrors, commandFailures, commandState, @@ -147,6 +149,7 @@ console.log(JSON.stringify({ pagePerformance: pagePerformance.summary, projectManagement: projectManagement.summary, runtimeAlerts: runtimeAlerts.summary, + apiDomLag: apiDomLag.summary, findingCount: findings.length, redFindingCount: findings.filter((item) => item.severity === "red").length, redFindings: prioritizeFindings(findings.filter((item) => item.severity === "red")).slice(0, 12).map((item) => ({ kind: item.id ?? item.kind ?? item.code, severity: item.severity, count: item.count ?? item.sampleCount ?? null, summary: String(item.summary ?? item.message ?? "").slice(0, 180) })), @@ -166,6 +169,7 @@ console.log(JSON.stringify({ projectManagement: compactProjectManagementForOutput(projectManagement), promptNetwork: recentWindow.promptNetwork.summary, runtimeAlerts: recentWindow.runtimeAlerts.summary, + apiDomLag: compactApiDomLagForOutput(apiDomLag), runnerErrors, commandFailures: commandFailures.slice(-8), commandState, @@ -1506,8 +1510,320 @@ function timestampMs(value) { return Number.isFinite(parsed) ? parsed : NaN; } -function buildFindings(samples, control, network, errors, sampleMetrics, promptNetwork, runtimeAlerts, pagePerformance, pageProvenance, commandFailures = [], manifest = {}) { +function buildApiDomLagReport(samples, network) { + const windowMs = 30_000; + const budgetMs = Number.isFinite(Number(alertThresholds.sameOriginApiSlowMs)) ? Number(alertThresholds.sameOriginApiSlowMs) : 10_000; + const sampleRows = (Array.isArray(samples) ? samples : []) + .map((sample) => { + const tsMs = timestampMs(sample?.ts); + return { + sample, + tsMs, + pageKey: samplePageKey(sample), + digest: digestSample(sample), + sessionIds: new Set([sample?.routeSessionId, sample?.activeSessionId].filter(Boolean).map(String)), + traceIds: sampleTraceIds(sample) + }; + }) + .filter((item) => Number.isFinite(item.tsMs)) + .sort((a, b) => a.tsMs - b.tsMs); + const samplesByPage = new Map(); + for (const row of sampleRows) { + const rows = samplesByPage.get(row.pageKey) || []; + rows.push(row); + samplesByPage.set(row.pageKey, rows); + } + const naturalApiResponses = (Array.isArray(network) ? network : []) + .filter((item) => item?.observerInitiated !== true && item?.type === "response" && isApiLikePath(urlPath(item?.url))); + const telemetryExcluded = []; + const nonStateRelevant = []; + const stateRelevantResponses = []; + for (const item of naturalApiResponses) { + const event = compactApiDomLagResponseEvent(item); + if (!Number.isFinite(event.tsMs)) { + nonStateRelevant.push(event); + continue; + } + if (isApiDomLagTelemetryPath(event.path)) telemetryExcluded.push(event); + else if (!isApiDomLagStateRelevantPath(event.path)) nonStateRelevant.push(event); + else stateRelevantResponses.push(event); + } + const candidates = []; + for (const event of stateRelevantResponses) { + const pageSamples = samplesByPage.get(event.pageKey) || []; + const before = lastSampleAtOrBefore(pageSamples, event.tsMs, event); + const firstAfter = firstSampleAfter(pageSamples, event.tsMs, event.tsMs + windowMs, event); + const baselineDigest = before?.digest ?? null; + const change = firstSampleAfter(pageSamples, event.tsMs, event.tsMs + windowMs, event, (row) => !baselineDigest || row.digest !== baselineDigest); + candidates.push({ + ...event, + windowMs, + budgetMs, + firstSampleDeltaMs: firstAfter ? Math.max(0, Math.round(firstAfter.tsMs - event.tsMs)) : null, + domChangeDeltaMs: change ? Math.max(0, Math.round(change.tsMs - event.tsMs)) : null, + overBudget: change ? (change.tsMs - event.tsMs) > budgetMs : false, + domChanged: Boolean(change), + noDomChangeWithinWindow: !change, + beforeSample: compactApiDomLagSample(before), + firstAfterSample: compactApiDomLagSample(firstAfter), + changeSample: compactApiDomLagSample(change), + confidence: apiDomLagConfidence(event.path), + valuesRedacted: true + }); + } + const changedDeltas = candidates.map((item) => nullableNumber(item.domChangeDeltaMs)).filter(Number.isFinite).sort((a, b) => a - b); + const groups = groupApiDomLagCandidates(candidates); + const overBudget = candidates.filter((item) => item.overBudget === true); + return { + summary: { + windowMs, + budgetMs, + naturalApiResponseCount: naturalApiResponses.length, + telemetryExcludedCount: telemetryExcluded.length, + nonStateRelevantResponseCount: nonStateRelevant.length, + stateRelevantResponseCount: stateRelevantResponses.length, + candidateCount: candidates.length, + domChangedCount: changedDeltas.length, + noDomChangeWithinWindowCount: candidates.filter((item) => item.noDomChangeWithinWindow === true).length, + lowConfidenceStreamOpenCount: candidates.filter((item) => item.confidence === "low-stream-open-only").length, + overBudgetCount: overBudget.length, + p50DomChangeDeltaMs: percentile(changedDeltas, 50), + p95DomChangeDeltaMs: percentile(changedDeltas, 95), + maxDomChangeDeltaMs: changedDeltas.length > 0 ? Math.max(...changedDeltas) : null, + groupCount: groups.length, + valuesRedacted: true + }, + groups, + worstCandidates: candidates + .filter((item) => Number.isFinite(nullableNumber(item.domChangeDeltaMs))) + .sort((a, b) => nullableNumber(b.domChangeDeltaMs) - nullableNumber(a.domChangeDeltaMs)) + .slice(0, 20), + recentCandidates: candidates.slice(-40), + telemetryExcluded: telemetryExcluded.slice(0, 20), + nonStateRelevant: nonStateRelevant.slice(0, 20), + valuesRedacted: true + }; +} + +function compactApiDomLagResponseEvent(item) { + const parsed = parseApiDomLagUrl(item?.url); + const tsMs = timestampMs(item?.ts); + return { + ts: item?.ts ?? null, + tsMs, + pageRole: item?.pageRole ?? null, + pageId: item?.pageId ?? null, + pageKey: String(item?.pageRole || "control") + ":" + String(item?.pageId || "default"), + commandId: item?.commandId ?? null, + method: String(item?.method || "GET").toUpperCase(), + status: Number.isFinite(Number(item?.status)) ? Number(item.status) : null, + path: parsed.path, + rawPath: parsed.rawPath, + queryKeys: parsed.queryKeys, + sessionId: parsed.sessionId, + traceId: parsed.traceId, + urlHash: item?.url ? sha256(item.url) : null, + routeKind: apiDomLagRouteKind(parsed.path), + valuesRedacted: true + }; +} + +function parseApiDomLagUrl(value) { + try { + const parsed = new URL(String(value || "http://invalid.local/")); + const rawPath = parsed.pathname || "-"; + const queryKeys = Array.from(parsed.searchParams.keys()).sort().slice(0, 12); + const sessionId = parsed.searchParams.get("sessionId") || parsed.searchParams.get("includeSessionId") || firstIdInText(parsed.pathname + " " + parsed.search, /\bses_[A-Za-z0-9_-]+\b/u); + const traceId = parsed.searchParams.get("traceId") || firstIdInText(parsed.pathname + " " + parsed.search, /\btrc_[A-Za-z0-9_-]+\b/u); + return { + rawPath, + path: normalizeApiPath(rawPath), + queryKeys, + sessionId, + traceId + }; + } catch { + const rawPath = urlPath(value); + return { + rawPath, + path: normalizeApiPath(rawPath), + queryKeys: [], + sessionId: firstIdInText(String(value || ""), /\bses_[A-Za-z0-9_-]+\b/u), + traceId: firstIdInText(String(value || ""), /\btrc_[A-Za-z0-9_-]+\b/u) + }; + } +} + +function firstIdInText(text, pattern) { + const match = String(text || "").match(pattern); + return match ? match[0] : null; +} + +function nullableNumber(value) { + if (value === null || value === undefined || value === "") return NaN; + const numeric = Number(value); + return Number.isFinite(numeric) ? numeric : NaN; +} + +function isApiDomLagTelemetryPath(path) { + const value = String(path || ""); + return value === "/v1/web-performance" || value === "/v1/health" || value === "/health"; +} + +function isApiDomLagStateRelevantPath(path) { + const value = String(path || ""); + return value.startsWith("/auth/") || value.startsWith("/v1/workbench/") || value === "/v1/agent/chat" || value === "/v1/agent/chat/steer"; +} + +function apiDomLagRouteKind(path) { + const value = String(path || ""); + if (value === "/v1/workbench/events") return "workbench-events-stream"; + if (value.startsWith("/v1/workbench/sessions")) return "workbench-sessions"; + if (value.startsWith("/v1/workbench/traces")) return "workbench-traces"; + if (value.startsWith("/v1/workbench/turns")) return "workbench-turns"; + if (value === "/v1/agent/chat" || value === "/v1/agent/chat/steer") return "agent-chat-submit"; + if (value.startsWith("/auth/")) return "auth"; + return "state-api"; +} + +function apiDomLagConfidence(path) { + return String(path || "") === "/v1/workbench/events" ? "low-stream-open-only" : "medium-response-to-dom"; +} + +function sampleTraceIds(sample) { + const ids = new Set(); + for (const group of [sample?.messages, sample?.traceRows, sample?.turns, sample?.diagnostics]) { + if (!Array.isArray(group)) continue; + for (const item of group) if (item?.traceId) ids.add(String(item.traceId)); + } + return ids; +} + +function lastSampleAtOrBefore(rows, tsMs, event) { + let result = null; + for (const row of rows) { + if (row.tsMs > tsMs) break; + if (apiDomLagSampleMatchesEvent(row, event)) result = row; + } + return result; +} + +function firstSampleAfter(rows, startMs, endMs, event, predicate = null) { + for (const row of rows) { + if (row.tsMs < startMs) continue; + if (row.tsMs > endMs) break; + if (!apiDomLagSampleMatchesEvent(row, event)) continue; + if (typeof predicate === "function" && !predicate(row)) continue; + return row; + } + return null; +} + +function apiDomLagSampleMatchesEvent(row, event) { + if (!row || !event) return false; + if (event.sessionId && !row.sessionIds.has(String(event.sessionId))) return false; + if (event.traceId && row.traceIds.size > 0 && !row.traceIds.has(String(event.traceId))) return false; + return true; +} + +function compactApiDomLagSample(row) { + if (!row) return null; + const sample = row.sample || {}; + return { + seq: sample.seq ?? null, + ts: sample.ts ?? null, + pageRole: sample.pageRole ?? null, + pageId: sample.pageId ?? null, + routeSessionId: sample.routeSessionId ?? null, + activeSessionId: sample.activeSessionId ?? null, + messageCount: Array.isArray(sample.messages) ? sample.messages.length : null, + traceRowCount: Array.isArray(sample.traceRows) ? sample.traceRows.length : null, + diagnosticCount: Array.isArray(sample.diagnostics) ? sample.diagnostics.length : null, + valuesRedacted: true + }; +} + +function groupApiDomLagCandidates(candidates) { + const groups = new Map(); + for (const item of candidates || []) { + const key = [item.method || "-", item.path || "-", item.status ?? "-", item.confidence || "-"].join(" "); + const group = groups.get(key) || { + method: item.method ?? null, + path: item.path ?? "-", + routeKind: item.routeKind ?? null, + status: item.status ?? null, + confidence: item.confidence ?? null, + count: 0, + domChangedCount: 0, + noDomChangeWithinWindowCount: 0, + overBudgetCount: 0, + firstAt: item.ts ?? null, + lastAt: item.ts ?? null, + deltas: [], + examples: [] + }; + group.count += 1; + group.firstAt = minIso(group.firstAt, item.ts ?? null); + group.lastAt = maxIso(group.lastAt, item.ts ?? null); + if (item.domChanged === true && Number.isFinite(Number(item.domChangeDeltaMs))) { + group.domChangedCount += 1; + group.deltas.push(Number(item.domChangeDeltaMs)); + } + if (item.noDomChangeWithinWindow === true) group.noDomChangeWithinWindowCount += 1; + if (item.overBudget === true) group.overBudgetCount += 1; + if (group.examples.length < 6) { + group.examples.push({ + ts: item.ts ?? null, + sessionId: item.sessionId ?? null, + traceId: item.traceId ?? null, + domChangeDeltaMs: item.domChangeDeltaMs ?? null, + firstSampleDeltaMs: item.firstSampleDeltaMs ?? null, + changeSeq: item.changeSample?.seq ?? null, + beforeSeq: item.beforeSample?.seq ?? null, + valuesRedacted: true + }); + } + groups.set(key, group); + } + return Array.from(groups.values()) + .map((item) => { + const deltas = item.deltas.slice().sort((a, b) => a - b); + return { + method: item.method, + path: item.path, + routeKind: item.routeKind, + status: item.status, + confidence: item.confidence, + count: item.count, + domChangedCount: item.domChangedCount, + noDomChangeWithinWindowCount: item.noDomChangeWithinWindowCount, + overBudgetCount: item.overBudgetCount, + p50DomChangeDeltaMs: percentile(deltas, 50), + p95DomChangeDeltaMs: percentile(deltas, 95), + maxDomChangeDeltaMs: deltas.length > 0 ? Math.max(...deltas) : null, + firstAt: item.firstAt, + lastAt: item.lastAt, + examples: item.examples, + valuesRedacted: true + }; + }) + .sort((a, b) => Number(b.maxDomChangeDeltaMs ?? -1) - Number(a.maxDomChangeDeltaMs ?? -1) || b.count - a.count || String(a.path).localeCompare(String(b.path))); +} + +function compactApiDomLagForOutput(report) { + if (!report || typeof report !== "object") return null; + return { + summary: report.summary ?? null, + groups: Array.isArray(report.groups) ? report.groups.slice(0, 8) : [], + worstCandidates: Array.isArray(report.worstCandidates) ? report.worstCandidates.slice(0, 8) : [], + recentCandidates: Array.isArray(report.recentCandidates) ? report.recentCandidates.slice(-8) : [], + valuesRedacted: true + }; +} + +function buildFindings(samples, control, network, errors, sampleMetrics, promptNetwork, runtimeAlerts, pagePerformance, pageProvenance, commandFailures = [], manifest = {}, apiDomLag = 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) }); findings.push(...buildSessionInvariantFindings(control, manifest)); const commandTimes = control @@ -1674,7 +1990,21 @@ function buildFindings(samples, control, network, errors, sampleMetrics, promptN 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) }); const naturalApi = network.filter((item) => item.observerInitiated === false && item.type === "response" && /\/v1\/|\/auth\//u.test(String(item.url || ""))); - findings.push({ id: "natural-api-dom-lag-baseline", severity: "info", summary: "natural API responses and DOM samples are available for manual lag correlation", naturalApiResponses: naturalApi.length, sampleCount: samples.length }); + const apiDomLagSummary = effectiveApiDomLag?.summary || {}; + findings.push({ id: "natural-api-dom-lag-baseline", severity: "info", summary: "natural API responses and DOM samples are available for API-to-DOM lag correlation", naturalApiResponses: naturalApi.length, sampleCount: samples.length, apiDomLag: apiDomLagSummary }); + findings.push({ + id: "natural-api-dom-lag-candidates", + severity: Number(apiDomLagSummary.overBudgetCount ?? 0) > 0 ? "amber" : "info", + summary: "state-relevant natural API responses were correlated to the first subsequent DOM digest change; over-budget lag is a non-blocking investigation alert", + count: apiDomLagSummary.candidateCount ?? 0, + domChangedCount: apiDomLagSummary.domChangedCount ?? 0, + noDomChangeWithinWindowCount: apiDomLagSummary.noDomChangeWithinWindowCount ?? 0, + overBudgetCount: apiDomLagSummary.overBudgetCount ?? 0, + budgetMs: apiDomLagSummary.budgetMs ?? null, + p95DomChangeDeltaMs: apiDomLagSummary.p95DomChangeDeltaMs ?? null, + maxDomChangeDeltaMs: apiDomLagSummary.maxDomChangeDeltaMs ?? null, + groups: Array.isArray(effectiveApiDomLag?.groups) ? effectiveApiDomLag.groups.slice(0, 12) : [], + }); if (errors.length > 0) findings.push({ id: "browser-console-or-page-errors", severity: "amber", summary: "pageerror/runner errors were captured", count: errors.length, first: errors.slice(0, 5) }); if (samples.length === 0) findings.push({ id: "no-samples", severity: "red", summary: "observer produced no samples" }); return findings; @@ -1699,7 +2029,8 @@ function buildRecentAnalysisWindow({ samples, control, network, consoleEvents, e const pagePerformance = buildPagePerformanceReport(windowSamples, manifest); const promptNetwork = buildPromptNetworkReport(windowControl, windowNetwork); const runtimeAlerts = buildRuntimeAlerts(windowSamples, control, windowNetwork, windowConsole, windowErrors); - const findings = buildFindings(windowSamples, control, windowNetwork, windowErrors, sampleMetrics, promptNetwork, runtimeAlerts, pagePerformance, pageProvenance); + const apiDomLag = buildApiDomLagReport(windowSamples, windowNetwork); + const findings = buildFindings(windowSamples, control, windowNetwork, windowErrors, sampleMetrics, promptNetwork, runtimeAlerts, pagePerformance, pageProvenance, [], {}, apiDomLag); return { summary: { name: "recent-5m", @@ -1718,6 +2049,7 @@ function buildRecentAnalysisWindow({ samples, control, network, consoleEvents, e pagePerformance, promptNetwork, runtimeAlerts, + apiDomLag, findings, valuesRedacted: true }; diff --git a/scripts/src/hwlab-node-web-observe-analyzer-timing-source.ts b/scripts/src/hwlab-node-web-observe-analyzer-timing-source.ts index 967fa0d2..730ec7f8 100644 --- a/scripts/src/hwlab-node-web-observe-analyzer-timing-source.ts +++ b/scripts/src/hwlab-node-web-observe-analyzer-timing-source.ts @@ -2164,6 +2164,8 @@ function renderMarkdown(report) { const traceOrder = report.sampleMetrics?.traceOrder || {}; const traceOrderSummary = traceOrder.summary || {}; const alertSummary = report.runtimeAlerts?.summary || {}; + const apiDomLag = report.apiDomLag || {}; + const apiDomLagSummary = apiDomLag.summary || {}; const projectManagement = report.projectManagement || {}; const projectSummary = projectManagement.summary || {}; const projectCommandLines = Array.isArray(projectManagement.commands) && projectManagement.commands.length > 0 @@ -2254,6 +2256,12 @@ function renderMarkdown(report) { const streamPerformanceLines = streamPerformanceItems.length > 0 ? streamPerformanceItems.slice(0, 80).map((item) => "- " + item.path + " kind=" + (item.routeKind || "same-origin-api-stream") + " samples=" + item.sampleCount + " streamOpenP50=" + (item.streamOpenP50Ms ?? "-") + "ms streamOpenP75=" + (item.streamOpenP75Ms ?? "-") + "ms streamOpenP95=" + (item.streamOpenP95Ms ?? "-") + "ms streamOpenMax=" + (item.streamOpenMaxMs ?? "-") + "ms streamOpen>budget=" + (item.streamOpenOverBudgetCount ?? item.streamOpenOverFiveSecondCount ?? 0) + " streamOpenBudgetMs=" + (item.streamOpenBudgetMs ?? streamOpenBudgetMs) + " streamOpenLegacy>5s=" + (item.streamOpenOverFiveSecondCount ?? 0) + " streamLifetime>5s=" + (item.streamLifetimeOverFiveSecondCount ?? 0) + " lifetimeMax=" + (item.maxMs ?? "-") + "ms window=" + (item.firstAt || "-") + ".." + (item.lastAt || "-")).join("\n") : "- 无同源长连接 Resource Timing 样本。"; + const apiDomLagGroupLines = Array.isArray(apiDomLag.groups) && apiDomLag.groups.length > 0 + ? apiDomLag.groups.slice(0, 80).map((item) => "- " + (item.method || "-") + " " + (item.path || "-") + " status=" + (item.status ?? "-") + " kind=" + (item.routeKind || "-") + " confidence=" + (item.confidence || "-") + " count=" + (item.count ?? 0) + " changed=" + (item.domChangedCount ?? 0) + " noChange=" + (item.noDomChangeWithinWindowCount ?? 0) + " p50=" + (item.p50DomChangeDeltaMs ?? "-") + "ms p95=" + (item.p95DomChangeDeltaMs ?? "-") + "ms max=" + (item.maxDomChangeDeltaMs ?? "-") + "ms overBudget=" + (item.overBudgetCount ?? 0) + " window=" + (item.firstAt || "-") + ".." + (item.lastAt || "-")).join("\n") + : "- 无 API-DOM 候选分组。"; + const apiDomLagWorstLines = Array.isArray(apiDomLag.worstCandidates) && apiDomLag.worstCandidates.length > 0 + ? apiDomLag.worstCandidates.slice(0, 40).map((item) => "- " + (item.ts || "-") + " " + (item.method || "-") + " " + (item.path || "-") + " status=" + (item.status ?? "-") + " confidence=" + (item.confidence || "-") + " domChange=" + (item.domChangeDeltaMs ?? "-") + "ms firstSample=" + (item.firstSampleDeltaMs ?? "-") + "ms session=" + (item.sessionId || "-") + " traceId=" + (item.traceId || "-") + " beforeSeq=" + (item.beforeSample?.seq ?? "-") + " changeSeq=" + (item.changeSample?.seq ?? "-")).join("\n") + : "- 无 API-DOM digest 变化候选。"; 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 + " loadingCount=" + (item.loadingCount ?? 0) + " loadingOwners=" + (item.loadingOwnerCount ?? 0) + " totalElapsedSeconds=" + (item.totalElapsedSeconds ?? "-") + " recentUpdateSeconds=" + (item.recentUpdateSeconds ?? "-") + " terminal=" + item.terminalSeen + " finalText=" + item.finalResultTextSeen + " diagnostic=" + item.diagnosticSeen).join("\n") : "- 无采样指标。"; @@ -2403,6 +2411,20 @@ function renderMarkdown(report) { + "### Page performance: long-lived streams\n\n" + "- policy: SSE/long-lived stream lifetime is not ordinary API load latency; only stream open latency is compared with the YAML usability budget, while disconnects remain runtime alerts.\n\n" + streamPerformanceLines + "\n\n" + + "### Natural API to DOM lag candidates\n\n" + + "- naturalApiResponseCount: " + (apiDomLagSummary.naturalApiResponseCount ?? 0) + "\n" + + "- stateRelevantResponseCount: " + (apiDomLagSummary.stateRelevantResponseCount ?? 0) + "\n" + + "- candidateCount: " + (apiDomLagSummary.candidateCount ?? 0) + "\n" + + "- domChangedCount: " + (apiDomLagSummary.domChangedCount ?? 0) + "\n" + + "- noDomChangeWithinWindowCount: " + (apiDomLagSummary.noDomChangeWithinWindowCount ?? 0) + "\n" + + "- budgetMs: " + (apiDomLagSummary.budgetMs ?? "-") + "\n" + + "- p95DomChangeDeltaMs: " + (apiDomLagSummary.p95DomChangeDeltaMs ?? "-") + "\n" + + "- maxDomChangeDeltaMs: " + (apiDomLagSummary.maxDomChangeDeltaMs ?? "-") + "\n" + + "- overBudgetCount: " + (apiDomLagSummary.overBudgetCount ?? 0) + "\n" + + "- lowConfidenceStreamOpenCount: " + (apiDomLagSummary.lowConfidenceStreamOpenCount ?? 0) + "\n" + + "- policy: 该指标是调查证据,不作为 Code Agent 阻塞项;/v1/workbench/events 只能代表 SSE stream-open 到后续 DOM 变化的低置信度候选。\n\n" + + "#### API-DOM groups\n\n" + apiDomLagGroupLines + "\n\n" + + "#### Worst API-DOM candidates\n\n" + apiDomLagWorstLines + "\n\n" + "### Prompt network\n\n" + promptNetworkLines + "\n\n" + "### Runtime alerts\n\n" + "- httpErrorCount: " + (alertSummary.httpErrorCount ?? 0) + "\n"