feat: add web probe performance hotspot analysis
This commit is contained in:
@@ -0,0 +1,595 @@
|
||||
// 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
|
||||
}
|
||||
};
|
||||
}
|
||||
`;
|
||||
}
|
||||
Reference in New Issue
Block a user