Files
pikasTech-unidesk/scripts/src/hwlab-node-web-observe-analyzer-window-page-source.ts
T

459 lines
23 KiB
TypeScript

// SPEC: PJ2026-01040111 long-running Workbench observation.
// Responsibility: Analyzer recent-window, page provenance, and Navigation Timing API performance source fragment.
export function nodeWebObserveAnalyzerWindowPageSource(): string {
return String.raw`async function buildRecentAnalysisWindow({ samples, control, network, consoleEvents, errors, artifacts, browserProcessRows, performanceRows, manifest }) {
const latestSampleMs = latestTimestampMs(samples);
const windowMs = 5 * 60 * 1000;
const fromMs = Number.isFinite(latestSampleMs) ? latestSampleMs - windowMs : Number.NEGATIVE_INFINITY;
const toMs = Number.isFinite(latestSampleMs) ? latestSampleMs : Number.POSITIVE_INFINITY;
const inWindow = (item) => {
const tsMs = Date.parse(item?.ts);
return Number.isFinite(tsMs) && tsMs >= fromMs && tsMs <= toMs;
};
const windowSamples = samples.filter(inWindow);
const windowControl = control.filter(inWindow);
const windowNetwork = network.filter(inWindow);
const windowConsole = consoleEvents.filter(inWindow);
const windowErrors = errors.filter(inWindow);
const windowArtifacts = (artifacts || []).filter(inWindow);
const windowBrowserProcessRows = (browserProcessRows || []).filter(inWindow);
const windowPerformanceRows = (performanceRows || []).filter(inWindow);
const sampleMetrics = buildSampleMetrics(windowSamples, control);
const pageProvenance = buildPageProvenanceReport(windowSamples, windowControl, manifest);
const pagePerformance = buildPagePerformanceReport(windowSamples, manifest);
const requestRate = buildRequestRateReport(windowNetwork);
const promptNetwork = buildPromptNetworkReport(windowControl, windowNetwork);
const runtimeAlerts = buildRuntimeAlerts(windowSamples, control, windowNetwork, windowConsole, windowErrors);
const apiDomLag = buildApiDomLagReport(windowSamples, windowNetwork);
const browserProcess = buildBrowserProcessReport(windowBrowserProcessRows);
const frontendPerformance = await buildFrontendPerformanceReport(windowPerformanceRows, windowArtifacts, windowSamples, windowNetwork);
const findings = buildFindings(windowSamples, control, windowNetwork, windowErrors, sampleMetrics, promptNetwork, runtimeAlerts, pagePerformance, requestRate, pageProvenance, [], {}, apiDomLag, browserProcess, frontendPerformance);
return {
summary: {
name: "recent-5m",
windowMs,
fromAt: Number.isFinite(fromMs) ? new Date(fromMs).toISOString() : null,
toAt: Number.isFinite(toMs) ? new Date(toMs).toISOString() : null,
samples: windowSamples.length,
control: windowControl.length,
network: windowNetwork.length,
console: windowConsole.length,
errors: windowErrors.length,
artifacts: windowArtifacts.length,
browserProcess: windowBrowserProcessRows.length,
performance: windowPerformanceRows.length,
valuesRedacted: true
},
sampleMetrics,
pageProvenance,
pagePerformance,
requestRate,
promptNetwork,
runtimeAlerts,
apiDomLag,
browserProcess,
frontendPerformance,
findings,
valuesRedacted: true
};
}
function latestTimestampMs(items) {
let latest = Number.NEGATIVE_INFINITY;
for (const item of items || []) {
const tsMs = Date.parse(item?.ts);
if (Number.isFinite(tsMs) && tsMs > latest) latest = tsMs;
}
return latest;
}
function buildPageProvenanceReport(samples, control, manifest) {
const groups = new Map();
for (const sample of samples) {
const provenance = sample?.pageProvenance;
if (!provenance) continue;
const key = provenance.assetFingerprint || "unknown";
const group = groups.get(key) || {
assetFingerprint: provenance.assetFingerprint || null,
pageLoadSeqs: [],
sampleCount: 0,
firstSeq: sample.seq ?? null,
lastSeq: sample.seq ?? null,
firstAt: sample.ts ?? null,
lastAt: sample.ts ?? null,
urlPaths: [],
scriptCount: provenance.scriptCount ?? null,
stylesheetCount: provenance.stylesheetCount ?? null,
metaCount: provenance.metaCount ?? null,
scripts: Array.isArray(provenance.scripts) ? provenance.scripts.slice(0, 12) : [],
stylesheets: Array.isArray(provenance.stylesheets) ? provenance.stylesheets.slice(0, 12) : [],
valuesRedacted: true
};
group.sampleCount += 1;
group.lastSeq = sample.seq ?? null;
group.lastAt = sample.ts ?? null;
if (provenance.pageLoadSeq !== null && provenance.pageLoadSeq !== undefined && !group.pageLoadSeqs.includes(provenance.pageLoadSeq)) group.pageLoadSeqs.push(provenance.pageLoadSeq);
if (provenance.urlPath && !group.urlPaths.includes(provenance.urlPath)) group.urlPaths.push(provenance.urlPath);
groups.set(key, group);
}
const segments = Array.from(groups.values()).sort((a, b) => Number(a.firstSeq ?? 0) - Number(b.firstSeq ?? 0));
const controlSegments = control
.filter((item) => item.type === "page-provenance" || item?.pageProvenance)
.map((item) => ({
ts: item.ts ?? null,
reason: item.reason ?? item.detail?.reason ?? null,
httpStatus: item.httpStatus ?? item.detail?.httpStatus ?? null,
pageProvenance: item.pageProvenance ?? item.detail?.pageProvenance ?? null,
}))
.slice(0, 80);
return {
summary: {
segmentCount: segments.length,
sampleCount: segments.reduce((sum, item) => sum + item.sampleCount, 0),
manifestFingerprint: manifest?.pageProvenance?.assetFingerprint ?? null,
controlSegmentCount: controlSegments.length
},
segments,
controlSegments,
valuesRedacted: true
};
}
function buildPagePerformanceReport(samples, manifest) {
const base = manifest?.baseUrl || "http://invalid.local";
const seen = new Set();
const groups = new Map();
const sampleTimes = samples.map((sample) => Date.parse(sample?.ts || "")).filter(Number.isFinite);
const windowStartMs = sampleTimes.length > 0 ? Math.min(...sampleTimes) : null;
const windowEndMs = sampleTimes.length > 0 ? Math.max(...sampleTimes) : null;
for (const sample of samples) {
const entries = Array.isArray(sample?.performance) ? sample.performance : [];
for (const entry of entries) {
const durationMs = Number(entry?.duration);
if (!Number.isFinite(durationMs) || durationMs < 0) continue;
const entryCompletedMs = performanceEntryCompletedEpochMs(sample, entry);
if (windowStartMs !== null && entryCompletedMs !== null && entryCompletedMs < windowStartMs) continue;
if (windowEndMs !== null && entryCompletedMs !== null && entryCompletedMs > windowEndMs + 1000) continue;
const entryTs = entryCompletedMs === null ? (sample.ts ?? null) : new Date(entryCompletedMs).toISOString();
const parsed = parsePerformanceUrl(entry?.name, base);
if (!parsed.sameOrigin || !isApiLikePath(parsed.path)) continue;
const normalizedPath = normalizeApiPath(parsed.path);
const routeKind = classifyApiPerformanceRoute(normalizedPath, entry);
const isLongLivedStream = routeKind === "same-origin-api-stream";
const streamOpenMs = streamOpenLatencyMs(entry);
const timingStatus = resourceTimingPhaseStatus(entry);
const dedupeKey = [parsed.path, entry.initiatorType || "", sample?.pageProvenance?.pageLoadSeq ?? "", sample?.pageProvenance?.timeOrigin ?? "", entry.startTime ?? "", Math.round(durationMs)].join("|");
if (seen.has(dedupeKey)) continue;
seen.add(dedupeKey);
const group = groups.get(normalizedPath) || {
routeKind,
path: normalizedPath,
isLongLivedStream,
budgetMetric: isLongLivedStream ? "streamOpenMs" : "durationMs",
rawPathSamples: [],
sampleCount: 0,
completeTimingSampleCount: 0,
partialTimingSampleCount: 0,
durationsMs: [],
streamOpenDurationsMs: [],
overFiveSecondCount: 0,
overBudgetCount: 0,
partialOverFiveSecondCount: 0,
partialOverBudgetCount: 0,
streamLifetimeOverFiveSecondCount: 0,
streamOpenOverFiveSecondCount: 0,
streamOpenOverBudgetCount: 0,
firstAt: entryTs,
lastAt: entryTs,
firstSeq: sample.seq ?? null,
lastSeq: sample.seq ?? null,
initiatorTypes: [],
pageAssetFingerprints: [],
slowSamples: [],
partialSamples: [],
valuesRedacted: true
};
group.sampleCount += 1;
const partialOrdinaryTiming = !isLongLivedStream && timingStatus.status !== "complete";
let overBudget = false;
if (partialOrdinaryTiming) {
group.partialTimingSampleCount += 1;
if (durationMs > 5000) group.partialOverFiveSecondCount += 1;
if (durationMs > alertThresholds.partialApiSlowMs) {
group.partialOverBudgetCount += 1;
if (group.partialSamples.length < 80) group.partialSamples.push(compactPagePerformanceSlowSample({ sample, entry, entryTs, normalizedPath, rawPath: parsed.path, durationMs, streamOpenMs }));
}
} else {
group.completeTimingSampleCount += 1;
group.durationsMs.push(durationMs);
if (isLongLivedStream) {
if (durationMs > 5000) group.streamLifetimeOverFiveSecondCount += 1;
if (streamOpenMs !== null) {
group.streamOpenDurationsMs.push(streamOpenMs);
if (streamOpenMs > 5000) {
group.streamOpenOverFiveSecondCount += 1;
group.overFiveSecondCount += 1;
}
if (streamOpenMs > alertThresholds.longLivedStreamOpenSlowMs) {
group.streamOpenOverBudgetCount += 1;
group.overBudgetCount += 1;
overBudget = true;
}
}
} else {
if (durationMs > 5000) group.overFiveSecondCount += 1;
if (durationMs > alertThresholds.sameOriginApiSlowMs) {
group.overBudgetCount += 1;
overBudget = true;
}
}
}
if (overBudget && group.slowSamples.length < 80) group.slowSamples.push(compactPagePerformanceSlowSample({ sample, entry, entryTs, normalizedPath, rawPath: parsed.path, durationMs, streamOpenMs }));
group.lastAt = entryTs;
group.lastSeq = sample.seq ?? null;
if (parsed.path && !group.rawPathSamples.includes(parsed.path)) group.rawPathSamples.push(parsed.path);
if (entry.initiatorType && !group.initiatorTypes.includes(entry.initiatorType)) group.initiatorTypes.push(entry.initiatorType);
const assetFingerprint = sample?.pageProvenance?.assetFingerprint;
if (assetFingerprint && !group.pageAssetFingerprints.includes(assetFingerprint)) group.pageAssetFingerprints.push(assetFingerprint);
groups.set(normalizedPath, group);
}
}
const sameOriginApiByPath = Array.from(groups.values()).map((group) => {
const durations = group.durationsMs.slice().sort((a, b) => a - b);
const streamOpenDurations = group.streamOpenDurationsMs.slice().sort((a, b) => a - b);
return {
routeKind: group.routeKind,
path: group.path,
isLongLivedStream: group.isLongLivedStream === true,
budgetMetric: group.budgetMetric,
sampleCount: group.sampleCount,
budgetMs: group.isLongLivedStream === true ? alertThresholds.longLivedStreamOpenSlowMs : alertThresholds.sameOriginApiSlowMs,
partialBudgetMs: alertThresholds.partialApiSlowMs,
streamOpenBudgetMs: alertThresholds.longLivedStreamOpenSlowMs,
completeTimingSampleCount: group.completeTimingSampleCount,
partialTimingSampleCount: group.partialTimingSampleCount,
p50Ms: percentile(durations, 50),
p75Ms: percentile(durations, 75),
p95Ms: percentile(durations, 95),
maxMs: durations.length > 0 ? durations[durations.length - 1] : null,
streamOpenSampleCount: streamOpenDurations.length,
streamOpenP50Ms: percentile(streamOpenDurations, 50),
streamOpenP75Ms: percentile(streamOpenDurations, 75),
streamOpenP95Ms: percentile(streamOpenDurations, 95),
streamOpenMaxMs: streamOpenDurations.length > 0 ? streamOpenDurations[streamOpenDurations.length - 1] : null,
streamOpenOverFiveSecondCount: group.streamOpenOverFiveSecondCount,
streamOpenOverBudgetCount: group.streamOpenOverBudgetCount,
streamLifetimeOverFiveSecondCount: group.streamLifetimeOverFiveSecondCount,
overFiveSecondCount: group.overFiveSecondCount,
overBudgetCount: group.overBudgetCount,
partialOverFiveSecondCount: group.partialOverFiveSecondCount,
partialOverBudgetCount: group.partialOverBudgetCount,
overFiveSecondRatio: group.sampleCount > 0 ? Number((group.overFiveSecondCount / group.sampleCount).toFixed(3)) : 0,
overBudgetRatio: group.sampleCount > 0 ? Number((group.overBudgetCount / group.sampleCount).toFixed(3)) : 0,
firstAt: group.firstAt,
lastAt: group.lastAt,
firstSeq: group.firstSeq,
lastSeq: group.lastSeq,
initiatorTypes: group.initiatorTypes,
rawPathSamples: group.rawPathSamples.slice(0, 8),
pageAssetFingerprints: group.pageAssetFingerprints.slice(0, 8),
slowSamples: group.slowSamples
.slice()
.sort((a, b) => Number(b.durationMs ?? 0) - Number(a.durationMs ?? 0))
.slice(0, 12),
partialSamples: group.partialSamples
.slice()
.sort((a, b) => Number(b.durationMs ?? 0) - Number(a.durationMs ?? 0))
.slice(0, 12),
valuesRedacted: true
};
}).sort((a, b) => (Number(b.overBudgetCount ?? b.overFiveSecondCount ?? 0) - Number(a.overBudgetCount ?? a.overFiveSecondCount ?? 0)) || (Number(b.p95Ms ?? 0) - Number(a.p95Ms ?? 0)) || a.path.localeCompare(b.path));
const longLivedStreams = sameOriginApiByPath.filter((item) => item.isLongLivedStream);
const ordinaryApi = sameOriginApiByPath.filter((item) => item.isLongLivedStream !== true);
const slow = ordinaryApi.filter((item) => Number(item.overBudgetCount ?? item.overFiveSecondCount ?? 0) > 0);
const slowFiveSecond = ordinaryApi.filter((item) => Number(item.overFiveSecondCount ?? 0) > 0);
const partialSlow = ordinaryApi.filter((item) => Number(item.partialOverBudgetCount ?? item.partialOverFiveSecondCount ?? 0) > 0);
const partialFiveSecond = ordinaryApi.filter((item) => Number(item.partialOverFiveSecondCount ?? 0) > 0);
const slowStreamOpen = longLivedStreams.filter((item) => Number(item.streamOpenOverBudgetCount ?? item.streamOpenOverFiveSecondCount ?? 0) > 0);
const slowStreamOpenFiveSecond = longLivedStreams.filter((item) => Number(item.streamOpenOverFiveSecondCount ?? 0) > 0);
const budgetP95Values = sameOriginApiByPath
.map((item) => Number(item.isLongLivedStream ? (item.streamOpenP95Ms ?? 0) : (item.p95Ms ?? 0)))
.filter((value) => Number.isFinite(value));
return {
summary: {
budgetMs: alertThresholds.sameOriginApiSlowMs,
alertThresholds,
sameOriginApiPathCount: sameOriginApiByPath.length,
sameOriginApiSampleCount: sameOriginApiByPath.reduce((sum, item) => sum + item.sampleCount, 0),
longLivedStreamPathCount: longLivedStreams.length,
longLivedStreamSampleCount: longLivedStreams.reduce((sum, item) => sum + item.sampleCount, 0),
longLivedStreamOpenOverFiveSecondPathCount: slowStreamOpenFiveSecond.length,
longLivedStreamOpenOverFiveSecondSampleCount: slowStreamOpenFiveSecond.reduce((sum, item) => sum + Number(item.streamOpenOverFiveSecondCount ?? 0), 0),
longLivedStreamOpenOverBudgetPathCount: slowStreamOpen.length,
longLivedStreamOpenOverBudgetSampleCount: slowStreamOpen.reduce((sum, item) => sum + Number(item.streamOpenOverBudgetCount ?? item.streamOpenOverFiveSecondCount ?? 0), 0),
longLivedStreamLifetimeOverFiveSecondSampleCount: longLivedStreams.reduce((sum, item) => sum + Number(item.streamLifetimeOverFiveSecondCount ?? 0), 0),
slowPathCount: slow.length,
slowSampleCount: slow.reduce((sum, item) => sum + Number(item.overBudgetCount ?? item.overFiveSecondCount ?? 0), 0),
overFiveSecondPathCount: slowFiveSecond.length,
overFiveSecondSampleCount: slowFiveSecond.reduce((sum, item) => sum + Number(item.overFiveSecondCount ?? 0), 0),
partialTimingSampleCount: ordinaryApi.reduce((sum, item) => sum + Number(item.partialTimingSampleCount ?? 0), 0),
partialOverFiveSecondPathCount: partialFiveSecond.length,
partialOverFiveSecondSampleCount: partialFiveSecond.reduce((sum, item) => sum + Number(item.partialOverFiveSecondCount ?? 0), 0),
partialOverBudgetPathCount: partialSlow.length,
partialOverBudgetSampleCount: partialSlow.reduce((sum, item) => sum + Number(item.partialOverBudgetCount ?? item.partialOverFiveSecondCount ?? 0), 0),
worstP95Ms: budgetP95Values.length > 0 ? Math.max(...budgetP95Values) : null,
valuesRedacted: true
},
sameOriginApiByPath,
valuesRedacted: true
};
}
function performanceEntryCompletedEpochMs(sample, entry) {
const origin = Number(sample?.pageProvenance?.timeOrigin);
const responseEnd = Number(entry?.responseEnd);
const startTime = Number(entry?.startTime);
const offset = Number.isFinite(responseEnd) && responseEnd > 0 ? responseEnd : startTime;
if (Number.isFinite(origin) && origin > 0 && Number.isFinite(offset) && offset >= 0) return Math.round(origin + offset);
const sampleTs = Date.parse(sample?.ts || "");
return Number.isFinite(sampleTs) ? sampleTs : null;
}
function compactPagePerformanceSlowSample({ sample, entry, entryTs, normalizedPath, rawPath, durationMs, streamOpenMs }) {
const timingStatus = resourceTimingPhaseStatus(entry);
const serverTiming = compactServerTiming(entry?.serverTiming);
return {
ts: entryTs ?? sample?.ts ?? null,
sampleTs: sample?.ts ?? null,
seq: sample?.seq ?? null,
path: normalizedPath ?? null,
rawPath: rawPath ?? null,
initiatorType: entry?.initiatorType ?? null,
durationMs: roundFinite(durationMs),
startTimeMs: roundFinite(entry?.startTime),
fetchStartMs: roundFinite(entry?.fetchStart),
requestStartMs: roundFinite(entry?.requestStart),
responseStartMs: roundFinite(entry?.responseStart),
responseEndMs: roundFinite(entry?.responseEnd),
streamOpenMs: roundFinite(streamOpenMs),
dnsMs: phaseDeltaMs(entry, "domainLookupEnd", "domainLookupStart"),
tcpMs: phaseDeltaMs(entry, "connectEnd", "connectStart"),
tlsStartMs: roundFinite(entry?.secureConnectionStart),
requestToResponseStartMs: phaseDeltaMs(entry, "responseStart", "requestStart"),
responseTransferMs: phaseDeltaMs(entry, "responseEnd", "responseStart"),
timingStatus: timingStatus.status,
invalidTimingPhases: timingStatus.invalidPhases,
partialTimingPhases: timingStatus.partialPhases,
transferSize: Number.isFinite(Number(entry?.transferSize)) ? Number(entry.transferSize) : null,
encodedBodySize: Number.isFinite(Number(entry?.encodedBodySize)) ? Number(entry.encodedBodySize) : null,
decodedBodySize: Number.isFinite(Number(entry?.decodedBodySize)) ? Number(entry.decodedBodySize) : null,
nextHopProtocol: entry?.nextHopProtocol ?? null,
serverTiming,
serverTimingNames: serverTiming.map((item) => item.name).filter(Boolean).slice(0, 8),
otelTraceId: extractOtelTraceIdFromServerTiming(serverTiming),
valuesRedacted: true
};
}
function phaseDeltaMs(entry, endKey, startKey) {
const end = Number(entry?.[endKey]);
const start = Number(entry?.[startKey]);
if (!Number.isFinite(end) || !Number.isFinite(start) || end <= 0 || start <= 0 || end < start) return null;
return Math.round(end - start);
}
function resourceTimingPhaseStatus(entry) {
const pairs = [
["requestToResponseStart", "requestStart", "responseStart"],
["responseTransfer", "responseStart", "responseEnd"],
];
const invalidPhases = [];
const partialPhases = [];
for (const [label, startKey, endKey] of pairs) {
const start = Number(entry?.[startKey]);
const end = Number(entry?.[endKey]);
if (!Number.isFinite(start) || !Number.isFinite(end) || start <= 0 || end <= 0) {
partialPhases.push(label);
} else if (end < start) {
invalidPhases.push(label);
}
}
return {
status: invalidPhases.length > 0 ? "invalid" : (partialPhases.length > 0 ? "partial" : "complete"),
invalidPhases,
partialPhases,
};
}
function compactServerTiming(value) {
const items = Array.isArray(value) ? value : [];
return items.slice(0, 8).map((item) => ({
name: truncate(String(item?.name || ""), 80),
duration: Number.isFinite(Number(item?.duration)) ? Math.round(Number(item.duration)) : null,
description: truncate(String(item?.description || ""), 120),
})).filter((item) => item.name || item.description || item.duration !== null);
}
function extractOtelTraceIdFromServerTiming(items) {
const text = (Array.isArray(items) ? items : []).map((item) => [item.name, item.description].filter(Boolean).join(" ")).join(" ");
const match = text.match(/\b[0-9a-f]{32}\b/iu);
return match ? match[0].toLowerCase() : null;
}
function roundFinite(value) {
const numeric = Number(value);
return Number.isFinite(numeric) ? Math.round(numeric) : null;
}
function classifyApiPerformanceRoute(normalizedPath, entry = {}) {
if (normalizedPath === "/v1/workbench/events") return "same-origin-api-stream";
if (String(entry?.initiatorType ?? "").toLowerCase() === "eventsource") return "same-origin-api-stream";
return "same-origin-api";
}
function streamOpenLatencyMs(entry = {}) {
const responseStart = Number(entry?.responseStart);
const startTime = Number(entry?.startTime);
if (!Number.isFinite(responseStart) || responseStart <= 0) return null;
if (!Number.isFinite(startTime) || startTime < 0) return Math.max(0, responseStart);
if (responseStart < startTime) return null;
return Math.max(0, responseStart - startTime);
}
function parsePerformanceUrl(value, base) {
try {
const url = new URL(String(value || ""), base);
const origin = new URL(String(base || "http://invalid.local")).origin;
return { sameOrigin: url.origin === origin, path: url.pathname };
} catch {
return { sameOrigin: false, path: "-" };
}
}
function isApiLikePath(path) {
return /^\/(?:v1(?:\/|$)|auth(?:\/|$)|health(?:\/|$))/u.test(String(path || ""));
}
function normalizeApiPath(path) {
return String(path || "-")
.replace(/\/v1\/workbench\/sessions\/ses_[^/]+/gu, "/v1/workbench/sessions/:id")
.replace(/\/v1\/workbench\/turns\/trc_[^/]+/gu, "/v1/workbench/turns/:traceId")
.replace(/\/v1\/workbench\/traces\/trc_[^/]+/gu, "/v1/workbench/traces/:traceId")
.replace(/\/v1\/workbench\/sessions\/[0-9a-f-]{12,}/giu, "/v1/workbench/sessions/:id")
.replace(/\/v1\/[^/]+\/[0-9a-f-]{16,}(?=\/|$)/giu, (match) => match.replace(/\/[0-9a-f-]{16,}$/iu, "/:id"));
}
function percentile(sortedValues, percentileValue) {
if (!Array.isArray(sortedValues) || sortedValues.length === 0) return null;
if (sortedValues.length === 1) return Math.round(sortedValues[0]);
const rank = (percentileValue / 100) * (sortedValues.length - 1);
const lower = Math.floor(rank);
const upper = Math.ceil(rank);
if (lower === upper) return Math.round(sortedValues[lower]);
const weight = rank - lower;
return Math.round(sortedValues[lower] * (1 - weight) + sortedValues[upper] * weight);
}
`;
}