fix: make web-probe observe thresholds yaml-driven
This commit is contained in:
@@ -6730,6 +6730,21 @@ function runNodeWebProbeObserveCollect(options: NodeWebProbeObserveOptions, spec
|
||||
}
|
||||
|
||||
function runNodeWebProbeObserveAnalyze(options: NodeWebProbeObserveOptions, spec: HwlabRuntimeLaneSpec): Record<string, unknown> {
|
||||
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<string, unknown>): 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<string, unknown>): Record<
|
||||
webObserveTable(
|
||||
["PERF", "VALUE"],
|
||||
[
|
||||
["sameOriginApiBudgetMs", thresholds.sameOriginApiBudgetMs],
|
||||
["sameOriginApiPaths", pagePerformance.sameOriginApiPathCount],
|
||||
["slowPathCount", pagePerformance.slowPathCount],
|
||||
["slowSampleCount", pagePerformance.slowSampleCount],
|
||||
@@ -7235,8 +7254,26 @@ function renderWebObserveAnalyzeResult(result: Record<string, unknown>): 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
|
||||
|
||||
@@ -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`);
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user