diff --git a/scripts/src/hwlab-node-impl.ts b/scripts/src/hwlab-node-impl.ts index ac88e1b4..16b83429 100644 --- a/scripts/src/hwlab-node-impl.ts +++ b/scripts/src/hwlab-node-impl.ts @@ -7982,7 +7982,7 @@ function runNodeWebProbeObserveAnalyze(options: NodeWebProbeObserveOptions, spec "const objectOrNull = (value) => value && typeof value === 'object' && !Array.isArray(value) ? value : null;", "const slimRound = (item) => { const v = objectOrNull(item) || {}; return { promptIndex: v.promptIndex ?? null, sampleCount: v.sampleCount ?? null, loadingSamples: v.loadingSamples ?? null, maxLoadingCount: v.maxLoadingCount ?? null, loadingOwnerCount: v.loadingOwnerCount ?? null, lastTotalElapsedSeconds: v.lastTotalElapsedSeconds ?? null, lastRecentUpdateSeconds: v.lastRecentUpdateSeconds ?? null, diagnosticSamples: v.diagnosticSamples ?? null, terminalSamples: v.terminalSamples ?? null, turnTimingTotalElapsedForwardJumpCount: v.turnTimingTotalElapsedForwardJumpCount ?? null, turnTimingTotalElapsedForwardJumpMaxSeconds: v.turnTimingTotalElapsedForwardJumpMaxSeconds ?? null, promptTextHash: clip(v.promptTextHash, 80) }; };", "const slimTurnColumn = (item) => { const v = objectOrNull(item) || {}; return { label: clip(v.label, 24), promptIndex: v.promptIndex ?? null, lastPromptIndex: v.lastPromptIndex ?? null, traceId: clip(v.traceId, 48), firstSeq: v.firstSeq ?? null, lastSeq: v.lastSeq ?? null, source: clip(v.source, 48) }; };", - "const slimSlowSample = (item) => { const v = objectOrNull(item) || {}; return { ts: v.ts ?? null, seq: v.seq ?? null, path: clip(v.path ?? v.rawPath, 96), initiatorType: clip(v.initiatorType, 24), durationMs: v.durationMs ?? null, requestToResponseStartMs: v.requestToResponseStartMs ?? v.streamOpenMs ?? null, responseTransferMs: v.responseTransferMs ?? null, nextHopProtocol: clip(v.nextHopProtocol, 24) }; };", + "const slimSlowSample = (item) => { const v = objectOrNull(item) || {}; return { ts: v.ts ?? null, seq: v.seq ?? null, path: clip(v.path ?? v.rawPath, 96), initiatorType: clip(v.initiatorType, 24), durationMs: v.durationMs ?? null, requestToResponseStartMs: v.requestToResponseStartMs ?? v.streamOpenMs ?? null, responseTransferMs: v.responseTransferMs ?? null, nextHopProtocol: clip(v.nextHopProtocol, 24), timingStatus: clip(v.timingStatus, 16), serverTimingNames: Array.isArray(v.serverTimingNames) ? v.serverTimingNames.slice(0, 4).map((x) => clip(x, 32)) : [], otelTraceId: clip(v.otelTraceId, 32) }; };", "const slimSlowApi = (item) => { const v = objectOrNull(item) || {}; return { path: clip(v.path ?? v.route, 96), route: clip(v.route ?? v.path, 96), sampleCount: v.sampleCount ?? null, p95Ms: v.p95Ms ?? v.p95 ?? null, maxMs: v.maxMs ?? v.max ?? null, overFiveSecondCount: v.overFiveSecondCount ?? null, slowSamples: Array.isArray(v.slowSamples) ? v.slowSamples.slice(0, 3).map(slimSlowSample) : [] }; };", "const slimFinding = (item) => { const v = objectOrNull(item) || {}; return { kind: clip(v.kind ?? v.id ?? v.code, 48), code: clip(v.code ?? v.id ?? v.kind, 48), severity: clip(v.severity ?? v.level, 24), level: clip(v.level ?? v.severity, 24), count: v.count ?? v.sampleCount ?? null, sampleCount: v.sampleCount ?? v.count ?? null, summary: clip(v.summary ?? v.message, 180), message: clip(v.message ?? v.summary, 180) }; };", "const slimDomGroup = (item) => { const v = objectOrNull(item) || {}; return { count: v.count ?? null, firstAt: v.firstAt ?? null, lastAt: v.lastAt ?? null, text: clip(v.text ?? v.preview, 180) }; };", @@ -8507,7 +8507,7 @@ function renderWebObserveAnalyzeTable(value: Record): string { ]) : [["-", "-", "-", "-", "-"]]), "", "Slow API samples (>5s):", - webObserveTable(["TS", "SEQ", "PATH", "DURATION", "REQ_WAIT", "RESP_XFER", "INIT", "PROTO"], (() => { + webObserveTable(["TS", "SEQ", "PATH", "DURATION", "REQ_WAIT", "RESP_XFER", "TIMING", "INIT", "PROTO", "SERVER", "OTEL"], (() => { const rows = slowApis.flatMap((item) => (Array.isArray(item.slowSamples) ? item.slowSamples : []).map((sample) => ({ ...sample, path: sample.path ?? item.path ?? item.route }))).slice(0, 8).map((sample) => [ webObserveShort(webObserveText(sample.ts), 24), webObserveText(sample.seq), @@ -8515,10 +8515,13 @@ function renderWebObserveAnalyzeTable(value: Record): string { webObserveText(sample.durationMs), webObserveText(sample.requestToResponseStartMs ?? sample.streamOpenMs), webObserveText(sample.responseTransferMs), + webObserveShort(webObserveText(sample.timingStatus), 12), webObserveShort(webObserveText(sample.initiatorType), 12), webObserveShort(webObserveText(sample.nextHopProtocol), 12), + webObserveShort(Array.isArray(sample.serverTimingNames) ? sample.serverTimingNames.join(",") : "-", 24), + webObserveShort(webObserveText(sample.otelTraceId), 16), ]); - return rows.length > 0 ? rows : [["-", "-", "-", "-", "-", "-", "-", "-"]]; + return rows.length > 0 ? rows : [["-", "-", "-", "-", "-", "-", "-", "-", "-", "-", "-"]]; })()), "", "Archive page performance:", diff --git a/scripts/src/hwlab-node-web-observe-runner-source.ts b/scripts/src/hwlab-node-web-observe-runner-source.ts index 480cb0c6..84a84cab 100644 --- a/scripts/src/hwlab-node-web-observe-runner-source.ts +++ b/scripts/src/hwlab-node-web-observe-runner-source.ts @@ -1408,6 +1408,11 @@ async function sampleOnePage(targetPage, { reason, groupSeq, pageRole, targetPag decodedBodySize: Number.isFinite(Number(entry.decodedBodySize)) ? Number(entry.decodedBodySize) : null, nextHopProtocol: entry.nextHopProtocol || null, responseStatus: Number.isFinite(Number(entry.responseStatus)) ? Number(entry.responseStatus) : null, + serverTiming: Array.from(entry.serverTiming || []).slice(0, 8).map((item) => ({ + name: String(item.name || "").slice(0, 80), + duration: Number.isFinite(Number(item.duration)) ? Math.round(Number(item.duration)) : null, + description: String(item.description || "").slice(0, 120) + })), }); const url = location.href; const routeSessionMatch = url.match(/\/workbench\/sessions\/([^/?#]+)/u); @@ -2456,6 +2461,8 @@ function performanceEntryCompletedEpochMs(sample, entry) { } function compactPagePerformanceSlowSample({ sample, entry, entryTs, normalizedPath, rawPath, durationMs, streamOpenMs }) { + const timingStatus = resourceTimingPhaseStatus(entry); + const serverTiming = compactServerTiming(entry?.serverTiming); return { ts: entryTs ?? sample?.ts ?? null, sampleTs: sample?.ts ?? null, @@ -2475,10 +2482,16 @@ function compactPagePerformanceSlowSample({ sample, entry, entryTs, normalizedPa tlsStartMs: roundFinite(entry?.secureConnectionStart), requestToResponseStartMs: phaseDeltaMs(entry, "responseStart", "requestStart"), responseTransferMs: phaseDeltaMs(entry, "responseEnd", "responseStart"), + timingStatus: timingStatus.status, + invalidTimingPhases: timingStatus.invalidPhases, + partialTimingPhases: timingStatus.partialPhases, transferSize: Number.isFinite(Number(entry?.transferSize)) ? Number(entry.transferSize) : null, encodedBodySize: Number.isFinite(Number(entry?.encodedBodySize)) ? Number(entry.encodedBodySize) : null, decodedBodySize: Number.isFinite(Number(entry?.decodedBodySize)) ? Number(entry.decodedBodySize) : null, nextHopProtocol: entry?.nextHopProtocol ?? null, + serverTiming, + serverTimingNames: serverTiming.map((item) => item.name).filter(Boolean).slice(0, 8), + otelTraceId: extractOtelTraceIdFromServerTiming(serverTiming), valuesRedacted: true }; } @@ -2486,10 +2499,48 @@ function compactPagePerformanceSlowSample({ sample, entry, entryTs, normalizedPa function phaseDeltaMs(entry, endKey, startKey) { const end = Number(entry?.[endKey]); const start = Number(entry?.[startKey]); - if (!Number.isFinite(end) || !Number.isFinite(start) || end <= 0 || start < 0 || end < start) return null; + if (!Number.isFinite(end) || !Number.isFinite(start) || end <= 0 || start <= 0 || end < start) return null; return Math.round(end - start); } +function resourceTimingPhaseStatus(entry) { + const pairs = [ + ["requestToResponseStart", "requestStart", "responseStart"], + ["responseTransfer", "responseStart", "responseEnd"], + ]; + const invalidPhases = []; + const partialPhases = []; + for (const [label, startKey, endKey] of pairs) { + const start = Number(entry?.[startKey]); + const end = Number(entry?.[endKey]); + if (!Number.isFinite(start) || !Number.isFinite(end) || start <= 0 || end <= 0) { + partialPhases.push(label); + } else if (end < start) { + invalidPhases.push(label); + } + } + return { + status: invalidPhases.length > 0 ? "invalid" : (partialPhases.length > 0 ? "partial" : "complete"), + invalidPhases, + partialPhases, + }; +} + +function compactServerTiming(value) { + const items = Array.isArray(value) ? value : []; + return items.slice(0, 8).map((item) => ({ + name: truncate(String(item?.name || ""), 80), + duration: Number.isFinite(Number(item?.duration)) ? Math.round(Number(item.duration)) : null, + description: truncate(String(item?.description || ""), 120), + })).filter((item) => item.name || item.description || item.duration !== null); +} + +function extractOtelTraceIdFromServerTiming(items) { + const text = (Array.isArray(items) ? items : []).map((item) => [item.name, item.description].filter(Boolean).join(" ")).join(" "); + const match = text.match(/\b[0-9a-f]{32}\b/iu); + return match ? match[0].toLowerCase() : null; +} + function roundFinite(value) { const numeric = Number(value); return Number.isFinite(numeric) ? Math.round(numeric) : null;