feat: add web probe performance hotspot analysis
This commit is contained in:
@@ -0,0 +1,915 @@
|
||||
// SPEC: PJ2026-01040111 long-running Workbench observation.
|
||||
// Responsibility: Analyzer request-rate, prompt network, runtime console/network alert, and diagnostic grouping source fragment.
|
||||
|
||||
export function nodeWebObserveAnalyzerRequestRuntimeSource(): string {
|
||||
return String.raw`function buildRequestRateReport(network) {
|
||||
const bucketMs = Math.max(1000, Number(alertThresholds.requestRateBucketMs || 0));
|
||||
const naturalApiRequests = (Array.isArray(network) ? network : [])
|
||||
.filter((item) => item?.observerInitiated !== true && item?.type === "request")
|
||||
.map((item) => requestRateEvent(item))
|
||||
.filter((item) => item !== null)
|
||||
.sort((a, b) => a.tsMs - b.tsMs);
|
||||
if (naturalApiRequests.length === 0) {
|
||||
return {
|
||||
summary: {
|
||||
bucketMs,
|
||||
bucketSeconds: Number((bucketMs / 1000).toFixed(3)),
|
||||
requestCount: 0,
|
||||
bucketCount: 0,
|
||||
pageCount: 0,
|
||||
apiPathCount: 0,
|
||||
totalRedPerMinute: alertThresholds.requestRateTotalRedPerMinute,
|
||||
pageRedPerMinute: alertThresholds.requestRatePageRedPerMinute,
|
||||
apiPathRedPerMinute: alertThresholds.requestRateApiPathRedPerMinute,
|
||||
totalPeakPerMinute: 0,
|
||||
pagePeakPerMinute: 0,
|
||||
apiPathPeakPerMinute: 0,
|
||||
overThresholdPeakCount: 0,
|
||||
valuesRedacted: true,
|
||||
},
|
||||
buckets: [],
|
||||
pageCurves: [],
|
||||
apiPathCurves: [],
|
||||
peaks: [],
|
||||
valuesRedacted: true,
|
||||
};
|
||||
}
|
||||
const firstBucketMs = Math.floor(naturalApiRequests[0].tsMs / bucketMs) * bucketMs;
|
||||
const lastBucketMs = Math.floor(naturalApiRequests[naturalApiRequests.length - 1].tsMs / bucketMs) * bucketMs;
|
||||
const makeBucket = (bucketStartMs, extra = {}) => ({
|
||||
bucketStartMs,
|
||||
bucketEndMs: bucketStartMs + bucketMs,
|
||||
startAt: new Date(bucketStartMs).toISOString(),
|
||||
endAt: new Date(bucketStartMs + bucketMs).toISOString(),
|
||||
count: 0,
|
||||
requestPerMinute: 0,
|
||||
...extra,
|
||||
valuesRedacted: true,
|
||||
});
|
||||
const totalBuckets = new Map();
|
||||
const pageBuckets = new Map();
|
||||
const apiPathBuckets = new Map();
|
||||
const pageTotals = new Map();
|
||||
const apiPathTotals = new Map();
|
||||
const allBucketStarts = [];
|
||||
for (let bucketStartMs = firstBucketMs; bucketStartMs <= lastBucketMs; bucketStartMs += bucketMs) {
|
||||
totalBuckets.set(bucketStartMs, makeBucket(bucketStartMs));
|
||||
allBucketStarts.push(bucketStartMs);
|
||||
}
|
||||
for (const item of naturalApiRequests) {
|
||||
const bucketStartMs = Math.floor(item.tsMs / bucketMs) * bucketMs;
|
||||
const totalBucket = totalBuckets.get(bucketStartMs) || makeBucket(bucketStartMs);
|
||||
totalBucket.count += 1;
|
||||
totalBuckets.set(bucketStartMs, totalBucket);
|
||||
|
||||
const pageKey = item.pageKey;
|
||||
const pageMapKey = pageKey + "|" + bucketStartMs;
|
||||
const pageBucket = pageBuckets.get(pageMapKey) || makeBucket(bucketStartMs, {
|
||||
pageKey,
|
||||
pageRole: item.pageRole,
|
||||
pageId: item.pageId,
|
||||
pageEpoch: item.pageEpoch,
|
||||
path: item.framePath,
|
||||
});
|
||||
pageBucket.count += 1;
|
||||
pageBuckets.set(pageMapKey, pageBucket);
|
||||
const pageTotal = pageTotals.get(pageKey) || { pageKey, pageRole: item.pageRole, pageId: item.pageId, pageEpoch: item.pageEpoch, path: item.framePath, count: 0 };
|
||||
pageTotal.count += 1;
|
||||
pageTotals.set(pageKey, pageTotal);
|
||||
|
||||
const apiKey = item.method + " " + item.apiPath;
|
||||
const apiMapKey = apiKey + "|" + bucketStartMs;
|
||||
const apiBucket = apiPathBuckets.get(apiMapKey) || makeBucket(bucketStartMs, {
|
||||
apiKey,
|
||||
method: item.method,
|
||||
path: item.apiPath,
|
||||
pageKey,
|
||||
pageRole: item.pageRole,
|
||||
pageId: item.pageId,
|
||||
pageEpoch: item.pageEpoch,
|
||||
});
|
||||
apiBucket.count += 1;
|
||||
apiPathBuckets.set(apiMapKey, apiBucket);
|
||||
const apiTotal = apiPathTotals.get(apiKey) || { apiKey, method: item.method, path: item.apiPath, count: 0 };
|
||||
apiTotal.count += 1;
|
||||
apiPathTotals.set(apiKey, apiTotal);
|
||||
}
|
||||
const finalizeBucket = (bucket) => ({
|
||||
...bucket,
|
||||
requestPerMinute: Number((Number(bucket.count || 0) * 60000 / bucketMs).toFixed(2)),
|
||||
});
|
||||
const buckets = Array.from(totalBuckets.values()).map(finalizeBucket);
|
||||
const pageBucketRows = Array.from(pageBuckets.values()).map(finalizeBucket);
|
||||
const apiPathBucketRows = Array.from(apiPathBuckets.values()).map(finalizeBucket);
|
||||
const peakForRows = (rows, thresholdPerMinute, scope, extra = {}) => rows
|
||||
.filter((row) => Number(row.requestPerMinute ?? 0) >= Number(thresholdPerMinute || Infinity))
|
||||
.map((row) => ({
|
||||
scope,
|
||||
thresholdPerMinute,
|
||||
overThreshold: true,
|
||||
bucketMs,
|
||||
bucketStartMs: row.bucketStartMs,
|
||||
bucketEndMs: row.bucketEndMs,
|
||||
startAt: row.startAt,
|
||||
endAt: row.endAt,
|
||||
count: row.count,
|
||||
requestPerMinute: row.requestPerMinute,
|
||||
...extra(row),
|
||||
valuesRedacted: true,
|
||||
}));
|
||||
const totalPeaks = peakForRows(buckets, alertThresholds.requestRateTotalRedPerMinute, "total", () => ({}));
|
||||
const pagePeaks = peakForRows(pageBucketRows, alertThresholds.requestRatePageRedPerMinute, "page", (row) => ({
|
||||
pageKey: row.pageKey,
|
||||
pageRole: row.pageRole,
|
||||
pageId: row.pageId,
|
||||
pageEpoch: row.pageEpoch,
|
||||
path: row.path,
|
||||
}));
|
||||
const apiPathPeaks = peakForRows(apiPathBucketRows, alertThresholds.requestRateApiPathRedPerMinute, "apiPath", (row) => ({
|
||||
apiKey: row.apiKey,
|
||||
method: row.method,
|
||||
path: row.path,
|
||||
pageKey: row.pageKey,
|
||||
pageRole: row.pageRole,
|
||||
pageId: row.pageId,
|
||||
pageEpoch: row.pageEpoch,
|
||||
}));
|
||||
const pageCurves = Array.from(pageTotals.values())
|
||||
.map((page) => {
|
||||
const rows = pageBucketRows
|
||||
.filter((row) => row.pageKey === page.pageKey)
|
||||
.sort((a, b) => a.bucketStartMs - b.bucketStartMs);
|
||||
const peak = maxByNumber(rows, (row) => row.requestPerMinute);
|
||||
return {
|
||||
...page,
|
||||
bucketCount: rows.length,
|
||||
peakRequestPerMinute: peak?.requestPerMinute ?? 0,
|
||||
peakBucket: peak ?? null,
|
||||
buckets: rows.slice(-60),
|
||||
valuesRedacted: true,
|
||||
};
|
||||
})
|
||||
.sort((a, b) => Number(b.peakRequestPerMinute ?? 0) - Number(a.peakRequestPerMinute ?? 0) || Number(b.count ?? 0) - Number(a.count ?? 0));
|
||||
const apiPathCurves = Array.from(apiPathTotals.values())
|
||||
.map((api) => {
|
||||
const rows = apiPathBucketRows
|
||||
.filter((row) => row.apiKey === api.apiKey)
|
||||
.sort((a, b) => a.bucketStartMs - b.bucketStartMs);
|
||||
const peak = maxByNumber(rows, (row) => row.requestPerMinute);
|
||||
return {
|
||||
...api,
|
||||
bucketCount: rows.length,
|
||||
peakRequestPerMinute: peak?.requestPerMinute ?? 0,
|
||||
peakBucket: peak ?? null,
|
||||
buckets: rows.slice(-60),
|
||||
valuesRedacted: true,
|
||||
};
|
||||
})
|
||||
.sort((a, b) => Number(b.peakRequestPerMinute ?? 0) - Number(a.peakRequestPerMinute ?? 0) || Number(b.count ?? 0) - Number(a.count ?? 0));
|
||||
const totalPeak = maxByNumber(buckets, (row) => row.requestPerMinute);
|
||||
const pagePeak = pageCurves[0] ?? null;
|
||||
const apiPathPeak = apiPathCurves[0] ?? null;
|
||||
const peaks = [...totalPeaks, ...pagePeaks, ...apiPathPeaks]
|
||||
.sort((a, b) => Number(b.requestPerMinute ?? 0) - Number(a.requestPerMinute ?? 0) || String(a.scope).localeCompare(String(b.scope)));
|
||||
return {
|
||||
summary: {
|
||||
bucketMs,
|
||||
bucketSeconds: Number((bucketMs / 1000).toFixed(3)),
|
||||
requestCount: naturalApiRequests.length,
|
||||
bucketCount: buckets.length,
|
||||
pageCount: pageCurves.length,
|
||||
apiPathCount: apiPathCurves.length,
|
||||
firstAt: new Date(firstBucketMs).toISOString(),
|
||||
lastAt: new Date(lastBucketMs + bucketMs).toISOString(),
|
||||
totalRedPerMinute: alertThresholds.requestRateTotalRedPerMinute,
|
||||
pageRedPerMinute: alertThresholds.requestRatePageRedPerMinute,
|
||||
apiPathRedPerMinute: alertThresholds.requestRateApiPathRedPerMinute,
|
||||
totalPeakPerMinute: totalPeak?.requestPerMinute ?? 0,
|
||||
totalPeakCount: totalPeak?.count ?? 0,
|
||||
totalPeakAt: totalPeak?.startAt ?? null,
|
||||
pagePeakPerMinute: pagePeak?.peakRequestPerMinute ?? 0,
|
||||
pagePeakKey: pagePeak?.pageKey ?? null,
|
||||
pagePeakPath: pagePeak?.path ?? null,
|
||||
apiPathPeakPerMinute: apiPathPeak?.peakRequestPerMinute ?? 0,
|
||||
apiPathPeakKey: apiPathPeak?.apiKey ?? null,
|
||||
overThresholdPeakCount: peaks.length,
|
||||
valuesRedacted: true,
|
||||
},
|
||||
buckets: buckets.slice(-120),
|
||||
pageCurves: pageCurves.slice(0, 20),
|
||||
apiPathCurves: apiPathCurves.slice(0, 40),
|
||||
peaks: peaks.slice(0, 80),
|
||||
valuesRedacted: true,
|
||||
};
|
||||
}
|
||||
|
||||
function requestRateEvent(item) {
|
||||
const tsMs = Date.parse(String(item?.ts || ""));
|
||||
const apiPath = normalizeApiRatePath(urlPath(item?.url));
|
||||
if (!Number.isFinite(tsMs) || !isApiLikePath(apiPath)) return null;
|
||||
const framePath = normalizeRoutePath(urlPath(item?.frameUrl || item?.url));
|
||||
const pageRole = String(item?.pageRole || "unknown");
|
||||
const pageId = String(item?.pageId || "unknown");
|
||||
const pageEpoch = Number.isFinite(Number(item?.pageEpoch)) ? Number(item.pageEpoch) : null;
|
||||
return {
|
||||
ts: item.ts,
|
||||
tsMs,
|
||||
method: String(item?.method || "GET").toUpperCase(),
|
||||
apiPath,
|
||||
framePath,
|
||||
pageRole,
|
||||
pageId,
|
||||
pageEpoch,
|
||||
pageKey: [pageRole, pageId, pageEpoch ?? "-", framePath || "-"].join("|"),
|
||||
valuesRedacted: true,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeApiRatePath(value) {
|
||||
return normalizeRoutePath(value)
|
||||
.replace(/\/sessions\/[^/?#]+/gu, "/sessions/:id")
|
||||
.replace(/\/turns\/[^/?#]+/gu, "/turns/:id")
|
||||
.replace(/\/traces\/[^/?#]+/gu, "/traces/:id")
|
||||
.replace(/\/messages\/[^/?#]+/gu, "/messages/:id")
|
||||
.replace(/\/files\/[^/?#]+/gu, "/files/:id")
|
||||
.replace(/\/tasks\/[^/?#]+/gu, "/tasks/:id")
|
||||
.replace(/\/runs\/[^/?#]+/gu, "/runs/:id")
|
||||
.replace(/\/[0-9a-f]{8,}(?=\/|$)/giu, "/:id")
|
||||
.replace(/\/[A-Za-z0-9_-]{24,}(?=\/|$)/gu, "/:id");
|
||||
}
|
||||
|
||||
function normalizeRoutePath(value) {
|
||||
const raw = String(value || "");
|
||||
if (!raw || raw === "-") return "-";
|
||||
return raw.replace(/\/+/gu, "/").replace(/\/$/u, "") || "/";
|
||||
}
|
||||
|
||||
function compactRequestRateForOutput(report) {
|
||||
if (!report || typeof report !== "object") return null;
|
||||
return {
|
||||
summary: report.summary ?? null,
|
||||
buckets: Array.isArray(report.buckets) ? report.buckets.slice(-12) : [],
|
||||
pageCurves: Array.isArray(report.pageCurves) ? report.pageCurves.slice(0, 8).map((item) => ({
|
||||
pageKey: item.pageKey ?? null,
|
||||
pageRole: item.pageRole ?? null,
|
||||
pageId: item.pageId ?? null,
|
||||
pageEpoch: item.pageEpoch ?? null,
|
||||
path: item.path ?? null,
|
||||
count: item.count ?? null,
|
||||
peakRequestPerMinute: item.peakRequestPerMinute ?? null,
|
||||
peakBucket: item.peakBucket ?? null,
|
||||
buckets: Array.isArray(item.buckets) ? item.buckets.slice(-12) : [],
|
||||
valuesRedacted: true,
|
||||
})) : [],
|
||||
apiPathCurves: Array.isArray(report.apiPathCurves) ? report.apiPathCurves.slice(0, 12).map((item) => ({
|
||||
apiKey: item.apiKey ?? null,
|
||||
method: item.method ?? null,
|
||||
path: item.path ?? null,
|
||||
count: item.count ?? null,
|
||||
peakRequestPerMinute: item.peakRequestPerMinute ?? null,
|
||||
peakBucket: item.peakBucket ?? null,
|
||||
buckets: Array.isArray(item.buckets) ? item.buckets.slice(-12) : [],
|
||||
valuesRedacted: true,
|
||||
})) : [],
|
||||
peaks: Array.isArray(report.peaks) ? report.peaks.slice(0, 12) : [],
|
||||
valuesRedacted: true,
|
||||
};
|
||||
}
|
||||
|
||||
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);
|
||||
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 workbenchSessionListReadCount = naturalNetwork
|
||||
.filter((item) => urlPath(item?.url) === "/v1/workbench/sessions")
|
||||
.length;
|
||||
const workbenchTraceEventsReadCount = naturalNetwork
|
||||
.filter((item) => /^\/v1\/workbench\/traces\/[^/]+\/events$/u.test(urlPath(item?.url)))
|
||||
.length;
|
||||
const webPerformanceBeaconFailureCount = naturalNetwork
|
||||
.filter((item) => urlPath(item?.url) === "/v1/web-performance" && (item?.type === "requestfailed" || Number(item?.status) >= 400))
|
||||
.length;
|
||||
const workbenchEventSourceFailureCount = naturalNetwork
|
||||
.filter((item) => urlPath(item?.url) === "/v1/workbench/events" && (item?.type === "requestfailed" || Number(item?.status) >= 400))
|
||||
.length;
|
||||
const significantRequestFailed = requestFailed.filter(
|
||||
(item) => !isBenignLongLivedStreamClosureAlert(item) && !isObserverRefreshClosureAlert(item, observerRefreshTimes),
|
||||
);
|
||||
const domDiagnostics = [];
|
||||
const executionErrors = [];
|
||||
const baselineExecutionErrors = [];
|
||||
const canarySessionIds = sessionInvariantCanarySessionIds(control);
|
||||
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)
|
||||
};
|
||||
const eventSessions = [event.routeSessionId, event.activeSessionId].filter(Boolean);
|
||||
const nonCanarySession = canarySessionIds.size > 0 && !eventSessions.some((sessionId) => canarySessionIds.has(sessionId));
|
||||
if (baseline || nonCanarySession) baselineExecutionErrors.push({ ...event, baseline: true, baselineReason: nonCanarySession ? "non-canary-session" : "pre-first-prompt" });
|
||||
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,
|
||||
pageRole: item.pageRole ?? item.error?.details?.pageRole ?? null,
|
||||
pageId: item.pageId ?? item.error?.details?.pageId ?? null,
|
||||
routeSessionId: item.routeSessionId ?? item.error?.details?.routeSessionId ?? null,
|
||||
activeSessionId: item.activeSessionId ?? item.error?.details?.activeSessionId ?? null,
|
||||
commandId: item.commandId ?? item.error?.details?.commandId ?? null,
|
||||
sampleSeq: item.sampleSeq ?? item.error?.details?.sampleSeq ?? null,
|
||||
timeoutMs: timeoutMsFromMessage(item.error?.message || item.message || item.error || ""),
|
||||
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,
|
||||
workbenchSessionListReadCount,
|
||||
workbenchTraceEventsReadCount,
|
||||
webPerformanceBeaconFailureCount,
|
||||
workbenchEventSourceFailureCount,
|
||||
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);
|
||||
}
|
||||
`;
|
||||
}
|
||||
Reference in New Issue
Block a user