426 lines
17 KiB
TypeScript
426 lines
17 KiB
TypeScript
// SPEC: PJ2026-01040111 long-running Workbench observation.
|
|
// Responsibility: Analyzer API-to-DOM lag and trace-events page-read source fragment.
|
|
|
|
export function nodeWebObserveAnalyzerApiDomLagSource(): string {
|
|
return String.raw`function buildApiDomLagReport(samples, network) {
|
|
const windowMs = 30_000;
|
|
const budgetMs = Number.isFinite(Number(alertThresholds.sameOriginApiSlowMs)) ? Number(alertThresholds.sameOriginApiSlowMs) : 10_000;
|
|
const sampleRows = (Array.isArray(samples) ? samples : [])
|
|
.map((sample) => {
|
|
const tsMs = timestampMs(sample?.ts);
|
|
return {
|
|
sample,
|
|
tsMs,
|
|
pageKey: samplePageKey(sample),
|
|
digest: digestSample(sample),
|
|
sessionIds: new Set([sample?.routeSessionId, sample?.activeSessionId].filter(Boolean).map(String)),
|
|
traceIds: sampleTraceIds(sample)
|
|
};
|
|
})
|
|
.filter((item) => Number.isFinite(item.tsMs))
|
|
.sort((a, b) => a.tsMs - b.tsMs);
|
|
const samplesByPage = new Map();
|
|
for (const row of sampleRows) {
|
|
const rows = samplesByPage.get(row.pageKey) || [];
|
|
rows.push(row);
|
|
samplesByPage.set(row.pageKey, rows);
|
|
}
|
|
const naturalApiResponses = (Array.isArray(network) ? network : [])
|
|
.filter((item) => item?.observerInitiated !== true && item?.type === "response" && isApiLikePath(urlPath(item?.url)));
|
|
const telemetryExcluded = [];
|
|
const nonStateRelevant = [];
|
|
const stateRelevantResponses = [];
|
|
for (const item of naturalApiResponses) {
|
|
const event = compactApiDomLagResponseEvent(item);
|
|
if (!Number.isFinite(event.tsMs)) {
|
|
nonStateRelevant.push(event);
|
|
continue;
|
|
}
|
|
if (isApiDomLagTelemetryPath(event.path)) telemetryExcluded.push(event);
|
|
else if (!isApiDomLagStateRelevantPath(event.path)) nonStateRelevant.push(event);
|
|
else stateRelevantResponses.push(event);
|
|
}
|
|
const candidates = [];
|
|
for (const event of stateRelevantResponses) {
|
|
const pageSamples = samplesByPage.get(event.pageKey) || [];
|
|
const before = lastSampleAtOrBefore(pageSamples, event.tsMs, event);
|
|
const firstAfter = firstSampleAfter(pageSamples, event.tsMs, event.tsMs + windowMs, event);
|
|
const baselineDigest = before?.digest ?? null;
|
|
const change = firstSampleAfter(pageSamples, event.tsMs, event.tsMs + windowMs, event, (row) => !baselineDigest || row.digest !== baselineDigest);
|
|
candidates.push({
|
|
...event,
|
|
windowMs,
|
|
budgetMs,
|
|
firstSampleDeltaMs: firstAfter ? Math.max(0, Math.round(firstAfter.tsMs - event.tsMs)) : null,
|
|
domChangeDeltaMs: change ? Math.max(0, Math.round(change.tsMs - event.tsMs)) : null,
|
|
overBudget: change ? (change.tsMs - event.tsMs) > budgetMs : false,
|
|
domChanged: Boolean(change),
|
|
noDomChangeWithinWindow: !change,
|
|
beforeSample: compactApiDomLagSample(before),
|
|
firstAfterSample: compactApiDomLagSample(firstAfter),
|
|
changeSample: compactApiDomLagSample(change),
|
|
confidence: apiDomLagConfidence(event.path),
|
|
valuesRedacted: true
|
|
});
|
|
}
|
|
const changedDeltas = candidates.map((item) => nullableNumber(item.domChangeDeltaMs)).filter(Number.isFinite).sort((a, b) => a - b);
|
|
const groups = groupApiDomLagCandidates(candidates);
|
|
const overBudget = candidates.filter((item) => item.overBudget === true);
|
|
return {
|
|
summary: {
|
|
windowMs,
|
|
budgetMs,
|
|
naturalApiResponseCount: naturalApiResponses.length,
|
|
telemetryExcludedCount: telemetryExcluded.length,
|
|
nonStateRelevantResponseCount: nonStateRelevant.length,
|
|
stateRelevantResponseCount: stateRelevantResponses.length,
|
|
candidateCount: candidates.length,
|
|
domChangedCount: changedDeltas.length,
|
|
noDomChangeWithinWindowCount: candidates.filter((item) => item.noDomChangeWithinWindow === true).length,
|
|
lowConfidenceStreamOpenCount: candidates.filter((item) => item.confidence === "low-stream-open-only").length,
|
|
overBudgetCount: overBudget.length,
|
|
p50DomChangeDeltaMs: percentile(changedDeltas, 50),
|
|
p95DomChangeDeltaMs: percentile(changedDeltas, 95),
|
|
maxDomChangeDeltaMs: changedDeltas.length > 0 ? Math.max(...changedDeltas) : null,
|
|
groupCount: groups.length,
|
|
valuesRedacted: true
|
|
},
|
|
groups,
|
|
worstCandidates: candidates
|
|
.filter((item) => Number.isFinite(nullableNumber(item.domChangeDeltaMs)))
|
|
.sort((a, b) => nullableNumber(b.domChangeDeltaMs) - nullableNumber(a.domChangeDeltaMs))
|
|
.slice(0, 20),
|
|
recentCandidates: candidates.slice(-40),
|
|
telemetryExcluded: telemetryExcluded.slice(0, 20),
|
|
nonStateRelevant: nonStateRelevant.slice(0, 20),
|
|
valuesRedacted: true
|
|
};
|
|
}
|
|
|
|
function compactApiDomLagResponseEvent(item) {
|
|
const parsed = parseApiDomLagUrl(item?.url);
|
|
const tsMs = timestampMs(item?.ts);
|
|
return {
|
|
ts: item?.ts ?? null,
|
|
tsMs,
|
|
pageRole: item?.pageRole ?? null,
|
|
pageId: item?.pageId ?? null,
|
|
pageKey: String(item?.pageRole || "control") + ":" + String(item?.pageId || "default"),
|
|
commandId: item?.commandId ?? null,
|
|
method: String(item?.method || "GET").toUpperCase(),
|
|
status: Number.isFinite(Number(item?.status)) ? Number(item.status) : null,
|
|
path: parsed.path,
|
|
rawPath: parsed.rawPath,
|
|
queryKeys: parsed.queryKeys,
|
|
sessionId: parsed.sessionId,
|
|
traceId: parsed.traceId,
|
|
urlHash: item?.url ? sha256(item.url) : null,
|
|
routeKind: apiDomLagRouteKind(parsed.path),
|
|
valuesRedacted: true
|
|
};
|
|
}
|
|
|
|
function parseApiDomLagUrl(value) {
|
|
try {
|
|
const parsed = new URL(String(value || "http://invalid.local/"));
|
|
const rawPath = parsed.pathname || "-";
|
|
const queryKeys = Array.from(parsed.searchParams.keys()).sort().slice(0, 12);
|
|
const sessionId = parsed.searchParams.get("sessionId") || parsed.searchParams.get("includeSessionId") || firstIdInText(parsed.pathname + " " + parsed.search, /\bses_[A-Za-z0-9_-]+\b/u);
|
|
const traceId = parsed.searchParams.get("traceId") || firstIdInText(parsed.pathname + " " + parsed.search, /\btrc_[A-Za-z0-9_-]+\b/u);
|
|
return {
|
|
rawPath,
|
|
path: normalizeApiPath(rawPath),
|
|
queryKeys,
|
|
sessionId,
|
|
traceId
|
|
};
|
|
} catch {
|
|
const rawPath = urlPath(value);
|
|
return {
|
|
rawPath,
|
|
path: normalizeApiPath(rawPath),
|
|
queryKeys: [],
|
|
sessionId: firstIdInText(String(value || ""), /\bses_[A-Za-z0-9_-]+\b/u),
|
|
traceId: firstIdInText(String(value || ""), /\btrc_[A-Za-z0-9_-]+\b/u)
|
|
};
|
|
}
|
|
}
|
|
|
|
function firstIdInText(text, pattern) {
|
|
const match = String(text || "").match(pattern);
|
|
return match ? match[0] : null;
|
|
}
|
|
|
|
function nullableNumber(value) {
|
|
if (value === null || value === undefined || value === "") return NaN;
|
|
const numeric = Number(value);
|
|
return Number.isFinite(numeric) ? numeric : NaN;
|
|
}
|
|
|
|
function isApiDomLagTelemetryPath(path) {
|
|
const value = String(path || "");
|
|
return value === "/v1/web-performance" || value === "/v1/health" || value === "/health";
|
|
}
|
|
|
|
function isApiDomLagStateRelevantPath(path) {
|
|
const value = String(path || "");
|
|
return value.startsWith("/auth/") || value.startsWith("/v1/workbench/") || value === "/v1/agent/chat" || value === "/v1/agent/chat/steer";
|
|
}
|
|
|
|
function apiDomLagRouteKind(path) {
|
|
const value = String(path || "");
|
|
if (value === "/v1/workbench/events") return "workbench-events-stream";
|
|
if (value.startsWith("/v1/workbench/sessions")) return "workbench-sessions";
|
|
if (value.startsWith("/v1/workbench/traces")) return "workbench-traces";
|
|
if (value.startsWith("/v1/workbench/turns")) return "workbench-turns";
|
|
if (value === "/v1/agent/chat" || value === "/v1/agent/chat/steer") return "agent-chat-submit";
|
|
if (value.startsWith("/auth/")) return "auth";
|
|
return "state-api";
|
|
}
|
|
|
|
function apiDomLagConfidence(path) {
|
|
return String(path || "") === "/v1/workbench/events" ? "low-stream-open-only" : "medium-response-to-dom";
|
|
}
|
|
|
|
function sampleTraceIds(sample) {
|
|
const ids = new Set();
|
|
for (const group of [sample?.messages, sample?.traceRows, sample?.turns, sample?.diagnostics]) {
|
|
if (!Array.isArray(group)) continue;
|
|
for (const item of group) if (item?.traceId) ids.add(String(item.traceId));
|
|
}
|
|
return ids;
|
|
}
|
|
|
|
function lastSampleAtOrBefore(rows, tsMs, event) {
|
|
let result = null;
|
|
for (const row of rows) {
|
|
if (row.tsMs > tsMs) break;
|
|
if (apiDomLagSampleMatchesEvent(row, event)) result = row;
|
|
}
|
|
return result;
|
|
}
|
|
|
|
function firstSampleAfter(rows, startMs, endMs, event, predicate = null) {
|
|
for (const row of rows) {
|
|
if (row.tsMs < startMs) continue;
|
|
if (row.tsMs > endMs) break;
|
|
if (!apiDomLagSampleMatchesEvent(row, event)) continue;
|
|
if (typeof predicate === "function" && !predicate(row)) continue;
|
|
return row;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function apiDomLagSampleMatchesEvent(row, event) {
|
|
if (!row || !event) return false;
|
|
if (event.sessionId && !row.sessionIds.has(String(event.sessionId))) return false;
|
|
if (event.traceId && row.traceIds.size > 0 && !row.traceIds.has(String(event.traceId))) return false;
|
|
return true;
|
|
}
|
|
|
|
function compactApiDomLagSample(row) {
|
|
if (!row) return null;
|
|
const sample = row.sample || {};
|
|
return {
|
|
seq: sample.seq ?? null,
|
|
ts: sample.ts ?? null,
|
|
pageRole: sample.pageRole ?? null,
|
|
pageId: sample.pageId ?? null,
|
|
routeSessionId: sample.routeSessionId ?? null,
|
|
activeSessionId: sample.activeSessionId ?? null,
|
|
messageCount: Array.isArray(sample.messages) ? sample.messages.length : null,
|
|
traceRowCount: Array.isArray(sample.traceRows) ? sample.traceRows.length : null,
|
|
diagnosticCount: Array.isArray(sample.diagnostics) ? sample.diagnostics.length : null,
|
|
valuesRedacted: true
|
|
};
|
|
}
|
|
|
|
function groupApiDomLagCandidates(candidates) {
|
|
const groups = new Map();
|
|
for (const item of candidates || []) {
|
|
const key = [item.method || "-", item.path || "-", item.status ?? "-", item.confidence || "-"].join(" ");
|
|
const group = groups.get(key) || {
|
|
method: item.method ?? null,
|
|
path: item.path ?? "-",
|
|
routeKind: item.routeKind ?? null,
|
|
status: item.status ?? null,
|
|
confidence: item.confidence ?? null,
|
|
count: 0,
|
|
domChangedCount: 0,
|
|
noDomChangeWithinWindowCount: 0,
|
|
overBudgetCount: 0,
|
|
firstAt: item.ts ?? null,
|
|
lastAt: item.ts ?? null,
|
|
deltas: [],
|
|
examples: []
|
|
};
|
|
group.count += 1;
|
|
group.firstAt = minIso(group.firstAt, item.ts ?? null);
|
|
group.lastAt = maxIso(group.lastAt, item.ts ?? null);
|
|
if (item.domChanged === true && Number.isFinite(Number(item.domChangeDeltaMs))) {
|
|
group.domChangedCount += 1;
|
|
group.deltas.push(Number(item.domChangeDeltaMs));
|
|
}
|
|
if (item.noDomChangeWithinWindow === true) group.noDomChangeWithinWindowCount += 1;
|
|
if (item.overBudget === true) group.overBudgetCount += 1;
|
|
if (group.examples.length < 6) {
|
|
group.examples.push({
|
|
ts: item.ts ?? null,
|
|
sessionId: item.sessionId ?? null,
|
|
traceId: item.traceId ?? null,
|
|
domChangeDeltaMs: item.domChangeDeltaMs ?? null,
|
|
firstSampleDeltaMs: item.firstSampleDeltaMs ?? null,
|
|
changeSeq: item.changeSample?.seq ?? null,
|
|
beforeSeq: item.beforeSample?.seq ?? null,
|
|
valuesRedacted: true
|
|
});
|
|
}
|
|
groups.set(key, group);
|
|
}
|
|
return Array.from(groups.values())
|
|
.map((item) => {
|
|
const deltas = item.deltas.slice().sort((a, b) => a - b);
|
|
return {
|
|
method: item.method,
|
|
path: item.path,
|
|
routeKind: item.routeKind,
|
|
status: item.status,
|
|
confidence: item.confidence,
|
|
count: item.count,
|
|
domChangedCount: item.domChangedCount,
|
|
noDomChangeWithinWindowCount: item.noDomChangeWithinWindowCount,
|
|
overBudgetCount: item.overBudgetCount,
|
|
p50DomChangeDeltaMs: percentile(deltas, 50),
|
|
p95DomChangeDeltaMs: percentile(deltas, 95),
|
|
maxDomChangeDeltaMs: deltas.length > 0 ? Math.max(...deltas) : null,
|
|
firstAt: item.firstAt,
|
|
lastAt: item.lastAt,
|
|
examples: item.examples,
|
|
valuesRedacted: true
|
|
};
|
|
})
|
|
.sort((a, b) => Number(b.maxDomChangeDeltaMs ?? -1) - Number(a.maxDomChangeDeltaMs ?? -1) || b.count - a.count || String(a.path).localeCompare(String(b.path)));
|
|
}
|
|
|
|
function detectTraceEventsPageReadIssues(network) {
|
|
const events = (Array.isArray(network) ? network : [])
|
|
.filter((item) => item?.observerInitiated !== true && (item?.type === "response" || item?.type === "requestfailed"))
|
|
.map(compactTraceEventsPageReadEvent)
|
|
.filter((item) => item !== null);
|
|
const http404 = events.filter((item) => item.type === "response" && Number(item.status) === 404);
|
|
const httpErrors = events.filter((item) => item.type === "response" && Number(item.status) >= 400 && Number(item.status) !== 404);
|
|
const requestFailed = events.filter((item) => item.type === "requestfailed");
|
|
return {
|
|
events,
|
|
http404,
|
|
httpErrors,
|
|
requestFailed,
|
|
summary: traceEventsPageReadIssueSummary(events),
|
|
valuesRedacted: true
|
|
};
|
|
}
|
|
|
|
function compactTraceEventsPageReadEvent(item) {
|
|
const parsed = parseTraceEventsPageReadUrl(item?.url);
|
|
if (!parsed.match) return null;
|
|
const failureText = item?.failureKind ?? item?.failure ?? item?.errorText ?? null;
|
|
return {
|
|
ts: item?.ts ?? null,
|
|
pageRole: item?.pageRole ?? null,
|
|
pageId: item?.pageId ?? null,
|
|
commandId: item?.commandId ?? null,
|
|
method: String(item?.method || "GET").toUpperCase(),
|
|
type: item?.type ?? null,
|
|
status: Number.isFinite(Number(item?.status)) ? Number(item.status) : null,
|
|
path: "/v1/workbench/traces/:traceId/events",
|
|
rawPath: parsed.rawPath,
|
|
traceId: parsed.traceId,
|
|
afterProjectedSeq: parsed.afterProjectedSeq,
|
|
sinceSeq: parsed.sinceSeq,
|
|
limit: parsed.limit,
|
|
tail: parsed.tail,
|
|
queryKeys: parsed.queryKeys,
|
|
failureKind: failureText ? limitText(String(failureText), 120) : null,
|
|
urlHash: item?.url ? sha256(item.url) : null,
|
|
valuesRedacted: true
|
|
};
|
|
}
|
|
|
|
function parseTraceEventsPageReadUrl(value) {
|
|
const fallback = {
|
|
match: false,
|
|
rawPath: urlPath(value),
|
|
traceId: firstIdInText(String(value || ""), /\btrc_[A-Za-z0-9_-]+\b/u),
|
|
afterProjectedSeq: null,
|
|
sinceSeq: null,
|
|
limit: null,
|
|
tail: null,
|
|
queryKeys: [],
|
|
};
|
|
try {
|
|
const parsed = new URL(String(value || ""), "http://invalid.local/");
|
|
const rawPath = parsed.pathname || "-";
|
|
const match = rawPath.match(/^\/v1\/workbench\/traces\/([^/]+)\/events$/u);
|
|
return {
|
|
match: Boolean(match),
|
|
rawPath,
|
|
traceId: match ? decodeURIComponent(match[1]) : fallback.traceId,
|
|
afterProjectedSeq: numericSearchParam(parsed.searchParams, "afterProjectedSeq"),
|
|
sinceSeq: numericSearchParam(parsed.searchParams, "sinceSeq") ?? numericSearchParam(parsed.searchParams, "afterSeq"),
|
|
limit: numericSearchParam(parsed.searchParams, "limit"),
|
|
tail: numericSearchParam(parsed.searchParams, "tail"),
|
|
queryKeys: Array.from(parsed.searchParams.keys()).sort().slice(0, 12),
|
|
};
|
|
} catch {
|
|
return fallback;
|
|
}
|
|
}
|
|
|
|
function numericSearchParam(searchParams, key) {
|
|
const raw = searchParams?.get?.(key);
|
|
if (raw === null || raw === undefined || raw === "") return null;
|
|
const parsed = Number(raw);
|
|
return Number.isFinite(parsed) ? parsed : null;
|
|
}
|
|
|
|
function traceEventsPageReadIssueSummary(events) {
|
|
const items = Array.isArray(events) ? events : [];
|
|
const statuses = uniqueSorted(items.map((item) => item.status).filter((item) => item !== null && item !== undefined).map(String));
|
|
const traceIds = uniqueSorted(items.map((item) => item.traceId).filter(Boolean).map(String)).slice(0, 8);
|
|
const afterProjectedSeqs = uniqueSorted(items.map((item) => item.afterProjectedSeq).filter((item) => item !== null && item !== undefined).map(String)).slice(0, 8);
|
|
const sinceSeqs = uniqueSorted(items.map((item) => item.sinceSeq).filter((item) => item !== null && item !== undefined).map(String)).slice(0, 8);
|
|
const failureKinds = uniqueSorted(items.map((item) => item.failureKind).filter(Boolean).map(String)).slice(0, 6);
|
|
return {
|
|
eventCount: items.length,
|
|
responseErrorCount: items.filter((item) => item.type === "response" && Number(item.status) >= 400).length,
|
|
http404Count: items.filter((item) => item.type === "response" && Number(item.status) === 404).length,
|
|
requestFailedCount: items.filter((item) => item.type === "requestfailed").length,
|
|
statuses,
|
|
traceIds,
|
|
afterProjectedSeqs,
|
|
sinceSeqs,
|
|
failureKinds,
|
|
firstAt: items.reduce((value, item) => minIso(value, item.ts ?? null), null),
|
|
lastAt: items.reduce((value, item) => maxIso(value, item.ts ?? null), null),
|
|
rootCauseVisibility: "browser network rows identify trace-events page-read path; OTel trace_events_read should confirm backend paging fields",
|
|
valuesRedacted: true
|
|
};
|
|
}
|
|
|
|
function uniqueSorted(values) {
|
|
return Array.from(new Set((values || []).filter((item) => item !== null && item !== undefined).map(String))).sort();
|
|
}
|
|
|
|
function compactApiDomLagForOutput(report) {
|
|
if (!report || typeof report !== "object") return null;
|
|
return {
|
|
summary: report.summary ?? null,
|
|
groups: Array.isArray(report.groups) ? report.groups.slice(0, 8) : [],
|
|
worstCandidates: Array.isArray(report.worstCandidates) ? report.worstCandidates.slice(0, 8) : [],
|
|
recentCandidates: Array.isArray(report.recentCandidates) ? report.recentCandidates.slice(-8) : [],
|
|
valuesRedacted: true
|
|
};
|
|
}
|
|
`;
|
|
}
|