diff --git a/scripts/src/hwlab-node-web-observe-analyzer-request-runtime-source.ts b/scripts/src/hwlab-node-web-observe-analyzer-request-runtime-source.ts index 840b1486..6ec3e6de 100644 --- a/scripts/src/hwlab-node-web-observe-analyzer-request-runtime-source.ts +++ b/scripts/src/hwlab-node-web-observe-analyzer-request-runtime-source.ts @@ -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,179 @@ 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), + deliveredCount: numberOrNull(event.deliveredCount), + 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, + deliveredCount: 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.deliveredCount += Number(item.deliveredCount || 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 (Number.isFinite(Number(item.replacedByKey))) group.replacedByKeyCount += Number(item.replacedByKey); + else 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, + deliveredCount: item.deliveredCount ?? 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, + deliveredCount: item.deliveredCount, + 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 || "")); diff --git a/scripts/src/hwlab-node-web-observe-analyzer-source.ts b/scripts/src/hwlab-node-web-observe-analyzer-source.ts index 4a0eca17..5339f43c 100644 --- a/scripts/src/hwlab-node-web-observe-analyzer-source.ts +++ b/scripts/src/hwlab-node-web-observe-analyzer-source.ts @@ -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, diff --git a/scripts/src/hwlab-node-web-observe-collect.ts b/scripts/src/hwlab-node-web-observe-collect.ts index 8cd57981..2aab0dd5 100644 --- a/scripts/src/hwlab-node-web-observe-collect.ts +++ b/scripts/src/hwlab-node-web-observe-collect.ts @@ -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,deliveredCount:item?.deliveredCount??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??'-')+' delivered='+String(item.deliveredCount??'-')+' 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)){ diff --git a/scripts/src/hwlab-node-web-observe-runner-runtime-source.ts b/scripts/src/hwlab-node-web-observe-runner-runtime-source.ts index c5f65f0b..f9eb716d 100644 --- a/scripts/src/hwlab-node-web-observe-runner-runtime-source.ts +++ b/scripts/src/hwlab-node-web-observe-runner-runtime-source.ts @@ -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,159 @@ 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")), + deliveredCount: numberOrNull(read("deliveredCount")), + 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); +} `; } diff --git a/scripts/src/hwlab-node-web-observe-runner-source.ts b/scripts/src/hwlab-node-web-observe-runner-source.ts index c5bfdecc..6492d7a3 100644 --- a/scripts/src/hwlab-node-web-observe-runner-source.ts +++ b/scripts/src/hwlab-node-web-observe-runner-source.ts @@ -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); diff --git a/scripts/src/hwlab-node/web-observe-web-performance-payload.test.ts b/scripts/src/hwlab-node/web-observe-web-performance-payload.test.ts new file mode 100644 index 00000000..acee5a16 --- /dev/null +++ b/scripts/src/hwlab-node/web-observe-web-performance-payload.test.ts @@ -0,0 +1,216 @@ +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>): Promise { + await writeFile(path, rows.map((row) => JSON.stringify(row)).join("\n") + (rows.length > 0 ? "\n" : "")); +} + +async function runAnalyzer(stateDir: string): Promise> { + 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; +} + +async function writeState(): Promise { + 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, + deliveredCount: 10, + chunkCount: 3, + flushDurationMs: 18.75, + droppedCount: 2, + maxItemsPerChunk: 5, + maxChunkMs: 7.5, + replacedByKey: 3, + 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; + const summary = runtimeAlerts.summary as Record; + 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>; + 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].deliveredCount, 10); + assert.equal(groups[0].chunkCount, 3); + assert.equal(groups[0].maxFlushDurationMs, 18.75); + assert.equal(groups[0].replacedByKeyCount, 3); + + 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 delivered=10 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);