b70b8d7334
Co-authored-by: Codex <codex@noreply.local>
2610 lines
144 KiB
TypeScript
2610 lines
144 KiB
TypeScript
// SPEC: PJ2026-01040111 long-running Workbench observation.
|
||
// Responsibility: Source string for the offline web-probe observe analyzer.
|
||
import { nodeWebObserveAnalyzerTimingSource } from "./hwlab-node-web-observe-analyzer-timing-source";
|
||
|
||
export function nodeWebObserveAnalyzerSource(): string {
|
||
return String.raw`#!/usr/bin/env node
|
||
import { createHash } from "node:crypto";
|
||
import { createReadStream } from "node:fs";
|
||
import { mkdir, readFile, readdir, stat, writeFile } from "node:fs/promises";
|
||
import path from "node:path";
|
||
import { createInterface } from "node:readline";
|
||
|
||
const stateDir = path.resolve(process.env.UNIDESK_WEB_OBSERVE_STATE_DIR || process.argv[2] || ".state/web-observe/manual");
|
||
const archivePrefix = safeArchivePrefix(process.env.UNIDESK_WEB_OBSERVE_ANALYZE_ARCHIVE_PREFIX || "");
|
||
const analyzeTailSamples = (() => {
|
||
const raw = process.env.UNIDESK_WEB_OBSERVE_ANALYZE_TAIL_SAMPLES;
|
||
if (raw === undefined || raw === null || String(raw).trim() === "") return 360;
|
||
const parsed = Number(raw);
|
||
return Number.isFinite(parsed) && parsed >= 0 ? Math.floor(parsed) : 360;
|
||
})();
|
||
const alertThresholds = parseAlertThresholds(process.env.UNIDESK_WEB_OBSERVE_ALERT_THRESHOLDS_JSON);
|
||
const dataDir = archivePrefix ? path.join(stateDir, "archive") : stateDir;
|
||
const dataFile = (name) => path.join(dataDir, archivePrefix ? archivePrefix + "-" + name : name);
|
||
const analysisDir = path.join(stateDir, "analysis");
|
||
const reportJsonPath = path.join(analysisDir, "report.json");
|
||
const reportMdPath = path.join(analysisDir, "report.md");
|
||
const jsonlReadIssues = [];
|
||
const sourceSamples = await readJsonl(dataFile("samples.jsonl"), { compact: compactSampleForAnalysis, tail: analyzeTailSamples });
|
||
const relatedJsonlTailLimit = analyzeTailSamples > 0 ? Math.max(1200, analyzeTailSamples * 50) : 0;
|
||
const smallJsonlTailLimit = analyzeTailSamples > 0 ? Math.max(500, analyzeTailSamples * 8) : 0;
|
||
const sourceSampleWindow = sampleTimeWindow(sourceSamples, 60_000);
|
||
const sourceControlAll = await readJsonl(dataFile("control.jsonl"), { tail: relatedJsonlTailLimit });
|
||
const analysisFocus = analysisFocusFromControl(sourceControlAll);
|
||
const sourceControl = filterRowsByTimeWindow(sourceControlAll, sourceSampleWindow);
|
||
const samples = applyAnalysisFocus(sourceSamples, analysisFocus);
|
||
const sampleWindow = sampleTimeWindow(samples, 60_000);
|
||
const controlWindow = analysisControlWindow(sampleWindow, analysisFocus, 1000);
|
||
const control = applyAnalysisFocus(filterRowsByTimeWindow(sourceControlAll, controlWindow), analysisFocus, 1000);
|
||
const sourceNetworkAll = await readJsonl(dataFile("network.jsonl"), { tail: relatedJsonlTailLimit });
|
||
const network = applyAnalysisFocus(filterRowsByTimeWindow(sourceNetworkAll, sampleWindow), analysisFocus);
|
||
const promptNetworkRows = applyAnalysisFocus(filterRowsByTimeWindow(sourceNetworkAll, controlWindow), analysisFocus);
|
||
const consoleEvents = applyAnalysisFocus(filterRowsByTimeWindow(await readJsonl(dataFile("console.jsonl"), { tail: smallJsonlTailLimit }), sampleWindow), analysisFocus);
|
||
const errors = applyAnalysisFocus(filterRowsByTimeWindow(await readJsonl(dataFile("errors.jsonl"), { tail: smallJsonlTailLimit }), sampleWindow), analysisFocus);
|
||
const artifacts = applyAnalysisFocus(filterRowsByTimeWindow(await readJsonl(dataFile("artifacts.jsonl"), { tail: smallJsonlTailLimit }), sampleWindow), analysisFocus);
|
||
const manifest = await readJson(path.join(stateDir, "manifest.json"));
|
||
const heartbeat = await readJson(path.join(stateDir, "heartbeat.json"));
|
||
|
||
await mkdir(analysisDir, { recursive: true, mode: 0o700 });
|
||
const transitions = buildTransitions(samples);
|
||
const sampleMetrics = buildSampleMetrics(samples, control);
|
||
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 runnerErrors = errors.slice(-8).map((item) => {
|
||
const attempts = Array.isArray(item.error?.attempts) ? item.error.attempts : [];
|
||
const lastAttempt = attempts.length > 0 ? attempts[attempts.length - 1] : null;
|
||
const readiness = lastAttempt?.readiness || item.error?.navigationReadiness || null;
|
||
const readinessSnapshot = readiness?.snapshot || readiness;
|
||
return {
|
||
ts: item.ts ?? null,
|
||
type: item.type ?? null,
|
||
commandId: item.commandId ?? null,
|
||
sampleSeq: item.sampleSeq ?? null,
|
||
message: limitText(item.error?.message ?? item.message ?? "", 240),
|
||
retry: item.error?.auth?.lastRetryLabel ?? null,
|
||
retryExhausted: item.error?.auth?.retryExhausted === true,
|
||
lastError: limitText(item.error?.auth?.lastError ?? "", 160),
|
||
attemptCount: attempts.length,
|
||
lastFailureKind: lastAttempt?.failureKind ?? null,
|
||
lastReadinessReason: readiness?.reason ?? null,
|
||
lastReadiness: readinessSnapshot ? {
|
||
reason: readiness?.reason ?? readinessSnapshot.reason ?? null,
|
||
path: readinessSnapshot.path ?? null,
|
||
readyState: readinessSnapshot.readyState ?? null,
|
||
workbenchShellVisible: readinessSnapshot.workbenchShellVisible === true,
|
||
sessionCreateVisible: readinessSnapshot.sessionCreateVisible === true,
|
||
commandInputPresent: readinessSnapshot.commandInputPresent === true,
|
||
activeTabPresent: readinessSnapshot.activeTabPresent === true,
|
||
warningPresent: readinessSnapshot.warningPresent === true,
|
||
loginVisible: readinessSnapshot.loginVisible === true,
|
||
bodyTextHash: readinessSnapshot.bodyTextHash ?? null,
|
||
valuesRedacted: true
|
||
} : null,
|
||
navigationAttempts: attempts.slice(-5).map((attempt) => ({
|
||
attempt: attempt?.attempt ?? null,
|
||
ok: attempt?.ok === true,
|
||
failureKind: attempt?.failureKind ?? null,
|
||
message: limitText(attempt?.message ?? "", 160),
|
||
readinessReason: attempt?.readiness?.reason ?? null,
|
||
valuesRedacted: true
|
||
})),
|
||
};
|
||
});
|
||
const commandFailures = summarizeCommandFailures(control);
|
||
const findings = buildFindings(samples, control, network, errors, sampleMetrics, promptNetwork, runtimeAlerts, pagePerformance, pageProvenance, commandFailures);
|
||
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 }));
|
||
const report = {
|
||
ok: findings.filter((item) => item.severity === "red").length === 0,
|
||
command: "web-probe-observe analyze",
|
||
generatedAt: new Date().toISOString(),
|
||
stateDir,
|
||
jsonlScope: { mode: archivePrefix ? "archive" : "current", archivePrefix: archivePrefix || null, dataDir, analyzeTailSamples, sourceSampleCount: sourceSamples.length, effectiveSampleCount: samples.length, sourceControlCount: sourceControlAll.length, sampleWindow, focus: analysisFocus, valuesRedacted: true },
|
||
alertThresholds,
|
||
manifest: compactManifest(manifest),
|
||
heartbeat: compactHeartbeat(heartbeat),
|
||
counts: { samples: samples.length, control: control.length, network: network.length, console: consoleEvents.length, errors: errors.length, artifacts: artifacts.length },
|
||
commandTimeline,
|
||
transitions,
|
||
sampleMetrics,
|
||
pageProvenance,
|
||
pagePerformance,
|
||
promptNetwork,
|
||
runtimeAlerts,
|
||
runnerErrors,
|
||
commandFailures,
|
||
findings,
|
||
windows: { recent: recentWindow },
|
||
readIssues: jsonlReadIssues,
|
||
artifactSummary: await artifactSummary(artifacts),
|
||
safety: { offlineOnly: true, browserDriven: false, apiFetch: false, valuesRedacted: true },
|
||
};
|
||
await writeFile(reportJsonPath, JSON.stringify(report, null, 2) + "\n", { mode: 0o600 });
|
||
await writeFile(reportMdPath, renderMarkdown(report), { mode: 0o600 });
|
||
const [jsonMeta, mdMeta] = await Promise.all([fileMeta(reportJsonPath), fileMeta(reportMdPath)]);
|
||
console.log(JSON.stringify({
|
||
ok: true,
|
||
command: "web-probe-observe analyze",
|
||
stateDir,
|
||
jsonlScope: report.jsonlScope,
|
||
reportJsonPath,
|
||
reportJsonSha256: jsonMeta.sha256,
|
||
reportMdPath,
|
||
reportMdSha256: mdMeta.sha256,
|
||
counts: report.counts,
|
||
archiveSummary: {
|
||
sampleMetrics: sampleMetrics.summary,
|
||
pagePerformance: pagePerformance.summary,
|
||
runtimeAlerts: runtimeAlerts.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) })),
|
||
},
|
||
analysisWindow: recentWindow.summary,
|
||
jsonlReadIssues: jsonlReadIssues.slice(0, 3).map((item) => ({ file: item.file, line: item.line ?? null, error: String(item.error ?? "").slice(0, 160) })),
|
||
readIssues: jsonlReadIssues.slice(0, 3).map((item) => ({ file: item.file, line: item.line ?? null, error: String(item.error ?? "").slice(0, 160) })),
|
||
sampleMetrics: {
|
||
...recentWindow.sampleMetrics.summary,
|
||
rounds: recentWindow.sampleMetrics.rounds.slice(-8).map((item) => ({ promptIndex: item.promptIndex, promptTextHash: item.promptTextHash, sampleCount: item.sampleCount, firstSeq: item.firstSeq, lastSeq: item.lastSeq, lastTotalElapsedSeconds: item.lastTotalElapsedSeconds, lastRecentUpdateSeconds: item.lastRecentUpdateSeconds, loadingSamples: item.loadingSamples, maxLoadingCount: item.maxLoadingCount, loadingOwnerCount: item.loadingOwnerCount, diagnosticSamples: item.diagnosticSamples, terminalSamples: item.terminalSamples, finalTextSamples: item.finalTextSamples, turnTimingTotalElapsedZeroResetCount: item.turnTimingTotalElapsedZeroResetCount, turnTimingTotalElapsedForwardJumpCount: item.turnTimingTotalElapsedForwardJumpCount, turnTimingTotalElapsedForwardJumpMaxSeconds: item.turnTimingTotalElapsedForwardJumpMaxSeconds, turnTimingRecentUpdateJumpCount: item.turnTimingRecentUpdateJumpCount, turnTimingRecentUpdateMaxIncreaseSeconds: item.turnTimingRecentUpdateMaxIncreaseSeconds })),
|
||
turnColumns: recentWindow.sampleMetrics.turnColumns.slice(-12).map((item) => ({ label: item.label, source: item.source, pageRole: item.pageRole ?? null, pageId: item.pageId ?? null, pageEpoch: item.pageEpoch ?? null, promptIndex: item.promptIndex, lastPromptIndex: item.lastPromptIndex, firstSeq: item.firstSeq, lastSeq: item.lastSeq, traceId: item.traceId, messageId: item.messageId })),
|
||
loading: compactLoadingMetricsForOutput(recentWindow.sampleMetrics.loading),
|
||
sessionRailTitles: compactSessionRailTitleMetricsForOutput(recentWindow.sampleMetrics.sessionRailTitles),
|
||
},
|
||
pageProvenance: recentWindow.pageProvenance.summary,
|
||
pagePerformance: recentWindow.pagePerformance.summary,
|
||
promptNetwork: recentWindow.promptNetwork.summary,
|
||
runtimeAlerts: recentWindow.runtimeAlerts.summary,
|
||
runnerErrors,
|
||
commandFailures: commandFailures.slice(-8),
|
||
httpErrorGroups: recentWindow.runtimeAlerts.networkHttpErrorsByPath.slice(0, 8).map((item) => ({
|
||
method: item.method ?? null,
|
||
status: item.status ?? null,
|
||
path: item.urlPath ?? null,
|
||
count: item.count,
|
||
firstAt: item.firstAt ?? null,
|
||
lastAt: item.lastAt ?? null,
|
||
promptIndexes: Array.isArray(item.promptIndexes) ? item.promptIndexes.slice(0, 6) : [],
|
||
failureKinds: Array.isArray(item.failureKinds) ? item.failureKinds.slice(0, 4) : [],
|
||
})),
|
||
requestFailedGroups: recentWindow.runtimeAlerts.networkRequestFailedByPath.slice(0, 8).map((item) => ({
|
||
method: item.method ?? null,
|
||
status: item.status ?? null,
|
||
path: item.urlPath ?? null,
|
||
count: item.count,
|
||
firstAt: item.firstAt ?? null,
|
||
lastAt: item.lastAt ?? null,
|
||
promptIndexes: Array.isArray(item.promptIndexes) ? item.promptIndexes.slice(0, 6) : [],
|
||
failureKinds: Array.isArray(item.failureKinds) ? item.failureKinds.slice(0, 4) : [],
|
||
})),
|
||
domDiagnosticGroups: recentWindow.runtimeAlerts.domDiagnosticsByFingerprint.slice(0, 5).map((item) => ({
|
||
diagnosticCode: item.diagnosticCode ?? null,
|
||
count: item.count,
|
||
firstAt: item.firstAt,
|
||
lastAt: item.lastAt,
|
||
promptIndexes: Array.isArray(item.promptIndexes) ? item.promptIndexes.slice(0, 6) : [],
|
||
traceIds: Array.isArray(item.traceIds) ? item.traceIds.slice(0, 4) : [],
|
||
text: String(item.preview ?? item.normalizedPreview ?? item.text ?? "").slice(0, 160),
|
||
})),
|
||
domDiagnosticSamples: recentWindow.runtimeAlerts.domDiagnostics.slice(-8).map((item) => ({
|
||
seq: item.seq ?? null,
|
||
ts: item.ts ?? null,
|
||
promptIndex: item.promptIndex ?? null,
|
||
source: item.source ?? null,
|
||
diagnosticCode: item.diagnosticCode ?? null,
|
||
traceId: item.traceId ?? null,
|
||
httpStatus: item.httpStatus ?? null,
|
||
idleSeconds: item.idleSeconds ?? null,
|
||
waitingFor: item.waitingFor ?? null,
|
||
lastEventLabel: item.lastEventLabel ?? null,
|
||
text: String(item.preview ?? item.text ?? "").slice(0, 180),
|
||
})),
|
||
consoleAlertGroups: recentWindow.runtimeAlerts.consoleAlertsByPath.slice(0, 8).map((item) => ({
|
||
type: item.type ?? null,
|
||
status: item.status ?? null,
|
||
path: item.urlPath ?? null,
|
||
count: item.count,
|
||
firstAt: item.firstAt ?? null,
|
||
lastAt: item.lastAt ?? null,
|
||
promptIndexes: Array.isArray(item.promptIndexes) ? item.promptIndexes.slice(0, 6) : [],
|
||
traceIds: Array.isArray(item.traceIds) ? item.traceIds.slice(0, 4) : [],
|
||
})),
|
||
consoleAlertSamples: recentWindow.runtimeAlerts.consoleAlerts.slice(-8).map((item) => ({
|
||
ts: item.ts ?? null,
|
||
promptIndex: item.promptIndex ?? null,
|
||
type: item.type ?? null,
|
||
status: item.status ?? null,
|
||
path: item.urlPath ?? null,
|
||
traceId: item.traceId ?? null,
|
||
text: String(item.preview ?? item.text ?? "").slice(0, 180),
|
||
})),
|
||
turnTimingRecentUpdateJumps: recentWindow.sampleMetrics.turnTimingRecentUpdateSawtoothJumps.slice(0, 8).map((item) => ({
|
||
columnLabel: item.columnLabel ?? item.columnId ?? null,
|
||
pageRole: item.pageRole ?? null,
|
||
pageId: item.pageId ?? null,
|
||
pageEpoch: item.pageEpoch ?? null,
|
||
promptIndex: item.promptIndex ?? null,
|
||
traceId: item.traceId ?? null,
|
||
fromSeq: item.fromSeq ?? null,
|
||
toSeq: item.toSeq ?? null,
|
||
fromTs: item.fromTs ?? null,
|
||
toTs: item.toTs ?? null,
|
||
fromValue: item.fromValue ?? null,
|
||
toValue: item.toValue ?? null,
|
||
delta: item.delta ?? null,
|
||
sampleDeltaSeconds: item.sampleDeltaSeconds ?? null,
|
||
allowedIncreaseSeconds: item.allowedIncreaseSeconds ?? null,
|
||
excessiveIncreaseSeconds: item.excessiveIncreaseSeconds ?? null,
|
||
})),
|
||
turnTimingElapsedZeroResets: recentWindow.sampleMetrics.turnTimingElapsedZeroResets.slice(0, 8).map((item) => ({
|
||
columnLabel: item.columnLabel ?? item.columnId ?? null,
|
||
pageRole: item.pageRole ?? null,
|
||
pageId: item.pageId ?? null,
|
||
pageEpoch: item.pageEpoch ?? null,
|
||
promptIndex: item.promptIndex ?? null,
|
||
traceId: item.traceId ?? null,
|
||
fromSeq: item.fromSeq ?? null,
|
||
toSeq: item.toSeq ?? null,
|
||
fromTs: item.fromTs ?? null,
|
||
toTs: item.toTs ?? null,
|
||
fromValue: item.fromValue ?? null,
|
||
toValue: item.toValue ?? null,
|
||
delta: item.delta ?? null,
|
||
anomaly: item.anomaly ?? null,
|
||
})),
|
||
turnTimingTotalElapsedForwardJumps: recentWindow.sampleMetrics.turnTimingTotalElapsedForwardJumps.slice(0, 8).map((item) => ({
|
||
columnLabel: item.columnLabel ?? item.columnId ?? null,
|
||
pageRole: item.pageRole ?? null,
|
||
pageId: item.pageId ?? null,
|
||
pageEpoch: item.pageEpoch ?? null,
|
||
promptIndex: item.promptIndex ?? null,
|
||
traceId: item.traceId ?? null,
|
||
fromSeq: item.fromSeq ?? null,
|
||
toSeq: item.toSeq ?? null,
|
||
fromTs: item.fromTs ?? null,
|
||
toTs: item.toTs ?? null,
|
||
fromValue: item.fromValue ?? null,
|
||
toValue: item.toValue ?? null,
|
||
delta: item.delta ?? null,
|
||
sampleDeltaSeconds: item.sampleDeltaSeconds ?? null,
|
||
allowedIncreaseSeconds: item.allowedIncreaseSeconds ?? null,
|
||
excessiveIncreaseSeconds: item.excessiveIncreaseSeconds ?? null,
|
||
anomaly: item.anomaly ?? null,
|
||
})),
|
||
pagePerformanceSlowApi: recentWindow.pagePerformance.sameOriginApiByPath.filter((item) => item.isLongLivedStream !== true && Number(item.overBudgetCount ?? item.overFiveSecondCount ?? 0) > 0).slice(0, 5).map((item) => ({ path: item.path, route: item.route, sampleCount: item.sampleCount, p95Ms: item.p95Ms, maxMs: item.maxMs, overBudgetCount: item.overBudgetCount, budgetMs: item.budgetMs, overFiveSecondCount: item.overFiveSecondCount, slowSamples: item.slowSamples })),
|
||
archivePagePerformanceSlowApi: pagePerformance.sameOriginApiByPath.filter((item) => item.isLongLivedStream !== true && Number(item.overBudgetCount ?? item.overFiveSecondCount ?? 0) > 0).slice(0, 8).map((item) => ({ path: item.path, route: item.route, sampleCount: item.sampleCount, p95Ms: item.p95Ms, maxMs: item.maxMs, overBudgetCount: item.overBudgetCount, budgetMs: item.budgetMs, overFiveSecondCount: item.overFiveSecondCount, slowSamples: item.slowSamples })),
|
||
pagePerformancePartialApi: recentWindow.pagePerformance.sameOriginApiByPath.filter((item) => item.isLongLivedStream !== true && Number(item.partialOverBudgetCount ?? item.partialOverFiveSecondCount ?? 0) > 0).slice(0, 5).map((item) => ({ path: item.path, route: item.route, sampleCount: item.sampleCount, completeTimingSampleCount: item.completeTimingSampleCount, partialTimingSampleCount: item.partialTimingSampleCount, partialOverBudgetCount: item.partialOverBudgetCount, budgetMs: item.partialBudgetMs, partialOverFiveSecondCount: item.partialOverFiveSecondCount, partialSamples: item.partialSamples })),
|
||
pagePerformanceSseStreams: recentWindow.pagePerformance.sameOriginApiByPath.filter((item) => item.isLongLivedStream === true).slice(0, 5).map((item) => ({ path: item.path, route: item.route, sampleCount: item.sampleCount, streamOpenSampleCount: item.streamOpenSampleCount, streamOpenP95Ms: item.streamOpenP95Ms, streamOpenMaxMs: item.streamOpenMaxMs, streamOpenOverBudgetCount: item.streamOpenOverBudgetCount, streamOpenBudgetMs: item.streamOpenBudgetMs, streamOpenOverFiveSecondCount: item.streamOpenOverFiveSecondCount, streamLifetimeOverFiveSecondCount: item.streamLifetimeOverFiveSecondCount, slowSamples: item.slowSamples })),
|
||
findings: prioritizeFindings(recentWindow.findings).slice(0, 8).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) })),
|
||
traceOrderAnomalies: (recentWindow.sampleMetrics?.traceOrder?.orderAnomalies || []).slice(0, 8).map((item) => ({
|
||
sampleSeq: item.sampleSeq ?? null,
|
||
sampleIndex: item.sampleIndex ?? null,
|
||
timestamp: item.timestamp ?? null,
|
||
pageRole: item.pageRole ?? null,
|
||
traceId: item.traceId ?? null,
|
||
reasons: item.reasons ?? [],
|
||
previousRowIndex: item.previousRowIndex ?? null,
|
||
currentRowIndex: item.currentRowIndex ?? null,
|
||
previousTotalSeconds: item.previousTotalSeconds ?? null,
|
||
currentTotalSeconds: item.currentTotalSeconds ?? null,
|
||
previousProjectedSeq: item.previousProjectedSeq ?? null,
|
||
currentProjectedSeq: item.currentProjectedSeq ?? null,
|
||
previousSourceSeq: item.previousSourceSeq ?? null,
|
||
currentSourceSeq: item.currentSourceSeq ?? null,
|
||
previousEventSeq: item.previousEventSeq ?? null,
|
||
currentEventSeq: item.currentEventSeq ?? null,
|
||
previousPreview: String(item.previousPreview || "").slice(0, 120),
|
||
currentPreview: String(item.currentPreview || "").slice(0, 120),
|
||
})),
|
||
traceCompletionNotLast: (recentWindow.sampleMetrics?.traceOrder?.completionNotLast || []).slice(0, 8).map((item) => ({
|
||
sampleSeq: item.sampleSeq ?? null,
|
||
sampleIndex: item.sampleIndex ?? null,
|
||
timestamp: item.timestamp ?? null,
|
||
pageRole: item.pageRole ?? null,
|
||
traceId: item.traceId ?? null,
|
||
completionRowIndex: item.completionRowIndex ?? null,
|
||
laterRowIndex: item.laterRowIndex ?? null,
|
||
completionTotalSeconds: item.completionTotalSeconds ?? null,
|
||
laterTotalSeconds: item.laterTotalSeconds ?? null,
|
||
completionProjectedSeq: item.completionProjectedSeq ?? null,
|
||
laterProjectedSeq: item.laterProjectedSeq ?? null,
|
||
completionPreview: String(item.completionPreview || "").slice(0, 120),
|
||
laterPreview: String(item.laterPreview || "").slice(0, 120),
|
||
})),
|
||
roundCompletionElapsedMismatches: (recentWindow.sampleMetrics?.codeAgentCardTiming?.roundCompletion?.elapsedMismatches || []).slice(0, 8).map((item) => ({
|
||
seq: item.seq ?? null,
|
||
ts: item.ts ?? null,
|
||
pageRole: item.pageRole ?? null,
|
||
promptIndex: item.promptIndex ?? null,
|
||
traceId: item.traceId ?? null,
|
||
completionElapsedSeconds: item.completionElapsedSeconds ?? null,
|
||
cardTotalElapsedSeconds: item.cardTotalElapsedSeconds ?? null,
|
||
deltaSeconds: item.deltaSeconds ?? null,
|
||
toleranceSeconds: item.toleranceSeconds ?? null,
|
||
})),
|
||
codeAgentCardDurationUnderreported: (recentWindow.sampleMetrics?.codeAgentCardTiming?.durationUnderreported || []).slice(0, 8).map((item) => ({
|
||
sampleIndex: item.sampleIndex ?? null,
|
||
timestamp: item.timestamp ?? null,
|
||
pageRole: item.pageRole ?? null,
|
||
traceId: item.traceId ?? null,
|
||
cardTotalElapsedSeconds: item.cardTotalElapsedSeconds ?? null,
|
||
expectedElapsedSeconds: item.expectedElapsedSeconds ?? null,
|
||
deltaSeconds: item.deltaSeconds ?? null,
|
||
evidenceKind: item.evidenceKind ?? null,
|
||
evidencePreview: String(item.evidencePreview || "").slice(0, 120),
|
||
})),
|
||
codeAgentCardDurationMismatches: (recentWindow.sampleMetrics?.codeAgentCardTiming?.durationMismatches || []).slice(0, 8).map((item) => ({
|
||
sampleIndex: item.sampleIndex ?? null,
|
||
timestamp: item.timestamp ?? null,
|
||
pageRole: item.pageRole ?? null,
|
||
traceId: item.traceId ?? null,
|
||
direction: item.direction ?? null,
|
||
cardTotalElapsedSeconds: item.cardTotalElapsedSeconds ?? null,
|
||
expectedElapsedSeconds: item.expectedElapsedSeconds ?? null,
|
||
signedDeltaSeconds: item.signedDeltaSeconds ?? null,
|
||
deltaSeconds: item.deltaSeconds ?? null,
|
||
evidenceKind: item.evidenceKind ?? null,
|
||
exactEvidence: item.exactEvidence === true,
|
||
evidencePreview: String(item.evidencePreview || "").slice(0, 120),
|
||
})),
|
||
valuesRedacted: true,
|
||
}));
|
||
|
||
async function readJson(file) {
|
||
try { return JSON.parse(await readFile(file, "utf8")); } catch { return null; }
|
||
}
|
||
|
||
function safeArchivePrefix(value) {
|
||
const text = String(value || "").trim();
|
||
if (!text) return "";
|
||
if (!/^[A-Za-z0-9_.-]+$/u.test(text) || text.includes("..")) throw new Error("unsafe archive prefix: " + text);
|
||
return text;
|
||
}
|
||
|
||
function positiveNumber(value, fallback) {
|
||
const parsed = Number(value);
|
||
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
|
||
}
|
||
|
||
function requiredPositiveThreshold(raw, key) {
|
||
const parsed = Number(raw?.[key]);
|
||
if (!Number.isFinite(parsed) || parsed <= 0) {
|
||
throw new Error("UNIDESK_WEB_OBSERVE_ALERT_THRESHOLDS_JSON requires positive " + key + "; configure config/hwlab-node-lanes.yaml webProbe.alertThresholds");
|
||
}
|
||
return parsed;
|
||
}
|
||
|
||
function parseAlertThresholds(value) {
|
||
if (!value) {
|
||
throw new Error("UNIDESK_WEB_OBSERVE_ALERT_THRESHOLDS_JSON is required; configure config/hwlab-node-lanes.yaml webProbe.alertThresholds for the selected node/lane");
|
||
}
|
||
const raw = (() => {
|
||
try { return JSON.parse(value); } catch (error) { throw new Error("UNIDESK_WEB_OBSERVE_ALERT_THRESHOLDS_JSON is invalid JSON: " + (error instanceof Error ? error.message : String(error))); }
|
||
})();
|
||
const sessionRailFallbackRatio = requiredPositiveThreshold(raw, "sessionRailFallbackRatio");
|
||
if (sessionRailFallbackRatio > 1) {
|
||
throw new Error("UNIDESK_WEB_OBSERVE_ALERT_THRESHOLDS_JSON sessionRailFallbackRatio must be <= 1");
|
||
}
|
||
return {
|
||
sameOriginApiSlowMs: requiredPositiveThreshold(raw, "sameOriginApiSlowMs"),
|
||
partialApiSlowMs: requiredPositiveThreshold(raw, "partialApiSlowMs"),
|
||
longLivedStreamOpenSlowMs: requiredPositiveThreshold(raw, "longLivedStreamOpenSlowMs"),
|
||
visibleLoadingSlowMs: requiredPositiveThreshold(raw, "visibleLoadingSlowMs"),
|
||
turnTimingSampleSlackSeconds: requiredPositiveThreshold(raw, "turnTimingSampleSlackSeconds"),
|
||
uncommandedStateChangeCommandWindowMs: requiredPositiveThreshold(raw, "uncommandedStateChangeCommandWindowMs"),
|
||
scrollJumpCommandWindowMs: requiredPositiveThreshold(raw, "scrollJumpCommandWindowMs"),
|
||
scrollJumpFromY: requiredPositiveThreshold(raw, "scrollJumpFromY"),
|
||
scrollJumpToY: requiredPositiveThreshold(raw, "scrollJumpToY"),
|
||
sessionRailFallbackRatio,
|
||
crossPageProjectionDivergenceRedMs: positiveNumber(raw.crossPageProjectionDivergenceRedMs, requiredPositiveThreshold(raw, "visibleLoadingSlowMs")),
|
||
source: "yaml-env",
|
||
};
|
||
}
|
||
|
||
async function readJsonl(file, options = {}) {
|
||
const tailLimit = Number.isFinite(Number(options.tail)) && Number(options.tail) > 0 ? Math.floor(Number(options.tail)) : 0;
|
||
if (tailLimit > 0) return readJsonlTail(file, tailLimit, options);
|
||
const rows = [];
|
||
try {
|
||
const input = createReadStream(file, { encoding: "utf8" });
|
||
const lines = createInterface({ input, crlfDelay: Infinity });
|
||
let lineNo = 0;
|
||
for await (const rawLine of lines) {
|
||
lineNo += 1;
|
||
const line = String(rawLine || "").trim();
|
||
if (!line) continue;
|
||
try {
|
||
const parsed = JSON.parse(line);
|
||
rows.push(typeof options.compact === "function" ? options.compact(parsed) : parsed);
|
||
} catch (error) {
|
||
const item = { parseError: true, lineNo, rawHash: sha256(line), errorMessage: limitText(error && error.message ? error.message : String(error), 240) };
|
||
rows.push(item);
|
||
if (jsonlReadIssues.length < 50) jsonlReadIssues.push({ file: path.basename(file), kind: "parse-error", lineNo, rawHash: item.rawHash, errorMessage: item.errorMessage });
|
||
}
|
||
}
|
||
return rows;
|
||
} catch (error) {
|
||
if (error && error.code === "ENOENT") return [];
|
||
if (jsonlReadIssues.length < 50) jsonlReadIssues.push({ file: path.basename(file), kind: "read-error", code: error && error.code ? String(error.code) : null, errorMessage: limitText(error && error.message ? error.message : String(error), 240) });
|
||
return [];
|
||
}
|
||
}
|
||
|
||
async function readJsonlTail(file, limit, options = {}) {
|
||
try {
|
||
const lines = await readTailLines(file, limit);
|
||
const rows = [];
|
||
let lineNo = 0;
|
||
for (const rawLine of lines) {
|
||
lineNo += 1;
|
||
const line = String(rawLine || "").trim();
|
||
if (!line) continue;
|
||
try {
|
||
const parsed = JSON.parse(line);
|
||
rows.push(typeof options.compact === "function" ? options.compact(parsed) : parsed);
|
||
} catch (error) {
|
||
const item = { parseError: true, lineNo, tail: true, rawHash: sha256(line), errorMessage: limitText(error && error.message ? error.message : String(error), 240) };
|
||
rows.push(item);
|
||
if (jsonlReadIssues.length < 50) jsonlReadIssues.push({ file: path.basename(file), kind: "parse-error", lineNo, tail: true, rawHash: item.rawHash, errorMessage: item.errorMessage });
|
||
}
|
||
}
|
||
return rows;
|
||
} catch (error) {
|
||
if (error && error.code === "ENOENT") return [];
|
||
if (jsonlReadIssues.length < 50) jsonlReadIssues.push({ file: path.basename(file), kind: "read-error", tail: true, code: error && error.code ? String(error.code) : null, errorMessage: limitText(error && error.message ? error.message : String(error), 240) });
|
||
return [];
|
||
}
|
||
}
|
||
|
||
async function readTailLines(file, limit) {
|
||
const info = await stat(file);
|
||
if (!info.size || limit <= 0) return [];
|
||
const chunkSize = 64 * 1024;
|
||
const maxBytes = Math.max(32 * 1024 * 1024, Math.min(256 * 1024 * 1024, limit * 512 * 1024));
|
||
const chunks = [];
|
||
let position = info.size;
|
||
let readBytes = 0;
|
||
let newlineCount = 0;
|
||
while (position > 0 && newlineCount <= limit && readBytes < maxBytes) {
|
||
const readSize = Math.min(chunkSize, position, maxBytes - readBytes);
|
||
position -= readSize;
|
||
const chunk = await readFileSlice(file, position, readSize);
|
||
chunks.unshift(chunk);
|
||
readBytes += chunk.length;
|
||
for (let index = 0; index < chunk.length; index += 1) {
|
||
if (chunk[index] === 10) newlineCount += 1;
|
||
}
|
||
}
|
||
if (position > 0 && newlineCount <= limit && jsonlReadIssues.length < 50) {
|
||
jsonlReadIssues.push({ file: path.basename(file), kind: "tail-scan-truncated", limit, readBytes, fileBytes: info.size });
|
||
}
|
||
const text = Buffer.concat(chunks).toString("utf8");
|
||
let lines = text.split(/\r?\n/u);
|
||
if (lines.length > 0 && lines[lines.length - 1] === "") lines.pop();
|
||
if (position > 0 && lines.length > 0) lines.shift();
|
||
return lines.slice(-limit);
|
||
}
|
||
|
||
async function readFileSlice(file, start, length) {
|
||
const chunks = [];
|
||
await new Promise((resolve, reject) => {
|
||
const stream = createReadStream(file, { start, end: start + length - 1 });
|
||
stream.on("data", (chunk) => chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)));
|
||
stream.on("error", reject);
|
||
stream.on("end", resolve);
|
||
});
|
||
return Buffer.concat(chunks);
|
||
}
|
||
|
||
function sampleTimeWindow(samples, paddingMs) {
|
||
const times = samples
|
||
.map((item) => Date.parse(String(item && item.ts || "")))
|
||
.filter((value) => Number.isFinite(value));
|
||
if (times.length === 0) return { startMs: null, endMs: null, startAt: null, endAt: null, paddingMs };
|
||
const startMs = Math.max(0, Math.min(...times) - Math.max(0, paddingMs || 0));
|
||
const endMs = Math.max(...times) + Math.max(0, paddingMs || 0);
|
||
return { startMs, endMs, startAt: new Date(startMs).toISOString(), endAt: new Date(endMs).toISOString(), paddingMs };
|
||
}
|
||
|
||
function filterRowsByTimeWindow(rows, window) {
|
||
if (!window || !Number.isFinite(window.startMs) || !Number.isFinite(window.endMs)) return rows;
|
||
return rows.filter((item) => {
|
||
const value = item && (item.ts || item.observedAt || item.startedAt || item.finishedAt || item.createdAt || item.updatedAt);
|
||
const ms = Date.parse(String(value || ""));
|
||
if (!Number.isFinite(ms)) return true;
|
||
return ms >= window.startMs && ms <= window.endMs;
|
||
});
|
||
}
|
||
|
||
function analysisFocusFromControl(control) {
|
||
const completedNewSessions = (Array.isArray(control) ? control : [])
|
||
.filter((item) => item?.type === "newSession" && item?.phase === "completed" && Number.isFinite(Date.parse(String(item.ts || ""))))
|
||
.sort((a, b) => Date.parse(String(a.ts || "")) - Date.parse(String(b.ts || "")));
|
||
const latest = completedNewSessions.at(-1) ?? null;
|
||
if (!latest) return { mode: "all", reason: "no-new-session-command", startAt: null, startMs: null, commandId: null, valuesRedacted: true };
|
||
const startMs = Date.parse(String(latest.ts));
|
||
return {
|
||
mode: "after-new-session",
|
||
reason: "latest-completed-new-session",
|
||
startAt: new Date(startMs).toISOString(),
|
||
startMs,
|
||
commandId: latest.commandId ?? null,
|
||
valuesRedacted: true
|
||
};
|
||
}
|
||
|
||
function applyAnalysisFocus(rows, focus, graceMs = 0) {
|
||
if (!focus || !Number.isFinite(focus.startMs)) return rows;
|
||
const minMs = focus.startMs - Math.max(0, Number(graceMs) || 0);
|
||
return (Array.isArray(rows) ? rows : []).filter((item) => {
|
||
const value = item && (item.ts || item.observedAt || item.startedAt || item.finishedAt || item.createdAt || item.updatedAt);
|
||
const ms = Date.parse(String(value || ""));
|
||
return Number.isFinite(ms) ? ms >= minMs : true;
|
||
});
|
||
}
|
||
|
||
function analysisControlWindow(sampleWindow, focus, graceMs = 0) {
|
||
if (!sampleWindow || !Number.isFinite(sampleWindow.endMs)) return sampleWindow;
|
||
if (!focus || !Number.isFinite(focus.startMs)) return sampleWindow;
|
||
const startMs = Math.max(0, Math.min(Number(sampleWindow.startMs ?? focus.startMs), focus.startMs - Math.max(0, Number(graceMs) || 0)));
|
||
return {
|
||
...sampleWindow,
|
||
startMs,
|
||
startAt: new Date(startMs).toISOString()
|
||
};
|
||
}
|
||
|
||
function compactSampleForAnalysis(sample) {
|
||
if (!sample || typeof sample !== "object") return sample;
|
||
return {
|
||
seq: sample.seq ?? null,
|
||
ts: sample.ts ?? null,
|
||
reason: sample.reason ?? null,
|
||
sampleGroupSeq: sample.sampleGroupSeq ?? null,
|
||
pageId: sample.pageId ?? null,
|
||
pageRole: sample.pageRole ?? null,
|
||
commandId: sample.commandId ?? null,
|
||
observerInitiated: sample.observerInitiated ?? null,
|
||
url: sample.url ?? null,
|
||
path: sample.path ?? null,
|
||
routeSessionId: sample.routeSessionId ?? null,
|
||
activeSessionId: sample.activeSessionId ?? null,
|
||
messages: compactDomItems(sample.messages),
|
||
traceRows: compactDomItems(sample.traceRows),
|
||
loadings: compactLoadingItems(sample.loadings),
|
||
sessionRail: compactSessionRail(sample.sessionRail),
|
||
turns: compactDomItems(sample.turns),
|
||
diagnostics: compactDomItems(sample.diagnostics),
|
||
pageProvenance: compactSamplePageProvenance(sample.pageProvenance),
|
||
performance: compactPerformanceItems(sample.performance)
|
||
};
|
||
}
|
||
|
||
function compactSessionRail(value) {
|
||
if (!value || typeof value !== "object") return null;
|
||
const items = Array.isArray(value.items) ? value.items.slice(0, 80).map((item) => ({
|
||
index: item?.index ?? null,
|
||
tag: item?.tag ?? null,
|
||
testId: item?.testId ?? null,
|
||
role: item?.role ?? null,
|
||
active: item?.active === true,
|
||
sessionIdPrefix: item?.sessionIdPrefix ?? (item?.sessionId ? String(item.sessionId).slice(0, 12) : null),
|
||
fallbackTitle: item?.fallbackTitle === true,
|
||
titlePreview: limitText(String(item?.titlePreview || item?.titleText || ""), 180),
|
||
titleHash: item?.titleHash ?? sha256(String(item?.titlePreview || item?.titleText || "")),
|
||
titleBytes: item?.titleBytes ?? null,
|
||
})) : [];
|
||
const fallbackItems = Array.isArray(value.fallbackItems)
|
||
? value.fallbackItems.slice(0, 20).map((item) => ({
|
||
index: item?.index ?? null,
|
||
active: item?.active === true,
|
||
sessionIdPrefix: item?.sessionIdPrefix ?? (item?.sessionId ? String(item.sessionId).slice(0, 12) : null),
|
||
titlePreview: limitText(String(item?.titlePreview || item?.titleText || ""), 180),
|
||
titleHash: item?.titleHash ?? sha256(String(item?.titlePreview || item?.titleText || "")),
|
||
}))
|
||
: items.filter((item) => item.fallbackTitle).slice(0, 20);
|
||
const visibleCount = Number(value.visibleCount ?? items.length);
|
||
const fallbackTitleCount = Number(value.fallbackTitleCount ?? fallbackItems.length);
|
||
return {
|
||
visibleCount: Number.isFinite(visibleCount) ? visibleCount : items.length,
|
||
fallbackTitleCount: Number.isFinite(fallbackTitleCount) ? fallbackTitleCount : fallbackItems.length,
|
||
fallbackTitleRatio: Number.isFinite(Number(value.fallbackTitleRatio)) ? Number(value.fallbackTitleRatio) : (items.length > 0 ? Number((fallbackItems.length / items.length).toFixed(4)) : 0),
|
||
items,
|
||
fallbackItems,
|
||
valuesRedacted: true
|
||
};
|
||
}
|
||
|
||
function compactLoadingItems(items) {
|
||
if (!Array.isArray(items)) return [];
|
||
return items.map((item) => {
|
||
if (!item || typeof item !== "object") return item;
|
||
const rawText = String(item.text ?? item.textPreview ?? "");
|
||
return {
|
||
index: item.index ?? null,
|
||
tag: item.tag ?? null,
|
||
testId: item.testId ?? null,
|
||
role: item.role ?? null,
|
||
ownerKind: item.ownerKind ?? null,
|
||
ownerKey: item.ownerKey ?? null,
|
||
ownerLabel: item.ownerLabel ?? null,
|
||
owner: item.owner && typeof item.owner === "object" ? {
|
||
tag: item.owner.tag ?? null,
|
||
testId: item.owner.testId ?? null,
|
||
role: item.owner.role ?? null,
|
||
id: item.owner.id ?? null,
|
||
className: item.owner.className ?? null,
|
||
status: item.owner.status ?? null,
|
||
sessionId: item.owner.sessionId ?? null,
|
||
messageId: item.owner.messageId ?? null,
|
||
traceId: item.owner.traceId ?? null,
|
||
ariaLabel: item.owner.ariaLabel ?? null,
|
||
} : null,
|
||
text: limitText(rawText, 400),
|
||
textPreview: limitText(String(item.textPreview ?? rawText), 240),
|
||
textHash: item.textHash ?? sha256(rawText),
|
||
textBytes: item.textBytes ?? Buffer.byteLength(rawText),
|
||
ownerTextHash: item.ownerTextHash ?? null,
|
||
ownerTextPreview: item.ownerTextPreview ? limitText(item.ownerTextPreview, 240) : null,
|
||
};
|
||
});
|
||
}
|
||
|
||
function compactDomItems(items) {
|
||
if (!Array.isArray(items)) return [];
|
||
return items.map(compactDomItem);
|
||
}
|
||
|
||
function compactDomItem(item) {
|
||
if (!item || typeof item !== "object") return item;
|
||
const rawText = String(item.text ?? item.textPreview ?? "");
|
||
const preview = String(item.textPreview ?? limitText(rawText, 240));
|
||
return {
|
||
index: item.index ?? null,
|
||
tag: item.tag ?? null,
|
||
testId: item.testId ?? null,
|
||
role: item.role ?? null,
|
||
dataRole: item.dataRole ?? null,
|
||
status: item.status ?? null,
|
||
sessionId: item.sessionId ?? null,
|
||
messageId: item.messageId ?? null,
|
||
traceId: item.traceId ?? null,
|
||
turnId: item.turnId ?? null,
|
||
projectedSeq: Number.isFinite(Number(item.projectedSeq)) ? Number(item.projectedSeq) : null,
|
||
sourceSeq: Number.isFinite(Number(item.sourceSeq)) ? Number(item.sourceSeq) : null,
|
||
eventSeq: Number.isFinite(Number(item.eventSeq)) ? Number(item.eventSeq) : null,
|
||
eventTimestamp: item.eventTimestamp ?? null,
|
||
eventTimeText: item.eventTimeText ?? null,
|
||
eventKind: item.eventKind ?? null,
|
||
durationText: item.durationText ?? null,
|
||
activityText: item.activityText ?? null,
|
||
className: item.className ?? null,
|
||
diagnosticCode: item.diagnosticCode ?? null,
|
||
source: item.source ?? null,
|
||
sources: Array.isArray(item.sources) ? item.sources.slice(0, 8) : undefined,
|
||
text: limitText(rawText, 4000),
|
||
textPreview: limitText(preview, 600),
|
||
textHash: item.textHash ?? sha256(rawText),
|
||
textBytes: item.textBytes ?? Buffer.byteLength(rawText)
|
||
};
|
||
}
|
||
|
||
function compactPerformanceItems(items) {
|
||
if (!Array.isArray(items)) return [];
|
||
return items.map((item) => ({
|
||
name: item?.name ?? null,
|
||
initiatorType: item?.initiatorType ?? null,
|
||
startTime: item?.startTime ?? null,
|
||
duration: item?.duration ?? null,
|
||
workerStart: item?.workerStart ?? null,
|
||
redirectStart: item?.redirectStart ?? null,
|
||
redirectEnd: item?.redirectEnd ?? null,
|
||
fetchStart: item?.fetchStart ?? null,
|
||
domainLookupStart: item?.domainLookupStart ?? null,
|
||
domainLookupEnd: item?.domainLookupEnd ?? null,
|
||
connectStart: item?.connectStart ?? null,
|
||
connectEnd: item?.connectEnd ?? null,
|
||
secureConnectionStart: item?.secureConnectionStart ?? null,
|
||
requestStart: item?.requestStart ?? null,
|
||
responseStart: item?.responseStart ?? null,
|
||
responseEnd: item?.responseEnd ?? null,
|
||
transferSize: item?.transferSize ?? null,
|
||
encodedBodySize: item?.encodedBodySize ?? null,
|
||
decodedBodySize: item?.decodedBodySize ?? null,
|
||
nextHopProtocol: item?.nextHopProtocol ?? null
|
||
}));
|
||
}
|
||
|
||
function compactLoadingMetricsForOutput(value) {
|
||
if (!value || typeof value !== "object") return null;
|
||
return {
|
||
summary: value.summary ?? null,
|
||
longestSegments: Array.isArray(value.segments) ? value.segments.slice(0, 8) : [],
|
||
owners: Array.isArray(value.owners) ? value.owners.slice(0, 8) : [],
|
||
timeline: Array.isArray(value.timeline) ? value.timeline.slice(-12) : [],
|
||
valuesRedacted: true
|
||
};
|
||
}
|
||
|
||
function compactSessionRailTitleMetricsForOutput(value) {
|
||
if (!value || typeof value !== "object") return null;
|
||
return {
|
||
summary: value.summary ?? null,
|
||
samples: Array.isArray(value.samples) ? value.samples.slice(0, 12) : [],
|
||
examples: Array.isArray(value.examples) ? value.examples.slice(0, 12) : [],
|
||
timeline: Array.isArray(value.timeline) ? value.timeline.slice(-12) : [],
|
||
valuesRedacted: true
|
||
};
|
||
}
|
||
|
||
function compactSamplePageProvenance(value) {
|
||
if (!value || typeof value !== "object") return null;
|
||
return {
|
||
pageLoadSeq: value.pageLoadSeq ?? null,
|
||
reason: value.reason ?? null,
|
||
observedAt: value.observedAt ?? null,
|
||
urlPath: value.urlPath ?? null,
|
||
documentReadyState: value.documentReadyState ?? null,
|
||
timeOrigin: value.timeOrigin ?? null,
|
||
httpStatus: value.httpStatus ?? null,
|
||
assetFingerprint: value.assetFingerprint ?? null,
|
||
scriptCount: value.scriptCount ?? null,
|
||
stylesheetCount: value.stylesheetCount ?? null,
|
||
metaCount: value.metaCount ?? null,
|
||
scripts: Array.isArray(value.scripts) ? value.scripts.slice(0, 20) : [],
|
||
stylesheets: Array.isArray(value.stylesheets) ? value.stylesheets.slice(0, 20) : [],
|
||
error: value.error ?? null,
|
||
valuesRedacted: true
|
||
};
|
||
}
|
||
|
||
function summarizeCommandFailures(control) {
|
||
return control.filter((item) => item?.phase === "failed").map((item) => {
|
||
const detail = item?.detail && typeof item.detail === "object" ? item.detail : {};
|
||
const error = detail?.error && typeof detail.error === "object" ? detail.error : detail;
|
||
return {
|
||
ts: item.ts ?? null,
|
||
commandId: item.commandId ?? null,
|
||
type: item.type ?? item.input?.type ?? null,
|
||
source: item.source ?? null,
|
||
durationMs: detail.durationMs ?? null,
|
||
beforePath: urlPath(detail.beforeUrl || item.beforeUrl),
|
||
afterPath: urlPath(detail.afterUrl || item.afterUrl),
|
||
name: error?.name ?? null,
|
||
message: limitText(error?.message ?? detail?.message ?? "", 240),
|
||
failureKind: error?.failureKind ?? detail?.failureKind ?? null,
|
||
failureSampleOk: detail?.failureSample?.ok === true,
|
||
sampleSeq: detail?.failureSample?.sampleSeq ?? null,
|
||
valuesRedacted: true
|
||
};
|
||
});
|
||
}
|
||
|
||
function buildFindings(samples, control, network, errors, sampleMetrics, promptNetwork, runtimeAlerts, pagePerformance, pageProvenance, commandFailures = []) {
|
||
const findings = [];
|
||
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) });
|
||
const commandTimes = control
|
||
.filter((item) => item.phase === "completed" || item.phase === "started" || item.type === "observer-periodic-refresh")
|
||
.map((item) => Date.parse(item.ts))
|
||
.filter(Number.isFinite);
|
||
const routeSessions = new Set(samples.map((item) => item.routeSessionId).filter(Boolean));
|
||
const activeSessions = new Set(samples.map((item) => item.activeSessionId).filter(Boolean));
|
||
if (routeSessions.size > 1) findings.push({ id: "session-route-changed", severity: "amber", summary: "route session changed during observation", routeSessionCount: routeSessions.size, samples: sampleRefs(samples, (item) => item.routeSessionId) });
|
||
if (activeSessions.size > 1) findings.push({ id: "active-session-changed", severity: "amber", summary: "active session changed during observation", activeSessionCount: activeSessions.size, samples: sampleRefs(samples, (item) => item.activeSessionId) });
|
||
const mismatches = samples.filter((item) => item.routeSessionId && item.activeSessionId && item.routeSessionId !== item.activeSessionId);
|
||
if (mismatches.length > 0) findings.push({ id: "route-active-session-mismatch", severity: "red", summary: "routeSessionId and activeSessionId diverged", count: mismatches.length, samples: mismatches.slice(0, 10).map(ref) });
|
||
const uncommandedChanges = [];
|
||
const commandedPromptSeqs = new Set((sampleMetrics?.timeline ?? []).filter((item) => Number(item?.promptIndex) > 0).map((item) => item.seq).filter((seq) => seq !== null && seq !== undefined));
|
||
const previousDigestByPage = new Map();
|
||
for (const sample of samples) {
|
||
const pageKey = samplePageKey(sample);
|
||
const previous = previousDigestByPage.get(pageKey);
|
||
const next = digestSample(sample);
|
||
if (previous && previous.digest !== next && !commandedPromptSeqs.has(sample?.seq) && !nearCommand(sample, commandTimes, alertThresholds.uncommandedStateChangeCommandWindowMs)) {
|
||
uncommandedChanges.push({
|
||
...ref(sample),
|
||
previousSeq: previous.sample?.seq ?? null,
|
||
previousTs: previous.sample?.timestamp ?? previous.sample?.ts ?? null,
|
||
fromDigest: previous.digest,
|
||
toDigest: next,
|
||
fromMessageCount: previous.sample?.messageCount ?? previous.sample?.messages?.length ?? null,
|
||
toMessageCount: sample?.messageCount ?? sample?.messages?.length ?? null,
|
||
fromTraceRowCount: Array.isArray(previous.sample?.traceRows) ? previous.sample.traceRows.length : previous.sample?.traceRowCount ?? null,
|
||
toTraceRowCount: Array.isArray(sample?.traceRows) ? sample.traceRows.length : sample?.traceRowCount ?? null,
|
||
fromPath: previous.sample?.path ?? previous.sample?.urlPath ?? null,
|
||
toPath: sample?.path ?? sample?.urlPath ?? null,
|
||
detail: "visible digest changed without nearby command",
|
||
});
|
||
}
|
||
previousDigestByPage.set(pageKey, { digest: next, sample });
|
||
}
|
||
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);
|
||
if (finalFlicker.length > 0) findings.push({ id: "final-response-flicker", severity: "red", summary: "message text digest disappeared or switched to diagnostic-like text after non-empty final text", count: finalFlicker.length, samples: finalFlicker.slice(0, 20) });
|
||
const terminalZeroElapsed = detectTerminalZeroElapsed(samples);
|
||
if (terminalZeroElapsed.length > 0) findings.push({ id: "turn-terminal-zero-elapsed", severity: "red", summary: "terminal Code Agent card displayed 耗时 0 秒; terminal duration must come from durable timing projection, not a missing/zero display fallback", count: terminalZeroElapsed.length, samples: terminalZeroElapsed.slice(0, 20) });
|
||
const cardTiming = sampleMetrics?.codeAgentCardTiming || {};
|
||
const cardTimingSummary = cardTiming.summary || {};
|
||
if (Number(cardTimingSummary.missingElapsedCount ?? 0) > 0) findings.push({ id: "code-agent-card-elapsed-missing", severity: "red", summary: "visible Code Agent card did not display total elapsed time; elapsed must be visible for running and terminal cards", count: cardTimingSummary.missingElapsedCount, samples: (cardTiming.missingElapsed || []).slice(0, 20) });
|
||
if (Number(cardTimingSummary.missingRecentUpdateCount ?? 0) > 0) findings.push({ id: "code-agent-card-running-recent-update-missing", severity: "red", summary: "non-terminal Code Agent card did not display 最近更新", count: cardTimingSummary.missingRecentUpdateCount, samples: (cardTiming.missingRecentUpdate || []).slice(0, 20) });
|
||
const roundCompletion = cardTiming.roundCompletion || {};
|
||
if (Number(cardTimingSummary.durationUnderreportedCount ?? 0) > 0) {
|
||
findings.push({
|
||
id: "code-agent-card-duration-underreported",
|
||
severity: "red",
|
||
summary: "completed Code Agent card total elapsed is shorter than trace/final-response duration evidence",
|
||
count: cardTimingSummary.durationUnderreportedCount,
|
||
samples: (cardTiming.durationUnderreported || []).slice(0, 20),
|
||
});
|
||
}
|
||
if (Number(cardTimingSummary.durationMismatchCount ?? 0) > 0) {
|
||
findings.push({
|
||
id: "code-agent-card-duration-mismatch",
|
||
severity: "red",
|
||
summary: "completed Code Agent card total elapsed does not match sealed completion/final-response timing evidence",
|
||
count: cardTimingSummary.durationMismatchCount,
|
||
samples: (cardTiming.durationMismatches || []).slice(0, 20),
|
||
});
|
||
}
|
||
const traceOrder = sampleMetrics?.traceOrder || {};
|
||
const traceOrderSummary = traceOrder.summary || {};
|
||
if (Number(traceOrderSummary.orderAnomalyCount ?? 0) > 0) {
|
||
findings.push({
|
||
id: "trace-row-order-nonmonotonic",
|
||
severity: "red",
|
||
summary: "visible trace rows are not monotonic by total time, clock time, or projected sequence in DOM order",
|
||
count: traceOrderSummary.orderAnomalyCount,
|
||
samples: (traceOrder.orderAnomalies || []).slice(0, 20),
|
||
});
|
||
}
|
||
if (Number(traceOrderSummary.completionNotLastCount ?? 0) > 0) {
|
||
findings.push({
|
||
id: "trace-completion-row-not-last",
|
||
severity: "red",
|
||
summary: "visible trace shows a completion row before later trace rows for the same trace",
|
||
count: traceOrderSummary.completionNotLastCount,
|
||
samples: (traceOrder.completionNotLast || []).slice(0, 20),
|
||
});
|
||
}
|
||
if (Number(cardTimingSummary.roundCompletionElapsedMismatchCount ?? 0) > 0) findings.push({ id: "round-completion-elapsed-mismatch", severity: "red", summary: "Trace row 轮次完成(总耗时 ...) does not match the visible Code Agent card total elapsed time within YAML timing slack", count: cardTimingSummary.roundCompletionElapsedMismatchCount, toleranceSeconds: cardTimingSummary.elapsedMismatchToleranceSeconds, samples: (roundCompletion.elapsedMismatches || []).slice(0, 20) });
|
||
if (Number(cardTimingSummary.roundCompletionFinalResponseMissingCount ?? 0) > 0) findings.push({ id: "round-completion-final-response-missing", severity: "red", summary: "Trace row showed 轮次完成, but no final response was visible in the Code Agent card afterward", count: cardTimingSummary.roundCompletionFinalResponseMissingCount, samples: (roundCompletion.finalResponseMissing || []).slice(0, 20) });
|
||
if (Number(cardTimingSummary.roundCompletionPostTimingChangeCount ?? 0) > 0) findings.push({ id: "round-completion-post-timing-change", severity: "red", summary: "After 轮次完成, card total elapsed or 最近更新 continued changing; terminal timing should be sealed", count: cardTimingSummary.roundCompletionPostTimingChangeCount, samples: (roundCompletion.postCompletionTimingChanges || []).slice(0, 20) });
|
||
if (Number(cardTimingSummary.roundCompletionPostRecentUpdateVisibleCount ?? 0) > 0) findings.push({ id: "round-completion-recent-update-still-visible", severity: "info", summary: "最近更新 was still visible after 轮次完成; inspect whether terminal cards should hide activity age or keep it sealed", count: cardTimingSummary.roundCompletionPostRecentUpdateVisibleCount, samples: (roundCompletion.postCompletionRecentUpdateVisible || []).slice(0, 20) });
|
||
const scrollJumps = [];
|
||
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 > alertThresholds.scrollJumpFromY && nextY < alertThresholds.scrollJumpToY && !nearCommand(samples[i], commandTimes, alertThresholds.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 || ""))));
|
||
const traceSeen = samples.some((item) => Array.isArray(item.traceRows) && item.traceRows.length > 0);
|
||
if (traceSeen && !traceTerminal) findings.push({ id: "trace-without-terminal", severity: "amber", summary: "trace rows were visible but no terminal status was sampled", firstTraceSample: ref(samples.find((item) => Array.isArray(item.traceRows) && item.traceRows.length > 0)) });
|
||
const promptFailures = Array.isArray(promptNetwork?.rounds) ? promptNetwork.rounds.filter((item) => item.chatPostOk === false) : [];
|
||
if (promptFailures.length > 0) findings.push({ id: "prompt-chat-submit-failed", severity: "red", summary: "sendPrompt command had no successful /v1/agent/chat or /v1/agent/chat/steer POST response in the sampling window", count: promptFailures.length, rounds: promptFailures.slice(0, 10) });
|
||
const promptSteerRounds = Array.isArray(promptNetwork?.rounds) ? promptNetwork.rounds.filter((item) => item.steerUsed === true) : [];
|
||
if (promptSteerRounds.length > 0) findings.push({ id: "prompt-routed-to-steer", severity: "amber", summary: "sendPrompt was submitted through /v1/agent/chat/steer; verify the previous turn was truly in-flight and not an unsealed terminal failure", count: promptSteerRounds.length, rounds: promptSteerRounds.slice(0, 10) });
|
||
const elapsedZeroResets = Array.isArray(sampleMetrics?.turnTimingElapsedZeroResets) ? sampleMetrics.turnTimingElapsedZeroResets : [];
|
||
if (elapsedZeroResets.length > 0) findings.push({ id: "turn-timing-total-elapsed-zero-reset", severity: "red", summary: "Code Agent total elapsed jumped from a non-zero value back to 0 seconds", count: elapsedZeroResets.length, samples: elapsedZeroResets.slice(0, 20) });
|
||
const elapsedDecreases = Array.isArray(sampleMetrics?.turnTimingNonMonotonic)
|
||
? sampleMetrics.turnTimingNonMonotonic.filter((item) => item.metric === "totalElapsedSeconds" && item.anomaly !== "zero-reset")
|
||
: [];
|
||
if (elapsedDecreases.length > 0) findings.push({ id: "turn-timing-total-elapsed-decrease", severity: "red", summary: "Code Agent total elapsed decreased between adjacent samples; total elapsed must be monotonic per turn", count: elapsedDecreases.length, samples: elapsedDecreases.slice(0, 20) });
|
||
const elapsedForwardJumps = Array.isArray(sampleMetrics?.turnTimingTotalElapsedForwardJumps) ? sampleMetrics.turnTimingTotalElapsedForwardJumps : [];
|
||
if (elapsedForwardJumps.length > 0) findings.push({ id: "turn-timing-total-elapsed-forward-jump", severity: "red", summary: "Code Agent total elapsed jumped forward faster than browser sample interval", count: elapsedForwardJumps.length, samples: elapsedForwardJumps.slice(0, 20) });
|
||
const terminalElapsedGrowth = Array.isArray(sampleMetrics?.turnTimingTerminalElapsedGrowth) ? sampleMetrics.turnTimingTerminalElapsedGrowth : [];
|
||
if (terminalElapsedGrowth.length > 0) findings.push({ id: "turn-timing-terminal-elapsed-growth", severity: "red", summary: "terminal Code Agent card total elapsed changed after terminal status; completed/failed/canceled timing must be sealed", count: terminalElapsedGrowth.length, samples: terminalElapsedGrowth.slice(0, 20) });
|
||
const recentUpdateSawtoothJumps = Array.isArray(sampleMetrics?.turnTimingRecentUpdateSawtoothJumps)
|
||
? sampleMetrics.turnTimingRecentUpdateSawtoothJumps
|
||
: Array.isArray(sampleMetrics?.turnTimingNonMonotonic)
|
||
? sampleMetrics.turnTimingNonMonotonic.filter((item) => item.metric === "recentUpdateSeconds" && item.anomaly === "jump")
|
||
: [];
|
||
if (recentUpdateSawtoothJumps.length > 0) findings.push({ id: "turn-timing-recent-update-sawtooth-jump", severity: "amber", summary: "最近更新 value jumped faster than sample interval; expected sawtooth increase-or-reset", count: recentUpdateSawtoothJumps.length, samples: recentUpdateSawtoothJumps.slice(0, 20) });
|
||
const loadingSummary = sampleMetrics?.loading?.summary || {};
|
||
const visibleLoadingSlowSeconds = alertThresholds.visibleLoadingSlowMs / 1000;
|
||
if (Number(loadingSummary.longestContinuousSeconds ?? 0) > visibleLoadingSlowSeconds) findings.push({ id: "page-loading-visible-over-budget", severity: "red", summary: "visible 加载中 stayed on screen longer than configured YAML budget; fix real loading latency instead of revealing incomplete content early", count: loadingSummary.overBudgetSegmentCount ?? loadingSummary.overFiveSecondSegmentCount ?? 1, longestContinuousSeconds: loadingSummary.longestContinuousSeconds, budgetSeconds: visibleLoadingSlowSeconds, segments: sampleMetrics.loading.segments.slice(0, 20), owners: sampleMetrics.loading.owners.slice(0, 20) });
|
||
if (Number(loadingSummary.maxSimultaneousCount ?? 0) > 1) findings.push({ id: "page-loading-concurrent", severity: "info", summary: "multiple 加载中 indicators were visible in the same sampled DOM point", count: loadingSummary.concurrentLoadingSampleCount ?? 0, maxSimultaneousCount: loadingSummary.maxSimultaneousCount, owners: sampleMetrics.loading.owners.slice(0, 20) });
|
||
const sessionRailTitleSummary = sampleMetrics?.sessionRailTitles?.summary || {};
|
||
if (Number(sessionRailTitleSummary.overThresholdSampleCount ?? sessionRailTitleSummary.majorityFallbackSampleCount ?? 0) > 0) findings.push({ id: "session-rail-title-fallback-over-threshold", severity: "red", summary: "visible session list rows exceeded configured YAML fallback-title ratio", count: sessionRailTitleSummary.overThresholdSampleCount ?? sessionRailTitleSummary.majorityFallbackSampleCount, thresholdRatio: alertThresholds.sessionRailFallbackRatio, maxFallbackRatio: sessionRailTitleSummary.maxFallbackRatio, maxFallbackTitleCount: sessionRailTitleSummary.maxFallbackTitleCount, samples: sampleMetrics.sessionRailTitles.samples.slice(0, 20), examples: sampleMetrics.sessionRailTitles.examples.slice(0, 20) });
|
||
if ((runtimeAlerts?.summary?.httpErrorCount ?? 0) > 0) findings.push({ id: "runtime-http-errors", severity: "amber", summary: "natural page requests returned HTTP error status during observation", count: runtimeAlerts.summary.httpErrorCount, groups: runtimeAlerts.networkHttpErrorsByPath.slice(0, 12) });
|
||
if ((runtimeAlerts?.summary?.significantRequestFailedCount ?? runtimeAlerts?.summary?.requestFailedCount ?? 0) > 0) findings.push({ id: "runtime-requestfailed", severity: "amber", summary: "browser requestfailed events were captured during observation", count: runtimeAlerts.summary.significantRequestFailedCount ?? runtimeAlerts.summary.requestFailedCount, groups: (runtimeAlerts.networkSignificantRequestFailedByPath ?? runtimeAlerts.networkRequestFailedByPath).slice(0, 12) });
|
||
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, groupCount: runtimeAlerts.summary.domDiagnosticGroupCount ?? 0, groups: runtimeAlerts.domDiagnosticsByText.slice(0, 12), 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?.significantConsoleAlertCount ?? 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.significantConsoleAlertCount ?? runtimeAlerts.summary.consoleAlertCount, groups: (runtimeAlerts.significantConsoleAlertsByPath ?? runtimeAlerts.consoleAlertsByPath).slice(0, 12) });
|
||
const crossPageDiffs = mergeCrossPageDiffRows(
|
||
detectCrossPageProjectionDiffs(samples),
|
||
detectAdjacentCrossPageProjectionDiffs(samples)
|
||
);
|
||
const crossPageProjectionDiffs = crossPageDiffs.filter((item) => item.diffKind !== "trace-visibility");
|
||
const crossPageTraceVisibilityDiffs = crossPageDiffs.filter((item) => item.diffKind === "trace-visibility");
|
||
const crossPageProjectionBudgetMs = alertThresholds.crossPageProjectionDivergenceRedMs;
|
||
const timedCrossPageProjectionDiffs = annotateCrossPageDiffTiming(crossPageProjectionDiffs);
|
||
const persistentCrossPageProjectionDiffs = timedCrossPageProjectionDiffs.filter((item) => Number(item.observedSpanMs ?? 0) > crossPageProjectionBudgetMs);
|
||
const transientCrossPageProjectionDiffs = timedCrossPageProjectionDiffs.filter((item) => Number(item.observedSpanMs ?? 0) <= crossPageProjectionBudgetMs);
|
||
if (persistentCrossPageProjectionDiffs.length > 0) findings.push({ id: "cross-page-projection-divergence", severity: "red", summary: "control and observer pages saw different projection state for the same sampled session beyond the configured budget", count: persistentCrossPageProjectionDiffs.length, budgetMs: crossPageProjectionBudgetMs, samples: persistentCrossPageProjectionDiffs.slice(0, 20) });
|
||
if (transientCrossPageProjectionDiffs.length > 0) findings.push({ id: "cross-page-projection-transient-divergence", severity: "info", summary: "control and observer pages briefly differed near a sampled transition; retained as transient evidence but not treated as persistent projection failure", count: transientCrossPageProjectionDiffs.length, budgetMs: crossPageProjectionBudgetMs, samples: transientCrossPageProjectionDiffs.slice(0, 20) });
|
||
if (crossPageTraceVisibilityDiffs.length > 0) findings.push({ id: "cross-page-trace-visibility-divergence", severity: "info", summary: "control and observer pages differed only in visible trace row count; this is local disclosure/hydration visibility, not session/message projection divergence", count: crossPageTraceVisibilityDiffs.length, samples: crossPageTraceVisibilityDiffs.slice(0, 20) });
|
||
const traceMessageDuplicates = detectTraceMessageDuplication(samples);
|
||
if (traceMessageDuplicates.length > 0) findings.push({ id: "trace-assistant-message-duplicates-final-response", severity: "amber", summary: "trace rendered assistant message rows that duplicate the sealed final response", count: traceMessageDuplicates.length, samples: traceMessageDuplicates.slice(0, 20) });
|
||
const turnTraceMissing = detectTurnTraceIdMissing(samples);
|
||
if (turnTraceMissing.length > 0) findings.push({ id: "turn-trace-id-missing", severity: "red", summary: "Code Agent turn/card was visible without a trace id, so historical trace hydration cannot be reliable", count: turnTraceMissing.length, samples: turnTraceMissing.slice(0, 20) });
|
||
const pagePerformanceItems = Array.isArray(pagePerformance?.sameOriginApiByPath) ? pagePerformance.sameOriginApiByPath : [];
|
||
const slowApi = pagePerformanceItems.filter((item) => item.isLongLivedStream !== true && Number(item.overBudgetCount ?? item.overFiveSecondCount ?? 0) > 0);
|
||
if (slowApi.length > 0) findings.push({ id: "page-performance-slow-same-origin-api", severity: "red", summary: "same-origin API resource timing exceeded configured YAML usability budget", count: slowApi.length, budgetMs: alertThresholds.sameOriginApiSlowMs, groups: slowApi.slice(0, 20) });
|
||
const longLivedStreams = pagePerformanceItems.filter((item) => item.isLongLivedStream);
|
||
const slowStreamOpen = longLivedStreams.filter((item) => Number(item.streamOpenOverBudgetCount ?? item.streamOpenOverFiveSecondCount ?? 0) > 0);
|
||
if (slowStreamOpen.length > 0) findings.push({ id: "page-performance-slow-long-lived-stream-open", severity: "red", summary: "long-lived stream open latency exceeded configured YAML usability budget; stream lifetime is still reported separately", count: slowStreamOpen.length, budgetMs: alertThresholds.longLivedStreamOpenSlowMs, groups: slowStreamOpen.slice(0, 20) });
|
||
if (longLivedStreams.length > 0) findings.push({ id: "page-performance-long-lived-streams", severity: "info", summary: "same-origin long-lived streams are reported separately; lifetime is not treated as API load latency", count: longLivedStreams.length, groups: longLivedStreams.slice(0, 20) });
|
||
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 });
|
||
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;
|
||
}
|
||
|
||
function buildRecentAnalysisWindow({ samples, control, network, consoleEvents, errors, manifest }) {
|
||
const latestSampleMs = latestTimestampMs(samples);
|
||
const windowMs = 5 * 60 * 1000;
|
||
const fromMs = Number.isFinite(latestSampleMs) ? latestSampleMs - windowMs : Number.NEGATIVE_INFINITY;
|
||
const toMs = Number.isFinite(latestSampleMs) ? latestSampleMs : Number.POSITIVE_INFINITY;
|
||
const inWindow = (item) => {
|
||
const tsMs = Date.parse(item?.ts);
|
||
return Number.isFinite(tsMs) && tsMs >= fromMs && tsMs <= toMs;
|
||
};
|
||
const windowSamples = samples.filter(inWindow);
|
||
const windowControl = control.filter(inWindow);
|
||
const windowNetwork = network.filter(inWindow);
|
||
const windowConsole = consoleEvents.filter(inWindow);
|
||
const windowErrors = errors.filter(inWindow);
|
||
const sampleMetrics = buildSampleMetrics(windowSamples, control);
|
||
const pageProvenance = buildPageProvenanceReport(windowSamples, windowControl, manifest);
|
||
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);
|
||
return {
|
||
summary: {
|
||
name: "recent-5m",
|
||
windowMs,
|
||
fromAt: Number.isFinite(fromMs) ? new Date(fromMs).toISOString() : null,
|
||
toAt: Number.isFinite(toMs) ? new Date(toMs).toISOString() : null,
|
||
samples: windowSamples.length,
|
||
control: windowControl.length,
|
||
network: windowNetwork.length,
|
||
console: windowConsole.length,
|
||
errors: windowErrors.length,
|
||
valuesRedacted: true
|
||
},
|
||
sampleMetrics,
|
||
pageProvenance,
|
||
pagePerformance,
|
||
promptNetwork,
|
||
runtimeAlerts,
|
||
findings,
|
||
valuesRedacted: true
|
||
};
|
||
}
|
||
|
||
function latestTimestampMs(items) {
|
||
let latest = Number.NEGATIVE_INFINITY;
|
||
for (const item of items || []) {
|
||
const tsMs = Date.parse(item?.ts);
|
||
if (Number.isFinite(tsMs) && tsMs > latest) latest = tsMs;
|
||
}
|
||
return latest;
|
||
}
|
||
|
||
function buildPageProvenanceReport(samples, control, manifest) {
|
||
const groups = new Map();
|
||
for (const sample of samples) {
|
||
const provenance = sample?.pageProvenance;
|
||
if (!provenance) continue;
|
||
const key = provenance.assetFingerprint || "unknown";
|
||
const group = groups.get(key) || {
|
||
assetFingerprint: provenance.assetFingerprint || null,
|
||
pageLoadSeqs: [],
|
||
sampleCount: 0,
|
||
firstSeq: sample.seq ?? null,
|
||
lastSeq: sample.seq ?? null,
|
||
firstAt: sample.ts ?? null,
|
||
lastAt: sample.ts ?? null,
|
||
urlPaths: [],
|
||
scriptCount: provenance.scriptCount ?? null,
|
||
stylesheetCount: provenance.stylesheetCount ?? null,
|
||
metaCount: provenance.metaCount ?? null,
|
||
scripts: Array.isArray(provenance.scripts) ? provenance.scripts.slice(0, 12) : [],
|
||
stylesheets: Array.isArray(provenance.stylesheets) ? provenance.stylesheets.slice(0, 12) : [],
|
||
valuesRedacted: true
|
||
};
|
||
group.sampleCount += 1;
|
||
group.lastSeq = sample.seq ?? null;
|
||
group.lastAt = sample.ts ?? null;
|
||
if (provenance.pageLoadSeq !== null && provenance.pageLoadSeq !== undefined && !group.pageLoadSeqs.includes(provenance.pageLoadSeq)) group.pageLoadSeqs.push(provenance.pageLoadSeq);
|
||
if (provenance.urlPath && !group.urlPaths.includes(provenance.urlPath)) group.urlPaths.push(provenance.urlPath);
|
||
groups.set(key, group);
|
||
}
|
||
const segments = Array.from(groups.values()).sort((a, b) => Number(a.firstSeq ?? 0) - Number(b.firstSeq ?? 0));
|
||
const controlSegments = control
|
||
.filter((item) => item.type === "page-provenance" || item?.pageProvenance)
|
||
.map((item) => ({
|
||
ts: item.ts ?? null,
|
||
reason: item.reason ?? item.detail?.reason ?? null,
|
||
httpStatus: item.httpStatus ?? item.detail?.httpStatus ?? null,
|
||
pageProvenance: item.pageProvenance ?? item.detail?.pageProvenance ?? null,
|
||
}))
|
||
.slice(0, 80);
|
||
return {
|
||
summary: {
|
||
segmentCount: segments.length,
|
||
sampleCount: segments.reduce((sum, item) => sum + item.sampleCount, 0),
|
||
manifestFingerprint: manifest?.pageProvenance?.assetFingerprint ?? null,
|
||
controlSegmentCount: controlSegments.length
|
||
},
|
||
segments,
|
||
controlSegments,
|
||
valuesRedacted: true
|
||
};
|
||
}
|
||
|
||
function buildPagePerformanceReport(samples, manifest) {
|
||
const base = manifest?.baseUrl || "http://invalid.local";
|
||
const seen = new Set();
|
||
const groups = new Map();
|
||
const sampleTimes = samples.map((sample) => Date.parse(sample?.ts || "")).filter(Number.isFinite);
|
||
const windowStartMs = sampleTimes.length > 0 ? Math.min(...sampleTimes) : null;
|
||
const windowEndMs = sampleTimes.length > 0 ? Math.max(...sampleTimes) : null;
|
||
for (const sample of samples) {
|
||
const entries = Array.isArray(sample?.performance) ? sample.performance : [];
|
||
for (const entry of entries) {
|
||
const durationMs = Number(entry?.duration);
|
||
if (!Number.isFinite(durationMs) || durationMs < 0) continue;
|
||
const entryCompletedMs = performanceEntryCompletedEpochMs(sample, entry);
|
||
if (windowStartMs !== null && entryCompletedMs !== null && entryCompletedMs < windowStartMs) continue;
|
||
if (windowEndMs !== null && entryCompletedMs !== null && entryCompletedMs > windowEndMs + 1000) continue;
|
||
const entryTs = entryCompletedMs === null ? (sample.ts ?? null) : new Date(entryCompletedMs).toISOString();
|
||
const parsed = parsePerformanceUrl(entry?.name, base);
|
||
if (!parsed.sameOrigin || !isApiLikePath(parsed.path)) continue;
|
||
const normalizedPath = normalizeApiPath(parsed.path);
|
||
const routeKind = classifyApiPerformanceRoute(normalizedPath, entry);
|
||
const isLongLivedStream = routeKind === "same-origin-api-stream";
|
||
const streamOpenMs = streamOpenLatencyMs(entry);
|
||
const timingStatus = resourceTimingPhaseStatus(entry);
|
||
const dedupeKey = [parsed.path, entry.initiatorType || "", sample?.pageProvenance?.pageLoadSeq ?? "", sample?.pageProvenance?.timeOrigin ?? "", entry.startTime ?? "", Math.round(durationMs)].join("|");
|
||
if (seen.has(dedupeKey)) continue;
|
||
seen.add(dedupeKey);
|
||
const group = groups.get(normalizedPath) || {
|
||
routeKind,
|
||
path: normalizedPath,
|
||
isLongLivedStream,
|
||
budgetMetric: isLongLivedStream ? "streamOpenMs" : "durationMs",
|
||
rawPathSamples: [],
|
||
sampleCount: 0,
|
||
completeTimingSampleCount: 0,
|
||
partialTimingSampleCount: 0,
|
||
durationsMs: [],
|
||
streamOpenDurationsMs: [],
|
||
overFiveSecondCount: 0,
|
||
overBudgetCount: 0,
|
||
partialOverFiveSecondCount: 0,
|
||
partialOverBudgetCount: 0,
|
||
streamLifetimeOverFiveSecondCount: 0,
|
||
streamOpenOverFiveSecondCount: 0,
|
||
streamOpenOverBudgetCount: 0,
|
||
firstAt: entryTs,
|
||
lastAt: entryTs,
|
||
firstSeq: sample.seq ?? null,
|
||
lastSeq: sample.seq ?? null,
|
||
initiatorTypes: [],
|
||
pageAssetFingerprints: [],
|
||
slowSamples: [],
|
||
partialSamples: [],
|
||
valuesRedacted: true
|
||
};
|
||
group.sampleCount += 1;
|
||
const partialOrdinaryTiming = !isLongLivedStream && timingStatus.status !== "complete";
|
||
let overBudget = false;
|
||
if (partialOrdinaryTiming) {
|
||
group.partialTimingSampleCount += 1;
|
||
if (durationMs > 5000) group.partialOverFiveSecondCount += 1;
|
||
if (durationMs > alertThresholds.partialApiSlowMs) {
|
||
group.partialOverBudgetCount += 1;
|
||
if (group.partialSamples.length < 80) group.partialSamples.push(compactPagePerformanceSlowSample({ sample, entry, entryTs, normalizedPath, rawPath: parsed.path, durationMs, streamOpenMs }));
|
||
}
|
||
} else {
|
||
group.completeTimingSampleCount += 1;
|
||
group.durationsMs.push(durationMs);
|
||
if (isLongLivedStream) {
|
||
if (durationMs > 5000) group.streamLifetimeOverFiveSecondCount += 1;
|
||
if (streamOpenMs !== null) {
|
||
group.streamOpenDurationsMs.push(streamOpenMs);
|
||
if (streamOpenMs > 5000) {
|
||
group.streamOpenOverFiveSecondCount += 1;
|
||
group.overFiveSecondCount += 1;
|
||
}
|
||
if (streamOpenMs > alertThresholds.longLivedStreamOpenSlowMs) {
|
||
group.streamOpenOverBudgetCount += 1;
|
||
group.overBudgetCount += 1;
|
||
overBudget = true;
|
||
}
|
||
}
|
||
} else {
|
||
if (durationMs > 5000) group.overFiveSecondCount += 1;
|
||
if (durationMs > alertThresholds.sameOriginApiSlowMs) {
|
||
group.overBudgetCount += 1;
|
||
overBudget = true;
|
||
}
|
||
}
|
||
}
|
||
if (overBudget && group.slowSamples.length < 80) group.slowSamples.push(compactPagePerformanceSlowSample({ sample, entry, entryTs, normalizedPath, rawPath: parsed.path, durationMs, streamOpenMs }));
|
||
group.lastAt = entryTs;
|
||
group.lastSeq = sample.seq ?? null;
|
||
if (parsed.path && !group.rawPathSamples.includes(parsed.path)) group.rawPathSamples.push(parsed.path);
|
||
if (entry.initiatorType && !group.initiatorTypes.includes(entry.initiatorType)) group.initiatorTypes.push(entry.initiatorType);
|
||
const assetFingerprint = sample?.pageProvenance?.assetFingerprint;
|
||
if (assetFingerprint && !group.pageAssetFingerprints.includes(assetFingerprint)) group.pageAssetFingerprints.push(assetFingerprint);
|
||
groups.set(normalizedPath, group);
|
||
}
|
||
}
|
||
const sameOriginApiByPath = Array.from(groups.values()).map((group) => {
|
||
const durations = group.durationsMs.slice().sort((a, b) => a - b);
|
||
const streamOpenDurations = group.streamOpenDurationsMs.slice().sort((a, b) => a - b);
|
||
return {
|
||
routeKind: group.routeKind,
|
||
path: group.path,
|
||
isLongLivedStream: group.isLongLivedStream === true,
|
||
budgetMetric: group.budgetMetric,
|
||
sampleCount: group.sampleCount,
|
||
budgetMs: group.isLongLivedStream === true ? alertThresholds.longLivedStreamOpenSlowMs : alertThresholds.sameOriginApiSlowMs,
|
||
partialBudgetMs: alertThresholds.partialApiSlowMs,
|
||
streamOpenBudgetMs: alertThresholds.longLivedStreamOpenSlowMs,
|
||
completeTimingSampleCount: group.completeTimingSampleCount,
|
||
partialTimingSampleCount: group.partialTimingSampleCount,
|
||
p50Ms: percentile(durations, 50),
|
||
p75Ms: percentile(durations, 75),
|
||
p95Ms: percentile(durations, 95),
|
||
maxMs: durations.length > 0 ? durations[durations.length - 1] : null,
|
||
streamOpenSampleCount: streamOpenDurations.length,
|
||
streamOpenP50Ms: percentile(streamOpenDurations, 50),
|
||
streamOpenP75Ms: percentile(streamOpenDurations, 75),
|
||
streamOpenP95Ms: percentile(streamOpenDurations, 95),
|
||
streamOpenMaxMs: streamOpenDurations.length > 0 ? streamOpenDurations[streamOpenDurations.length - 1] : null,
|
||
streamOpenOverFiveSecondCount: group.streamOpenOverFiveSecondCount,
|
||
streamOpenOverBudgetCount: group.streamOpenOverBudgetCount,
|
||
streamLifetimeOverFiveSecondCount: group.streamLifetimeOverFiveSecondCount,
|
||
overFiveSecondCount: group.overFiveSecondCount,
|
||
overBudgetCount: group.overBudgetCount,
|
||
partialOverFiveSecondCount: group.partialOverFiveSecondCount,
|
||
partialOverBudgetCount: group.partialOverBudgetCount,
|
||
overFiveSecondRatio: group.sampleCount > 0 ? Number((group.overFiveSecondCount / group.sampleCount).toFixed(3)) : 0,
|
||
overBudgetRatio: group.sampleCount > 0 ? Number((group.overBudgetCount / group.sampleCount).toFixed(3)) : 0,
|
||
firstAt: group.firstAt,
|
||
lastAt: group.lastAt,
|
||
firstSeq: group.firstSeq,
|
||
lastSeq: group.lastSeq,
|
||
initiatorTypes: group.initiatorTypes,
|
||
rawPathSamples: group.rawPathSamples.slice(0, 8),
|
||
pageAssetFingerprints: group.pageAssetFingerprints.slice(0, 8),
|
||
slowSamples: group.slowSamples
|
||
.slice()
|
||
.sort((a, b) => Number(b.durationMs ?? 0) - Number(a.durationMs ?? 0))
|
||
.slice(0, 12),
|
||
partialSamples: group.partialSamples
|
||
.slice()
|
||
.sort((a, b) => Number(b.durationMs ?? 0) - Number(a.durationMs ?? 0))
|
||
.slice(0, 12),
|
||
valuesRedacted: true
|
||
};
|
||
}).sort((a, b) => (Number(b.overBudgetCount ?? b.overFiveSecondCount ?? 0) - Number(a.overBudgetCount ?? a.overFiveSecondCount ?? 0)) || (Number(b.p95Ms ?? 0) - Number(a.p95Ms ?? 0)) || a.path.localeCompare(b.path));
|
||
const longLivedStreams = sameOriginApiByPath.filter((item) => item.isLongLivedStream);
|
||
const ordinaryApi = sameOriginApiByPath.filter((item) => item.isLongLivedStream !== true);
|
||
const slow = ordinaryApi.filter((item) => Number(item.overBudgetCount ?? item.overFiveSecondCount ?? 0) > 0);
|
||
const slowFiveSecond = ordinaryApi.filter((item) => Number(item.overFiveSecondCount ?? 0) > 0);
|
||
const partialSlow = ordinaryApi.filter((item) => Number(item.partialOverBudgetCount ?? item.partialOverFiveSecondCount ?? 0) > 0);
|
||
const partialFiveSecond = ordinaryApi.filter((item) => Number(item.partialOverFiveSecondCount ?? 0) > 0);
|
||
const slowStreamOpen = longLivedStreams.filter((item) => Number(item.streamOpenOverBudgetCount ?? item.streamOpenOverFiveSecondCount ?? 0) > 0);
|
||
const slowStreamOpenFiveSecond = longLivedStreams.filter((item) => Number(item.streamOpenOverFiveSecondCount ?? 0) > 0);
|
||
const budgetP95Values = sameOriginApiByPath
|
||
.map((item) => Number(item.isLongLivedStream ? (item.streamOpenP95Ms ?? 0) : (item.p95Ms ?? 0)))
|
||
.filter((value) => Number.isFinite(value));
|
||
return {
|
||
summary: {
|
||
budgetMs: alertThresholds.sameOriginApiSlowMs,
|
||
alertThresholds,
|
||
sameOriginApiPathCount: sameOriginApiByPath.length,
|
||
sameOriginApiSampleCount: sameOriginApiByPath.reduce((sum, item) => sum + item.sampleCount, 0),
|
||
longLivedStreamPathCount: longLivedStreams.length,
|
||
longLivedStreamSampleCount: longLivedStreams.reduce((sum, item) => sum + item.sampleCount, 0),
|
||
longLivedStreamOpenOverFiveSecondPathCount: slowStreamOpenFiveSecond.length,
|
||
longLivedStreamOpenOverFiveSecondSampleCount: slowStreamOpenFiveSecond.reduce((sum, item) => sum + Number(item.streamOpenOverFiveSecondCount ?? 0), 0),
|
||
longLivedStreamOpenOverBudgetPathCount: slowStreamOpen.length,
|
||
longLivedStreamOpenOverBudgetSampleCount: slowStreamOpen.reduce((sum, item) => sum + Number(item.streamOpenOverBudgetCount ?? item.streamOpenOverFiveSecondCount ?? 0), 0),
|
||
longLivedStreamLifetimeOverFiveSecondSampleCount: longLivedStreams.reduce((sum, item) => sum + Number(item.streamLifetimeOverFiveSecondCount ?? 0), 0),
|
||
slowPathCount: slow.length,
|
||
slowSampleCount: slow.reduce((sum, item) => sum + Number(item.overBudgetCount ?? item.overFiveSecondCount ?? 0), 0),
|
||
overFiveSecondPathCount: slowFiveSecond.length,
|
||
overFiveSecondSampleCount: slowFiveSecond.reduce((sum, item) => sum + Number(item.overFiveSecondCount ?? 0), 0),
|
||
partialTimingSampleCount: ordinaryApi.reduce((sum, item) => sum + Number(item.partialTimingSampleCount ?? 0), 0),
|
||
partialOverFiveSecondPathCount: partialFiveSecond.length,
|
||
partialOverFiveSecondSampleCount: partialFiveSecond.reduce((sum, item) => sum + Number(item.partialOverFiveSecondCount ?? 0), 0),
|
||
partialOverBudgetPathCount: partialSlow.length,
|
||
partialOverBudgetSampleCount: partialSlow.reduce((sum, item) => sum + Number(item.partialOverBudgetCount ?? item.partialOverFiveSecondCount ?? 0), 0),
|
||
worstP95Ms: budgetP95Values.length > 0 ? Math.max(...budgetP95Values) : null,
|
||
valuesRedacted: true
|
||
},
|
||
sameOriginApiByPath,
|
||
valuesRedacted: true
|
||
};
|
||
}
|
||
|
||
function performanceEntryCompletedEpochMs(sample, entry) {
|
||
const origin = Number(sample?.pageProvenance?.timeOrigin);
|
||
const responseEnd = Number(entry?.responseEnd);
|
||
const startTime = Number(entry?.startTime);
|
||
const offset = Number.isFinite(responseEnd) && responseEnd > 0 ? responseEnd : startTime;
|
||
if (Number.isFinite(origin) && origin > 0 && Number.isFinite(offset) && offset >= 0) return Math.round(origin + offset);
|
||
const sampleTs = Date.parse(sample?.ts || "");
|
||
return Number.isFinite(sampleTs) ? sampleTs : null;
|
||
}
|
||
|
||
function compactPagePerformanceSlowSample({ sample, entry, entryTs, normalizedPath, rawPath, durationMs, streamOpenMs }) {
|
||
const timingStatus = resourceTimingPhaseStatus(entry);
|
||
const serverTiming = compactServerTiming(entry?.serverTiming);
|
||
return {
|
||
ts: entryTs ?? sample?.ts ?? null,
|
||
sampleTs: sample?.ts ?? null,
|
||
seq: sample?.seq ?? null,
|
||
path: normalizedPath ?? null,
|
||
rawPath: rawPath ?? null,
|
||
initiatorType: entry?.initiatorType ?? null,
|
||
durationMs: roundFinite(durationMs),
|
||
startTimeMs: roundFinite(entry?.startTime),
|
||
fetchStartMs: roundFinite(entry?.fetchStart),
|
||
requestStartMs: roundFinite(entry?.requestStart),
|
||
responseStartMs: roundFinite(entry?.responseStart),
|
||
responseEndMs: roundFinite(entry?.responseEnd),
|
||
streamOpenMs: roundFinite(streamOpenMs),
|
||
dnsMs: phaseDeltaMs(entry, "domainLookupEnd", "domainLookupStart"),
|
||
tcpMs: phaseDeltaMs(entry, "connectEnd", "connectStart"),
|
||
tlsStartMs: roundFinite(entry?.secureConnectionStart),
|
||
requestToResponseStartMs: phaseDeltaMs(entry, "responseStart", "requestStart"),
|
||
responseTransferMs: phaseDeltaMs(entry, "responseEnd", "responseStart"),
|
||
timingStatus: timingStatus.status,
|
||
invalidTimingPhases: timingStatus.invalidPhases,
|
||
partialTimingPhases: timingStatus.partialPhases,
|
||
transferSize: Number.isFinite(Number(entry?.transferSize)) ? Number(entry.transferSize) : null,
|
||
encodedBodySize: Number.isFinite(Number(entry?.encodedBodySize)) ? Number(entry.encodedBodySize) : null,
|
||
decodedBodySize: Number.isFinite(Number(entry?.decodedBodySize)) ? Number(entry.decodedBodySize) : null,
|
||
nextHopProtocol: entry?.nextHopProtocol ?? null,
|
||
serverTiming,
|
||
serverTimingNames: serverTiming.map((item) => item.name).filter(Boolean).slice(0, 8),
|
||
otelTraceId: extractOtelTraceIdFromServerTiming(serverTiming),
|
||
valuesRedacted: true
|
||
};
|
||
}
|
||
|
||
function phaseDeltaMs(entry, endKey, startKey) {
|
||
const end = Number(entry?.[endKey]);
|
||
const start = Number(entry?.[startKey]);
|
||
if (!Number.isFinite(end) || !Number.isFinite(start) || end <= 0 || start <= 0 || end < start) return null;
|
||
return Math.round(end - start);
|
||
}
|
||
|
||
function resourceTimingPhaseStatus(entry) {
|
||
const pairs = [
|
||
["requestToResponseStart", "requestStart", "responseStart"],
|
||
["responseTransfer", "responseStart", "responseEnd"],
|
||
];
|
||
const invalidPhases = [];
|
||
const partialPhases = [];
|
||
for (const [label, startKey, endKey] of pairs) {
|
||
const start = Number(entry?.[startKey]);
|
||
const end = Number(entry?.[endKey]);
|
||
if (!Number.isFinite(start) || !Number.isFinite(end) || start <= 0 || end <= 0) {
|
||
partialPhases.push(label);
|
||
} else if (end < start) {
|
||
invalidPhases.push(label);
|
||
}
|
||
}
|
||
return {
|
||
status: invalidPhases.length > 0 ? "invalid" : (partialPhases.length > 0 ? "partial" : "complete"),
|
||
invalidPhases,
|
||
partialPhases,
|
||
};
|
||
}
|
||
|
||
function compactServerTiming(value) {
|
||
const items = Array.isArray(value) ? value : [];
|
||
return items.slice(0, 8).map((item) => ({
|
||
name: truncate(String(item?.name || ""), 80),
|
||
duration: Number.isFinite(Number(item?.duration)) ? Math.round(Number(item.duration)) : null,
|
||
description: truncate(String(item?.description || ""), 120),
|
||
})).filter((item) => item.name || item.description || item.duration !== null);
|
||
}
|
||
|
||
function extractOtelTraceIdFromServerTiming(items) {
|
||
const text = (Array.isArray(items) ? items : []).map((item) => [item.name, item.description].filter(Boolean).join(" ")).join(" ");
|
||
const match = text.match(/\b[0-9a-f]{32}\b/iu);
|
||
return match ? match[0].toLowerCase() : null;
|
||
}
|
||
|
||
function roundFinite(value) {
|
||
const numeric = Number(value);
|
||
return Number.isFinite(numeric) ? Math.round(numeric) : null;
|
||
}
|
||
|
||
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";
|
||
return "same-origin-api";
|
||
}
|
||
|
||
function streamOpenLatencyMs(entry = {}) {
|
||
const responseStart = Number(entry?.responseStart);
|
||
const startTime = Number(entry?.startTime);
|
||
if (!Number.isFinite(responseStart) || responseStart <= 0) return null;
|
||
if (!Number.isFinite(startTime) || startTime < 0) return Math.max(0, responseStart);
|
||
if (responseStart < startTime) return null;
|
||
return Math.max(0, responseStart - startTime);
|
||
}
|
||
|
||
function parsePerformanceUrl(value, base) {
|
||
try {
|
||
const url = new URL(String(value || ""), base);
|
||
const origin = new URL(String(base || "http://invalid.local")).origin;
|
||
return { sameOrigin: url.origin === origin, path: url.pathname };
|
||
} catch {
|
||
return { sameOrigin: false, path: "-" };
|
||
}
|
||
}
|
||
|
||
function isApiLikePath(path) {
|
||
return /^\/(?:v1(?:\/|$)|auth(?:\/|$)|health(?:\/|$))/u.test(String(path || ""));
|
||
}
|
||
|
||
function normalizeApiPath(path) {
|
||
return String(path || "-")
|
||
.replace(/\/v1\/workbench\/sessions\/ses_[^/]+/gu, "/v1/workbench/sessions/:id")
|
||
.replace(/\/v1\/workbench\/turns\/trc_[^/]+/gu, "/v1/workbench/turns/:traceId")
|
||
.replace(/\/v1\/workbench\/traces\/trc_[^/]+/gu, "/v1/workbench/traces/:traceId")
|
||
.replace(/\/v1\/workbench\/sessions\/[0-9a-f-]{12,}/giu, "/v1/workbench/sessions/:id")
|
||
.replace(/\/v1\/[^/]+\/[0-9a-f-]{16,}(?=\/|$)/giu, (match) => match.replace(/\/[0-9a-f-]{16,}$/iu, "/:id"));
|
||
}
|
||
|
||
function percentile(sortedValues, percentileValue) {
|
||
if (!Array.isArray(sortedValues) || sortedValues.length === 0) return null;
|
||
if (sortedValues.length === 1) return Math.round(sortedValues[0]);
|
||
const rank = (percentileValue / 100) * (sortedValues.length - 1);
|
||
const lower = Math.floor(rank);
|
||
const upper = Math.ceil(rank);
|
||
if (lower === upper) return Math.round(sortedValues[lower]);
|
||
const weight = rank - lower;
|
||
return Math.round(sortedValues[lower] * (1 - weight) + sortedValues[upper] * weight);
|
||
}
|
||
|
||
function buildPromptNetworkReport(control, network) {
|
||
const promptsById = new Map();
|
||
for (const item of control) {
|
||
if (item?.type !== "sendPrompt" || !item.commandId) continue;
|
||
const existing = promptsById.get(item.commandId) || {
|
||
commandId: item.commandId,
|
||
promptIndex: promptsById.size + 1,
|
||
promptTextHash: item.input?.textHash ?? null,
|
||
promptTextBytes: item.input?.textBytes ?? null,
|
||
startedAt: null,
|
||
completedAt: null,
|
||
failedAt: null,
|
||
phase: null
|
||
};
|
||
if (!existing.promptTextHash && item.input?.textHash) existing.promptTextHash = item.input.textHash;
|
||
if (!existing.promptTextBytes && item.input?.textBytes) existing.promptTextBytes = item.input.textBytes;
|
||
if (item.phase === "started") existing.startedAt = item.ts ?? existing.startedAt;
|
||
if (item.phase === "completed") existing.completedAt = item.ts ?? existing.completedAt;
|
||
if (item.phase === "failed") existing.failedAt = item.ts ?? existing.failedAt;
|
||
existing.phase = item.phase ?? existing.phase;
|
||
promptsById.set(item.commandId, existing);
|
||
}
|
||
const prompts = Array.from(promptsById.values()).sort((a, b) => Date.parse(a.startedAt || a.completedAt || a.failedAt || "") - Date.parse(b.startedAt || b.completedAt || b.failedAt || ""));
|
||
prompts.forEach((item, index) => { item.promptIndex = index + 1; });
|
||
const chatEvents = network
|
||
.filter((item) => String(item?.method || "").toUpperCase() === "POST" && promptSubmitModeForUrl(item?.url) !== null)
|
||
.map((item) => {
|
||
const failureText = item.failureKind ?? item.failure ?? item.errorText ?? null;
|
||
const urlPathValue = urlPath(item.url);
|
||
return {
|
||
ts: item.ts ?? null,
|
||
tsMs: Date.parse(item.ts),
|
||
type: item.type ?? null,
|
||
status: Number.isFinite(Number(item.status)) ? Number(item.status) : null,
|
||
commandId: item.commandId ?? null,
|
||
urlPath: urlPathValue,
|
||
submitMode: promptSubmitModeForUrl(item.url),
|
||
failureKind: failureText ? String(failureText) : null,
|
||
errorTextHash: failureText ? sha256(failureText) : null
|
||
};
|
||
})
|
||
.filter((item) => Number.isFinite(item.tsMs))
|
||
.sort((a, b) => a.tsMs - b.tsMs);
|
||
const rounds = prompts.map((prompt) => {
|
||
const startMs = Date.parse(prompt.startedAt || prompt.completedAt || prompt.failedAt || "");
|
||
const endAnchorMs = Date.parse(prompt.completedAt || prompt.failedAt || prompt.startedAt || "");
|
||
const fromMs = Number.isFinite(startMs) ? startMs - 3000 : Number.NEGATIVE_INFINITY;
|
||
const toMs = Number.isFinite(endAnchorMs) ? endAnchorMs + 30000 : Number.POSITIVE_INFINITY;
|
||
const events = chatEvents.filter((event) => {
|
||
if (event.commandId && prompt.commandId && event.commandId === prompt.commandId) return true;
|
||
return event.tsMs >= fromMs && event.tsMs <= toMs;
|
||
});
|
||
const responses = events.filter((event) => event.type === "response");
|
||
const failures = events.filter((event) => event.type === "requestfailed");
|
||
const responseStatuses = responses.map((event) => event.status).filter((status) => status !== null);
|
||
const submitModes = Array.from(new Set(events.map((event) => event.submitMode).filter(Boolean))).sort();
|
||
const chatPostOk = responseStatuses.some((status) => status >= 200 && status < 300) && failures.length === 0;
|
||
const failureKind = chatPostOk
|
||
? null
|
||
: failures.length > 0
|
||
? "requestfailed"
|
||
: responseStatuses.length === 0
|
||
? "missing-response"
|
||
: "http-status";
|
||
return {
|
||
promptIndex: prompt.promptIndex,
|
||
promptCommandId: prompt.commandId,
|
||
promptTextHash: prompt.promptTextHash,
|
||
promptTextBytes: prompt.promptTextBytes,
|
||
startedAt: prompt.startedAt,
|
||
completedAt: prompt.completedAt,
|
||
failedAt: prompt.failedAt,
|
||
chatPostOk,
|
||
failureKind,
|
||
requestCount: events.filter((event) => event.type === "request").length,
|
||
responseCount: responses.length,
|
||
requestFailedCount: failures.length,
|
||
responseStatuses,
|
||
submitModes,
|
||
steerUsed: submitModes.includes("steer"),
|
||
firstChatEventAt: events[0]?.ts ?? null,
|
||
lastChatEventAt: events[events.length - 1]?.ts ?? null,
|
||
events: events.slice(0, 12).map((event) => ({ ts: event.ts, type: event.type, status: event.status, urlPath: event.urlPath, submitMode: event.submitMode, failureKind: event.failureKind, errorTextHash: event.errorTextHash }))
|
||
};
|
||
});
|
||
return {
|
||
summary: {
|
||
promptCount: rounds.length,
|
||
chatPostOk: rounds.filter((item) => item.chatPostOk === true).length,
|
||
chatPostFailed: rounds.filter((item) => item.chatPostOk === false).length,
|
||
chatPostMissing: rounds.filter((item) => item.failureKind === "missing-response").length
|
||
},
|
||
rounds
|
||
};
|
||
}
|
||
|
||
function promptSubmitModeForUrl(value) {
|
||
const pathValue = urlPath(value);
|
||
if (pathValue === "/v1/agent/chat") return "chat";
|
||
if (pathValue === "/v1/agent/chat/steer") return "steer";
|
||
return null;
|
||
}
|
||
|
||
function parseDomDiagnosticSummary(text) {
|
||
const value = String(text || "");
|
||
const traceMatch = value.match(/\b(?:trace_id=)?(trc_[A-Za-z0-9_-]+|[a-f0-9]{16,64})\b/iu);
|
||
const httpStatusMatch = value.match(/\bHTTP\s+([1-5][0-9]{2})\b/iu);
|
||
const idleMatch = value.match(/\bidle\s+(\d+)s\b/iu);
|
||
const waitingForMatch = value.match(/\bwaitingFor=([^\s;;,,)]+)/iu);
|
||
const lastEventLabelMatch = value.match(/\blastEventLabel=([^\s;;,,)]+)/iu);
|
||
const diagnosticCode = httpStatusMatch
|
||
? "http-" + httpStatusMatch[1]
|
||
: /turn\s*超过|无新活动/iu.test(value)
|
||
? "turn-idle-no-activity"
|
||
: /Failed to fetch/iu.test(value)
|
||
? "failed-to-fetch"
|
||
: "diagnostic";
|
||
return {
|
||
diagnosticCode,
|
||
traceId: traceMatch?.[1] || null,
|
||
httpStatus: httpStatusMatch ? Number(httpStatusMatch[1]) : null,
|
||
idleSeconds: idleMatch ? Number(idleMatch[1]) : null,
|
||
waitingFor: waitingForMatch?.[1] || null,
|
||
lastEventLabel: lastEventLabelMatch?.[1] || null
|
||
};
|
||
}
|
||
|
||
function isDomDiagnosticSampleText(text) {
|
||
const value = String(text || "").replace(/\s+/g, " ").trim();
|
||
if (!value) return false;
|
||
const strongDiagnostic = [
|
||
/\bHTTP\s+[45][0-9]{2}\b(?:[\s\S]{0,120}\btrace_id=|\b)/iu,
|
||
/\btrace_id=(?:trc_[A-Za-z0-9_-]+|[a-f0-9]{16,64})\b/iu,
|
||
/workbench\s+turn\s*超过\s*\d+ms\s*无新活动/iu,
|
||
/\bturn\s*超过\b[\s\S]{0,120}\b无新活动\b/iu,
|
||
/\bprojection-resume:sync-failed\b/iu,
|
||
/\bAgentRun\s+GET\b[\s\S]*\/result\b[\s\S]*timed out after\s+\d+ms\b/iu,
|
||
/\bFailed to fetch\b/iu,
|
||
/\bFailed to load resource\b[\s\S]{0,180}\bstatus of\s+[45][0-9]{2}\b/iu,
|
||
/\bserver responded with a status of\s+[45][0-9]{2}\b/iu,
|
||
/Code Agent\b[\s\S]{0,120}(?:无法连接上游|请求已结束)/iu
|
||
].some((pattern) => pattern.test(value));
|
||
if (!strongDiagnostic) return false;
|
||
const looksLikeToolStdout = /\b(?:stdout|stderr):/iu.test(value)
|
||
&& /(?:\becho\s+["']?===|\bnode\s+|\.tspy\b|tspy\/|===\s*[A-Za-z0-9_.-]+\s*===)/iu.test(value);
|
||
if (!looksLikeToolStdout) return true;
|
||
return /\b(?:trace_id=|HTTP\s+[45][0-9]{2}|workbench\s+turn\s*超过|projection-resume:sync-failed|Failed to fetch|Failed to load resource|server responded with a status of\s+[45][0-9]{2}|Code Agent\b[\s\S]{0,120}(?:无法连接上游|请求已结束))\b/iu.test(value);
|
||
}
|
||
|
||
function buildRuntimeAlerts(samples, control, network, consoleEvents, errors) {
|
||
const promptTimes = control
|
||
.filter((item) => item.type === "sendPrompt" && item.phase === "completed")
|
||
.map((item) => Date.parse(item.ts))
|
||
.filter(Number.isFinite)
|
||
.sort((a, b) => a - b);
|
||
const observerRefreshTimes = control
|
||
.filter((item) => item.type === "observer-periodic-refresh")
|
||
.map((item) => Date.parse(item.ts))
|
||
.filter(Number.isFinite)
|
||
.sort((a, b) => a - b);
|
||
const naturalNetwork = network.filter((item) => item?.observerInitiated !== true);
|
||
const httpErrors = naturalNetwork
|
||
.filter((item) => item?.type === "response" && Number(item.status) >= 400)
|
||
.map((item) => networkAlertEvent(item, promptTimes));
|
||
const requestFailed = naturalNetwork
|
||
.filter((item) => item?.type === "requestfailed")
|
||
.map((item) => networkAlertEvent(item, promptTimes));
|
||
const significantRequestFailed = requestFailed.filter(
|
||
(item) => !isBenignLongLivedStreamClosureAlert(item) && !isObserverRefreshClosureAlert(item, observerRefreshTimes),
|
||
);
|
||
const domDiagnostics = [];
|
||
const executionErrors = [];
|
||
const baselineExecutionErrors = [];
|
||
const firstPromptMs = promptTimes.length > 0 ? promptTimes[0] : Infinity;
|
||
const firstSeenExecutionErrorMs = new Map();
|
||
for (const sample of samples) {
|
||
const tsMs = Date.parse(sample?.ts);
|
||
const promptIndex = Number.isFinite(tsMs) ? latestPromptIndex(promptTimes, tsMs) : 0;
|
||
if (Array.isArray(sample?.diagnostics)) {
|
||
for (const diagnostic of sample.diagnostics.slice(0, 12)) {
|
||
const text = diagnostic?.textPreview || diagnostic?.text || "";
|
||
if (!String(text).trim()) continue;
|
||
const parsedDiagnostic = parseDomDiagnosticSummary(text);
|
||
domDiagnostics.push({
|
||
seq: sample.seq ?? null,
|
||
ts: sample.ts ?? null,
|
||
promptIndex,
|
||
source: "diagnostic-node",
|
||
className: diagnostic.className ?? null,
|
||
diagnosticCode: diagnostic.diagnosticCode ?? parsedDiagnostic.diagnosticCode,
|
||
traceId: diagnostic.traceId ?? parsedDiagnostic.traceId,
|
||
httpStatus: diagnostic.httpStatus ?? parsedDiagnostic.httpStatus,
|
||
idleSeconds: diagnostic.idleSeconds ?? parsedDiagnostic.idleSeconds,
|
||
waitingFor: diagnostic.waitingFor ?? parsedDiagnostic.waitingFor,
|
||
lastEventLabel: diagnostic.lastEventLabel ?? parsedDiagnostic.lastEventLabel,
|
||
compact: diagnostic.compact ?? null,
|
||
expanded: diagnostic.expanded ?? null,
|
||
routeSessionId: sample.routeSessionId ?? null,
|
||
activeSessionId: sample.activeSessionId ?? null,
|
||
textHash: diagnostic.textHash || sha256(text),
|
||
preview: limitText(text, 260)
|
||
});
|
||
}
|
||
}
|
||
const texts = sampleTexts(sample).filter(isDomDiagnosticSampleText);
|
||
for (const text of texts.slice(0, 4)) {
|
||
const parsedDiagnostic = parseDomDiagnosticSummary(text);
|
||
domDiagnostics.push({
|
||
seq: sample.seq ?? null,
|
||
ts: sample.ts ?? null,
|
||
promptIndex,
|
||
source: "sample-text",
|
||
diagnosticCode: parsedDiagnostic.diagnosticCode,
|
||
traceId: parsedDiagnostic.traceId,
|
||
httpStatus: parsedDiagnostic.httpStatus,
|
||
idleSeconds: parsedDiagnostic.idleSeconds,
|
||
waitingFor: parsedDiagnostic.waitingFor,
|
||
lastEventLabel: parsedDiagnostic.lastEventLabel,
|
||
routeSessionId: sample.routeSessionId ?? null,
|
||
activeSessionId: sample.activeSessionId ?? null,
|
||
textHash: sha256(text),
|
||
preview: limitText(text, 220)
|
||
});
|
||
}
|
||
const seenExecutionErrors = new Set();
|
||
for (const candidate of sampleExecutionErrorCandidates(sample)) {
|
||
const parsed = parseExecutionErrorText(candidate.text);
|
||
if (!parsed) continue;
|
||
const textHash = sha256(candidate.text);
|
||
const dedupeKey = [candidate.source, candidate.traceId || "-", parsed.backend || "-", parsed.code || "-", parsed.status || "-", textHash].join("|");
|
||
if (seenExecutionErrors.has(dedupeKey)) continue;
|
||
seenExecutionErrors.add(dedupeKey);
|
||
const firstSeenMs = firstSeenExecutionErrorMs.has(dedupeKey) ? firstSeenExecutionErrorMs.get(dedupeKey) : tsMs;
|
||
if (!firstSeenExecutionErrorMs.has(dedupeKey) && Number.isFinite(tsMs)) firstSeenExecutionErrorMs.set(dedupeKey, tsMs);
|
||
const baseline = Number.isFinite(firstSeenMs) && firstSeenMs < firstPromptMs;
|
||
const event = {
|
||
seq: sample.seq ?? null,
|
||
ts: sample.ts ?? null,
|
||
promptIndex,
|
||
baseline,
|
||
firstSeenAt: Number.isFinite(firstSeenMs) ? new Date(firstSeenMs).toISOString() : null,
|
||
source: candidate.source,
|
||
backend: parsed.backend,
|
||
status: parsed.status,
|
||
code: parsed.code,
|
||
rawCode: parsed.rawCode,
|
||
totalSeconds: parsed.totalSeconds,
|
||
traceId: candidate.traceId || parsed.traceId || null,
|
||
messageId: candidate.messageId || null,
|
||
routeSessionId: sample.routeSessionId ?? null,
|
||
activeSessionId: sample.activeSessionId ?? null,
|
||
textHash,
|
||
preview: limitText(candidate.text, 260)
|
||
};
|
||
if (baseline) baselineExecutionErrors.push(event);
|
||
else executionErrors.push(event);
|
||
domDiagnostics.push({
|
||
seq: sample.seq ?? null,
|
||
ts: sample.ts ?? null,
|
||
promptIndex,
|
||
source: "execution-row",
|
||
diagnosticCode: parsed.rawCode || parsed.code || "execution-error",
|
||
traceId: candidate.traceId || parsed.traceId || null,
|
||
routeSessionId: sample.routeSessionId ?? null,
|
||
activeSessionId: sample.activeSessionId ?? null,
|
||
textHash,
|
||
preview: limitText(candidate.text, 220)
|
||
});
|
||
}
|
||
}
|
||
const consoleAlerts = consoleEvents
|
||
.filter((item) => /error|warning|warn|assert/iu.test(String(item?.type || "")) || isDiagnosticText(item?.text))
|
||
.map((item) => consoleAlertEvent(item, promptTimes));
|
||
const significantConsoleAlerts = consoleAlerts.filter((item) => !isBenignLongLivedStreamClosureAlert(item) && !isObserverRefreshClosureAlert(item, observerRefreshTimes));
|
||
const pageErrors = errors.map((item) => ({
|
||
ts: item.ts ?? null,
|
||
promptIndex: promptIndexForTs(promptTimes, item.ts),
|
||
type: item.type ?? null,
|
||
errorName: item.error?.name ?? item.name ?? null,
|
||
messageHash: item.error?.message ? sha256(item.error.message) : item.message ? sha256(item.message) : null,
|
||
preview: limitText(item.error?.message || item.message || item.error || "", 220)
|
||
}));
|
||
return {
|
||
summary: {
|
||
httpErrorCount: httpErrors.length,
|
||
requestFailedCount: requestFailed.length,
|
||
significantRequestFailedCount: significantRequestFailed.length,
|
||
benignLongLivedStreamClosureCount: requestFailed.length - significantRequestFailed.length,
|
||
domDiagnosticSampleCount: domDiagnostics.length,
|
||
domDiagnosticGroupCount: groupDomDiagnostics(domDiagnostics).length,
|
||
executionErrorCount: executionErrors.length,
|
||
baselineExecutionErrorCount: baselineExecutionErrors.length,
|
||
consoleAlertCount: consoleAlerts.length,
|
||
significantConsoleAlertCount: significantConsoleAlerts.length,
|
||
pageErrorCount: pageErrors.length,
|
||
networkErrorGroupCount: groupNetworkAlerts(httpErrors).length,
|
||
requestFailedGroupCount: groupNetworkAlerts(requestFailed).length,
|
||
significantRequestFailedGroupCount: groupNetworkAlerts(significantRequestFailed).length,
|
||
executionErrorGroupCount: groupExecutionErrors(executionErrors).length,
|
||
baselineExecutionErrorGroupCount: groupExecutionErrors(baselineExecutionErrors).length,
|
||
consoleAlertGroupCount: groupConsoleAlerts(consoleAlerts).length,
|
||
significantConsoleAlertGroupCount: groupConsoleAlerts(significantConsoleAlerts).length
|
||
},
|
||
networkHttpErrorsByPath: groupNetworkAlerts(httpErrors),
|
||
networkRequestFailedByPath: groupNetworkAlerts(requestFailed),
|
||
networkSignificantRequestFailedByPath: groupNetworkAlerts(significantRequestFailed),
|
||
domDiagnostics: domDiagnostics.slice(-80),
|
||
domDiagnosticsByText: groupDomDiagnostics(domDiagnostics),
|
||
domDiagnosticsByFingerprint: groupDomDiagnostics(domDiagnostics).slice(0, 80),
|
||
runtimeExecutionErrors: executionErrors.slice(0, 120),
|
||
runtimeExecutionErrorsByCode: groupExecutionErrors(executionErrors),
|
||
baselineRuntimeExecutionErrors: baselineExecutionErrors.slice(0, 80),
|
||
baselineRuntimeExecutionErrorsByCode: groupExecutionErrors(baselineExecutionErrors),
|
||
consoleAlerts: consoleAlerts.slice(0, 80),
|
||
consoleAlertsByPath: groupConsoleAlerts(consoleAlerts),
|
||
significantConsoleAlerts: significantConsoleAlerts.slice(0, 80),
|
||
significantConsoleAlertsByPath: groupConsoleAlerts(significantConsoleAlerts),
|
||
pageErrors: pageErrors.slice(0, 40)
|
||
};
|
||
}
|
||
|
||
function groupDomDiagnostics(events) {
|
||
const groups = new Map();
|
||
for (const item of events || []) {
|
||
const preview = String(item?.preview || "").trim();
|
||
if (!isReportableDomDiagnostic(item, preview)) continue;
|
||
const normalizedPreview = normalizeDiagnosticPreview(preview);
|
||
const key = [
|
||
item?.diagnosticCode || "",
|
||
normalizedPreview
|
||
].join("|");
|
||
const existing = groups.get(key) || {
|
||
source: item?.source || null,
|
||
sources: new Set(),
|
||
diagnosticCode: item?.diagnosticCode || null,
|
||
textHash: item?.textHash || null,
|
||
normalizedPreview,
|
||
preview,
|
||
count: 0,
|
||
firstAt: item?.ts || null,
|
||
lastAt: item?.ts || null,
|
||
promptIndexes: new Set(),
|
||
traceIds: new Set(),
|
||
sampleSeqs: []
|
||
};
|
||
if (item?.source) existing.sources.add(String(item.source));
|
||
existing.count += 1;
|
||
existing.firstAt = minIso(existing.firstAt, item?.ts || null);
|
||
existing.lastAt = maxIso(existing.lastAt, item?.ts || null);
|
||
if (Number.isFinite(Number(item?.promptIndex))) existing.promptIndexes.add(Number(item.promptIndex));
|
||
for (const traceId of extractDiagnosticTraceIds(item, preview)) existing.traceIds.add(traceId);
|
||
if (existing.sampleSeqs.length < 12 && item?.seq !== undefined && item?.seq !== null) existing.sampleSeqs.push(item.seq);
|
||
groups.set(key, existing);
|
||
}
|
||
return Array.from(groups.values())
|
||
.map((item) => ({
|
||
source: item.source,
|
||
sources: Array.from(item.sources).sort(),
|
||
diagnosticCode: item.diagnosticCode,
|
||
textHash: item.textHash,
|
||
normalizedPreview: item.normalizedPreview,
|
||
preview: item.preview,
|
||
count: item.count,
|
||
firstAt: item.firstAt,
|
||
lastAt: item.lastAt,
|
||
promptIndexes: Array.from(item.promptIndexes).sort((a, b) => a - b),
|
||
traceIds: Array.from(item.traceIds).sort(),
|
||
sampleSeqs: item.sampleSeqs
|
||
}))
|
||
.sort((a, b) => (b.count - a.count) || String(a.firstAt || "").localeCompare(String(b.firstAt || "")));
|
||
}
|
||
|
||
function isReportableDomDiagnostic(item, preview) {
|
||
if (item?.source === "diagnostic-node" || item?.source === "execution-row") return true;
|
||
return /trace_id=|HTTP\s+\d{3}\b|Failed to load resource|ERR_[A-Z_]+|provider-unavailable|AgentRun error|超过\s*\d+\s*ms\s*无新活动|代理暂时无法连接上游|Trace 更新超时|加载失败/iu.test(String(preview || ""));
|
||
}
|
||
|
||
function normalizeDiagnosticPreview(text) {
|
||
return String(text || "")
|
||
.replace(/trace_id=[A-Za-z0-9_-]+/gu, "trace_id=:traceId")
|
||
.replace(/\btrc_[A-Za-z0-9_-]+\b/gu, "trc_:traceId")
|
||
.replace(/\bses_[A-Za-z0-9_-]+\b/gu, "ses_:sessionId")
|
||
.replace(/\brun_[A-Za-z0-9_-]+\b/gu, "run_:runId")
|
||
.replace(/\bcmd_[A-Za-z0-9_-]+\b/gu, "cmd_:commandId")
|
||
.replace(/[!!]+$/gu, "")
|
||
.replace(/\s+/gu, " ")
|
||
.trim();
|
||
}
|
||
|
||
function extractDiagnosticTraceIds(item, preview) {
|
||
const ids = new Set();
|
||
if (item?.traceId) ids.add(String(item.traceId));
|
||
const text = String(preview || "");
|
||
for (const match of text.matchAll(/\btrc_[A-Za-z0-9_-]+\b/gu)) ids.add(match[0]);
|
||
for (const match of text.matchAll(/trace_id=([A-Za-z0-9_-]+)/gu)) ids.add(match[1]);
|
||
return ids;
|
||
}
|
||
|
||
function minIso(a, b) {
|
||
if (!a) return b || null;
|
||
if (!b) return a || null;
|
||
return Date.parse(a) <= Date.parse(b) ? a : b;
|
||
}
|
||
|
||
function maxIso(a, b) {
|
||
if (!a) return b || null;
|
||
if (!b) return a || null;
|
||
return Date.parse(a) >= Date.parse(b) ? a : b;
|
||
}
|
||
|
||
function sampleExecutionErrorCandidates(sample) {
|
||
const candidates = [];
|
||
const add = (source, items) => {
|
||
if (!Array.isArray(items)) return;
|
||
for (const item of items) {
|
||
const text = String(item?.textPreview || item?.text || item?.preview || "").trim();
|
||
if (!text) continue;
|
||
if (!parseExecutionErrorText(text)) continue;
|
||
candidates.push({
|
||
source,
|
||
text,
|
||
traceId: item?.traceId ?? null,
|
||
messageId: item?.messageId ?? null,
|
||
status: item?.status ?? null
|
||
});
|
||
}
|
||
};
|
||
add("diagnostic-node", sample?.diagnostics);
|
||
add("message", sample?.messages);
|
||
add("trace-row", sample?.traceRows);
|
||
add("turn", sample?.turns);
|
||
const specific = candidates.filter((candidate) => {
|
||
const parsed = parseExecutionErrorText(candidate.text);
|
||
return parsed && parsed.code !== "error";
|
||
});
|
||
return specific.length > 0 ? specific : candidates;
|
||
}
|
||
|
||
function parseExecutionErrorText(text) {
|
||
const value = String(text || "");
|
||
const agentRunCodeMatch = value.match(/\bagentrun:error:([A-Za-z0-9_.:-]+)/u);
|
||
const agentRunText = /\bAgentRun\s+error\b|\bagentrun:error:/iu.test(value);
|
||
const providerUnavailable = /\bprovider[-_\s]*unavailable\b/iu.test(value);
|
||
if (!agentRunCodeMatch && !agentRunText && !providerUnavailable) return null;
|
||
const statusMatch = value.match(/\b(fail(?:ed)?|error|blocked|cancel(?:ed)?)\b/iu);
|
||
const traceMatch = value.match(/\btrc_[A-Za-z0-9_-]+\b/u);
|
||
const totalMatch = value.match(/\btotal\s*=\s*([0-9]{1,2}:[0-9]{2}(?::[0-9]{2})?)\b/iu)
|
||
|| value.match(/总耗时\s*[::]?\s*([0-9]{1,2}:[0-9]{2}(?::[0-9]{2})?)/iu);
|
||
const agentRunCode = cleanExecutionCode(agentRunCodeMatch?.[1] || "");
|
||
const rawCode = agentRunCode ? "agentrun:error:" + agentRunCode : providerUnavailable ? "provider-unavailable" : "agentrun:error";
|
||
return {
|
||
backend: agentRunText || agentRunCodeMatch ? "agentrun" : "unknown",
|
||
status: normalizeExecutionStatus(statusMatch?.[1] || "error"),
|
||
code: agentRunCode || (providerUnavailable ? "provider-unavailable" : "error"),
|
||
rawCode,
|
||
totalSeconds: totalMatch ? parseClockDurationSeconds(totalMatch[1]) : null,
|
||
traceId: traceMatch?.[0] || null
|
||
};
|
||
}
|
||
|
||
function cleanExecutionCode(code) {
|
||
const value = String(code || "").replace(/(?:AgentRun|Error|Failed).*$/u, "").replace(/[^A-Za-z0-9_.:-].*$/u, "");
|
||
return value || null;
|
||
}
|
||
|
||
function normalizeExecutionStatus(status) {
|
||
const value = String(status || "").toLowerCase();
|
||
if (value === "failed") return "fail";
|
||
if (value === "cancelled" || value === "canceled") return "cancel";
|
||
return value || "error";
|
||
}
|
||
|
||
function parseClockDurationSeconds(value) {
|
||
const parts = String(value || "").split(":").map((part) => Number(part));
|
||
if (parts.length === 2 && parts.every(Number.isFinite)) return parts[0] * 60 + parts[1];
|
||
if (parts.length === 3 && parts.every(Number.isFinite)) return parts[0] * 3600 + parts[1] * 60 + parts[2];
|
||
return null;
|
||
}
|
||
|
||
function groupExecutionErrors(events) {
|
||
const groups = new Map();
|
||
for (const event of events) {
|
||
const key = [event.backend || "-", event.status || "-", event.code || "-"].join(" ");
|
||
const group = groups.get(key) || {
|
||
backend: event.backend ?? null,
|
||
status: event.status ?? null,
|
||
code: event.code ?? null,
|
||
rawCode: event.rawCode ?? null,
|
||
count: 0,
|
||
firstAt: event.ts,
|
||
lastAt: event.ts,
|
||
promptIndexes: [],
|
||
traceIds: [],
|
||
sources: []
|
||
};
|
||
group.count += 1;
|
||
group.lastAt = event.ts;
|
||
if (event.promptIndex && !group.promptIndexes.includes(event.promptIndex)) group.promptIndexes.push(event.promptIndex);
|
||
if (event.traceId && !group.traceIds.includes(event.traceId)) group.traceIds.push(event.traceId);
|
||
if (event.source && !group.sources.includes(event.source)) group.sources.push(event.source);
|
||
groups.set(key, group);
|
||
}
|
||
return Array.from(groups.values()).sort((a, b) => b.count - a.count || String(a.code).localeCompare(String(b.code)));
|
||
}
|
||
|
||
function consoleAlertEvent(item, promptTimes) {
|
||
const text = String(item?.text || "");
|
||
const statusMatch = text.match(/\bstatus\s+of\s+([1-5][0-9]{2})\b/iu) || text.match(/\bHTTP\s+([1-5][0-9]{2})\b/iu);
|
||
const location = compactLocation(item.location);
|
||
const traceMatch = (location?.urlPath || text).match(/\btrc_[A-Za-z0-9_-]+\b/u);
|
||
return {
|
||
ts: item.ts ?? null,
|
||
promptIndex: promptIndexForTs(promptTimes, item.ts),
|
||
type: item.type ?? null,
|
||
status: statusMatch ? Number(statusMatch[1]) : null,
|
||
urlPath: location?.urlPath || "-",
|
||
traceId: traceMatch?.[0] || null,
|
||
textHash: item.text ? sha256(item.text) : null,
|
||
preview: limitText(text, 220),
|
||
location
|
||
};
|
||
}
|
||
|
||
function groupConsoleAlerts(events) {
|
||
const groups = new Map();
|
||
for (const event of events) {
|
||
const key = [event.type || "-", event.status ?? "-", event.urlPath || "-"].join(" ");
|
||
const group = groups.get(key) || {
|
||
type: event.type ?? null,
|
||
status: event.status ?? null,
|
||
urlPath: event.urlPath || "-",
|
||
count: 0,
|
||
firstAt: event.ts,
|
||
lastAt: event.ts,
|
||
promptIndexes: [],
|
||
traceIds: []
|
||
};
|
||
group.count += 1;
|
||
group.lastAt = event.ts;
|
||
if (event.promptIndex && !group.promptIndexes.includes(event.promptIndex)) group.promptIndexes.push(event.promptIndex);
|
||
if (event.traceId && !group.traceIds.includes(event.traceId)) group.traceIds.push(event.traceId);
|
||
groups.set(key, group);
|
||
}
|
||
return Array.from(groups.values()).sort((a, b) => b.count - a.count || String(a.urlPath).localeCompare(String(b.urlPath)));
|
||
}
|
||
|
||
function isBenignLongLivedStreamClosureAlert(event) {
|
||
if (event?.urlPath !== "/v1/workbench/events") return false;
|
||
if (event.status !== null && event.status !== undefined && Number(event.status) > 0) return false;
|
||
const text = String(event.failureKind || event.errorPreview || event.preview || "");
|
||
return /ERR_NETWORK_CHANGED|ERR_ABORTED|net::ERR_NETWORK_CHANGED|net::ERR_ABORTED|aborted|network changed/iu.test(text);
|
||
}
|
||
|
||
function isObserverRefreshClosureAlert(event, observerRefreshTimes) {
|
||
const urlPath = String(event?.urlPath || "");
|
||
if (!["/v1/workbench/events", "/v1/web-performance"].includes(urlPath) && !/^\/v1\/workbench\/traces\/[^/]+\/events$/u.test(urlPath)) return false;
|
||
if (event.status !== null && event.status !== undefined && Number(event.status) > 0) return false;
|
||
const text = String(event.failureKind || event.errorPreview || event.preview || "");
|
||
if (!/ERR_NETWORK_CHANGED|ERR_ABORTED|ERR_INCOMPLETE_CHUNKED_ENCODING|ERR_INVALID_CHUNKED_ENCODING|net::ERR_NETWORK_CHANGED|net::ERR_ABORTED|aborted|network changed|incomplete chunked|invalid chunked/iu.test(text)) return false;
|
||
const ts = Date.parse(String(event.ts || ""));
|
||
return Number.isFinite(ts) && observerRefreshTimes.some((refreshTs) => Math.abs(ts - refreshTs) <= 8000);
|
||
}
|
||
|
||
function networkAlertEvent(item, promptTimes) {
|
||
const failureText = item.failureKind ?? item.failure ?? item.errorText ?? null;
|
||
return {
|
||
ts: item.ts ?? null,
|
||
promptIndex: promptIndexForTs(promptTimes, item.ts),
|
||
method: String(item.method || "GET").toUpperCase(),
|
||
status: Number.isFinite(Number(item.status)) ? Number(item.status) : null,
|
||
type: item.type ?? null,
|
||
urlPath: urlPath(item.url),
|
||
urlHash: item.url ? sha256(item.url) : null,
|
||
failureKind: failureText ? String(failureText) : null,
|
||
errorTextHash: failureText ? sha256(failureText) : null,
|
||
errorPreview: failureText ? limitText(failureText, 160) : null
|
||
};
|
||
}
|
||
|
||
function groupNetworkAlerts(events) {
|
||
const groups = new Map();
|
||
for (const event of events) {
|
||
const key = [event.method, event.urlPath, event.status ?? "-", event.type].join(" ");
|
||
const group = groups.get(key) || {
|
||
method: event.method,
|
||
urlPath: event.urlPath,
|
||
status: event.status,
|
||
type: event.type,
|
||
count: 0,
|
||
firstAt: event.ts,
|
||
lastAt: event.ts,
|
||
promptIndexes: [],
|
||
failureKinds: [],
|
||
errorTextHashes: []
|
||
};
|
||
group.count += 1;
|
||
group.lastAt = event.ts;
|
||
if (event.promptIndex && !group.promptIndexes.includes(event.promptIndex)) group.promptIndexes.push(event.promptIndex);
|
||
if (event.failureKind && !group.failureKinds.includes(event.failureKind)) group.failureKinds.push(event.failureKind);
|
||
if (event.errorTextHash && !group.errorTextHashes.includes(event.errorTextHash)) group.errorTextHashes.push(event.errorTextHash);
|
||
groups.set(key, group);
|
||
}
|
||
return Array.from(groups.values()).sort((a, b) => b.count - a.count || String(a.urlPath).localeCompare(String(b.urlPath)));
|
||
}
|
||
|
||
function isDiagnosticText(text) {
|
||
const value = String(text || "");
|
||
return /Failed to (?:fetch|load resource)|request failed|net::ERR_[A-Z0-9_:-]+|server responded with a status of [45][0-9]{2}|HTTP\s+[45][0-9]{2}\b|trace_id=|workbench turn\s*超过|turn\s*超过|无新活动|idle\s+\d+s|waitingFor=|lastEventLabel=|无法连接上游|代理暂时无法连接上游|provider-unavailable|agentrun:error|AgentRun error|projection-resume|sync-failed|durable projection store|realtime-gap|Trace 更新超时|加载失败|请求失败|请求已失败/iu.test(value);
|
||
}
|
||
|
||
function prioritizeFindings(findings) {
|
||
const items = Array.isArray(findings) ? findings : [];
|
||
const severityRank = (severity) => {
|
||
const value = String(severity || "").toLowerCase();
|
||
if (value === "red") return 0;
|
||
if (value === "amber" || value === "warning") return 1;
|
||
if (value === "info") return 3;
|
||
return 2;
|
||
};
|
||
const kindRank = (item) => {
|
||
const id = String(item?.id ?? item?.kind ?? item?.code ?? "");
|
||
if (id === "page-performance-slow-same-origin-api") return 0;
|
||
if (id === "session-rail-title-fallback-majority") return 0.5;
|
||
if (id.startsWith("code-agent-card-")) return 0.8;
|
||
if (id.startsWith("round-completion-")) return 0.9;
|
||
if (id.startsWith("turn-timing-total-elapsed")) return 1;
|
||
if (id.startsWith("turn-timing-terminal-elapsed")) return 1.1;
|
||
if (id.startsWith("turn-timing-recent-update")) return 2;
|
||
if (id.includes("runtime-execution") || id.includes("prompt-chat-submit-failed")) return 3;
|
||
return 10;
|
||
};
|
||
return items.slice().sort((left, right) => {
|
||
const kindDelta = kindRank(left) - kindRank(right);
|
||
if (kindDelta !== 0) return kindDelta;
|
||
return severityRank(left?.severity ?? left?.level) - severityRank(right?.severity ?? right?.level);
|
||
});
|
||
}
|
||
|
||
function isTerminalTraceText(text) {
|
||
return /轮次完成|轮次失败|轮次取消|已记录|已完成第\d+轮|final response|sealed final response|turn completed|turn failed|turn canceled|terminal result|\bcompleted\b|\bfailed\b|\bcanceled\b|\bcancelled\b|\bterminal\b|\bdone\b/iu.test(String(text || ""));
|
||
}
|
||
|
||
function isFinalResultText(text) {
|
||
return /已完成第\d+轮|已按第\d+轮完成|final response|sealed final response|最终结果|已完成[::]|smoke\s*测试结果|benchmark|PVC\/workspace|修改文件|Results:/iu.test(String(text || ""));
|
||
}
|
||
|
||
function buildSampleMetrics(samples, control) {
|
||
const promptCommands = buildSendPromptCommandTimeline(control);
|
||
const promptTimes = promptCommands.map((item) => item.tsMs);
|
||
const timeline = samples.map((sample) => {
|
||
const texts = sampleTexts(sample);
|
||
const timingTexts = sampleTurnTimingTexts(sample);
|
||
const tsMs = Date.parse(sample.ts);
|
||
const promptIndex = Number.isFinite(tsMs) ? latestPromptIndex(promptTimes, tsMs) : 0;
|
||
const totalElapsedValues = timingTexts.flatMap(parseTotalElapsedSeconds).filter(Number.isFinite);
|
||
const recentUpdateValues = timingTexts.flatMap(parseRecentUpdateSeconds).filter(Number.isFinite);
|
||
const diagnosticTexts = texts.filter(isDiagnosticText).slice(0, 5);
|
||
const terminalTexts = texts.filter(isTerminalTraceText).slice(0, 5);
|
||
const finalResultTexts = texts.filter(isFinalResultText).slice(0, 5);
|
||
const loadings = Array.isArray(sample.loadings) ? sample.loadings : [];
|
||
const loadingOwners = uniqueLoadingOwners(loadings);
|
||
return {
|
||
seq: sample.seq ?? null,
|
||
ts: sample.ts ?? null,
|
||
routeSessionId: sample.routeSessionId ?? null,
|
||
activeSessionId: sample.activeSessionId ?? null,
|
||
promptIndex,
|
||
messageCount: Array.isArray(sample.messages) ? sample.messages.length : 0,
|
||
traceRowCount: Array.isArray(sample.traceRows) ? sample.traceRows.length : 0,
|
||
loadingCount: loadings.length,
|
||
loadingOwnerCount: loadingOwners.length,
|
||
loadingOwners: loadingOwners.map((item) => ({ ownerKey: item.ownerKey, ownerKind: item.ownerKind, ownerLabel: item.ownerLabel, count: item.count })).slice(0, 12),
|
||
sessionRailVisibleCount: Number(sample?.sessionRail?.visibleCount ?? 0),
|
||
sessionRailFallbackTitleCount: Number(sample?.sessionRail?.fallbackTitleCount ?? 0),
|
||
sessionRailFallbackTitleRatio: Number(sample?.sessionRail?.fallbackTitleRatio ?? 0),
|
||
totalElapsedSeconds: totalElapsedValues.length > 0 ? Math.max(...totalElapsedValues) : null,
|
||
recentUpdateSeconds: recentUpdateValues.length > 0 ? Math.max(...recentUpdateValues) : null,
|
||
terminalSeen: terminalTexts.length > 0,
|
||
finalResultTextSeen: finalResultTexts.length > 0,
|
||
diagnosticSeen: diagnosticTexts.length > 0,
|
||
diagnosticTextHashes: diagnosticTexts.map(sha256).slice(0, 5),
|
||
textDigest: digestSample(sample)
|
||
};
|
||
});
|
||
const turnTiming = buildTurnTimingTable(samples, timeline);
|
||
const traceOrder = buildTraceOrderMetrics(samples, timeline);
|
||
const codeAgentCardTiming = buildCodeAgentCardTimingMetrics(samples, timeline, turnTiming);
|
||
const codeAgentCardDurationUnderreported = buildCodeAgentCardDurationUnderreportedMetrics(samples, timeline);
|
||
const codeAgentCardDurationMismatches = buildCodeAgentCardDurationMismatchMetrics(samples, timeline);
|
||
if (codeAgentCardTiming && codeAgentCardTiming.summary) {
|
||
codeAgentCardTiming.summary.durationUnderreportedCount = codeAgentCardDurationUnderreported.length;
|
||
codeAgentCardTiming.summary.durationMismatchCount = codeAgentCardDurationMismatches.length;
|
||
codeAgentCardTiming.durationUnderreported = codeAgentCardDurationUnderreported;
|
||
codeAgentCardTiming.durationMismatches = codeAgentCardDurationMismatches;
|
||
}
|
||
const turnCells = turnTiming.rows.flatMap((row) => Object.values(row.cells || {}));
|
||
const turnTimingNonMonotonic = Array.isArray(turnTiming.nonMonotonic) ? turnTiming.nonMonotonic : [];
|
||
const turnTimingElapsedZeroResets = Array.isArray(turnTiming.elapsedZeroResets) ? turnTiming.elapsedZeroResets : [];
|
||
const turnTimingTotalElapsedForwardJumps = Array.isArray(turnTiming.totalElapsedForwardJumps) ? turnTiming.totalElapsedForwardJumps : [];
|
||
const turnTimingRecentUpdateSawtoothJumps = turnTimingNonMonotonic.filter((item) => item.metric === "recentUpdateSeconds" && item.anomaly === "jump");
|
||
const turnTimingTerminalElapsedGrowth = Array.isArray(turnTiming.terminalElapsedGrowth) ? turnTiming.terminalElapsedGrowth : [];
|
||
const turnTimingRecentUpdateResets = Array.isArray(turnTiming.recentUpdateResets) ? turnTiming.recentUpdateResets : [];
|
||
const turnTimingRecentUpdateSteps = Array.isArray(turnTiming.recentUpdateSteps) ? turnTiming.recentUpdateSteps : [];
|
||
const turnTimingTerminalElapsedGrowthDeltas = turnTimingTerminalElapsedGrowth
|
||
.map((item) => Number(item.delta))
|
||
.filter((value) => Number.isFinite(value) && value > 0);
|
||
const turnTimingRecentUpdateLargestSteps = turnTimingRecentUpdateSteps
|
||
.filter((item) => Number.isFinite(Number(item.delta)))
|
||
.slice()
|
||
.sort((a, b) => Number(b.delta) - Number(a.delta))
|
||
.slice(0, 200);
|
||
const turnTimingRecentUpdatePositiveSteps = turnTimingRecentUpdateSteps
|
||
.map((item) => Number(item.delta))
|
||
.filter((value) => Number.isFinite(value) && value >= 0);
|
||
const turnTimingRecentUpdateExcessSteps = turnTimingRecentUpdateSteps
|
||
.map((item) => Number(item.excessiveIncreaseSeconds))
|
||
.filter((value) => Number.isFinite(value) && value > 0);
|
||
const withTotal = timeline.filter((item) => item.totalElapsedSeconds !== null).length;
|
||
const withRecent = timeline.filter((item) => item.recentUpdateSeconds !== null).length;
|
||
const diagnostics = timeline.filter((item) => item.diagnosticSeen).length;
|
||
const loading = buildLoadingMetrics(samples, timeline);
|
||
const sessionRailTitles = buildSessionRailTitleMetrics(samples, timeline);
|
||
const reportTurnTimingRows = boundedTurnTimingRowsForReport(turnTiming.rows);
|
||
const reportTimeline = boundedRowsForReport(timeline);
|
||
const rounds = buildRoundMetricSummaries(timeline, promptCommands, {
|
||
columns: turnTiming.columns,
|
||
rows: turnTiming.rows,
|
||
nonMonotonic: turnTimingNonMonotonic,
|
||
elapsedZeroResets: turnTimingElapsedZeroResets,
|
||
totalElapsedForwardJumps: turnTimingTotalElapsedForwardJumps,
|
||
terminalElapsedGrowth: turnTimingTerminalElapsedGrowth,
|
||
recentUpdateResets: turnTimingRecentUpdateResets,
|
||
recentUpdateSteps: turnTimingRecentUpdateSteps
|
||
});
|
||
const recentUpdateJumpCount = turnTimingRecentUpdateSawtoothJumps.length;
|
||
return {
|
||
summary: {
|
||
sampleCount: timeline.length,
|
||
withTotalElapsed: withTotal,
|
||
withRecentUpdate: withRecent,
|
||
diagnostics,
|
||
loadingSampleCount: loading.summary.loadingSampleCount,
|
||
loadingMaxCount: loading.summary.maxSimultaneousCount,
|
||
loadingMaxOwnerCount: loading.summary.maxSimultaneousOwnerCount,
|
||
loadingOwnerCount: loading.summary.ownerCount,
|
||
loadingConcurrentSampleCount: loading.summary.concurrentLoadingSampleCount,
|
||
loadingLongestContinuousSeconds: loading.summary.longestContinuousSeconds,
|
||
loadingCurrentContinuousSeconds: loading.summary.currentContinuousSeconds,
|
||
loadingOverFiveSecondSegmentCount: loading.summary.overFiveSecondSegmentCount,
|
||
loadingOverBudgetSegmentCount: loading.summary.overBudgetSegmentCount,
|
||
sessionRailSampleCount: sessionRailTitles.summary.sampleCount,
|
||
sessionRailVisibleSampleCount: sessionRailTitles.summary.visibleSampleCount,
|
||
sessionRailFallbackMajoritySampleCount: sessionRailTitles.summary.majorityFallbackSampleCount,
|
||
sessionRailFallbackMaxRatio: sessionRailTitles.summary.maxFallbackRatio,
|
||
sessionRailFallbackMaxVisibleCount: sessionRailTitles.summary.maxVisibleCount,
|
||
sessionRailFallbackMaxCount: sessionRailTitles.summary.maxFallbackTitleCount,
|
||
promptSegments: Math.max(0, promptTimes.length),
|
||
rounds: rounds.length,
|
||
turnColumns: turnTiming.columns.length,
|
||
turnTimingRows: turnTiming.rows.length,
|
||
turnCellsWithTotalElapsed: turnCells.filter((item) => item.totalElapsedSeconds !== null).length,
|
||
turnCellsWithRecentUpdate: turnCells.filter((item) => item.recentUpdateSeconds !== null).length,
|
||
turnTimingNonMonotonicCount: turnTimingNonMonotonic.length,
|
||
turnTimingTotalElapsedDecreaseCount: turnTimingNonMonotonic.filter((item) => item.metric === "totalElapsedSeconds").length,
|
||
turnTimingTotalElapsedZeroResetCount: turnTimingElapsedZeroResets.length,
|
||
turnTimingTotalElapsedForwardJumpCount: turnTimingTotalElapsedForwardJumps.length,
|
||
turnTimingTotalElapsedForwardJumpMaxSeconds: maxPositiveDelta(turnTimingTotalElapsedForwardJumps),
|
||
turnTimingTerminalElapsedGrowthCount: turnTimingTerminalElapsedGrowth.length,
|
||
turnTimingTerminalElapsedGrowthMaxSeconds: turnTimingTerminalElapsedGrowthDeltas.length > 0 ? Math.max(...turnTimingTerminalElapsedGrowthDeltas) : 0,
|
||
turnTimingRecentUpdateJumpCount: recentUpdateJumpCount,
|
||
turnTimingRecentUpdateSawtoothJumpCount: recentUpdateJumpCount,
|
||
turnTimingRecentUpdateStepCount: turnTimingRecentUpdateSteps.length,
|
||
turnTimingRecentUpdateMaxIncreaseSeconds: turnTimingRecentUpdatePositiveSteps.length > 0 ? Math.max(...turnTimingRecentUpdatePositiveSteps) : null,
|
||
turnTimingRecentUpdateMaxExcessSeconds: turnTimingRecentUpdateExcessSteps.length > 0 ? Math.max(...turnTimingRecentUpdateExcessSteps) : 0,
|
||
turnTimingRecentUpdateResetCount: turnTimingRecentUpdateResets.length,
|
||
turnTimingRecentUpdateDecreaseCount: turnTimingRecentUpdateResets.length,
|
||
codeAgentCardSampleCount: codeAgentCardTiming.summary.cardSampleCount,
|
||
codeAgentCardMissingElapsedCount: codeAgentCardTiming.summary.missingElapsedCount,
|
||
codeAgentCardMissingRecentUpdateCount: codeAgentCardTiming.summary.missingRecentUpdateCount,
|
||
roundCompletionEventCount: codeAgentCardTiming.summary.roundCompletionEventCount,
|
||
roundCompletionElapsedMismatchCount: codeAgentCardTiming.summary.roundCompletionElapsedMismatchCount,
|
||
roundCompletionFinalResponseMissingCount: codeAgentCardTiming.summary.roundCompletionFinalResponseMissingCount,
|
||
roundCompletionPostTimingChangeCount: codeAgentCardTiming.summary.roundCompletionPostTimingChangeCount,
|
||
codeAgentCardDurationUnderreportedCount: codeAgentCardTiming.summary.durationUnderreportedCount,
|
||
codeAgentCardDurationMismatchCount: codeAgentCardTiming.summary.durationMismatchCount,
|
||
traceRowCount: traceOrder.summary.traceRowCount,
|
||
traceRowOrderAnomalyCount: traceOrder.summary.orderAnomalyCount,
|
||
traceRowCompletionNotLastCount: traceOrder.summary.completionNotLastCount,
|
||
roundsWithTurnTimingNonMonotonic: rounds.filter((item) => item.turnTimingNonMonotonicCount > 0).length,
|
||
roundsWithTurnTimingTotalElapsedForwardJumps: rounds.filter((item) => item.turnTimingTotalElapsedForwardJumpCount > 0).length,
|
||
roundsWithTerminalElapsedGrowth: rounds.filter((item) => item.turnTimingTerminalElapsedGrowthCount > 0).length,
|
||
roundsWithRecentUpdateJumps: rounds.filter((item) => item.turnTimingRecentUpdateJumpCount > 0).length
|
||
},
|
||
loading,
|
||
sessionRailTitles,
|
||
codeAgentCardTiming,
|
||
traceOrder,
|
||
rounds,
|
||
turnColumns: turnTiming.columns,
|
||
turnTimingTable: reportTurnTimingRows.rows,
|
||
turnTimingTableDisclosure: reportTurnTimingRows.disclosure,
|
||
turnTimingNonMonotonic,
|
||
turnTimingElapsedZeroResets,
|
||
turnTimingTotalElapsedForwardJumps,
|
||
turnTimingTerminalElapsedGrowth,
|
||
turnTimingRecentUpdateSawtoothJumps,
|
||
turnTimingRecentUpdateSteps,
|
||
turnTimingRecentUpdateLargestSteps,
|
||
turnTimingRecentUpdateResets,
|
||
timeline: reportTimeline.rows,
|
||
timelineDisclosure: reportTimeline.disclosure
|
||
};
|
||
}
|
||
|
||
function boundedRowsForReport(rows) {
|
||
const sourceRows = Array.isArray(rows) ? rows : [];
|
||
const maxRows = 1200;
|
||
const headRows = 120;
|
||
if (sourceRows.length <= maxRows) return { rows: sourceRows, disclosure: { truncated: false, totalRows: sourceRows.length, includedRows: sourceRows.length, omittedRows: 0, headRows: sourceRows.length, tailRows: 0 } };
|
||
const tailRows = Math.max(0, maxRows - headRows);
|
||
return {
|
||
rows: [...sourceRows.slice(0, headRows), ...sourceRows.slice(-tailRows)],
|
||
disclosure: {
|
||
truncated: true,
|
||
totalRows: sourceRows.length,
|
||
includedRows: maxRows,
|
||
omittedRows: Math.max(0, sourceRows.length - maxRows),
|
||
headRows,
|
||
tailRows,
|
||
policy: "report-bounded-head-tail; summary metrics are computed before truncation",
|
||
valuesRedacted: true
|
||
}
|
||
};
|
||
}
|
||
|
||
function buildSendPromptCommandTimeline(control) {
|
||
const byCommand = new Map();
|
||
let ordinal = 0;
|
||
for (const item of Array.isArray(control) ? control : []) {
|
||
if (item?.type !== "sendPrompt") continue;
|
||
const commandId = item.commandId ? String(item.commandId) : "sendPrompt-" + String(ordinal++);
|
||
const existing = byCommand.get(commandId) || {
|
||
commandId,
|
||
startedAt: null,
|
||
startedTsMs: null,
|
||
completedAt: null,
|
||
completedTsMs: null,
|
||
failedAt: null,
|
||
failedTsMs: null,
|
||
textHash: null,
|
||
textBytes: null,
|
||
};
|
||
if (!existing.textHash && item.input?.textHash) existing.textHash = item.input.textHash;
|
||
if (!existing.textBytes && item.input?.textBytes) existing.textBytes = item.input.textBytes;
|
||
const tsMs = Date.parse(item.ts);
|
||
if (item.phase === "started" && Number.isFinite(tsMs)) {
|
||
existing.startedAt = item.ts ?? existing.startedAt;
|
||
existing.startedTsMs = tsMs;
|
||
} else if (item.phase === "completed" && Number.isFinite(tsMs)) {
|
||
existing.completedAt = item.ts ?? existing.completedAt;
|
||
existing.completedTsMs = tsMs;
|
||
} else if (item.phase === "failed" && Number.isFinite(tsMs)) {
|
||
existing.failedAt = item.ts ?? existing.failedAt;
|
||
existing.failedTsMs = tsMs;
|
||
}
|
||
byCommand.set(commandId, existing);
|
||
}
|
||
return Array.from(byCommand.values())
|
||
.map((item) => {
|
||
const tsMs = Number.isFinite(item.startedTsMs) ? item.startedTsMs : Number.isFinite(item.completedTsMs) ? item.completedTsMs : item.failedTsMs;
|
||
const ts = Number.isFinite(item.startedTsMs) ? item.startedAt : Number.isFinite(item.completedTsMs) ? item.completedAt : item.failedAt;
|
||
return { ...item, ts, tsMs };
|
||
})
|
||
.filter((item) => Number.isFinite(item.tsMs))
|
||
.sort((a, b) => a.tsMs - b.tsMs)
|
||
.map((item, index) => ({ ...item, promptIndex: index + 1 }));
|
||
}
|
||
|
||
function buildSessionRailTitleMetrics(samples, timeline) {
|
||
const rows = [];
|
||
const examplesByHash = new Map();
|
||
for (let index = 0; index < (Array.isArray(samples) ? samples : []).length; index += 1) {
|
||
const sample = samples[index];
|
||
const rail = sample?.sessionRail && typeof sample.sessionRail === "object" ? sample.sessionRail : null;
|
||
if (!rail) continue;
|
||
const visibleCount = Number(rail.visibleCount ?? 0);
|
||
const fallbackTitleCount = Number(rail.fallbackTitleCount ?? 0);
|
||
const safeVisibleCount = Number.isFinite(visibleCount) && visibleCount > 0 ? visibleCount : 0;
|
||
const safeFallbackTitleCount = Number.isFinite(fallbackTitleCount) && fallbackTitleCount > 0 ? fallbackTitleCount : 0;
|
||
const fallbackTitleRatio = safeVisibleCount > 0 ? Number((safeFallbackTitleCount / safeVisibleCount).toFixed(4)) : 0;
|
||
const fallbackItems = Array.isArray(rail.fallbackItems) ? rail.fallbackItems : [];
|
||
for (const item of fallbackItems) {
|
||
const hash = String(item?.titleHash || item?.titlePreview || item?.sessionIdPrefix || "").trim();
|
||
if (!hash || examplesByHash.has(hash)) continue;
|
||
examplesByHash.set(hash, {
|
||
titleHash: item?.titleHash ?? null,
|
||
titlePreview: limitText(String(item?.titlePreview || ""), 160),
|
||
sessionIdPrefix: item?.sessionIdPrefix ?? null,
|
||
active: item?.active === true,
|
||
firstSeq: sample?.seq ?? null,
|
||
firstAt: sample?.ts ?? null,
|
||
pageRole: sample?.pageRole ?? null,
|
||
});
|
||
}
|
||
rows.push({
|
||
...ref(sample),
|
||
promptIndex: timeline[index]?.promptIndex ?? 0,
|
||
visibleCount: safeVisibleCount,
|
||
fallbackTitleCount: safeFallbackTitleCount,
|
||
fallbackTitleRatio,
|
||
majorityFallback: safeVisibleCount > 0 && safeFallbackTitleCount > safeVisibleCount / 2,
|
||
overThreshold: safeVisibleCount > 0 && fallbackTitleRatio > alertThresholds.sessionRailFallbackRatio,
|
||
examples: fallbackItems.slice(0, 5).map((item) => ({
|
||
titleHash: item?.titleHash ?? null,
|
||
titlePreview: limitText(String(item?.titlePreview || ""), 160),
|
||
sessionIdPrefix: item?.sessionIdPrefix ?? null,
|
||
active: item?.active === true,
|
||
})),
|
||
});
|
||
}
|
||
const visibleRows = rows.filter((item) => item.visibleCount > 0);
|
||
const majorityRows = rows.filter((item) => item.majorityFallback);
|
||
const overThresholdRows = rows.filter((item) => item.overThreshold);
|
||
const fallbackRows = rows.filter((item) => item.fallbackTitleCount > 0);
|
||
const maxFallbackRatio = rows.length > 0 ? Math.max(...rows.map((item) => Number(item.fallbackTitleRatio) || 0)) : 0;
|
||
const maxVisibleCount = rows.length > 0 ? Math.max(...rows.map((item) => Number(item.visibleCount) || 0)) : 0;
|
||
const maxFallbackTitleCount = rows.length > 0 ? Math.max(...rows.map((item) => Number(item.fallbackTitleCount) || 0)) : 0;
|
||
return {
|
||
summary: {
|
||
sampleCount: rows.length,
|
||
visibleSampleCount: visibleRows.length,
|
||
fallbackSampleCount: fallbackRows.length,
|
||
majorityFallbackSampleCount: majorityRows.length,
|
||
overThresholdSampleCount: overThresholdRows.length,
|
||
thresholdRatio: alertThresholds.sessionRailFallbackRatio,
|
||
maxFallbackRatio,
|
||
maxVisibleCount,
|
||
maxFallbackTitleCount,
|
||
},
|
||
samples: majorityRows.slice(0, 80),
|
||
examples: Array.from(examplesByHash.values()).slice(0, 80),
|
||
timeline: rows.slice(-200),
|
||
valuesRedacted: true
|
||
};
|
||
}
|
||
|
||
function uniqueLoadingOwners(loadings) {
|
||
const groups = new Map();
|
||
for (let index = 0; index < (Array.isArray(loadings) ? loadings : []).length; index += 1) {
|
||
const item = loadings[index];
|
||
const ownerKey = loadingOwnerKey(item, index);
|
||
const ownerIdentity = loadingOwnerIdentity(item);
|
||
const existing = groups.get(ownerKey) || {
|
||
ownerKey,
|
||
ownerKind: item?.ownerKind ?? "unknown",
|
||
ownerLabel: loadingOwnerLabel(item, ownerKey),
|
||
...ownerIdentity,
|
||
count: 0,
|
||
textHashes: []
|
||
};
|
||
existing.count += 1;
|
||
if (item?.textHash && !existing.textHashes.includes(item.textHash)) existing.textHashes.push(item.textHash);
|
||
for (const key of ["ownerSessionId", "ownerMessageId", "ownerTraceId"]) {
|
||
if (!existing[key] && ownerIdentity[key]) existing[key] = ownerIdentity[key];
|
||
}
|
||
groups.set(ownerKey, existing);
|
||
}
|
||
return Array.from(groups.values()).sort((a, b) => b.count - a.count || String(a.ownerLabel).localeCompare(String(b.ownerLabel)));
|
||
}
|
||
|
||
function loadingOwnerIdentity(item) {
|
||
const owner = item?.owner && typeof item.owner === "object" ? item.owner : {};
|
||
return {
|
||
ownerSessionId: owner.sessionId ?? null,
|
||
ownerMessageId: owner.messageId ?? null,
|
||
ownerTraceId: owner.traceId ?? null,
|
||
};
|
||
}
|
||
|
||
function loadingOwnerKey(item, index = 0) {
|
||
const key = String(item?.ownerKey || "").trim();
|
||
if (key) return key.slice(0, 240);
|
||
const owner = item?.owner && typeof item.owner === "object" ? item.owner : {};
|
||
return [
|
||
item?.ownerKind || "unknown",
|
||
owner.testId || item?.testId || owner.id || owner.role || owner.className || item?.role || item?.tag || "node",
|
||
owner.sessionId || owner.messageId || owner.traceId || item?.textHash || String(index)
|
||
].filter(Boolean).join(":").slice(0, 240);
|
||
}
|
||
|
||
function loadingOwnerLabel(item, fallback) {
|
||
return limitText(String(item?.ownerLabel || item?.owner?.ariaLabel || item?.owner?.testId || item?.owner?.className || fallback || "unknown"), 160);
|
||
}
|
||
|
||
function buildLoadingMetrics(samples, timeline) {
|
||
const events = samples.map((sample, index) => {
|
||
const tsMs = Date.parse(sample?.ts);
|
||
const loadings = Array.isArray(sample?.loadings) ? sample.loadings : [];
|
||
const owners = uniqueLoadingOwners(loadings);
|
||
return {
|
||
seq: sample?.seq ?? null,
|
||
ts: sample?.ts ?? null,
|
||
tsMs,
|
||
promptIndex: timeline[index]?.promptIndex ?? 0,
|
||
routeSessionId: sample?.routeSessionId ?? null,
|
||
activeSessionId: sample?.activeSessionId ?? null,
|
||
loadingCount: loadings.length,
|
||
ownerCount: owners.length,
|
||
owners,
|
||
ownerKeys: owners.map((item) => item.ownerKey),
|
||
ownerLabels: owners.map((item) => item.ownerLabel).slice(0, 8)
|
||
};
|
||
}).filter((item) => Number.isFinite(item.tsMs));
|
||
const continuityThresholdMs = loadingContinuityThresholdMs(events);
|
||
const segments = buildLoadingSegments(events, continuityThresholdMs, (event) => event.loadingCount, (event) => event.owners)
|
||
.sort((a, b) => Number(b.durationSeconds ?? 0) - Number(a.durationSeconds ?? 0) || Number(b.maxCount ?? 0) - Number(a.maxCount ?? 0));
|
||
const ownerMap = new Map();
|
||
for (const event of events) {
|
||
for (const owner of event.owners) {
|
||
const existing = ownerMap.get(owner.ownerKey) || {
|
||
ownerKey: owner.ownerKey,
|
||
ownerKind: owner.ownerKind,
|
||
ownerLabel: owner.ownerLabel,
|
||
ownerSessionId: owner.ownerSessionId ?? null,
|
||
ownerMessageId: owner.ownerMessageId ?? null,
|
||
ownerTraceId: owner.ownerTraceId ?? null,
|
||
sampleCount: 0,
|
||
occurrenceCount: 0,
|
||
maxSimultaneousCount: 0,
|
||
firstAt: event.ts,
|
||
lastAt: event.ts,
|
||
firstSeq: event.seq,
|
||
lastSeq: event.seq,
|
||
promptIndexes: new Set(),
|
||
events: []
|
||
};
|
||
existing.sampleCount += 1;
|
||
existing.occurrenceCount += owner.count;
|
||
existing.maxSimultaneousCount = Math.max(existing.maxSimultaneousCount, owner.count);
|
||
existing.lastAt = event.ts;
|
||
existing.lastSeq = event.seq;
|
||
for (const key of ["ownerSessionId", "ownerMessageId", "ownerTraceId"]) {
|
||
if (!existing[key] && owner[key]) existing[key] = owner[key];
|
||
}
|
||
if (Number.isFinite(Number(event.promptIndex))) existing.promptIndexes.add(Number(event.promptIndex));
|
||
existing.events.push({ ...event, loadingCount: owner.count, owners: [owner] });
|
||
ownerMap.set(owner.ownerKey, existing);
|
||
}
|
||
}
|
||
const owners = Array.from(ownerMap.values()).map((owner) => {
|
||
const ownerSegments = buildLoadingSegments(owner.events, continuityThresholdMs, (event) => event.loadingCount, (event) => event.owners);
|
||
const longest = ownerSegments.reduce((max, item) => Math.max(max, Number(item.durationSeconds ?? 0)), 0);
|
||
return {
|
||
ownerKey: owner.ownerKey,
|
||
ownerKind: owner.ownerKind,
|
||
ownerLabel: owner.ownerLabel,
|
||
ownerSessionId: owner.ownerSessionId ?? null,
|
||
ownerMessageId: owner.ownerMessageId ?? null,
|
||
ownerTraceId: owner.ownerTraceId ?? null,
|
||
sampleCount: owner.sampleCount,
|
||
occurrenceCount: owner.occurrenceCount,
|
||
maxSimultaneousCount: owner.maxSimultaneousCount,
|
||
longestContinuousSeconds: longest,
|
||
firstAt: owner.firstAt,
|
||
lastAt: owner.lastAt,
|
||
firstSeq: owner.firstSeq,
|
||
lastSeq: owner.lastSeq,
|
||
promptIndexes: Array.from(owner.promptIndexes).sort((a, b) => a - b),
|
||
segments: ownerSegments.sort((a, b) => Number(b.durationSeconds ?? 0) - Number(a.durationSeconds ?? 0)).slice(0, 8),
|
||
valuesRedacted: true
|
||
};
|
||
}).sort((a, b) => Number(b.longestContinuousSeconds ?? 0) - Number(a.longestContinuousSeconds ?? 0) || Number(b.occurrenceCount ?? 0) - Number(a.occurrenceCount ?? 0));
|
||
const latest = events[events.length - 1] || null;
|
||
const currentSegment = latest && latest.loadingCount > 0
|
||
? segments.find((segment) => segment.ongoing === true && segment.lastSeq === latest.seq) || null
|
||
: null;
|
||
const timelineRows = events
|
||
.filter((event, index) => event.loadingCount > 0 || (index > 0 && events[index - 1]?.loadingCount > 0))
|
||
.slice(0, 500)
|
||
.map((event) => ({
|
||
seq: event.seq,
|
||
ts: event.ts,
|
||
promptIndex: event.promptIndex,
|
||
loadingCount: event.loadingCount,
|
||
ownerCount: event.ownerCount,
|
||
owners: event.owners.map((owner) => ({ ownerKind: owner.ownerKind, ownerLabel: owner.ownerLabel, ownerSessionId: owner.ownerSessionId ?? null, ownerMessageId: owner.ownerMessageId ?? null, ownerTraceId: owner.ownerTraceId ?? null, count: owner.count })).slice(0, 8)
|
||
}));
|
||
return {
|
||
summary: {
|
||
sampleCount: events.length,
|
||
loadingSampleCount: events.filter((event) => event.loadingCount > 0).length,
|
||
maxSimultaneousCount: events.reduce((max, event) => Math.max(max, event.loadingCount), 0),
|
||
maxSimultaneousOwnerCount: events.reduce((max, event) => Math.max(max, event.ownerCount), 0),
|
||
concurrentLoadingSampleCount: events.filter((event) => event.loadingCount > 1).length,
|
||
ownerCount: owners.length,
|
||
segmentCount: segments.length,
|
||
overFiveSecondSegmentCount: segments.filter((segment) => Number(segment.durationSeconds ?? 0) > 5).length,
|
||
overBudgetSegmentCount: segments.filter((segment) => Number(segment.durationSeconds ?? 0) > alertThresholds.visibleLoadingSlowMs / 1000).length,
|
||
budgetSeconds: alertThresholds.visibleLoadingSlowMs / 1000,
|
||
longestContinuousSeconds: segments.length > 0 ? Number(segments[0].durationSeconds ?? 0) : 0,
|
||
currentContinuousSeconds: currentSegment ? Number(currentSegment.durationSeconds ?? 0) : 0,
|
||
continuityThresholdMs,
|
||
latestLoadingCount: latest?.loadingCount ?? 0,
|
||
latestOwnerCount: latest?.ownerCount ?? 0,
|
||
valuesRedacted: true
|
||
},
|
||
segments: segments.slice(0, 80),
|
||
owners: owners.slice(0, 80),
|
||
timeline: timelineRows,
|
||
valuesRedacted: true
|
||
};
|
||
}
|
||
|
||
function loadingContinuityThresholdMs(events) {
|
||
const deltas = [];
|
||
for (let index = 1; index < events.length; index += 1) {
|
||
const delta = events[index].tsMs - events[index - 1].tsMs;
|
||
if (Number.isFinite(delta) && delta > 0) deltas.push(delta);
|
||
}
|
||
if (deltas.length === 0) return 5000;
|
||
const sorted = deltas.slice().sort((a, b) => a - b);
|
||
const median = sorted[Math.floor(sorted.length / 2)];
|
||
return Math.min(15000, Math.max(1500, Math.round(median * 2.5)));
|
||
}
|
||
|
||
function buildLoadingSegments(events, continuityThresholdMs, countForEvent, ownersForEvent) {
|
||
const segments = [];
|
||
let segment = null;
|
||
let previousTsMs = null;
|
||
for (const event of events) {
|
||
const count = Number(countForEvent(event) ?? 0);
|
||
const gapOk = previousTsMs === null || !Number.isFinite(event.tsMs) || event.tsMs - previousTsMs <= continuityThresholdMs;
|
||
if (count > 0) {
|
||
if (!segment || !gapOk) {
|
||
if (segment) segments.push(finalizeLoadingSegment(segment, null));
|
||
segment = {
|
||
firstAt: event.ts,
|
||
lastAt: event.ts,
|
||
firstSeq: event.seq,
|
||
lastSeq: event.seq,
|
||
promptIndexes: new Set(),
|
||
ownerKeys: new Set(),
|
||
ownerLabels: new Map(),
|
||
sampleCount: 0,
|
||
maxCount: 0,
|
||
ongoing: true
|
||
};
|
||
}
|
||
segment.lastAt = event.ts;
|
||
segment.lastSeq = event.seq;
|
||
segment.sampleCount += 1;
|
||
segment.maxCount = Math.max(segment.maxCount, count);
|
||
if (Number.isFinite(Number(event.promptIndex))) segment.promptIndexes.add(Number(event.promptIndex));
|
||
for (const owner of ownersForEvent(event) || []) {
|
||
if (!owner?.ownerKey) continue;
|
||
segment.ownerKeys.add(owner.ownerKey);
|
||
if (!segment.ownerLabels.has(owner.ownerKey)) segment.ownerLabels.set(owner.ownerKey, { ownerKey: owner.ownerKey, ownerKind: owner.ownerKind, ownerLabel: owner.ownerLabel, count: 0 });
|
||
const label = segment.ownerLabels.get(owner.ownerKey);
|
||
label.count += owner.count ?? 1;
|
||
}
|
||
} else if (segment) {
|
||
segment.ongoing = false;
|
||
segment.endedAt = event.ts;
|
||
segment.endSeq = event.seq;
|
||
segments.push(finalizeLoadingSegment(segment, event));
|
||
segment = null;
|
||
}
|
||
previousTsMs = event.tsMs;
|
||
}
|
||
if (segment) segments.push(finalizeLoadingSegment(segment, null));
|
||
return segments;
|
||
}
|
||
|
||
function finalizeLoadingSegment(segment, absentEvent) {
|
||
const startMs = Date.parse(segment.firstAt || "");
|
||
const lastMs = Date.parse(segment.lastAt || "");
|
||
const absentMs = Date.parse(absentEvent?.ts || "");
|
||
const durationSeconds = Number.isFinite(startMs) && Number.isFinite(lastMs) && lastMs >= startMs ? Number(((lastMs - startMs) / 1000).toFixed(3)) : 0;
|
||
const upperBoundSeconds = Number.isFinite(startMs) && Number.isFinite(absentMs) && absentMs >= startMs ? Number(((absentMs - startMs) / 1000).toFixed(3)) : durationSeconds;
|
||
const endedGapSeconds = Number.isFinite(lastMs) && Number.isFinite(absentMs) && absentMs >= lastMs ? Number(((absentMs - lastMs) / 1000).toFixed(3)) : null;
|
||
return {
|
||
firstAt: segment.firstAt,
|
||
lastAt: segment.lastAt,
|
||
endedAt: absentEvent?.ts ?? null,
|
||
firstSeq: segment.firstSeq,
|
||
lastSeq: segment.lastSeq,
|
||
endSeq: absentEvent?.seq ?? null,
|
||
durationSeconds,
|
||
upperBoundSeconds,
|
||
endedGapSeconds,
|
||
sampleCount: segment.sampleCount,
|
||
maxCount: segment.maxCount,
|
||
ownerCount: segment.ownerKeys.size,
|
||
owners: Array.from(segment.ownerLabels.values()).sort((a, b) => b.count - a.count || String(a.ownerLabel).localeCompare(String(b.ownerLabel))).slice(0, 12),
|
||
promptIndexes: Array.from(segment.promptIndexes).sort((a, b) => a - b),
|
||
ongoing: absentEvent ? false : segment.ongoing === true,
|
||
valuesRedacted: true
|
||
};
|
||
}
|
||
|
||
function boundedTurnTimingRowsForReport(rows) {
|
||
const sourceRows = Array.isArray(rows) ? rows : [];
|
||
const maxRows = 1200;
|
||
const headRows = 120;
|
||
if (sourceRows.length <= maxRows) return { rows: sourceRows, disclosure: { truncated: false, totalRows: sourceRows.length, includedRows: sourceRows.length, omittedRows: 0, headRows: sourceRows.length, tailRows: 0 } };
|
||
const tailRows = Math.max(0, maxRows - headRows);
|
||
return {
|
||
rows: [...sourceRows.slice(0, headRows), ...sourceRows.slice(-tailRows)],
|
||
disclosure: {
|
||
truncated: true,
|
||
totalRows: sourceRows.length,
|
||
includedRows: maxRows,
|
||
omittedRows: Math.max(0, sourceRows.length - maxRows),
|
||
headRows,
|
||
tailRows,
|
||
policy: "report-bounded-head-tail; full anomaly counters are computed before truncation",
|
||
valuesRedacted: true
|
||
}
|
||
};
|
||
}
|
||
|
||
` + nodeWebObserveAnalyzerTimingSource();
|
||
}
|