fix(web-probe): capture web performance diagnostics payloads

This commit is contained in:
Codex
2026-07-02 17:03:45 +00:00
parent 6ebe5e377a
commit 049ebf2539
6 changed files with 577 additions and 0 deletions
@@ -458,6 +458,9 @@ function buildRuntimeAlerts(samples, control, network, consoleEvents, errors) {
const significantRequestFailed = requestFailed.filter(
(item) => !isBenignLongLivedStreamClosureAlert(item) && !isObserverRefreshClosureAlert(item, observerRefreshTimes),
);
const webPerformanceDiagnostics = extractWebPerformanceRuntimeDiagnostics(naturalNetwork, promptTimes);
const webPerformancePayloadStates = summarizeWebPerformancePayloadStates(naturalNetwork);
const webPerformanceDiagnosticGroups = groupWebPerformanceRuntimeDiagnostics(webPerformanceDiagnostics);
const domDiagnostics = [];
const executionErrors = [];
const baselineExecutionErrors = [];
@@ -588,6 +591,11 @@ function buildRuntimeAlerts(samples, control, network, consoleEvents, errors) {
workbenchSessionListReadCount,
workbenchTraceEventsReadCount,
webPerformanceBeaconFailureCount,
webPerformancePayloadRequestCount: webPerformancePayloadStates.total,
webPerformancePayloadParsedCount: webPerformancePayloadStates.parsed,
webPerformancePayloadParseIssueCount: webPerformancePayloadStates.parseIssue,
webPerformanceRuntimeDiagnosticCount: webPerformanceDiagnostics.length,
webPerformanceRuntimeDiagnosticGroupCount: webPerformanceDiagnosticGroups.length,
workbenchEventSourceFailureCount,
benignLongLivedStreamClosureCount: requestFailed.length - significantRequestFailed.length,
domDiagnosticSampleCount: domDiagnostics.length,
@@ -608,6 +616,9 @@ function buildRuntimeAlerts(samples, control, network, consoleEvents, errors) {
networkHttpErrorsByPath: groupNetworkAlerts(httpErrors),
networkRequestFailedByPath: groupNetworkAlerts(requestFailed),
networkSignificantRequestFailedByPath: groupNetworkAlerts(significantRequestFailed),
webPerformancePayloadStates,
webPerformanceRuntimeDiagnostics: webPerformanceDiagnostics.slice(0, 120),
webPerformanceRuntimeDiagnosticsByCode: webPerformanceDiagnosticGroups,
domDiagnostics: domDiagnostics.slice(-80),
domDiagnosticsByText: groupDomDiagnostics(domDiagnostics),
domDiagnosticsByFingerprint: groupDomDiagnostics(domDiagnostics).slice(0, 80),
@@ -674,6 +685,173 @@ function groupDomDiagnostics(events) {
.sort((a, b) => (b.count - a.count) || String(a.firstAt || "").localeCompare(String(b.firstAt || "")));
}
function extractWebPerformanceRuntimeDiagnostics(network, promptTimes) {
const rows = [];
for (const item of Array.isArray(network) ? network : []) {
const payload = item?.webPerformancePayload && typeof item.webPerformancePayload === "object" ? item.webPerformancePayload : null;
if (!payload || payload.parseStatus !== "parsed" || payload.schemaVersion !== "hwlab-web-performance-v2") continue;
const events = Array.isArray(payload.events) ? payload.events : [];
for (const event of events) {
if (!event || typeof event !== "object") continue;
const diagnosticCode = limitText(event.diagnosticCode || event.code || "", 120);
const reason = limitText(event.reason || "", 120);
const eventType = limitText(event.eventType || event.type || event.kind || "", 120);
if (!diagnosticCode && !reason && !eventType) continue;
rows.push({
ts: item.ts ?? event.ts ?? null,
promptIndex: promptIndexForTs(promptTimes, item.ts ?? event.ts),
pageRole: item.pageRole ?? null,
pageId: item.pageId ?? null,
commandId: item.commandId ?? null,
method: item.method ?? null,
urlPath: urlPath(item.url),
schemaVersion: payload.schemaVersion,
captureStatus: payload.captureStatus ?? null,
parseStatus: payload.parseStatus ?? null,
byteCount: numberOrNull(payload.byteCount),
bodyHash: payload.bodyHash ?? null,
eventType,
diagnosticCode,
reason,
module: limitText(event.module || "", 120),
traceId: event.traceId ?? null,
sessionIdHash: event.sessionIdHash ?? null,
eventIdHash: event.eventIdHash ?? null,
eventCount: numberOrNull(event.eventCount),
chunkCount: numberOrNull(event.chunkCount),
flushDurationMs: numberOrNull(event.flushDurationMs),
droppedCount: numberOrNull(event.droppedCount),
maxItemsPerChunk: numberOrNull(event.maxItemsPerChunk),
maxChunkMs: numberOrNull(event.maxChunkMs),
replacedByKey: typeof event.replacedByKey === "boolean" || typeof event.replacedByKey === "number" ? event.replacedByKey : null,
replacedByKeyHash: event.replacedByKeyHash ?? null,
valuesRedacted: true,
});
}
}
return rows.sort((left, right) => String(left.ts || "").localeCompare(String(right.ts || ""))).slice(-200);
}
function summarizeWebPerformancePayloadStates(network) {
const stateCounts = new Map();
let total = 0;
let parsed = 0;
let parseIssue = 0;
let overLimit = 0;
let invalidJson = 0;
let missingBody = 0;
let unsupportedSchema = 0;
let capturedEventCount = 0;
let storedEventCount = 0;
for (const item of Array.isArray(network) ? network : []) {
const payload = item?.webPerformancePayload && typeof item.webPerformancePayload === "object" ? item.webPerformancePayload : null;
if (!payload) continue;
total += 1;
const state = String(payload.parseStatus || payload.captureStatus || "unknown");
stateCounts.set(state, (stateCounts.get(state) || 0) + 1);
if (payload.parseStatus === "parsed") parsed += 1;
else parseIssue += 1;
if (payload.parseStatus === "not-parsed-over-limit" || payload.captureStatus === "skipped-over-limit") overLimit += 1;
if (payload.parseStatus === "invalid-json") invalidJson += 1;
if (payload.parseStatus === "missing-body") missingBody += 1;
if (payload.parseStatus === "unsupported-schema") unsupportedSchema += 1;
if (Number.isFinite(Number(payload.eventCount))) capturedEventCount += Number(payload.eventCount);
if (Number.isFinite(Number(payload.storedEventCount))) storedEventCount += Number(payload.storedEventCount);
}
return {
total,
parsed,
parseIssue,
overLimit,
invalidJson,
missingBody,
unsupportedSchema,
capturedEventCount,
storedEventCount,
states: Array.from(stateCounts.entries()).map(([state, count]) => ({ state, count })).sort((a, b) => b.count - a.count || a.state.localeCompare(b.state)),
valuesRedacted: true,
};
}
function groupWebPerformanceRuntimeDiagnostics(events) {
const groups = new Map();
for (const item of Array.isArray(events) ? events : []) {
const key = [item.diagnosticCode || item.eventType || "-", item.reason || "-", item.module || "-"].join("|");
const group = groups.get(key) || {
diagnosticCode: item.diagnosticCode || null,
reason: item.reason || null,
module: item.module || null,
eventType: item.eventType || null,
count: 0,
firstAt: item.ts || null,
lastAt: item.ts || null,
promptIndexes: new Set(),
traceIds: new Set(),
eventCount: 0,
chunkCount: 0,
droppedCount: 0,
maxFlushDurationMs: null,
maxItemsPerChunk: null,
maxChunkMs: null,
replacedByKeyCount: 0,
examples: [],
};
group.count += 1;
group.firstAt = minIso(group.firstAt, item.ts || null);
group.lastAt = maxIso(group.lastAt, item.ts || null);
if (Number.isFinite(Number(item.promptIndex))) group.promptIndexes.add(Number(item.promptIndex));
if (item.traceId) group.traceIds.add(String(item.traceId));
group.eventCount += Number(item.eventCount || 0);
group.chunkCount += Number(item.chunkCount || 0);
group.droppedCount += Number(item.droppedCount || 0);
group.maxFlushDurationMs = maxNumber(group.maxFlushDurationMs, item.flushDurationMs);
group.maxItemsPerChunk = maxNumber(group.maxItemsPerChunk, item.maxItemsPerChunk);
group.maxChunkMs = maxNumber(group.maxChunkMs, item.maxChunkMs);
if (item.replacedByKey === true || item.replacedByKeyHash) group.replacedByKeyCount += 1;
if (group.examples.length < 6) group.examples.push({
ts: item.ts || null,
traceId: item.traceId || null,
eventCount: item.eventCount ?? null,
chunkCount: item.chunkCount ?? null,
flushDurationMs: item.flushDurationMs ?? null,
droppedCount: item.droppedCount ?? null,
maxItemsPerChunk: item.maxItemsPerChunk ?? null,
maxChunkMs: item.maxChunkMs ?? null,
replacedByKey: item.replacedByKey ?? null,
replacedByKeyHash: item.replacedByKeyHash ?? null,
valuesRedacted: true,
});
groups.set(key, group);
}
return Array.from(groups.values()).map((item) => ({
diagnosticCode: item.diagnosticCode,
reason: item.reason,
module: item.module,
eventType: item.eventType,
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().slice(0, 12),
eventCount: item.eventCount,
chunkCount: item.chunkCount,
droppedCount: item.droppedCount,
maxFlushDurationMs: item.maxFlushDurationMs,
maxItemsPerChunk: item.maxItemsPerChunk,
maxChunkMs: item.maxChunkMs,
replacedByKeyCount: item.replacedByKeyCount,
examples: item.examples,
valuesRedacted: true,
})).sort((left, right) => right.count - left.count || String(left.firstAt || "").localeCompare(String(right.firstAt || "")));
}
function maxNumber(current, candidate) {
const value = Number(candidate);
if (!Number.isFinite(value)) return current ?? null;
const existing = Number(current);
return Number.isFinite(existing) ? Math.max(existing, value) : value;
}
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 || ""));
@@ -276,6 +276,18 @@ console.log(JSON.stringify({
traceId: item.traceId ?? null,
text: String(item.preview ?? item.text ?? "").slice(0, 180),
})),
webPerformanceRuntimeDiagnostics: {
summary: {
payloadRequestCount: recentWindow.runtimeAlerts.summary.webPerformancePayloadRequestCount ?? 0,
payloadParsedCount: recentWindow.runtimeAlerts.summary.webPerformancePayloadParsedCount ?? 0,
payloadParseIssueCount: recentWindow.runtimeAlerts.summary.webPerformancePayloadParseIssueCount ?? 0,
runtimeDiagnosticCount: recentWindow.runtimeAlerts.summary.webPerformanceRuntimeDiagnosticCount ?? 0,
runtimeDiagnosticGroupCount: recentWindow.runtimeAlerts.summary.webPerformanceRuntimeDiagnosticGroupCount ?? 0,
valuesRedacted: true,
},
groups: Array.isArray(recentWindow.runtimeAlerts.webPerformanceRuntimeDiagnosticsByCode) ? recentWindow.runtimeAlerts.webPerformanceRuntimeDiagnosticsByCode.slice(0, 8) : [],
valuesRedacted: true,
},
turnTimingRecentUpdateJumps: recentWindow.sampleMetrics.turnTimingRecentUpdateSawtoothJumps.slice(0, 8).map((item) => ({
columnLabel: item.columnLabel ?? item.columnId ?? null,
pageRole: item.pageRole ?? null,
@@ -753,6 +753,12 @@ function compactPerformanceWindow(item){
function compactPerfFinding(item){
return {severity:item?.severity??item?.level??null,id:short(item?.id||item?.kind||item?.code||'',56),count:item?.count??item?.sampleCount??null,rootCause:item?.rootCause??item?.rootCauseStatus??null,summary:short(item?.summary||item?.message||'',110),valuesRedacted:true};
}
function compactWebPerformanceDiagnosticGroup(item){
return {diagnosticCode:short(item?.diagnosticCode||'',64),reason:short(item?.reason||'',48),module:short(item?.module||'',48),eventType:short(item?.eventType||'',48),count:item?.count??null,eventCount:item?.eventCount??null,chunkCount:item?.chunkCount??null,droppedCount:item?.droppedCount??null,maxFlushDurationMs:item?.maxFlushDurationMs??null,maxItemsPerChunk:item?.maxItemsPerChunk??null,maxChunkMs:item?.maxChunkMs??null,replacedByKeyCount:item?.replacedByKeyCount??null,firstAt:item?.firstAt??null,lastAt:item?.lastAt??null,traceIds:Array.isArray(item?.traceIds)?item.traceIds.slice(0,2).map((id)=>short(id,32)):[],valuesRedacted:true};
}
function compactWebPerformancePayloadState(item){
return {state:short(item?.state||'',48),count:item?.count??null,valuesRedacted:true};
}
function compactPerfCommand(row){
return {bucket:row?.bucket||null,id:short(commandFileId(row)||'',40),type:short(commandFileType(row)||'',32),ts:short(commandFileTs(row)||'',32),ageSeconds:row?.data?.ageSeconds??null,relative:short(row?.relative||'',72),valuesRedacted:true};
}
@@ -830,6 +836,9 @@ function performanceWindowsForSummary(rows){
function performanceSummaryFromReport(){
const perf=report.frontendPerformance&&typeof report.frontendPerformance==='object'?report.frontendPerformance:{};
const summary=perf.summary&&typeof perf.summary==='object'?perf.summary:{};
const runtime=report.runtimeAlerts&&typeof report.runtimeAlerts==='object'?report.runtimeAlerts:{};
const runtimeSummary=runtime.summary&&typeof runtime.summary==='object'?runtime.summary:{};
const payloadStates=runtime.webPerformancePayloadStates&&typeof runtime.webPerformancePayloadStates==='object'?runtime.webPerformancePayloadStates:{};
const findings=Array.isArray(report.findings)?report.findings.filter((item)=>String(item?.id||item?.kind||'').match(/^frontend-(?:long|event-loop|cpu-profile|performance)/u)).slice(0,6).map(compactPerfFinding):[];
const captureRows=Array.isArray(perf.captures)?perf.captures:[];
const commandFiles=readCommandFiles();
@@ -843,6 +852,7 @@ function performanceSummaryFromReport(){
profileStacks:Array.isArray(perf.profileStacks)?perf.profileStacks.slice(0,2).map(compactProfileStack):[],
captures:captureRows.slice(-3).map(compactPerfCapture),
sourceAttribution:perf.sourceAttribution&&typeof perf.sourceAttribution==='object'?{sourceMapStatus:perf.sourceAttribution.sourceMapStatus??null,note:short(perf.sourceAttribution.note||'',120),sourceFiles:Array.isArray(perf.sourceAttribution.sourceFiles)?perf.sourceAttribution.sourceFiles.slice(0,4):[],valuesRedacted:true}:null,
webPerformanceRuntime:{summary:{payloadRequestCount:runtimeSummary.webPerformancePayloadRequestCount??0,payloadParsedCount:runtimeSummary.webPerformancePayloadParsedCount??0,payloadParseIssueCount:runtimeSummary.webPerformancePayloadParseIssueCount??0,runtimeDiagnosticCount:runtimeSummary.webPerformanceRuntimeDiagnosticCount??0,runtimeDiagnosticGroupCount:runtimeSummary.webPerformanceRuntimeDiagnosticGroupCount??0,capturedEventCount:payloadStates.capturedEventCount??0,storedEventCount:payloadStates.storedEventCount??0,valuesRedacted:true},states:Array.isArray(payloadStates.states)?payloadStates.states.slice(0,6).map(compactWebPerformancePayloadState):[],groups:Array.isArray(runtime.webPerformanceRuntimeDiagnosticsByCode)?runtime.webPerformanceRuntimeDiagnosticsByCode.slice(0,6).map(compactWebPerformanceDiagnosticGroup):[],valuesRedacted:true},
findings,
toolFindings:performanceToolFindings(),
valuesRedacted:true
@@ -881,6 +891,13 @@ function renderPerformanceSummary(perf){
lines.push('','LoAF script hotspots');
if(perf.scriptHotspots.length===0) lines.push('-');
for(const item of perf.scriptHotspots.slice(0,4)) lines.push(String(item.totalDurationMs??0)+'ms total count='+String(item.count??0)+' '+short(item.sourceFunctionName||item.invoker||'(anonymous)',40)+' '+short(item.sourceFile||item.sourceURL||'-',70)+' sourceLine='+String(item.sourceLine??'-')+' rawLine='+String(item.lineNumber??'-')+' char='+String(item.sourceCharPosition??'-')+' sourceMap='+String(item.sourceMapStatus||'-'));
const webPerf=perf.webPerformanceRuntime||{};
const webPerfSummary=webPerf.summary||{};
lines.push('','Web performance runtime diagnostics');
lines.push('payloads='+String(webPerfSummary.payloadRequestCount??0)+' parsed='+String(webPerfSummary.payloadParsedCount??0)+' parseIssues='+String(webPerfSummary.payloadParseIssueCount??0)+' events='+String(webPerfSummary.runtimeDiagnosticCount??0)+' groups='+String(webPerfSummary.runtimeDiagnosticGroupCount??0));
if(Array.isArray(webPerf.states)&&webPerf.states.length>0) lines.push(' payload states='+webPerf.states.map((item)=>String(item.state||'-')+':'+String(item.count??0)).join(', '));
if(!Array.isArray(webPerf.groups)||webPerf.groups.length===0) lines.push('-');
for(const item of (webPerf.groups||[]).slice(0,6)) lines.push(String(item.diagnosticCode||item.eventType||'-')+' reason='+String(item.reason||'-')+' module='+String(item.module||'-')+' count='+String(item.count??0)+' eventCount='+String(item.eventCount??'-')+' chunks='+String(item.chunkCount??'-')+' flushMax='+String(item.maxFlushDurationMs??'-')+'ms dropped='+String(item.droppedCount??'-')+' maxItemsPerChunk='+String(item.maxItemsPerChunk??'-')+' maxChunkMs='+String(item.maxChunkMs??'-')+' replacedByKey='+String(item.replacedByKeyCount??'-'));
lines.push('','Time-window correlation');
if(!Array.isArray(perf.performanceWindows)||perf.performanceWindows.length===0) lines.push('-');
for(const item of (perf.performanceWindows||[]).slice(0,3)){
@@ -786,6 +786,7 @@ function attachPassiveListeners(targetPage, pageRole = "control", targetPageId =
void installPagePerformanceProbe(targetPage, pageRole, targetPageId)
.catch((error) => appendJsonl(files.errors, eventRecord("performance-probe-install-error", { pageRole, pageId: targetPageId, error: errorSummary(error), valuesRedacted: true })));
targetPage.on("request", (request) => {
const webPerformancePayload = summarizeWebPerformanceRequestPayload(request);
void appendJsonl(files.network, eventRecord("request", {
pageRole,
pageId: targetPageId,
@@ -795,6 +796,7 @@ function attachPassiveListeners(targetPage, pageRole = "control", targetPageId =
url: safeUrl(request.url()),
resourceType: request.resourceType(),
frameUrl: safeFrameUrl(request.frame()),
...(webPerformancePayload ? { webPerformancePayload } : {}),
}));
});
targetPage.on("response", (response) => {
@@ -851,5 +853,158 @@ function attachPassiveListeners(targetPage, pageRole = "control", targetPageId =
void appendJsonl(files.control, eventRecord("continuity-break", { pageRole, pageId: targetPageId, reason: "page-closed" }));
});
}
function summarizeWebPerformanceRequestPayload(request) {
if (!request || request.method() !== "POST" || !isSameOriginWebPerformanceRequestUrl(request.url())) return null;
let raw = null;
try {
raw = request.postData();
} catch (error) {
return {
captureStatus: "post-data-read-failed",
parseStatus: "post-data-read-failed",
byteCount: null,
byteLimit: webPerformanceRequestBodyMaxBytes,
error: errorSummary(error),
eventCount: null,
events: [],
valuesRedacted: true,
};
}
if (raw === null || raw === undefined || raw === "") {
return {
captureStatus: "missing-body",
parseStatus: "missing-body",
byteCount: 0,
byteLimit: webPerformanceRequestBodyMaxBytes,
eventCount: 0,
events: [],
valuesRedacted: true,
};
}
const byteCount = Buffer.byteLength(raw, "utf8");
const bodyHash = sha256Text(raw);
if (byteCount > webPerformanceRequestBodyMaxBytes) {
return {
captureStatus: "skipped-over-limit",
parseStatus: "not-parsed-over-limit",
byteCount,
byteLimit: webPerformanceRequestBodyMaxBytes,
bodyHash,
truncated: true,
eventCount: null,
events: [],
valuesRedacted: true,
};
}
try {
const parsed = JSON.parse(raw);
const schemaVersion = typeof parsed?.schemaVersion === "string" ? parsed.schemaVersion : null;
if (schemaVersion !== "hwlab-web-performance-v2") {
return {
captureStatus: "captured",
parseStatus: "unsupported-schema",
schemaVersion,
byteCount,
byteLimit: webPerformanceRequestBodyMaxBytes,
bodyHash,
eventCount: Array.isArray(parsed?.events) ? parsed.events.length : null,
events: [],
valuesRedacted: true,
};
}
const events = (Array.isArray(parsed.events) ? parsed.events : []).slice(0, 80).map(compactWebPerformancePayloadEvent).filter(Boolean);
return {
captureStatus: "captured",
parseStatus: "parsed",
schemaVersion,
byteCount,
byteLimit: webPerformanceRequestBodyMaxBytes,
bodyHash,
truncated: false,
eventCount: Array.isArray(parsed.events) ? parsed.events.length : 0,
storedEventCount: events.length,
events,
valuesRedacted: true,
};
} catch (error) {
return {
captureStatus: "captured",
parseStatus: "invalid-json",
byteCount,
byteLimit: webPerformanceRequestBodyMaxBytes,
bodyHash,
parseError: truncate(error?.message || String(error), 180),
eventCount: null,
events: [],
valuesRedacted: true,
};
}
}
function isSameOriginWebPerformanceRequestUrl(value) {
try {
const url = new URL(String(value || ""), baseUrl);
return url.origin === baseUrl && url.pathname === "/v1/web-performance";
} catch {
return false;
}
}
function compactWebPerformancePayloadEvent(event) {
if (!event || typeof event !== "object") return null;
const detail = firstObject(event.detail, event.details, event.payload, event.data, event.metrics);
const read = (key) => event?.[key] ?? detail?.[key] ?? null;
const diagnosticCode = compactString(read("diagnosticCode") ?? read("code") ?? read("name"), 96);
const reason = compactString(read("reason"), 96);
const module = compactString(read("module"), 96);
const eventType = compactString(read("eventType") ?? read("type") ?? read("kind"), 96);
const traceId = compactTraceId(read("traceId"));
const sessionId = compactString(read("sessionId"), 120);
const replacedByKey = compactScalar(read("replacedByKey"));
const eventId = compactString(read("eventId") ?? read("id"), 120);
return {
ts: compactString(read("ts") ?? read("timestamp") ?? read("time"), 64),
eventType,
diagnosticCode,
reason,
module,
traceId,
sessionIdHash: sessionId ? sha256Text(sessionId) : null,
eventIdHash: eventId ? sha256Text(eventId) : null,
eventCount: numberOrNull(read("eventCount")),
chunkCount: numberOrNull(read("chunkCount")),
flushDurationMs: numberOrNull(read("flushDurationMs")),
droppedCount: numberOrNull(read("droppedCount")),
maxItemsPerChunk: numberOrNull(read("maxItemsPerChunk")),
maxChunkMs: numberOrNull(read("maxChunkMs")),
replacedByKey: typeof replacedByKey === "boolean" || typeof replacedByKey === "number" ? replacedByKey : null,
replacedByKeyHash: typeof replacedByKey === "string" ? sha256Text(replacedByKey) : null,
valuesRedacted: true,
};
}
function firstObject(...values) {
for (const value of values) if (value && typeof value === "object" && !Array.isArray(value)) return value;
return {};
}
function compactString(value, limit) {
if (value === null || value === undefined) return null;
return truncate(String(value), limit);
}
function compactTraceId(value) {
const text = String(value || "");
return /^trc_[A-Za-z0-9_-]+$/u.test(text) ? text : null;
}
function compactScalar(value) {
if (value === null || value === undefined) return null;
if (typeof value === "boolean") return value;
const numeric = Number(value);
if (Number.isFinite(numeric) && String(value).trim() !== "") return numeric;
return truncate(String(value), 120);
}
`;
}
@@ -33,6 +33,7 @@ const observerRefreshIntervalMs = positiveInteger(process.env.UNIDESK_WEB_OBSERV
const maxRunMs = positiveInteger(process.env.UNIDESK_WEB_OBSERVE_MAX_RUN_MS, 0);
const viewport = parseViewport(process.env.UNIDESK_WEB_OBSERVE_VIEWPORT || "1440x900");
const browserProxyMode = parseBrowserProxyMode(process.env.UNIDESK_WEB_OBSERVE_BROWSER_PROXY_MODE || "auto");
const webPerformanceRequestBodyMaxBytes = boundedInteger(process.env.UNIDESK_WEB_OBSERVE_WEB_PERFORMANCE_BODY_MAX_BYTES, 65536, 1024, 1048576);
const authLoginMaxAttempts = boundedInteger(process.env.UNIDESK_WEB_OBSERVE_AUTH_LOGIN_MAX_ATTEMPTS, 6, 1, 20);
const authLoginRequestTimeoutMs = boundedInteger(process.env.UNIDESK_WEB_OBSERVE_AUTH_LOGIN_REQUEST_TIMEOUT_MS, 30000, 1000, 120000);
const authLoginInitialDelayMs = boundedInteger(process.env.UNIDESK_WEB_OBSERVE_AUTH_LOGIN_INITIAL_DELAY_MS, 500, 0, 60000);
@@ -0,0 +1,214 @@
import assert from "node:assert/strict";
import { mkdir, mkdtemp, readFile, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { spawnSync } from "node:child_process";
import { test } from "bun:test";
import { nodeWebObserveAnalyzerSource } from "../hwlab-node-web-observe-analyzer-source";
import { nodeWebObserveCollectViewNodeScript } from "../hwlab-node-web-observe-collect";
const alertThresholds = {
sameOriginApiSlowMs: 60000,
partialApiSlowMs: 60000,
longLivedStreamOpenSlowMs: 60000,
visibleLoadingSlowMs: 60000,
turnTimingSampleSlackSeconds: 60,
turnElapsedSevereTimeoutSeconds: 3600,
domEvaluateTimeoutRedCount: 99,
domEvaluateTimeoutRedWindowMs: 60000,
screenshotTimeoutRedCount: 99,
pageErrorRedCount: 99,
longTaskRedMs: 1000,
longAnimationFrameRedMs: 1000,
eventLoopGapRedMs: 1000,
browserProcessSampleIntervalMs: 1000,
requestRateBucketMs: 10000,
requestRateTotalRedPerMinute: 999999,
requestRatePageRedPerMinute: 999999,
requestRateApiPathRedPerMinute: 999999,
browserTotalRssRedMb: 999999,
browserProcessRssRedMb: 999999,
browserRssGrowthRedMb: 999999,
browserRssGrowthWindowMs: 60000,
playwrightResponsivenessRedMs: 60000,
playwrightResponsivenessTimeoutRedCount: 99,
cdpMetricsTimeoutRedCount: 99,
uncommandedStateChangeCommandWindowMs: 1000,
scrollJumpCommandWindowMs: 1000,
scrollJumpFromY: 999999,
scrollJumpToY: 999999,
sessionRailFallbackRatio: 0.5,
};
const browserFreezePolicy = {
enabled: true,
blockerWindowMs: 60000,
memory: { totalRssBlockerMb: 999999, processRssBlockerMb: 999999, growthBlockerMb: 999999 },
responsiveness: { latencyBlockerMs: 60000, eventBlockerCount: 99 },
cdp: { metricsTimeoutBlockerCount: 99 },
kill: { enabled: false, gracefulSignal: "SIGTERM", forceSignal: "SIGKILL", graceMs: 1000, pollIntervalMs: 100, exitCode: 124 },
};
function shellQuote(value: string): string {
return `'${value.replace(/'/g, `'\\''`)}'`;
}
async function writeJsonl(path: string, rows: Array<Record<string, unknown>>): Promise<void> {
await writeFile(path, rows.map((row) => JSON.stringify(row)).join("\n") + (rows.length > 0 ? "\n" : ""));
}
async function runAnalyzer(stateDir: string): Promise<Record<string, unknown>> {
const analyzerPath = join(stateDir, "analyze.mjs");
await writeFile(analyzerPath, nodeWebObserveAnalyzerSource(), { mode: 0o700 });
const result = spawnSync("bun", [analyzerPath, stateDir], {
cwd: join(import.meta.dir, "../../.."),
env: {
...process.env,
UNIDESK_WEB_OBSERVE_ANALYZE_TAIL_SAMPLES: "0",
UNIDESK_WEB_OBSERVE_ALERT_THRESHOLDS_JSON: JSON.stringify(alertThresholds),
UNIDESK_WEB_OBSERVE_BROWSER_FREEZE_POLICY_JSON: JSON.stringify(browserFreezePolicy),
},
encoding: "utf8",
});
assert.equal(result.status, 0, result.stderr || result.stdout);
return JSON.parse(await readFile(join(stateDir, "analysis", "report.json"), "utf8")) as Record<string, unknown>;
}
async function writeState(): Promise<string> {
const stateDir = await mkdtemp(join(tmpdir(), "unidesk-web-observe-web-performance-"));
await mkdir(join(stateDir, "analysis"), { recursive: true });
await writeFile(join(stateDir, "manifest.json"), JSON.stringify({ jobId: "webobs-web-performance-test", status: "completed" }) + "\n");
await writeFile(join(stateDir, "heartbeat.json"), JSON.stringify({ status: "completed", updatedAt: "2026-07-02T17:00:05.000Z" }) + "\n");
await writeJsonl(join(stateDir, "samples.jsonl"), []);
await writeJsonl(join(stateDir, "console.jsonl"), []);
await writeJsonl(join(stateDir, "errors.jsonl"), []);
await writeJsonl(join(stateDir, "browser-process.jsonl"), []);
await writeJsonl(join(stateDir, "performance-events.jsonl"), []);
await writeJsonl(join(stateDir, "artifacts.jsonl"), []);
await writeJsonl(join(stateDir, "control.jsonl"), [{
type: "sendPrompt",
phase: "completed",
commandId: "cmd-prompt",
ts: "2026-07-02T17:00:00.000Z",
}]);
await writeJsonl(join(stateDir, "network.jsonl"), [
{
type: "request",
ts: "2026-07-02T17:00:01.000Z",
pageRole: "control",
pageId: "page-1",
method: "POST",
url: "https://hwlab.example.test/v1/web-performance",
resourceType: "fetch",
webPerformancePayload: {
captureStatus: "captured",
parseStatus: "parsed",
schemaVersion: "hwlab-web-performance-v2",
byteCount: 768,
byteLimit: 65536,
bodyHash: "sha256:payload",
eventCount: 1,
storedEventCount: 1,
events: [{
ts: "2026-07-02T17:00:01.000Z",
eventType: "runtime_diagnostic",
diagnosticCode: "workbench_sse_flush",
reason: "sse_flush",
module: "workbench-runtime",
traceId: "trc_webperf_fixture",
sessionIdHash: "sha256:session",
eventCount: 12,
chunkCount: 3,
flushDurationMs: 18.75,
droppedCount: 2,
maxItemsPerChunk: 5,
maxChunkMs: 7.5,
replacedByKey: true,
valuesRedacted: true,
}],
valuesRedacted: true,
},
},
{
type: "request",
ts: "2026-07-02T17:00:02.000Z",
method: "POST",
url: "https://hwlab.example.test/v1/web-performance",
webPerformancePayload: {
captureStatus: "captured",
parseStatus: "invalid-json",
byteCount: 12,
byteLimit: 65536,
bodyHash: "sha256:invalid",
eventCount: null,
events: [],
valuesRedacted: true,
},
},
{
type: "request",
ts: "2026-07-02T17:00:03.000Z",
method: "POST",
url: "https://hwlab.example.test/v1/web-performance",
webPerformancePayload: {
captureStatus: "skipped-over-limit",
parseStatus: "not-parsed-over-limit",
byteCount: 70000,
byteLimit: 65536,
bodyHash: "sha256:large",
eventCount: null,
events: [],
valuesRedacted: true,
},
},
]);
return stateDir;
}
test("analyzer and performance-summary include bounded web-performance runtime diagnostics", async () => {
const stateDir = await writeState();
const report = await runAnalyzer(stateDir);
const runtimeAlerts = report.runtimeAlerts as Record<string, unknown>;
const summary = runtimeAlerts.summary as Record<string, unknown>;
assert.equal(summary.webPerformancePayloadRequestCount, 3);
assert.equal(summary.webPerformancePayloadParsedCount, 1);
assert.equal(summary.webPerformancePayloadParseIssueCount, 2);
assert.equal(summary.webPerformanceRuntimeDiagnosticCount, 1);
const groups = runtimeAlerts.webPerformanceRuntimeDiagnosticsByCode as Array<Record<string, unknown>>;
assert.equal(groups[0].diagnosticCode, "workbench_sse_flush");
assert.equal(groups[0].reason, "sse_flush");
assert.equal(groups[0].module, "workbench-runtime");
assert.equal(groups[0].eventCount, 12);
assert.equal(groups[0].chunkCount, 3);
assert.equal(groups[0].maxFlushDurationMs, 18.75);
assert.equal(groups[0].replacedByKeyCount, 1);
const reportText = JSON.stringify(report);
assert.match(reportText, /workbench_sse_flush/u);
assert.doesNotMatch(reportText, /rawBody|super-secret|ses_unredacted/u);
const script = nodeWebObserveCollectViewNodeScript({
maxFiles: 100,
view: "performance-summary",
traceId: null,
sampleSeq: null,
timestamp: null,
turn: null,
commandId: null,
windowMs: null,
});
const result = spawnSync("bash", ["-lc", `state_dir=${shellQuote(stateDir)}\n${script}`], {
cwd: join(import.meta.dir, "../../.."),
encoding: "utf8",
});
assert.equal(result.status, 0, result.stderr || result.stdout);
const output = JSON.parse(result.stdout);
const renderedText = String(output.renderedText ?? "");
assert.match(renderedText, /Web performance runtime diagnostics/u);
assert.match(renderedText, /payloads=3 parsed=1 parseIssues=2 events=1 groups=1/u);
assert.match(renderedText, /workbench_sse_flush reason=sse_flush module=workbench-runtime/u);
assert.match(renderedText, /eventCount=12 chunks=3 flushMax=18.75ms dropped=2/u);
assert.match(renderedText, /payload states=invalid-json:1, not-parsed-over-limit:1, parsed:1/u);
}, 20_000);