From 88fa7db59f0b8d20cbbea033216802d6add45fd3 Mon Sep 17 00:00:00 2001 From: Codex Date: Tue, 30 Jun 2026 08:28:04 +0000 Subject: [PATCH] fix: bound sentinel report evidence output --- .agents/skills/unidesk-monitor/SKILL.md | 3 + scripts/src/hwlab-node-help.ts | 3 + scripts/src/hwlab-node-web-sentinel-cicd.ts | 1 + .../src/hwlab-node-web-sentinel-p5-observe.ts | 26 ++- scripts/src/hwlab-node-web-sentinel-p5.ts | 169 +++++++++++++++++- .../src/hwlab-node-web-sentinel-service.ts | 24 +++ scripts/src/hwlab-node/web-probe-observe.ts | 3 +- 7 files changed, 226 insertions(+), 3 deletions(-) diff --git a/.agents/skills/unidesk-monitor/SKILL.md b/.agents/skills/unidesk-monitor/SKILL.md index ab0a0e64..d88b2a5d 100644 --- a/.agents/skills/unidesk-monitor/SKILL.md +++ b/.agents/skills/unidesk-monitor/SKILL.md @@ -30,6 +30,8 @@ bun scripts/cli.ts web-probe sentinel validate --node --lane --sen bun scripts/cli.ts web-probe sentinel dashboard verify --node --lane --sentinel bun scripts/cli.ts web-probe sentinel dashboard screenshot --node --lane --sentinel bun scripts/cli.ts web-probe sentinel report --node --lane --sentinel --latest --view summary +bun scripts/cli.ts web-probe sentinel report --node --lane --sentinel --latest --view summary --raw +bun scripts/cli.ts web-probe sentinel report --node --lane --sentinel --latest --view summary --full bun scripts/cli.ts web-probe sentinel control-plane trigger-current --node --lane --sentinel --confirm trans :k3s kubectl -n get cronjob -l app.kubernetes.io/component=cadence-scheduler trans :k3s kubectl -n create job --from=cronjob/ @@ -56,6 +58,7 @@ bun scripts/cli.ts web-probe observe analyze 6. Separate check type counts and sample counts: `findingCount`/`findingTypeCount` is a type count, while `severityCounts` and finding `count` are sample counts. 7. Trace-frame reports should prefer latest terminal/completed samples. If a report shows an early running/non-terminal sample, check whether the frame reports a later terminal sample and rerun with that `--sample-seq` before concluding the business turn is still running. 8. Browser memory/responsiveness/CDP red findings may include `rootCauseSignals` such as session list reads, trace event reads, web-performance beacon failures, EventSource failures and requestfailed/http TopN. Use those fields as first-line root-cause evidence for refresh storms before manually grepping JSONL artifacts. +9. `web-probe sentinel report --raw` is the bounded issue-evidence JSON view. It should include run/report SHA, compact findings, artifact summary and `rootCauseSignalFindings` when available. Use `--full` only when the complete indexed service payload is explicitly needed. ## Architecture Preference diff --git a/scripts/src/hwlab-node-help.ts b/scripts/src/hwlab-node-help.ts index 75d7985a..dc73be8a 100644 --- a/scripts/src/hwlab-node-help.ts +++ b/scripts/src/hwlab-node-help.ts @@ -66,6 +66,8 @@ export function hwlabNodeWebProbeHelp(): Record { "bun scripts/cli.ts web-probe sentinel plan --node D601 --lane v03 --sentinel workbench-auth-session-switch-2users", "bun scripts/cli.ts web-probe sentinel dashboard verify --node D601 --lane v03 --sentinel workbench-dsflash-go-tool-call-10x", "bun scripts/cli.ts web-probe sentinel dashboard screenshot --node D601 --lane v03 --sentinel workbench-auth-session-switch-2users", + "bun scripts/cli.ts web-probe sentinel report --node D601 --lane v03 --sentinel workbench-dsflash-go-tool-call-10x --latest --view summary --raw", + "bun scripts/cli.ts web-probe sentinel report --node D601 --lane v03 --sentinel workbench-dsflash-go-tool-call-10x --latest --view summary --full", "bun scripts/cli.ts web-probe sentinel maintenance stop --node D601 --lane v03 --sentinel workbench-dsflash-go-tool-call-10x --confirm --wait --release-id ", ], actions: { @@ -82,6 +84,7 @@ export function hwlabNodeWebProbeHelp(): Record { "After observe start, prefer observe status|command|stop|collect|analyze instead of repeating --node/--lane/--state-dir.", "collect views render bounded summaries from existing artifacts and do not create a second source of truth.", "analyze is offline-only: it reads artifact JSONL and writes analysis/report.md plus analysis/report.json.", + "`web-probe sentinel report --raw` returns bounded issue evidence JSON, including report/artifact SHA and root-cause signal summaries when available; use `--full` for the full indexed service payload.", "When multiple web-probe sentinels are declared, sentinel image/control-plane/validate/maintenance/dashboard/report require `--sentinel `; plan/status without it show the registry drill-down.", "Issue evidence should cite observer id, stateDir, report SHA, screenshot SHA, command ids and concise summaries, not prompt/provider/secret payloads.", ], diff --git a/scripts/src/hwlab-node-web-sentinel-cicd.ts b/scripts/src/hwlab-node-web-sentinel-cicd.ts index 3e321525..5a864b1e 100644 --- a/scripts/src/hwlab-node-web-sentinel-cicd.ts +++ b/scripts/src/hwlab-node-web-sentinel-cicd.ts @@ -97,6 +97,7 @@ export type WebProbeSentinelOptions = readonly traceId: string | null; readonly sampleSeq: number | null; readonly raw: boolean; + readonly full: boolean; readonly timeoutSeconds: number; } | { diff --git a/scripts/src/hwlab-node-web-sentinel-p5-observe.ts b/scripts/src/hwlab-node-web-sentinel-p5-observe.ts index 7e9b0fed..48d63057 100644 --- a/scripts/src/hwlab-node-web-sentinel-p5-observe.ts +++ b/scripts/src/hwlab-node-web-sentinel-p5-observe.ts @@ -709,6 +709,7 @@ function compactQuickVerifyRecordFinding(value: unknown): Record | null { + const item = record(value); + const keys = [ + "sessionListReadCount", + "traceEventsReadCount", + "webPerformanceBeaconFailureCount", + "eventSourceFailureCount", + "requestFailedCount", + "httpErrorCount", + "consoleAlertCount", + "requestfailedTop", + "httpStatusTop", + ]; + const compact: Record = {}; + for (const key of keys) { + const raw = item[key]; + if (raw === null || raw === undefined) continue; + compact[key] = Array.isArray(raw) ? raw.slice(0, 8).map(record) : raw; + } + return Object.keys(compact).length === 0 ? null : { ...compact, valuesRedacted: true }; +} + function compactQuickVerifyFindingEvidence(value: unknown): string | null { const item = record(value); if (Object.keys(item).length === 0) return null; @@ -787,11 +810,12 @@ function readAnalysisSummaryFromWorkspace(state: SentinelCicdState, stateDir: st "const read=(p)=>{try{return fs.readFileSync(p)}catch{return null}}; const jsonBuf=read(reportPath);", "const sha=(buf)=>buf?`sha256:${crypto.createHash('sha256').update(buf).digest('hex')}`:null;", "const rec=(v)=>v&&typeof v==='object'&&!Array.isArray(v)?v:{}; const arr=(v)=>Array.isArray(v)?v:[]; const clip=(v,n=180)=>v==null?null:String(v).slice(0,n);", + "const compactRootCauseSignals=(value)=>{const v=rec(value); const keys=['sessionListReadCount','traceEventsReadCount','webPerformanceBeaconFailureCount','eventSourceFailureCount','requestFailedCount','httpErrorCount','consoleAlertCount','requestfailedTop','httpStatusTop']; const out={}; for(const key of keys){if(v[key]!=null)out[key]=Array.isArray(v[key])?v[key].slice(0,8):v[key];} if(Object.keys(out).length===0)return null; out.valuesRedacted=true; return out;};", "let report=null; try{report=jsonBuf?JSON.parse(jsonBuf.toString('utf8')):null}catch{}", "let artifactCount=0; let screenshot=null;", "function walk(dir){let entries=[]; try{entries=fs.readdirSync(dir,{withFileTypes:true})}catch{return}; for(const e of entries){const p=path.join(dir,e.name); if(e.isDirectory()) walk(p); else { artifactCount++; if(/\\.png$/i.test(e.name)){const b=read(p); screenshot={path:p,sha256:sha(b),bytes:b?b.length:0}; } } }}", "walk(stateDir);", - "const findings=arr(report?.findings ?? report?.archiveSummary?.redFindings).slice(0,20).map((item)=>{const v=rec(item); return {id:clip(v.id??v.kind??v.code,80),kind:clip(v.kind??v.id??v.code,80),code:clip(v.code??v.kind??v.id,80),severity:clip(v.severity??v.level,32),level:clip(v.level??v.severity,32),count:Number(v.count??v.sampleCount??1),summary:clip(v.summary??v.message,220),message:clip(v.message??v.summary,220),rootCause:clip(v.rootCause,140),rootCauseStatus:clip(v.rootCauseStatus,90),rootCauseConfidence:clip(v.rootCauseConfidence,40),nextAction:clip(v.nextAction,240),evidenceSummary:v.evidence?clip(JSON.stringify(rec(v.evidence)),220):clip(v.evidenceSummary,220),timingSourceOfTruth:clip(v.timingSourceOfTruth??v.expectedElapsedSource??v.evidenceKind,100),timingStatus:clip(v.timingStatus,60),timingAlert:v.timingAlert===true,blocking:v.blocking===true,afterRound:v.afterRound??null,canarySessionId:clip(v.canarySessionId,80),routeSessionId:clip(v.routeSessionId,80),activeSessionId:clip(v.activeSessionId,80),consecutiveUserMessageCount:v.consecutiveUserMessageCount??null,sentinelRange:clip(v.sentinelRange,80),sampleSeq:v.sampleSeq??null,traceIds:arr(v.traceIds).slice(0,8).map((x)=>clip(x,80)),pageRole:clip(v.pageRole,32),pageId:clip(v.pageId,80),observerId:clip(v.observerId,80),stateDir:clip(v.stateDir,160),commandId:clip(v.commandId,80),valuesRedacted:true};});", + "const findings=arr(report?.findings ?? report?.archiveSummary?.redFindings).slice(0,20).map((item)=>{const v=rec(item); return {id:clip(v.id??v.kind??v.code,80),kind:clip(v.kind??v.id??v.code,80),code:clip(v.code??v.kind??v.id,80),severity:clip(v.severity??v.level,32),level:clip(v.level??v.severity,32),count:Number(v.count??v.sampleCount??1),summary:clip(v.summary??v.message,220),message:clip(v.message??v.summary,220),rootCause:clip(v.rootCause,140),rootCauseStatus:clip(v.rootCauseStatus,90),rootCauseConfidence:clip(v.rootCauseConfidence,40),nextAction:clip(v.nextAction,240),evidenceSummary:v.evidence?clip(JSON.stringify(rec(v.evidence)),220):clip(v.evidenceSummary,220),timingSourceOfTruth:clip(v.timingSourceOfTruth??v.expectedElapsedSource??v.evidenceKind,100),timingStatus:clip(v.timingStatus,60),timingAlert:v.timingAlert===true,rootCauseSignals:compactRootCauseSignals(v.rootCauseSignals),blocking:v.blocking===true,afterRound:v.afterRound??null,canarySessionId:clip(v.canarySessionId,80),routeSessionId:clip(v.routeSessionId,80),activeSessionId:clip(v.activeSessionId,80),consecutiveUserMessageCount:v.consecutiveUserMessageCount??null,sentinelRange:clip(v.sentinelRange,80),sampleSeq:v.sampleSeq??null,traceIds:arr(v.traceIds).slice(0,8).map((x)=>clip(x,80)),pageRole:clip(v.pageRole,32),pageId:clip(v.pageId,80),observerId:clip(v.observerId,80),stateDir:clip(v.stateDir,160),commandId:clip(v.commandId,80),valuesRedacted:true};});", "const slow=arr(report?.pagePerformanceSlowApi ?? report?.archivePagePerformanceSlowApi).slice(0,8).map((item)=>{const v=rec(item); return {path:clip(v.path??v.route,120),sampleCount:v.sampleCount??null,p95Ms:v.p95Ms??null,maxMs:v.maxMs??null,overFiveSecondCount:v.overFiveSecondCount??null};});", "console.log(JSON.stringify({ok:!!report,reportOk:!!report&&report.ok!==false,stateDir,reportJsonPath:reportPath,reportJsonSha256:sha(jsonBuf),reportMdPath,reportMdSha256:sha(read(reportMdPath)),findingCount:Number(report?.findingCount??findings.length),artifactCount,screenshot,findings,counts:rec(report?.counts),analysisWindow:rec(report?.analysisWindow??report?.windows?.recent?.summary),pagePerformanceSlowApi:slow,valuesRedacted:true}));", "NODE", diff --git a/scripts/src/hwlab-node-web-sentinel-p5.ts b/scripts/src/hwlab-node-web-sentinel-p5.ts index ba62bb9b..d6e8a4a5 100644 --- a/scripts/src/hwlab-node-web-sentinel-p5.ts +++ b/scripts/src/hwlab-node-web-sentinel-p5.ts @@ -208,7 +208,174 @@ export function runSentinelReport(state: SentinelCicdState, options: Extract 0 ? body : report; - return rendered(report.ok && body.ok !== false, command, options.raw ? JSON.stringify(rawPayload, null, 2) : renderedText); + if (options.full) return rendered(report.ok && body.ok !== false, command, JSON.stringify(rawPayload, null, 2)); + if (options.raw) { + const artifactSummary = readSentinelReportArtifactSummary(state, body, Math.min(options.timeoutSeconds, 55)); + return rendered(report.ok && body.ok !== false, command, JSON.stringify(compactSentinelReportRawPayload(state, body, report, artifactSummary), null, 2)); + } + return rendered(report.ok && body.ok !== false, command, renderedText); +} + +function compactSentinelReportRawPayload( + state: SentinelCicdState, + body: Record, + report: Record, + artifactSummary: Record | null, +): Record { + const run = record(body.run); + const artifact = record(artifactSummary); + const findings = Array.isArray(body.findings) ? body.findings.map(record) : []; + const artifactFindings = Array.isArray(artifact.findings) ? artifact.findings.map(record) : []; + const rootCauseSignalFindings = artifactFindings + .filter((item) => Object.keys(record(item.rootCauseSignals)).length > 0) + .slice(0, 8) + .map((item) => ({ + id: stringAtNullable(item, "id") ?? stringAtNullable(item, "kind") ?? stringAtNullable(item, "code"), + severity: stringAtNullable(item, "severity") ?? stringAtNullable(item, "level"), + summary: reportText(item.summary ?? item.message, 220), + rootCauseSignals: compactRootCauseSignals(item.rootCauseSignals), + valuesRedacted: true, + })); + return { + ok: body.ok !== false && report.ok !== false, + view: body.view ?? null, + node: state.spec.nodeId, + lane: state.spec.lane, + sentinelId: state.sentinelId, + run: { + id: run.id ?? null, + scenarioId: run.scenario_id ?? run.scenarioId ?? null, + status: run.status ?? null, + observerId: run.observer_id ?? run.observerId ?? null, + stateDir: run.state_dir ?? run.stateDir ?? null, + reportJsonSha256: run.report_json_sha256 ?? run.reportJsonSha256 ?? artifact.reportJsonSha256 ?? null, + findingCount: run.finding_count ?? run.findingCount ?? findings.length, + artifactCount: run.artifact_count ?? run.artifactCount ?? artifact.artifactCount ?? null, + updatedAt: run.updated_at ?? run.updatedAt ?? null, + valuesRedacted: true, + }, + summary: pickFields(record(body.summary), ["reason", "status", "businessStatus", "failure", "valuesRedacted"]), + findings: findings.slice(0, 12).map(compactSentinelReportFinding), + artifactSummary: Object.keys(artifact).length === 0 ? null : { + ok: artifact.ok === true, + reportOk: artifact.reportOk === true ? true : artifact.reportOk === false ? false : null, + reportJsonPath: artifact.reportJsonPath ?? null, + reportJsonSha256: artifact.reportJsonSha256 ?? null, + reportMdSha256: artifact.reportMdSha256 ?? null, + screenshot: record(artifact.screenshot), + counts: record(artifact.counts), + analysisWindow: compactSentinelAnalysisWindow(artifact.analysisWindow), + pagePerformanceSlowApi: Array.isArray(artifact.pagePerformanceSlowApi) ? artifact.pagePerformanceSlowApi.slice(0, 8).map(record) : [], + rootCauseSignalFindings, + valuesRedacted: true, + }, + next: { + text: "Default report is bounded text; use --full for the full indexed service payload.", + report: `bun scripts/cli.ts web-probe sentinel report --node ${state.spec.nodeId} --lane ${state.spec.lane}${sentinelCliSuffix(state)} --latest --view ${String(body.view ?? "summary")}`, + full: `bun scripts/cli.ts web-probe sentinel report --node ${state.spec.nodeId} --lane ${state.spec.lane}${sentinelCliSuffix(state)} --latest --view ${String(body.view ?? "summary")} --full`, + }, + valuesRedacted: true, + }; +} + +function compactSentinelReportFinding(value: Record): Record { + const result: Record = { + id: stringAtNullable(value, "finding_id") ?? stringAtNullable(value, "findingId") ?? stringAtNullable(value, "id") ?? stringAtNullable(value, "kind") ?? stringAtNullable(value, "code"), + severity: stringAtNullable(value, "severity") ?? stringAtNullable(value, "level"), + count: value.count ?? null, + summary: reportText(value.summary ?? value.message, 220), + valuesRedacted: true, + }; + const rootCause = stringAtNullable(value, "rootCause"); + if (rootCause !== null) result.rootCause = rootCause; + const rootCauseStatus = stringAtNullable(value, "rootCauseStatus"); + if (rootCauseStatus !== null) result.rootCauseStatus = rootCauseStatus; + const rootCauseConfidence = stringAtNullable(value, "rootCauseConfidence"); + if (rootCauseConfidence !== null) result.rootCauseConfidence = rootCauseConfidence; + const nextAction = reportText(value.nextAction, 240); + if (nextAction !== null) result.nextAction = nextAction; + const evidenceSummary = reportText(value.evidenceSummary, 240); + if (evidenceSummary !== null) result.evidenceSummary = evidenceSummary; + const timingSourceOfTruth = stringAtNullable(value, "timingSourceOfTruth"); + if (timingSourceOfTruth !== null) result.timingSourceOfTruth = timingSourceOfTruth; + const timingStatus = stringAtNullable(value, "timingStatus"); + if (timingStatus !== null) result.timingStatus = timingStatus; + if (value.timingAlert === true) result.timingAlert = true; + const rootCauseSignals = compactRootCauseSignals(value.rootCauseSignals); + if (rootCauseSignals !== null) result.rootCauseSignals = rootCauseSignals; + const check = record(value.check); + const checkCode = stringAtNullable(check, "code"); + const checkTitle = stringAtNullable(check, "titleZh"); + if (checkCode !== null || checkTitle !== null) { + result.check = pickFields(check, ["id", "code", "level", "titleZh", "blocking", "registered"]); + } + return result; +} + +function readSentinelReportArtifactSummary(state: SentinelCicdState, body: Record, timeoutSeconds: number): Record | null { + const run = record(body.run); + const stateDir = stringAtNullable(run, "state_dir") ?? stringAtNullable(run, "stateDir"); + if (stateDir === null || !isSafeSentinelReportStateDir(stateDir)) return null; + const script = [ + "set -eu", + `state_dir=${shellQuote(stateDir)}`, + "node - \"$state_dir\" <<'NODE'", + "const fs=require('node:fs'); const path=require('node:path'); const crypto=require('node:crypto');", + "const stateDir=process.argv[2]; const reportPath=path.join(stateDir,'analysis','report.json'); const reportMdPath=path.join(stateDir,'analysis','report.md');", + "const read=(p)=>{try{return fs.readFileSync(p)}catch{return null}}; const jsonBuf=read(reportPath);", + "const sha=(buf)=>buf?`sha256:${crypto.createHash('sha256').update(buf).digest('hex')}`:null;", + "const rec=(v)=>v&&typeof v==='object'&&!Array.isArray(v)?v:{}; const arr=(v)=>Array.isArray(v)?v:[]; const clip=(v,n=180)=>v==null?null:String(v).slice(0,n);", + "const compactRootCauseSignals=(value)=>{const v=rec(value); const keys=['sessionListReadCount','traceEventsReadCount','webPerformanceBeaconFailureCount','eventSourceFailureCount','requestFailedCount','httpErrorCount','consoleAlertCount','requestfailedTop','httpStatusTop']; const out={}; for(const key of keys){if(v[key]!=null)out[key]=Array.isArray(v[key])?v[key].slice(0,8):v[key];} if(Object.keys(out).length===0)return null; out.valuesRedacted=true; return out;};", + "let report=null; try{report=jsonBuf?JSON.parse(jsonBuf.toString('utf8')):null}catch{}", + "let artifactCount=0; let screenshot=null;", + "function walk(dir){let entries=[]; try{entries=fs.readdirSync(dir,{withFileTypes:true})}catch{return}; for(const e of entries){const p=path.join(dir,e.name); if(e.isDirectory()) walk(p); else { artifactCount++; if(/\\.png$/i.test(e.name)){const b=read(p); screenshot={path:p,sha256:sha(b),bytes:b?b.length:0}; } } }}", + "walk(stateDir);", + "const findings=arr(report?.findings ?? report?.archiveSummary?.redFindings).slice(0,20).map((item)=>{const v=rec(item); return {id:clip(v.id??v.kind??v.code,80),kind:clip(v.kind??v.id??v.code,80),code:clip(v.code??v.kind??v.id,80),severity:clip(v.severity??v.level,32),level:clip(v.level??v.severity,32),count:Number(v.count??v.sampleCount??1),summary:clip(v.summary??v.message,220),message:clip(v.message??v.summary,220),rootCause:clip(v.rootCause,140),rootCauseStatus:clip(v.rootCauseStatus,90),rootCauseConfidence:clip(v.rootCauseConfidence,40),nextAction:clip(v.nextAction,240),evidenceSummary:v.evidence?clip(JSON.stringify(rec(v.evidence)),220):clip(v.evidenceSummary,220),timingSourceOfTruth:clip(v.timingSourceOfTruth??v.expectedElapsedSource??v.evidenceKind,100),timingStatus:clip(v.timingStatus,60),timingAlert:v.timingAlert===true,rootCauseSignals:compactRootCauseSignals(v.rootCauseSignals),blocking:v.blocking===true,afterRound:v.afterRound??null,canarySessionId:clip(v.canarySessionId,80),routeSessionId:clip(v.routeSessionId,80),activeSessionId:clip(v.activeSessionId,80),consecutiveUserMessageCount:v.consecutiveUserMessageCount??null,sentinelRange:clip(v.sentinelRange,80),sampleSeq:v.sampleSeq??null,traceIds:arr(v.traceIds).slice(0,8).map((x)=>clip(x,80)),pageRole:clip(v.pageRole,32),pageId:clip(v.pageId,80),observerId:clip(v.observerId,80),stateDir:clip(v.stateDir,160),commandId:clip(v.commandId,80),valuesRedacted:true};});", + "const slow=arr(report?.pagePerformanceSlowApi ?? report?.archivePagePerformanceSlowApi).slice(0,8).map((item)=>{const v=rec(item); return {path:clip(v.path??v.route,120),sampleCount:v.sampleCount??null,p95Ms:v.p95Ms??null,maxMs:v.maxMs??null,overFiveSecondCount:v.overFiveSecondCount??null};});", + "console.log(JSON.stringify({ok:!!report,reportOk:!!report&&report.ok!==false,stateDir,reportJsonPath:reportPath,reportJsonSha256:sha(jsonBuf),reportMdPath,reportMdSha256:sha(read(reportMdPath)),findingCount:Number(report?.findingCount??findings.length),artifactCount,screenshot,findings,counts:rec(report?.counts),analysisWindow:rec(report?.analysisWindow??report?.windows?.recent?.summary),pagePerformanceSlowApi:slow,valuesRedacted:true}));", + "NODE", + ].join("\n"); + const result = runCommand(["trans", `${state.spec.nodeId}:${state.spec.workspace}`, "sh"], repoRoot, { input: script, timeoutMs: Math.max(5, Math.min(timeoutSeconds, 55)) * 1000 }); + const parsed = parseJsonObject(result.stdout); + return { ok: result.exitCode === 0 && parsed?.ok === true, ...record(parsed), result: compactCommand(result), valuesRedacted: true }; +} + +function isSafeSentinelReportStateDir(value: string): boolean { + return value.startsWith(".state/web-observe/") && !value.includes("\0") && !value.includes("..") && !value.startsWith("/"); +} + +function compactRootCauseSignals(value: unknown): Record | null { + const item = record(value); + const keys = [ + "sessionListReadCount", + "traceEventsReadCount", + "webPerformanceBeaconFailureCount", + "eventSourceFailureCount", + "requestFailedCount", + "httpErrorCount", + "consoleAlertCount", + "requestfailedTop", + "httpStatusTop", + ]; + const out: Record = {}; + for (const key of keys) { + const raw = item[key]; + if (raw === null || raw === undefined) continue; + out[key] = Array.isArray(raw) ? raw.slice(0, 8).map(record) : raw; + } + return Object.keys(out).length === 0 ? null : { ...out, valuesRedacted: true }; +} + +function compactSentinelAnalysisWindow(value: unknown): Record | null { + const item = record(value); + if (Object.keys(item).length === 0) return null; + return pickFields(item, ["name", "windowMs", "samples", "control", "network", "console", "valuesRedacted"]); +} + +function reportText(value: unknown, maxChars: number): string | null { + if (value === undefined || value === null || value === "") return null; + const raw = text(value); + return raw.length <= maxChars ? raw : `${raw.slice(0, Math.max(0, maxChars - 1))}…`; } export function runSentinelDashboard(state: SentinelCicdState, options: Extract): RenderedCliResult { diff --git a/scripts/src/hwlab-node-web-sentinel-service.ts b/scripts/src/hwlab-node-web-sentinel-service.ts index c0387369..802b7ac4 100644 --- a/scripts/src/hwlab-node-web-sentinel-service.ts +++ b/scripts/src/hwlab-node-web-sentinel-service.ts @@ -1150,6 +1150,7 @@ function enrichFindingRowWithStoredDetail(config: WebProbeSentinelServiceConfig, rootCauseConfidence: stringOrNull(detail?.rootCauseConfidence), nextAction: stringOrNull(detail?.nextAction), evidenceSummary: stringOrNull(detail?.evidenceSummary), + rootCauseSignals: record(detail?.rootCauseSignals), timingSourceOfTruth: stringOrNull(detail?.timingSourceOfTruth), timingStatus: stringOrNull(detail?.timingStatus), timingAlert: detail?.timingAlert === true, @@ -1190,6 +1191,7 @@ function compactStoredFinding(value: unknown): Record { rootCauseConfidence: stringOrNull(item.rootCauseConfidence), nextAction: stringOrNull(item.nextAction), evidenceSummary: stringOrNull(item.evidenceSummary) ?? compactFindingEvidenceSummary(item.evidence), + rootCauseSignals: compactFindingRootCauseSignals(item.rootCauseSignals), timingSourceOfTruth: stringOrNull(item.timingSourceOfTruth) ?? stringOrNull(item.expectedElapsedSource) ?? stringOrNull(item.evidenceKind), timingStatus: stringOrNull(item.timingStatus), timingAlert: item.timingAlert === true, @@ -1197,6 +1199,28 @@ function compactStoredFinding(value: unknown): Record { }; } +function compactFindingRootCauseSignals(value: unknown): Record | null { + const item = record(value); + const keys = [ + "sessionListReadCount", + "traceEventsReadCount", + "webPerformanceBeaconFailureCount", + "eventSourceFailureCount", + "requestFailedCount", + "httpErrorCount", + "consoleAlertCount", + "requestfailedTop", + "httpStatusTop", + ]; + const compact: Record = {}; + for (const key of keys) { + const raw = item[key]; + if (raw === null || raw === undefined) continue; + compact[key] = Array.isArray(raw) ? raw.slice(0, 8).map(record) : raw; + } + return Object.keys(compact).length === 0 ? null : { ...compact, valuesRedacted: true }; +} + function compactFindingEvidenceSummary(value: unknown): string | null { if (value === null || value === undefined) return null; if (typeof value === "string") return value.slice(0, 240); diff --git a/scripts/src/hwlab-node/web-probe-observe.ts b/scripts/src/hwlab-node/web-probe-observe.ts index f3d02037..27d03573 100644 --- a/scripts/src/hwlab-node/web-probe-observe.ts +++ b/scripts/src/hwlab-node/web-probe-observe.ts @@ -71,7 +71,7 @@ export function parseNodeWebProbeSentinelOptions(args: string[]): NodeWebProbeSe "--timeout-ms", "--wait-timeout-ms", "--command-timeout-seconds", - ]), new Set(["--dry-run", "--confirm", "--wait", "--quick-verify", "--raw", "--latest", "--full-page", "--no-full-page"])); + ]), new Set(["--dry-run", "--confirm", "--wait", "--quick-verify", "--raw", "--full", "--latest", "--full-page", "--no-full-page"])); const node = requiredOption(args, "--node"); assertNodeId(node); const lane = requiredOption(args, "--lane"); @@ -158,6 +158,7 @@ export function parseNodeWebProbeSentinelOptions(args: string[]): NodeWebProbeSe traceId: optionValue(args, "--trace-id") ?? null, sampleSeq, raw: args.includes("--raw"), + full: args.includes("--full"), timeoutSeconds, }; }