fix(web-probe): capture web performance diagnostics payloads
This commit is contained in:
@@ -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);
|
||||
Reference in New Issue
Block a user