@@ -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.overFiveSecond Count > 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.overBudget Count > 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.overFiveSecond Count > 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.overBudget Count > 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.overFiveSecond Count > 0);
const slow = sameOriginApiByPath.filter((item) => item.overBudget Count > 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.overFiveSecond Count, 0),
slowSampleCount: slow.reduce((sum, item) => sum + item.overBudget Count, 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"