596 lines
31 KiB
TypeScript
596 lines
31 KiB
TypeScript
// SPEC: PJ2026-01040111 long-running Workbench observation.
|
||
// Responsibility: Analyzer sample metrics, loading continuity, session rail title, and bounded report rows source fragment.
|
||
|
||
export function nodeWebObserveAnalyzerSampleMetricsSource(): string {
|
||
return String.raw`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 workbenchTurnStateTriad = buildWorkbenchTurnStateTriadMetrics(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,
|
||
workbenchTurnStateTriadRows: workbenchTurnStateTriad.summary.rowCount,
|
||
workbenchTurnStateTriadInvalidRows: workbenchTurnStateTriad.summary.invalidRowCount,
|
||
workbenchTurnStateTriadFullInvalidRows: workbenchTurnStateTriad.summary.invalidFullTriadCount,
|
||
workbenchTurnStateCardFinalResponseMismatchRows: workbenchTurnStateTriad.summary.cardFinalResponseMismatchCount,
|
||
workbenchTurnStateCollectorMissingRows: workbenchTurnStateTriad.summary.collectorMissingRowCount,
|
||
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,
|
||
workbenchTurnStateTriad,
|
||
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
|
||
}
|
||
};
|
||
}
|
||
`;
|
||
}
|