diff --git a/scripts/src/hwlab-node-impl.ts b/scripts/src/hwlab-node-impl.ts index 7573dd58..bdea9dc2 100644 --- a/scripts/src/hwlab-node-impl.ts +++ b/scripts/src/hwlab-node-impl.ts @@ -8305,6 +8305,7 @@ function renderWebObserveAnalyzeTable(value: Record): string { const roundCount = Array.isArray(sampleMetrics?.rounds) ? sampleMetrics.rounds.length : sampleMetrics?.rounds; const turnColumnCount = Array.isArray(sampleMetrics?.turnColumns) ? sampleMetrics.turnColumns.length : sampleMetrics?.turnColumns; const slowApis = Array.isArray(analysis?.pagePerformanceSlowApi) ? analysis.pagePerformanceSlowApi.map(record).filter((item): item is Record => item !== null).slice(0, 8) : []; + const partialApis = Array.isArray(analysis?.pagePerformancePartialApi) ? analysis.pagePerformancePartialApi.map(record).filter((item): item is Record => item !== null).slice(0, 8) : []; const sseStreams = Array.isArray(analysis?.pagePerformanceSseStreams) ? analysis.pagePerformanceSseStreams.map(record).filter((item): item is Record => item !== null).slice(0, 8) : []; const findings = Array.isArray(analysis?.findings) ? analysis.findings.map(record).filter((item): item is Record => item !== null).slice(0, 8) : []; const httpErrorGroups = Array.isArray(analysis?.httpErrorGroups) ? analysis.httpErrorGroups.map(record).filter((item): item is Record => item !== null).slice(0, 8) : []; @@ -8626,6 +8627,29 @@ function renderWebObserveAnalyzeTable(value: Record): string { return rows.length > 0 ? rows : [["-", "-", "-", "-", "-", "-", "-", "-", "-", "-", "-"]]; })()), "", + "Partial API timing (>5s, not counted as completed slow API):", + webObserveTable(["PATH", "SAMPLES", "COMPLETE", "PARTIAL", "PARTIAL>5S"], partialApis.length > 0 ? partialApis.map((item) => [ + webObserveShort(webObserveText(item.path ?? item.route), 52), + webObserveText(item.sampleCount), + webObserveText(item.completeTimingSampleCount), + webObserveText(item.partialTimingSampleCount), + webObserveText(item.partialOverFiveSecondCount), + ]) : [["-", "-", "-", "-", "-"]]), + "", + "Partial API samples:", + webObserveTable(["TS", "SEQ", "PATH", "DURATION", "TIMING", "INIT", "PROTO"], (() => { + const rows = partialApis.flatMap((item) => (Array.isArray(item.partialSamples) ? item.partialSamples : []).map((sample) => ({ ...sample, path: sample.path ?? item.path ?? item.route }))).slice(0, 8).map((sample) => [ + webObserveShort(webObserveText(sample.ts), 24), + webObserveText(sample.seq), + webObserveShort(webObserveText(sample.path), 44), + webObserveText(sample.durationMs), + webObserveShort(webObserveText(sample.timingStatus), 12), + webObserveShort(webObserveText(sample.initiatorType), 12), + webObserveShort(webObserveText(sample.nextHopProtocol), 12), + ]); + return rows.length > 0 ? rows : [["-", "-", "-", "-", "-", "-", "-"]]; + })()), + "", "Long-lived streams (SSE):", webObserveTable(["PATH", "SAMPLES", "OPEN_P95", "OPEN_MAX", "OPEN>5S", "LIFE>5S"], sseStreams.length > 0 ? sseStreams.map((item) => [ webObserveShort(webObserveText(item.path ?? item.route), 52), @@ -9403,7 +9427,19 @@ const slimJsonValue=(value,depth=0)=>{ } return short(value,180); }; -const slimFinding=(item)=>item&&typeof item==='object'?{kind:short(item.kind||item.code||item.id,80),severity:short(item.severity||item.level,24),count:item.count??item.sampleCount??null,summary:short(item.summary||item.message,240)}:null; +const slimFinding=(item)=>{ + if(!item||typeof item!=='object') return null; + const samples=Array.isArray(item.samples)?item.samples.length:null; + const groups=Array.isArray(item.groups)?item.groups.length:null; + return { + kind:short(item.kind||item.code||item.id,80), + severity:short(item.severity||item.level,24), + count:item.count??item.sampleCount??null, + summary:short(item.summary||item.message,240), + sampleRows:samples, + groupRows:groups + }; +}; const slimRunnerError=(item)=>item&&typeof item==='object'?{ts:short(item.ts||item.at,32),type:short(item.type,48),retry:item.retry||item.lastRetryLabel||null,retryExhausted:item.retryExhausted===true,attemptCount:item.attemptCount??(Array.isArray(item.attempts)?item.attempts.length:null),lastFailureKind:short(item.lastFailureKind||item.failureKind,80),lastReadinessReason:short(item.lastReadinessReason,80),message:short(item.message||item.lastError,240)}:null; const slimJsonl=(value)=>{ if(!value||typeof value!=='object'||Array.isArray(value)) return {value:short(value)}; @@ -9444,16 +9480,17 @@ if(selected){ const lines=text.split(/\\r?\\n/).filter(Boolean); if(isJsonl&&truncated&&lines.length>0) lines.shift(); let jsonSummary=null; let jsonContent=null; - if(isJson){try{const parsed=JSON.parse(raw.toString('utf8')); jsonContent=slimJsonValue(parsed); jsonSummary={topLevelKeys:parsed&&typeof parsed==='object'&&!Array.isArray(parsed)?Object.keys(parsed).slice(0,40):[],counts:slimObject(parsed&&parsed.counts),sampleMetrics:slimObject(parsed&&parsed.sampleMetrics),runtimeAlerts:slimObject(parsed&&parsed.runtimeAlerts),pagePerformance:slimObject(parsed&&parsed.pagePerformance),runnerErrors:Array.isArray(parsed&&parsed.runnerErrors)?parsed.runnerErrors.slice(-8).map(slimRunnerError).filter(Boolean):[],findings:Array.isArray(parsed&&parsed.findings)?parsed.findings.slice(0,8).map(slimFinding).filter(Boolean):[]}}catch(error){jsonSummary={parseError:String(error&&error.message||error).slice(0,160)}}} - const jsonlTail=isJsonl?lines.slice(-20).map((line,index)=>{try{return slimJsonl(JSON.parse(line))}catch(error){return {parseError:true,index,lineTail:line.slice(-240),error:String(error&&error.message||error).slice(0,160)}}}):null; + if(isJson){try{const parsed=JSON.parse(raw.toString('utf8')); jsonContent=raw.length<=4096?slimJsonValue(parsed):null; jsonSummary={topLevelKeys:parsed&&typeof parsed==='object'&&!Array.isArray(parsed)?Object.keys(parsed).slice(0,24):[],counts:slimObject(parsed&&parsed.counts,16),sampleMetrics:slimObject(parsed&&parsed.sampleMetrics,16),runtimeAlerts:slimObject(parsed&&parsed.runtimeAlerts,16),pagePerformance:slimObject(parsed&&parsed.pagePerformance,16),runnerErrors:Array.isArray(parsed&&parsed.runnerErrors)?parsed.runnerErrors.slice(-4).map(slimRunnerError).filter(Boolean):[],findings:Array.isArray(parsed&&parsed.findings)?parsed.findings.slice(0,6).map(slimFinding).filter(Boolean):[]}}catch(error){jsonSummary={parseError:String(error&&error.message||error).slice(0,160)}}} + const jsonlTail=isJsonl?lines.slice(-8).map((line,index)=>{try{return slimJsonl(JSON.parse(line))}catch(error){return {parseError:true,index,lineTail:line.slice(-240),error:String(error&&error.message||error).slice(0,160)}}}):null; console.log(JSON.stringify({ok:true,command:'web-probe-observe collect',stateDir:dir,mode:'file',file:{path:file,relative,byteCount:st.size,sha256:shaFile(file),truncated,content:isJsonl||isJson||jsonSummary?undefined:text.slice(0,maxTextPreviewBytes),contentTruncated:!isJsonl&&!isJson&&!jsonSummary&&text.length>maxTextPreviewBytes,jsonlTail,jsonSummary,jsonContent,lineCount:lines.length},valuesRedacted:true},null,2)); process.exit(0); } const out=[]; const walk=(p)=>{for(const ent of fs.readdirSync(p,{withFileTypes:true})){const full=path.join(p,ent.name); if(ent.isDirectory()) walk(full); else out.push(full); if(out.length>=maxFiles) return;}}; walk(dir); -const files=out.slice(0,maxFiles).map(file=>{const st=fs.statSync(file); return {path:file,relative:path.relative(dir,file),byteCount:st.size,sha256:shaFile(file)}}); -const totalBytes=files.reduce((sum,item)=>sum+item.byteCount,0); -console.log(JSON.stringify({ok:true,command:'web-probe-observe collect',stateDir:dir,fileCount:files.length,totalBytes,files,valuesRedacted:true},null,2)); +const selectedFiles=out.slice(0,maxFiles); +const files=selectedFiles.slice(0,24).map(file=>{const st=fs.statSync(file); return {relative:path.relative(dir,file),byteCount:st.size,sha256:shaFile(file)}}); +const totalBytes=selectedFiles.reduce((sum,file)=>sum+fs.statSync(file).size,0); +console.log(JSON.stringify({ok:true,command:'web-probe-observe collect',stateDir:dir,fileCount:selectedFiles.length,listedFileCount:files.length,omittedFileCount:Math.max(0,selectedFiles.length-files.length),totalBytes,files,valuesRedacted:true},null,2)); `)} "$state_dir" ${shellQuote(collectFile ?? "")}`; } diff --git a/scripts/src/hwlab-node-web-observe-runner-source.ts b/scripts/src/hwlab-node-web-observe-runner-source.ts index b73cbf48..88f2badf 100644 --- a/scripts/src/hwlab-node-web-observe-runner-source.ts +++ b/scripts/src/hwlab-node-web-observe-runner-source.ts @@ -2102,6 +2102,7 @@ console.log(JSON.stringify({ anomaly: item.anomaly ?? null, })), pagePerformanceSlowApi: recentWindow.pagePerformance.sameOriginApiByPath.filter((item) => item.isLongLivedStream !== true && item.overFiveSecondCount > 0).slice(0, 5).map((item) => ({ path: item.path, route: item.route, sampleCount: item.sampleCount, p95Ms: item.p95Ms, maxMs: item.maxMs, overFiveSecondCount: item.overFiveSecondCount, slowSamples: item.slowSamples })), + pagePerformancePartialApi: recentWindow.pagePerformance.sameOriginApiByPath.filter((item) => item.isLongLivedStream !== true && Number(item.partialOverFiveSecondCount ?? 0) > 0).slice(0, 5).map((item) => ({ path: item.path, route: item.route, sampleCount: item.sampleCount, completeTimingSampleCount: item.completeTimingSampleCount, partialTimingSampleCount: item.partialTimingSampleCount, partialOverFiveSecondCount: item.partialOverFiveSecondCount, partialSamples: item.partialSamples })), pagePerformanceSseStreams: recentWindow.pagePerformance.sameOriginApiByPath.filter((item) => item.isLongLivedStream === true).slice(0, 5).map((item) => ({ path: item.path, route: item.route, sampleCount: item.sampleCount, streamOpenSampleCount: item.streamOpenSampleCount, streamOpenP95Ms: item.streamOpenP95Ms, streamOpenMaxMs: item.streamOpenMaxMs, streamOpenOverFiveSecondCount: item.streamOpenOverFiveSecondCount, streamLifetimeOverFiveSecondCount: item.streamLifetimeOverFiveSecondCount, slowSamples: item.slowSamples })), findings: prioritizeFindings(recentWindow.findings).slice(0, 8).map((item) => ({ kind: item.id ?? item.kind ?? item.code, severity: item.severity, count: item.count ?? item.sampleCount ?? null, summary: String(item.summary ?? item.message ?? "").slice(0, 180) })), valuesRedacted: true, @@ -2545,6 +2546,7 @@ function buildPagePerformanceReport(samples, manifest) { const routeKind = classifyApiPerformanceRoute(normalizedPath, entry); const isLongLivedStream = routeKind === "same-origin-api-stream"; const streamOpenMs = streamOpenLatencyMs(entry); + const timingStatus = resourceTimingPhaseStatus(entry); const dedupeKey = [parsed.path, entry.initiatorType || "", sample?.pageProvenance?.pageLoadSeq ?? "", sample?.pageProvenance?.timeOrigin ?? "", entry.startTime ?? "", Math.round(durationMs)].join("|"); if (seen.has(dedupeKey)) continue; seen.add(dedupeKey); @@ -2555,9 +2557,12 @@ function buildPagePerformanceReport(samples, manifest) { budgetMetric: isLongLivedStream ? "streamOpenMs" : "durationMs", rawPathSamples: [], sampleCount: 0, + completeTimingSampleCount: 0, + partialTimingSampleCount: 0, durationsMs: [], streamOpenDurationsMs: [], overFiveSecondCount: 0, + partialOverFiveSecondCount: 0, streamLifetimeOverFiveSecondCount: 0, streamOpenOverFiveSecondCount: 0, firstAt: entryTs, @@ -2567,24 +2572,35 @@ function buildPagePerformanceReport(samples, manifest) { initiatorTypes: [], pageAssetFingerprints: [], slowSamples: [], + partialSamples: [], valuesRedacted: true }; group.sampleCount += 1; - group.durationsMs.push(durationMs); + const partialOrdinaryTiming = !isLongLivedStream && timingStatus.status !== "complete"; let overBudget = false; - if (isLongLivedStream) { - if (durationMs > 5000) group.streamLifetimeOverFiveSecondCount += 1; - if (streamOpenMs !== null) { - group.streamOpenDurationsMs.push(streamOpenMs); - if (streamOpenMs > 5000) { - group.streamOpenOverFiveSecondCount += 1; - group.overFiveSecondCount += 1; - overBudget = true; - } + if (partialOrdinaryTiming) { + group.partialTimingSampleCount += 1; + if (durationMs > 5000) { + group.partialOverFiveSecondCount += 1; + if (group.partialSamples.length < 80) group.partialSamples.push(compactPagePerformanceSlowSample({ sample, entry, entryTs, normalizedPath, rawPath: parsed.path, durationMs, streamOpenMs })); + } + } else { + group.completeTimingSampleCount += 1; + group.durationsMs.push(durationMs); + if (isLongLivedStream) { + if (durationMs > 5000) group.streamLifetimeOverFiveSecondCount += 1; + if (streamOpenMs !== null) { + group.streamOpenDurationsMs.push(streamOpenMs); + if (streamOpenMs > 5000) { + group.streamOpenOverFiveSecondCount += 1; + group.overFiveSecondCount += 1; + overBudget = true; + } + } + } else if (durationMs > 5000) { + group.overFiveSecondCount += 1; + overBudget = true; } - } else if (durationMs > 5000) { - group.overFiveSecondCount += 1; - overBudget = true; } if (overBudget && group.slowSamples.length < 80) group.slowSamples.push(compactPagePerformanceSlowSample({ sample, entry, entryTs, normalizedPath, rawPath: parsed.path, durationMs, streamOpenMs })); group.lastAt = entryTs; @@ -2605,6 +2621,8 @@ function buildPagePerformanceReport(samples, manifest) { isLongLivedStream: group.isLongLivedStream === true, budgetMetric: group.budgetMetric, sampleCount: group.sampleCount, + completeTimingSampleCount: group.completeTimingSampleCount, + partialTimingSampleCount: group.partialTimingSampleCount, p50Ms: percentile(durations, 50), p75Ms: percentile(durations, 75), p95Ms: percentile(durations, 95), @@ -2617,6 +2635,7 @@ function buildPagePerformanceReport(samples, manifest) { streamOpenOverFiveSecondCount: group.streamOpenOverFiveSecondCount, streamLifetimeOverFiveSecondCount: group.streamLifetimeOverFiveSecondCount, overFiveSecondCount: group.overFiveSecondCount, + partialOverFiveSecondCount: group.partialOverFiveSecondCount, overFiveSecondRatio: group.sampleCount > 0 ? Number((group.overFiveSecondCount / group.sampleCount).toFixed(3)) : 0, firstAt: group.firstAt, lastAt: group.lastAt, @@ -2629,12 +2648,17 @@ function buildPagePerformanceReport(samples, manifest) { .slice() .sort((a, b) => Number(b.durationMs ?? 0) - Number(a.durationMs ?? 0)) .slice(0, 12), + partialSamples: group.partialSamples + .slice() + .sort((a, b) => Number(b.durationMs ?? 0) - Number(a.durationMs ?? 0)) + .slice(0, 12), valuesRedacted: true }; }).sort((a, b) => (b.overFiveSecondCount - a.overFiveSecondCount) || (Number(b.p95Ms ?? 0) - Number(a.p95Ms ?? 0)) || a.path.localeCompare(b.path)); const longLivedStreams = sameOriginApiByPath.filter((item) => item.isLongLivedStream); const ordinaryApi = sameOriginApiByPath.filter((item) => item.isLongLivedStream !== true); const slow = ordinaryApi.filter((item) => item.overFiveSecondCount > 0); + const partialSlow = ordinaryApi.filter((item) => Number(item.partialOverFiveSecondCount ?? 0) > 0); const slowStreamOpen = longLivedStreams.filter((item) => Number(item.streamOpenOverFiveSecondCount ?? 0) > 0); const budgetP95Values = sameOriginApiByPath .map((item) => Number(item.isLongLivedStream ? (item.streamOpenP95Ms ?? 0) : (item.p95Ms ?? 0))) @@ -2651,6 +2675,9 @@ function buildPagePerformanceReport(samples, manifest) { longLivedStreamLifetimeOverFiveSecondSampleCount: longLivedStreams.reduce((sum, item) => sum + Number(item.streamLifetimeOverFiveSecondCount ?? 0), 0), slowPathCount: slow.length, slowSampleCount: slow.reduce((sum, item) => sum + item.overFiveSecondCount, 0), + partialTimingSampleCount: ordinaryApi.reduce((sum, item) => sum + Number(item.partialTimingSampleCount ?? 0), 0), + partialOverFiveSecondPathCount: partialSlow.length, + partialOverFiveSecondSampleCount: partialSlow.reduce((sum, item) => sum + Number(item.partialOverFiveSecondCount ?? 0), 0), worstP95Ms: budgetP95Values.length > 0 ? Math.max(...budgetP95Values) : null, valuesRedacted: true },