From 45c834004aa0f140fa6f6fb3a12bacbd75643aec Mon Sep 17 00:00:00 2001 From: Codex Date: Tue, 7 Jul 2026 00:36:26 +0000 Subject: [PATCH] feat: add sentinel error detail deep links --- .../check-catalog.yaml | 12 +- .../monitor-web.css | 23 ++ .../monitor-web.js | 179 +++++++++++- ...de-web-observe-analyzer-findings-source.ts | 24 +- ...observe-analyzer-request-runtime-source.ts | 7 + .../hwlab-node-web-sentinel-cicd-shared.ts | 11 + scripts/src/hwlab-node-web-sentinel-cicd.ts | 3 +- .../hwlab-node-web-sentinel-error-detail.ts | 267 ++++++++++++++++++ scripts/src/hwlab-node-web-sentinel-p5.ts | 57 ++++ .../src/hwlab-node-web-sentinel-service.ts | 12 +- scripts/src/hwlab-node/web-probe-observe.ts | 10 +- .../verify-web-probe-sentinel-monitor-web.ts | 7 + 12 files changed, 595 insertions(+), 17 deletions(-) create mode 100644 scripts/src/hwlab-node-web-sentinel-error-detail.ts diff --git a/config/hwlab-web-probe-sentinel/check-catalog.yaml b/config/hwlab-web-probe-sentinel/check-catalog.yaml index 75a82ef4..ee61d384 100644 --- a/config/hwlab-web-probe-sentinel/check-catalog.yaml +++ b/config/hwlab-web-probe-sentinel/check-catalog.yaml @@ -222,8 +222,8 @@ sentinel: - code: WBC-026 id: runtime-http-errors level: warning - titleZh: 页面请求返回错误 - summaryZh: 页面加载过程中有请求返回错误状态。 + titleZh: 页面请求返回非 5xx 错误 + summaryZh: 页面加载过程中有请求返回非 5xx 错误状态。 actionZh: 查看详情后处理。 blocking: false order: 260 @@ -1003,3 +1003,11 @@ sentinel: actionZh: 修复 analyzer/report 对 commandFailures 的 compact 输出,先补齐命令证据再判断页面业务状态。 blocking: true order: 1230 + - code: WBC-124 + id: page-response-error + level: error + titleZh: 页面响应错误 + summaryZh: 运行页面浏览器网络事件捕获到 HTTP 5xx 响应。 + actionZh: 按详情中的状态码和路径追溯后端日志/OTel,先修复 5xx 响应。 + blocking: true + order: 1240 diff --git a/scripts/assets/web-probe-sentinel-monitor-web/monitor-web.css b/scripts/assets/web-probe-sentinel-monitor-web/monitor-web.css index 18d3daa2..f5fcf0f0 100644 --- a/scripts/assets/web-probe-sentinel-monitor-web/monitor-web.css +++ b/scripts/assets/web-probe-sentinel-monitor-web/monitor-web.css @@ -986,6 +986,8 @@ select { display: flex; flex: 0 0 auto; align-items: center; + flex-wrap: wrap; + justify-content: flex-end; gap: 8px; } @@ -999,6 +1001,27 @@ select { cursor: pointer; } +.copy-button { + white-space: nowrap; +} + +.copy-status { + margin-top: 8px; + color: var(--green); + font-size: 12px; + font-weight: 700; +} + +.error-id-pill { + display: inline-block; + max-width: 100%; + margin-left: 8px; + overflow-wrap: anywhere; + color: var(--muted); + font-family: var(--mono); + font-size: 12px; +} + .check-dialog-body { display: grid; grid-template-columns: minmax(300px, 0.82fr) minmax(420px, 1fr); diff --git a/scripts/assets/web-probe-sentinel-monitor-web/monitor-web.js b/scripts/assets/web-probe-sentinel-monitor-web/monitor-web.js index 11531850..46c09501 100644 --- a/scripts/assets/web-probe-sentinel-monitor-web/monitor-web.js +++ b/scripts/assets/web-probe-sentinel-monitor-web/monitor-web.js @@ -37,12 +37,14 @@ createApp({ const checkSeverityFilter = ref("alert"); const findingFilter = ref(""); const activeCheckItem = ref(null); + const detailCopyState = ref(""); const autoRefresh = ref(true); const refreshSeconds = ref(30); const lastLoadedAt = ref(""); const hoveredTrendDot = ref(null); const deepLinkRunId = ref(initialDeepLink.runId); const deepLinkFocus = ref(initialDeepLink.focus); + let pendingInitialErrorId = initialDeepLink.errorId; let pendingInitialFocus = initialDeepLink.focus; let lastAutoRefreshAt = 0; @@ -279,6 +281,13 @@ createApp({ void refreshRunCheckSummaries(runs.value); lastLoadedAt.value = new Date().toISOString(); lastAutoRefreshAt = Date.now(); + if (pendingInitialErrorId) { + const errorId = pendingInitialErrorId; + pendingInitialErrorId = ""; + await openErrorById(errorId, { initial: true }); + pendingInitialFocus = ""; + return; + } const deepLinkedRun = deepLinkRunId.value ? runs.value.find((run) => run.id === deepLinkRunId.value) : null; const keepSelected = runs.value.find((run) => run.id === selectedRunId.value); const nextRun = deepLinkedRun || keepSelected || runs.value[0] || latestRun.value; @@ -303,7 +312,7 @@ createApp({ deepLinkRunId.value = runId; const nextFocus = normalizeDeepLinkFocus(options.focus || deepLinkFocus.value || ""); deepLinkFocus.value = nextFocus; - syncMonitorDeepLink(runId, nextFocus); + syncMonitorDeepLink(runId, nextFocus, options.errorId || ""); if (!silent) selectedDetail.value = null; try { selectedDetail.value = await fetchJson(`/api/runs/${encodeURIComponent(runId)}`); @@ -407,12 +416,48 @@ createApp({ return number(run?.runDurationMinutes ?? run?.durationMinutes ?? run?.timing?.durationMinutes); } - function openCheckDetail(item) { + async function openErrorById(errorId, options = {}) { + if (!errorId) return; + const payload = await fetchJson(`/api/errors/${encodeURIComponent(errorId)}`); + const runId = payload?.run?.id || payload?.detail?.identity?.runId || ""; + if (runId) await selectRun(runId, true, { focus: "detail", errorId }); + const rows = Array.isArray(selectedDetail.value?.findings) ? selectedDetail.value.findings : []; + const row = rows.find((item) => checkErrorId(item) === errorId) || payload.finding || {}; + activeCheckItem.value = { ...row, ...(payload.finding || {}), errorId, detail: payload.detail || row.detail || null }; + deepLinkFocus.value = "detail"; + if (runId) syncMonitorDeepLink(runId, "detail", errorId); + if (options.initial === true) await nextTick(); + } + + async function openCheckDetail(item) { activeCheckItem.value = item || null; + detailCopyState.value = ""; + const errorId = checkErrorId(item); + const runId = item?.latestRunId || item?.runId || item?.run?.id || selectedRunId.value || ""; + if (runId) syncMonitorDeepLink(runId, "detail", errorId); + if (!errorId) return; + try { + const payload = await fetchJson(`/api/errors/${encodeURIComponent(errorId)}`); + activeCheckItem.value = { ...item, ...(payload.finding || {}), errorId, detail: payload.detail || item.detail || null }; + } catch (cause) { + activeCheckItem.value = { ...item, errorId, detail: { ok: false, error: String(cause?.message || cause), identity: { errorId }, valuesRedacted: true } }; + } } function closeCheckDetail() { activeCheckItem.value = null; + detailCopyState.value = ""; + if (selectedRunId.value) syncMonitorDeepLink(selectedRunId.value, deepLinkFocus.value === "detail" ? "" : deepLinkFocus.value, ""); + } + + async function copyErrorId(item) { + await copyText(checkErrorId(item)); + detailCopyState.value = "error-id"; + } + + async function copyDetailLink(item) { + await copyText(checkDeepLink(item)); + detailCopyState.value = "link"; } async function refreshHistoricalFindings() { @@ -554,6 +599,7 @@ createApp({ checkSeverityFilter, findingFilter, activeCheckItem, + detailCopyState, autoRefresh, refreshSeconds, lastLoadedAt, @@ -604,6 +650,8 @@ createApp({ selectCheckRun, openCheckDetail, closeCheckDetail, + copyErrorId, + copyDetailLink, refreshNow, triggerQuickVerify, currentHref, @@ -646,6 +694,10 @@ createApp({ checkActionText, checkDetailRows, checkEvidenceRows, + checkErrorId, + checkDeepLink, + checkTraceRows, + checkOtelRows, levelLabel, findingGroupCountLabel, timeWindowLabel, @@ -1116,6 +1168,8 @@ createApp({ role="button" :aria-label="'查看监测项详情 ' + findingCode(item) + ' ' + findingTitle(item)" :data-check-code="findingCode(item)" + :data-error-id="checkErrorId(item)" + :data-check-deep-link="checkDeepLink(item)" @click="openCheckDetail(item)" @keydown.enter.prevent="openCheckDetail(item)" @keydown.space.prevent="openCheckDetail(item)" @@ -1139,6 +1193,7 @@ createApp({ v-if="activeCheckItem" class="check-dialog-backdrop" data-check-dialog="true" + :data-error-id="checkErrorId(activeCheckItem)" @click.self="closeCheckDetail" @keydown.esc="closeCheckDetail" > @@ -1147,9 +1202,14 @@ createApp({
{{ findingCode(activeCheckItem) }}

{{ findingTitle(activeCheckItem) }}

-

{{ checkScopeText }}

+

+ {{ checkScopeText }} + {{ checkErrorId(activeCheckItem) }} +

+ + {{ levelLabel(activeCheckItem) }} · {{ findingGroupCountLabel(activeCheckItem) }}
@@ -1165,12 +1225,13 @@ createApp({
-

用户影响

+

详情

{{ rootCauseText(activeCheckItem) }}

处理建议: {{ checkActionText(activeCheckItem) }}

+

{{ detailCopyState === "link" ? "已复制深链" : "已复制错误ID" }}

-

关联证据

+

哨兵证据

{{ row.label }} @@ -1178,6 +1239,24 @@ createApp({
+
+

Trace 引用

+
+
+ {{ row.label }} + {{ row.value }} +
+
+
+
+

OTel 追溯

+
+
+ {{ row.label }} + {{ row.value }} +
+
+
@@ -1208,23 +1287,34 @@ function readBootstrap() { function readMonitorDeepLink() { const params = new URLSearchParams(window.location.search || ""); const runId = String(params.get("run") || params.get("runId") || "").trim(); + const errorId = String(params.get("error") || params.get("errorId") || "").trim(); const focus = normalizeDeepLinkFocus(params.get("focus") || params.get("panel") || window.location.hash.replace(/^#/u, "")); - return { runId, focus }; + return { runId, focus, errorId }; } -function syncMonitorDeepLink(runId, focus) { +function syncMonitorDeepLink(runId, focus, errorId = "") { if (!window.history?.replaceState) return; const nextFocus = normalizeDeepLinkFocus(focus); const url = new URL(window.location.href); - url.searchParams.set("run", runId); + if (runId) url.searchParams.set("run", runId); + else url.searchParams.delete("run"); if (nextFocus) url.searchParams.set("focus", nextFocus); else url.searchParams.delete("focus"); + const nextErrorId = String(errorId || "").trim(); + if (nextErrorId) { + url.searchParams.set("error", nextErrorId); + url.searchParams.set("panel", "detail"); + } else { + url.searchParams.delete("error"); + url.searchParams.delete("panel"); + } window.history.replaceState(null, "", url.toString()); } function normalizeDeepLinkFocus(value) { const text = String(value || "").trim().toLowerCase(); if (["memory", "memory-chart", "run-memory", "page-memory"].includes(text)) return "memory"; + if (["detail", "error", "finding"].includes(text)) return "detail"; return ""; } @@ -1259,6 +1349,24 @@ function apiUrl(path) { return `${prefix}${suffix}`; } +async function copyText(value) { + const text = String(value || ""); + if (!text) return; + if (navigator.clipboard?.writeText) { + await navigator.clipboard.writeText(text); + return; + } + const input = document.createElement("textarea"); + input.value = text; + input.setAttribute("readonly", "true"); + input.style.position = "fixed"; + input.style.left = "-1000px"; + document.body.appendChild(input); + input.select(); + document.execCommand("copy"); + input.remove(); +} + function setReady(value) { document.querySelector("#monitor-web-root")?.setAttribute("data-monitor-ready", value ? "true" : "false"); } @@ -1559,15 +1667,34 @@ function checkActionText(item) { return display.action || safeUserText(item?.checkActionZh || item?.check?.actionZh) || "查看详情后处理"; } +function checkErrorId(item) { + return safeDetailValue(item?.errorId || item?.detail?.identity?.errorId) === "-" ? "" : safeDetailValue(item?.errorId || item?.detail?.identity?.errorId); +} + +function checkDeepLink(item) { + const direct = item?.detail?.links?.canonicalUrl || item?.detail?.links?.canonicalPath || ""; + if (direct) return String(direct); + const errorId = checkErrorId(item); + if (!errorId) return window.location.href; + const url = new URL(window.location.href); + url.searchParams.set("error", errorId); + url.searchParams.set("panel", "detail"); + const runId = item?.latestRunId || item?.runId || item?.run?.id || item?.detail?.identity?.runId || ""; + if (runId) url.searchParams.set("run", runId); + return url.toString(); +} + function checkDetailRows(item) { if (!item) return [{ key: "empty", label: "状态", value: "未选择监测项" }]; + const identity = item?.detail?.identity || {}; return [ + { key: "errorId", label: "错误ID", value: checkErrorId(item) || "-" }, { key: "code", label: "编号", value: findingCode(item) }, { key: "level", label: "等级", value: levelLabel(item) }, { key: "samples", label: "样本", value: findingGroupCountLabel(item) }, - { key: "run", label: "运行记录", value: checkRunText(item) }, + { key: "run", label: "运行记录", value: safeDetailValue(identity.runId || checkRunText(item)) }, { key: "time", label: "时间", value: checkTimeText(item) }, - { key: "scenario", label: "场景", value: safeDetailValue(item?.scenarioId || item?.scenario_id) }, + { key: "scenario", label: "场景", value: safeDetailValue(identity.scenarioId || item?.scenarioId || item?.scenario_id) }, { key: "observer", label: "观察任务", value: safeDetailValue(item?.observerId || item?.observer_id) }, { key: "timing", label: "计时来源", value: checkTimingText(item) }, { key: "report", label: "报告", value: shortHash(item?.reportJsonSha256 || item?.report_json_sha256 || item?.reportSha256 || "") || "-" }, @@ -1577,8 +1704,11 @@ function checkDetailRows(item) { function checkEvidenceRows(item) { if (!item) return [{ key: "empty", label: "状态", value: "未选择监测项" }]; const evidence = item?.evidence && typeof item.evidence === "object" && !Array.isArray(item.evidence) ? item.evidence : {}; + const detailEvidence = item?.detail?.sentinelEvidence && typeof item.detail.sentinelEvidence === "object" ? item.detail.sentinelEvidence : {}; const rows = [ - { key: "summary", label: "证据摘要", value: rootCauseText(item) || safeUserText(item?.evidenceSummary || evidence.summary) }, + { key: "summary", label: "证据摘要", value: safeDetailValue(detailEvidence.evidenceSummary || item?.evidenceSummary || evidence.summary || rootCauseText(item)) }, + { key: "rootCause", label: "根因", value: safeDetailValue(detailEvidence.rootCause || item?.rootCause) }, + { key: "rootCauseStatus", label: "根因状态", value: safeDetailValue(detailEvidence.rootCauseStatus || item?.rootCauseStatus) }, { key: "sample", label: "样本序号", value: safeDetailValue(item?.sampleSeq ?? evidence.sampleSeq) }, { key: "page", label: "页面", value: safeDetailValue(item?.pageRole || evidence.pageRole) }, { key: "command", label: "命令编号", value: safeDetailValue(item?.commandId || evidence.commandId) }, @@ -1589,6 +1719,33 @@ function checkEvidenceRows(item) { return rows.length > 0 ? rows : [{ key: "none", label: "证据摘要", value: "已记录到报告详情。" }]; } +function checkTraceRows(item) { + const refs = Array.isArray(item?.detail?.traceRefs) ? item.detail.traceRefs : Array.isArray(item?.traceRefs) ? item.traceRefs : []; + if (refs.length === 0) return [{ key: "none", label: "状态", value: "未发现 trace 引用" }]; + return refs.slice(0, 8).map((ref, index) => ({ + key: `${index}-${ref.traceId || ""}`, + label: ref.type || "trace", + value: safeDetailValue(ref.traceId), + })); +} + +function checkOtelRows(item) { + const otel = item?.detail?.otel || {}; + const summaries = Array.isArray(otel.summaries) ? otel.summaries : []; + const rows = [{ key: "status", label: "状态", value: safeDetailValue(otel.status) }]; + if (summaries.length === 0) return rows; + return rows.concat(summaries.slice(0, 6).map((summary, index) => ({ + key: `otel-${index}`, + label: safeDetailValue(summary.traceId || `trace ${index + 1}`), + value: [ + summary.status || "-", + summary.spanCount !== undefined ? `spans=${summary.spanCount}` : "", + summary.errorSpanCount !== undefined ? `errors=${summary.errorSpanCount}` : "", + summary.httpStatus !== undefined ? `http=${summary.httpStatus}` : "", + ].filter(Boolean).join(" "), + }))); +} + function checkTimingText(item) { const status = item?.timingStatus || ""; const source = item?.timingSourceOfTruth || item?.expectedElapsedSource || item?.evidenceKind || ""; diff --git a/scripts/src/hwlab-node-web-observe-analyzer-findings-source.ts b/scripts/src/hwlab-node-web-observe-analyzer-findings-source.ts index f0a71e78..60c44bbd 100644 --- a/scripts/src/hwlab-node-web-observe-analyzer-findings-source.ts +++ b/scripts/src/hwlab-node-web-observe-analyzer-findings-source.ts @@ -279,7 +279,29 @@ export function nodeWebObserveAnalyzerFindingsSource(): string { samples: traceEventsPageReadIssues.requestFailed.slice(0, 20), valuesRedacted: true }); - if ((runtimeAlerts?.summary?.httpErrorCount ?? 0) > 0) findings.push({ id: "runtime-http-errors", severity: "amber", summary: "natural page requests returned HTTP error status during observation", count: runtimeAlerts.summary.httpErrorCount, groups: runtimeAlerts.networkHttpErrorsByPath.slice(0, 12) }); + const pageResponseErrorGroups = Array.isArray(runtimeAlerts?.networkPageResponseErrorsByPath) + ? runtimeAlerts.networkPageResponseErrorsByPath + : (Array.isArray(runtimeAlerts?.networkHttpErrorsByPath) ? runtimeAlerts.networkHttpErrorsByPath.filter((item) => Number(item?.status) >= 500) : []); + const nonBlockingHttpErrorGroups = Array.isArray(runtimeAlerts?.networkNonBlockingHttpErrorsByPath) + ? runtimeAlerts.networkNonBlockingHttpErrorsByPath + : (Array.isArray(runtimeAlerts?.networkHttpErrorsByPath) ? runtimeAlerts.networkHttpErrorsByPath.filter((item) => Number(item?.status) < 500) : []); + if ((runtimeAlerts?.summary?.pageResponseErrorCount ?? pageResponseErrorGroups.reduce((sum, item) => sum + Number(item?.count ?? 0), 0)) > 0) findings.push({ + id: "page-response-error", + severity: "red", + summary: "页面响应错误:浏览器在运行页面捕获到 HTTP 5xx 响应,属于阻塞错误。", + displayTitleZh: "页面响应错误", + errorTitleZh: "页面响应错误", + rootCause: "page-response-http-5xx", + rootCauseStatus: "confirmed-from-browser-network-response", + rootCauseConfidence: "high", + nextAction: "按状态码和路径追溯后端日志/OTel;先修复 5xx 响应,不要把它归类为前端渲染或采样抖动。", + blocking: true, + count: runtimeAlerts?.summary?.pageResponseErrorCount ?? pageResponseErrorGroups.reduce((sum, item) => sum + Number(item?.count ?? 0), 0), + evidenceSummary: pageResponseErrorGroups.slice(0, 4).map((item) => "HTTP " + (item.status ?? "-") + " " + (item.method || "-") + " " + (item.urlPath || "-") + " count=" + (item.count ?? "-")).join("; "), + groups: pageResponseErrorGroups.slice(0, 12), + valuesRedacted: true + }); + if ((runtimeAlerts?.summary?.nonBlockingHttpErrorCount ?? nonBlockingHttpErrorGroups.reduce((sum, item) => sum + Number(item?.count ?? 0), 0)) > 0) findings.push({ id: "runtime-http-errors", severity: "amber", summary: "natural page requests returned non-5xx HTTP error status during observation", count: runtimeAlerts?.summary?.nonBlockingHttpErrorCount ?? nonBlockingHttpErrorGroups.reduce((sum, item) => sum + Number(item?.count ?? 0), 0), groups: nonBlockingHttpErrorGroups.slice(0, 12) }); if ((runtimeAlerts?.summary?.significantRequestFailedCount ?? runtimeAlerts?.summary?.requestFailedCount ?? 0) > 0) findings.push({ id: "runtime-requestfailed", severity: "amber", summary: "browser requestfailed events were captured during observation", count: runtimeAlerts.summary.significantRequestFailedCount ?? runtimeAlerts.summary.requestFailedCount, groups: (runtimeAlerts.networkSignificantRequestFailedByPath ?? runtimeAlerts.networkRequestFailedByPath).slice(0, 12) }); if ((runtimeAlerts?.summary?.domDiagnosticSampleCount ?? 0) > 0) findings.push({ id: "runtime-dom-diagnostics", severity: "amber", summary: "diagnostic/error/warning-like text was visible in sampled DOM", count: runtimeAlerts.summary.domDiagnosticSampleCount, groupCount: runtimeAlerts.summary.domDiagnosticGroupCount ?? 0, groups: runtimeAlerts.domDiagnosticsByText.slice(0, 12), samples: runtimeAlerts.domDiagnostics.slice(0, 12) }); if ((runtimeAlerts?.summary?.executionErrorCount ?? 0) > 0) findings.push({ id: "runtime-execution-errors", severity: "red", summary: "Workbench rendered execution failure/error rows during observation", count: runtimeAlerts.summary.executionErrorCount, groups: runtimeAlerts.runtimeExecutionErrorsByCode.slice(0, 12) }); 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 62473448..efb28c48 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 @@ -440,6 +440,8 @@ function buildRuntimeAlerts(samples, control, network, consoleEvents, errors) { const httpErrors = naturalNetwork .filter((item) => item?.type === "response" && Number(item.status) >= 400) .map((item) => networkAlertEvent(item, promptTimes)); + const pageResponseErrors = httpErrors.filter((item) => Number(item.status) >= 500); + const nonBlockingHttpErrors = httpErrors.filter((item) => Number(item.status) < 500); const requestFailed = naturalNetwork .filter((item) => item?.type === "requestfailed") .map((item) => networkAlertEvent(item, promptTimes)); @@ -586,6 +588,8 @@ function buildRuntimeAlerts(samples, control, network, consoleEvents, errors) { return { summary: { httpErrorCount: httpErrors.length, + pageResponseErrorCount: pageResponseErrors.length, + nonBlockingHttpErrorCount: nonBlockingHttpErrors.length, requestFailedCount: requestFailed.length, significantRequestFailedCount: significantRequestFailed.length, workbenchSessionListReadCount, @@ -606,6 +610,7 @@ function buildRuntimeAlerts(samples, control, network, consoleEvents, errors) { significantConsoleAlertCount: significantConsoleAlerts.length, pageErrorCount: pageErrors.length, networkErrorGroupCount: groupNetworkAlerts(httpErrors).length, + pageResponseErrorGroupCount: groupNetworkAlerts(pageResponseErrors).length, requestFailedGroupCount: groupNetworkAlerts(requestFailed).length, significantRequestFailedGroupCount: groupNetworkAlerts(significantRequestFailed).length, executionErrorGroupCount: groupExecutionErrors(executionErrors).length, @@ -614,6 +619,8 @@ function buildRuntimeAlerts(samples, control, network, consoleEvents, errors) { significantConsoleAlertGroupCount: groupConsoleAlerts(significantConsoleAlerts).length }, networkHttpErrorsByPath: groupNetworkAlerts(httpErrors), + networkPageResponseErrorsByPath: groupNetworkAlerts(pageResponseErrors), + networkNonBlockingHttpErrorsByPath: groupNetworkAlerts(nonBlockingHttpErrors), networkRequestFailedByPath: groupNetworkAlerts(requestFailed), networkSignificantRequestFailedByPath: groupNetworkAlerts(significantRequestFailed), webPerformancePayloadStates, diff --git a/scripts/src/hwlab-node-web-sentinel-cicd-shared.ts b/scripts/src/hwlab-node-web-sentinel-cicd-shared.ts index 5c09b92c..04638dfe 100644 --- a/scripts/src/hwlab-node-web-sentinel-cicd-shared.ts +++ b/scripts/src/hwlab-node-web-sentinel-cicd-shared.ts @@ -115,6 +115,17 @@ export type WebProbeSentinelOptions = readonly full: boolean; readonly timeoutSeconds: number; } + | { + readonly kind: "error"; + readonly action: "get"; + readonly node: string; + readonly lane: string; + readonly sentinelId: string | null; + readonly errorId: string; + readonly raw: boolean; + readonly full: boolean; + readonly timeoutSeconds: number; + } | { readonly kind: "dashboard"; readonly action: WebProbeSentinelDashboardAction; diff --git a/scripts/src/hwlab-node-web-sentinel-cicd.ts b/scripts/src/hwlab-node-web-sentinel-cicd.ts index d51d7aa8..c88756ad 100644 --- a/scripts/src/hwlab-node-web-sentinel-cicd.ts +++ b/scripts/src/hwlab-node-web-sentinel-cicd.ts @@ -22,7 +22,7 @@ import { readWebProbeSentinelConfigRefTarget } from "./hwlab-node-web-sentinel-c import { effectiveWebProbeSentinelPublicExposure, requireSentinelIdForRegistry, resolveWebProbeSentinel } from "./hwlab-node-web-sentinel-resolver"; import type { HwlabRuntimeLaneSpec } from "./hwlab-node-lanes"; import type { RenderedCliResult } from "./output"; -import { probeSentinelRuntimeHealthEndpoint, runSentinelDashboard, runSentinelMaintenance, runSentinelReport, runSentinelValidate } from "./hwlab-node-web-sentinel-p5"; +import { probeSentinelRuntimeHealthEndpoint, runSentinelDashboard, runSentinelErrorDetail, runSentinelMaintenance, runSentinelReport, runSentinelValidate } from "./hwlab-node-web-sentinel-p5"; import { runChildCli, sentinelP5Next } from "./hwlab-node-web-sentinel-p5-observe"; import { emitWebProbeSentinelSpan, webProbeSentinelOtelSummary } from "./hwlab-node-web-sentinel-otel"; import { @@ -159,6 +159,7 @@ export function runWebProbeSentinelCommand(spec: HwlabRuntimeLaneSpec, options: if (options.kind === "maintenance") return runSentinelMaintenance(state, options); if (options.kind === "validate") return runSentinelValidate(state, options); if (options.kind === "dashboard") return runSentinelDashboard(state, options); + if (options.kind === "error") return runSentinelErrorDetail(state, options); return runSentinelReport(state, options); } diff --git a/scripts/src/hwlab-node-web-sentinel-error-detail.ts b/scripts/src/hwlab-node-web-sentinel-error-detail.ts new file mode 100644 index 00000000..795745ef --- /dev/null +++ b/scripts/src/hwlab-node-web-sentinel-error-detail.ts @@ -0,0 +1,267 @@ +// SPEC: PJ2026-01060508 Web哨兵 issue-1569 error detail deep links. +// Responsibility: Stable monitor-web error ids, bounded error detail payloads, and server-side OTel enrichment. +import { createHash } from "node:crypto"; +import type { Database } from "bun:sqlite"; +import type { WebProbeSentinelServiceConfig } from "./hwlab-node-web-sentinel-service"; + +export interface WebProbeSentinelErrorReaders { + readonly findingsForRow: (row: Record, limit: number) => readonly Record[]; + readonly runSummary: (row: Record) => Record; + readonly traceability: (row: Record) => Record; +} + +export function decorateRunFindingsWithErrorDetails(config: WebProbeSentinelServiceConfig, row: Record, findings: readonly Record[]): Record[] { + return findings.map((finding, index) => { + const detail = buildErrorDetail(config, row, finding, index, false); + return { + ...finding, + errorId: detail.identity.errorId, + detail, + valuesRedacted: true, + }; + }); +} + +export async function dashboardErrorDetail(config: WebProbeSentinelServiceConfig, db: Database, errorId: string, readers: WebProbeSentinelErrorReaders): Promise> { + if (!/^wbe_[a-f0-9]{20}$/u.test(errorId)) return { ok: false, error: "invalid-error-id", errorId, valuesRedacted: true }; + const rows = db.query("SELECT * FROM runs WHERE sentinel_id = ? AND node = ? AND lane = ? ORDER BY created_at DESC LIMIT ?") + .all(config.sentinelId, config.node, config.lane, 200) as Record[]; + for (const row of rows) { + const findings = decorateRunFindingsWithErrorDetails(config, row, readers.findingsForRow(row, 200)); + const findingIndex = findings.findIndex((item) => stringOrNull(item.errorId) === errorId); + if (findingIndex < 0) continue; + const finding = findings[findingIndex] ?? {}; + const detail = buildErrorDetail(config, row, finding, findingIndex, true); + const traceRefs = arrayRecords(detail.traceRefs); + return { + ok: true, + errorId, + run: readers.runSummary(row), + finding, + detail: { + ...detail, + otel: await buildOtelSummary(traceRefs), + }, + traceability: readers.traceability(row), + valuesRedacted: true, + }; + } + return { + ok: false, + error: "error-not-found", + errorId, + searchedRunCount: rows.length, + valuesRedacted: true, + }; +} + +function buildErrorDetail(config: WebProbeSentinelServiceConfig, row: Record, finding: Record, occurrenceIndex: number, includeEvidence: boolean): Record { + const identity = errorIdentity(config, row, finding, occurrenceIndex); + const traceRefs = extractTraceRefs(finding); + return { + identity, + sentinelEvidence: includeEvidence ? compactSentinelEvidence(finding) : compactSentinelEvidenceSummary(finding), + traceRefs, + otel: traceRefs.length === 0 ? { status: "trace-ref-missing", summaries: [], valuesRedacted: true } : { status: "detail-api-required", summaries: [], valuesRedacted: true }, + links: errorLinks(config, identity.errorId), + valuesRedacted: true, + }; +} + +function errorIdentity(config: WebProbeSentinelServiceConfig, row: Record, finding: Record, occurrenceIndex: number): Record { + const runId = stringOrNull(row.id) ?? stringOrNull(finding.runId) ?? stringOrNull(finding.latestRunId) ?? "run"; + const findingId = stringOrNull(finding.findingId) ?? stringOrNull(finding.finding_id) ?? stringOrNull(finding.id) ?? stringOrNull(finding.kind) ?? stringOrNull(finding.code) ?? "finding"; + const severity = stringOrNull(finding.severity) ?? stringOrNull(finding.level) ?? stringOrNull(finding.checkLevel) ?? "unknown"; + const sampleSeq = stringOrNull(finding.sampleSeq) ?? stringOrNull(record(finding.evidence).sampleSeq); + const commandId = stringOrNull(finding.commandId) ?? stringOrNull(record(finding.firstCommandFailure).commandId); + const groupHash = sha256(JSON.stringify({ + summary: stringOrNull(finding.summary), + evidenceSummary: stringOrNull(finding.evidenceSummary), + groups: arrayRecords(finding.groups).slice(0, 4), + peaks: arrayRecords(finding.peaks).slice(0, 4), + })).slice(0, 16); + const stableInput = { node: config.node, lane: config.lane, sentinelId: config.sentinelId, runId, findingId, severity, sampleSeq, commandId, occurrenceIndex, groupHash }; + return { + errorId: `wbe_${sha256(JSON.stringify(stableInput)).slice(0, 20)}`, + node: config.node, + lane: config.lane, + sentinelId: config.sentinelId, + runId, + scenarioId: stringOrNull(row.scenario_id) ?? stringOrNull(finding.scenarioId), + findingId, + severity, + occurrenceIndex, + sampleSeq, + commandId, + groupHash, + valuesRedacted: true, + }; +} + +function compactSentinelEvidenceSummary(finding: Record): Record { + return { + summary: stringOrNull(finding.summary), + displayTitleZh: stringOrNull(finding.displayTitleZh) ?? stringOrNull(finding.errorTitleZh), + evidenceSummary: stringOrNull(finding.evidenceSummary), + rootCause: stringOrNull(finding.rootCause), + rootCauseStatus: stringOrNull(finding.rootCauseStatus), + rootCauseConfidence: stringOrNull(finding.rootCauseConfidence), + nextAction: stringOrNull(finding.nextAction), + blocking: finding.blocking === true, + valuesRedacted: true, + }; +} + +function compactSentinelEvidence(finding: Record): Record { + return { + ...compactSentinelEvidenceSummary(finding), + firstCommandFailure: compactRecord(record(finding.firstCommandFailure), ["commandId", "type", "phase", "status", "resultOk", "failureKind", "failedReason", "message", "exitCode", "timedOut", "artifactBucket", "artifactRef", "failedAt", "completedAt", "abandonedAt"]), + rootCauseSignals: compactRootCauseSignals(finding.rootCauseSignals), + browserNetworkErrors: arrayRecords(finding.groups).slice(0, 12).map((item) => compactRecord(item, ["method", "urlPath", "status", "type", "count", "firstAt", "lastAt", "promptIndexes", "failureKinds"])), + samples: arrayRecords(finding.samples).slice(0, 8), + peaks: arrayRecords(finding.peaks).slice(0, 8), + valuesRedacted: true, + }; +} + +async function buildOtelSummary(traceRefs: readonly Record[]): Promise> { + if (traceRefs.length === 0) return { status: "trace-ref-missing", summaries: [], valuesRedacted: true }; + const summaries = []; + for (const ref of traceRefs.slice(0, 4)) { + const traceId = stringOrNull(ref.traceId); + if (traceId === null) continue; + if (ref.type !== "otel") { + summaries.push({ + traceId, + status: "business-trace-id-not-tempo-id", + note: "This id is not a 32 hex OTel trace id; use the linked CLI drill-down to map business trace ids first.", + command: `bun scripts/cli.ts platform-infra observability diagnose-code-agent --trace-id ${traceId}`, + valuesRedacted: true, + }); + continue; + } + summaries.push(await queryTempoTrace(traceId)); + } + return { + status: summaries.some((item) => item.status === "queried") ? "queried" : "not-queried", + summaries, + valuesRedacted: true, + }; +} + +async function queryTempoTrace(traceId: string): Promise> { + const baseUrl = String(process.env.UNIDESK_WEB_PROBE_SENTINEL_TEMPO_BASE_URL || process.env.UNIDESK_OBSERVABILITY_TEMPO_BASE_URL || "http://tempo.platform-infra.svc.cluster.local:3200").replace(/\/$/u, ""); + const url = `${baseUrl}/api/traces/${encodeURIComponent(traceId)}`; + try { + const response = await fetch(url, { signal: AbortSignal.timeout(2500), headers: { accept: "application/json" } }); + const payload = await response.json().catch(() => ({})); + if (!response.ok) return { traceId, status: "query-failed", httpStatus: response.status, backend: "tempo", valuesRedacted: true }; + const spans = tempoSpans(payload); + return { + traceId, + status: "queried", + httpStatus: response.status, + backend: "tempo", + spanCount: spans.length, + errorSpanCount: spans.filter((span) => span.error).length, + services: [...new Set(spans.map((span) => span.service).filter(Boolean))].slice(0, 12), + keySpans: spans.filter((span) => span.error || span.httpStatus !== null).slice(0, 8), + valuesRedacted: true, + }; + } catch (error) { + return { traceId, status: "query-failed", backend: "tempo", error: error instanceof Error ? error.message.slice(0, 180) : String(error).slice(0, 180), valuesRedacted: true }; + } +} + +function tempoSpans(payload: unknown): Record[] { + const batches = arrayRecords(record(payload).batches ?? record(record(payload).data).batches); + const spans: Record[] = []; + for (const batch of batches) { + const service = stringOrNull(record(record(batch.resource).attributes?.find?.((item: unknown) => record(item).key === "service.name")).value?.stringValue); + for (const scope of arrayRecords(batch.scopeSpans ?? batch.instrumentationLibrarySpans)) { + for (const span of arrayRecords(scope.spans)) { + const attrs = spanAttributes(span.attributes); + const httpStatus = numberOrNull(attrs["http.response.status_code"]) ?? numberOrNull(attrs["http.status_code"]); + spans.push({ + name: stringOrNull(span.name), + service, + statusCode: stringOrNull(record(span.status).code), + statusMessage: stringOrNull(record(span.status).message), + httpRoute: stringOrNull(attrs["http.route"]) ?? stringOrNull(attrs["url.path"]), + httpStatus, + error: stringOrNull(record(span.status).code) === "STATUS_CODE_ERROR" || Number(httpStatus) >= 500, + valuesRedacted: true, + }); + } + } + } + return spans; +} + +function spanAttributes(value: unknown): Record { + const output: Record = {}; + for (const item of arrayRecords(value)) { + const key = stringOrNull(item.key); + if (key === null) continue; + const raw = record(item.value); + output[key] = raw.stringValue ?? raw.intValue ?? raw.doubleValue ?? raw.boolValue ?? null; + } + return output; +} + +function extractTraceRefs(value: unknown): Record[] { + const text = JSON.stringify(value ?? {}).slice(0, 24000); + const refs = new Map>(); + for (const match of text.matchAll(/\b[0-9a-fA-F]{32}\b/gu)) refs.set(match[0].toLowerCase(), { traceId: match[0].toLowerCase(), type: "otel", source: "sentinel-finding", valuesRedacted: true }); + for (const match of text.matchAll(/\btrc_[A-Za-z0-9_-]{8,}\b/gu)) refs.set(match[0], { traceId: match[0], type: "business", source: "sentinel-finding", valuesRedacted: true }); + return Array.from(refs.values()).slice(0, 12); +} + +function errorLinks(config: WebProbeSentinelServiceConfig, errorId: string): Record { + const base = stringOrNull(config.publicExposure.publicBaseUrl) ?? stringOrNull(config.publicExposure.origin) ?? ""; + const path = `/monitor-web?error=${encodeURIComponent(errorId)}&panel=detail`; + return { + canonicalPath: path, + canonicalUrl: base ? `${base.replace(/\/$/u, "")}${path}` : path, + apiPath: `/api/errors/${encodeURIComponent(errorId)}`, + cli: `bun scripts/cli.ts web-probe sentinel error get --node ${config.node} --lane ${config.lane} --sentinel ${config.sentinelId} --error-id ${errorId}`, + valuesRedacted: true, + }; +} + +function compactRootCauseSignals(value: unknown): Record | null { + const source = record(value); + if (Object.keys(source).length === 0) return null; + return compactRecord(source, ["sessionListReadCount", "traceEventsReadCount", "webPerformanceBeaconFailureCount", "eventSourceFailureCount", "requestFailedCount", "httpErrorCount", "requestfailedTop", "httpStatusTop", "topHttpErrorPaths", "topRequestFailedPaths"]); +} + +function compactRecord(source: Record, keys: readonly string[]): Record | null { + const output: Record = {}; + for (const key of keys) { + const value = source[key]; + if (value === null || value === undefined) continue; + output[key] = Array.isArray(value) ? value.slice(0, 8) : typeof value === "string" ? value.slice(0, 500) : value; + } + return Object.keys(output).length === 0 ? null : { ...output, valuesRedacted: true }; +} + +function record(value: unknown): Record { + return value !== null && typeof value === "object" && !Array.isArray(value) ? value as Record : {}; +} + +function arrayRecords(value: unknown): Record[] { + return Array.isArray(value) ? value.map(record).filter((item) => Object.keys(item).length > 0) : []; +} + +function stringOrNull(value: unknown): string | null { + return typeof value === "string" && value.length > 0 ? value : null; +} + +function numberOrNull(value: unknown): number | null { + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : null; +} + +function sha256(value: string): string { + return createHash("sha256").update(value).digest("hex"); +} diff --git a/scripts/src/hwlab-node-web-sentinel-p5.ts b/scripts/src/hwlab-node-web-sentinel-p5.ts index cb74de50..7256175c 100644 --- a/scripts/src/hwlab-node-web-sentinel-p5.ts +++ b/scripts/src/hwlab-node-web-sentinel-p5.ts @@ -254,6 +254,15 @@ export function runSentinelReport(state: SentinelCicdState, options: Extract): RenderedCliResult { + const command = "web-probe sentinel error get"; + const response = callSentinelService(state, "GET", `/api/errors/${encodeURIComponent(options.errorId)}`, null, options.timeoutSeconds); + const body = record(response.bodyJson); + const rawPayload = Object.keys(body).length > 0 ? body : response; + if (options.full || options.raw) return rendered(response.ok && body.ok !== false, command, JSON.stringify(rawPayload, null, 2)); + return rendered(response.ok && body.ok !== false, command, renderSentinelErrorDetailResult({ command, node: state.spec.nodeId, lane: state.spec.lane, sentinelId: state.sentinelId, response, body, valuesRedacted: true })); +} + function compactSentinelReportRawPayload( state: SentinelCicdState, body: Record, @@ -580,6 +589,8 @@ function compactSentinelReportFinding(value: Record): Record): string { " report reads sentinel indexed analyze summaries/views only; it does not resample, rerun analyze, or read Workbench.", ].join("\n"); } + +function renderSentinelErrorDetailResult(result: Record): string { + const body = record(result.body); + const detail = record(body.detail); + const identity = record(detail.identity); + const evidence = record(detail.sentinelEvidence); + const otel = record(detail.otel); + const finding = record(body.finding); + const links = record(detail.links); + return [ + String(result.command), + "", + table(["NODE", "LANE", "STATUS", "ERROR_ID", "RUN", "FINDING"], [[ + result.node, + result.lane, + body.ok === false ? "blocked" : "ok", + body.errorId ?? identity.errorId ?? "-", + record(body.run).id ?? identity.runId ?? "-", + identity.findingId ?? finding.id ?? "-", + ]]), + "", + table(["LEVEL", "BLOCKING", "TITLE", "SUMMARY"], [[ + identity.severity ?? finding.severity ?? "-", + evidence.blocking === true ? "yes" : "no", + evidence.displayTitleZh ?? finding.displayTitleZh ?? "-", + short(evidence.summary ?? finding.summary ?? "-"), + ]]), + "", + table(["OTEL_STATUS", "TRACE_REFS", "API", "LINK"], [[ + otel.status ?? "-", + Array.isArray(detail.traceRefs) ? detail.traceRefs.length : 0, + links.apiPath ?? "-", + links.canonicalPath ?? "-", + ]]), + "", + "EVIDENCE", + ` rootCause=${evidence.rootCause ?? "-"} status=${evidence.rootCauseStatus ?? "-"}`, + ` evidence=${evidence.evidenceSummary ?? "-"}`, + "", + "NEXT", + ` json: bun scripts/cli.ts web-probe sentinel error get --node ${result.node} --lane ${result.lane} --sentinel ${result.sentinelId} --error-id ${body.errorId ?? identity.errorId ?? "-"} --raw`, + "", + "DISCLOSURE", + " error get reads one bounded sentinel error detail by stable errorId and performs backend-controlled OTel lookup when an OTel trace id is present.", + ].join("\n"); +} diff --git a/scripts/src/hwlab-node-web-sentinel-service.ts b/scripts/src/hwlab-node-web-sentinel-service.ts index 80aa8f64..335e0959 100644 --- a/scripts/src/hwlab-node-web-sentinel-service.ts +++ b/scripts/src/hwlab-node-web-sentinel-service.ts @@ -17,6 +17,7 @@ import { webProbeSentinelConfigPlan, type WebProbeSentinelConfigPlan } from "./h import type { HwlabRuntimeLaneSpec } from "./hwlab-node-lanes"; import { effectiveWebProbeSentinelPublicExposure, resolveWebProbeSentinel, readConfigRefTarget as readSentinelConfigRefTarget } from "./hwlab-node-web-sentinel-resolver"; import { emitWebProbeSentinelSpan, webProbeSentinelOtelSummary } from "./hwlab-node-web-sentinel-otel"; +import { dashboardErrorDetail, decorateRunFindingsWithErrorDetails } from "./hwlab-node-web-sentinel-error-detail"; const DASHBOARD_CONTRACT_VERSION = "draft-2026-06-27-p11-monitor-web-observability-dashboard"; const DASHBOARD_MAX_TEXT_BYTES = 16_000; @@ -78,6 +79,7 @@ export interface WebProbeSentinelService { overview(): Record; dashboardRuns(url: URL): Record; runDetail(runId: string): Record; + errorDetail(errorId: string): Promise>; findings(url: URL): Record; runViews(runId: string, view: string | null, url: URL): Record; maintenance(): MaintenanceState; @@ -207,6 +209,9 @@ export function createWebProbeSentinelService(options: WebProbeSentinelServiceOp runDetail(runId: string) { return dashboardRunDetail(config, db, runId); }, + errorDetail(errorId: string) { + return dashboardErrorDetail(config, db, errorId, { findingsForRow: (row, limit) => visibleFindingsForRun(config, db, row, limit, readAnalysisArtifactSummaryForRun(config, row, limit)), runSummary: (row) => dashboardRunSummary(config, db, row), traceability: (row) => runTraceability(config, row) }); + }, findings(url: URL) { return dashboardFindings(config, db, url); }, @@ -321,6 +326,11 @@ async function sentinelFetch(service: WebProbeSentinelService, request: Request) if (request.method === "GET" && pathname === "/api/overview") return jsonResponse(service.overview()); if (request.method === "GET" && pathname === "/api/runs") return jsonResponse(service.dashboardRuns(url)); if (request.method === "GET" && pathname === "/api/findings") return jsonResponse(service.findings(url)); + const errorDetailMatch = /^\/api\/errors\/([^/]+)$/u.exec(pathname); + if (request.method === "GET" && errorDetailMatch !== null) { + const result = await service.errorDetail(decodeURIComponent(errorDetailMatch[1])); + return jsonResponse(result, result.ok === false ? 404 : 200); + } const runViewsMatch = /^\/api\/runs\/([^/]+)\/views$/u.exec(pathname); if (request.method === "GET" && runViewsMatch !== null) { const view = url.searchParams.get("view"); @@ -941,7 +951,7 @@ function dashboardRunDetail(config: WebProbeSentinelServiceConfig, db: Database, if (row === null) return { ok: false, error: "run-not-found", runId, valuesRedacted: true }; const stored = readMetadata(db, `run.report.${runId}`) ?? {}; const artifactSummary = readAnalysisArtifactSummaryForRun(config, row, dashboardMaxPageSize(config)); - const findings = visibleFindingsForRun(config, db, row, dashboardMaxPageSize(config), artifactSummary); + const findings = decorateRunFindingsWithErrorDetails(config, row, visibleFindingsForRun(config, db, row, dashboardMaxPageSize(config), artifactSummary)); const views = record(stored.views); const reportJsonSha256 = stringOrNull(row.report_json_sha256) ?? stringOrNull(artifactSummary.reportJsonSha256); return { diff --git a/scripts/src/hwlab-node/web-probe-observe.ts b/scripts/src/hwlab-node/web-probe-observe.ts index a4d6b330..9d3ea37b 100644 --- a/scripts/src/hwlab-node/web-probe-observe.ts +++ b/scripts/src/hwlab-node/web-probe-observe.ts @@ -54,9 +54,10 @@ export function parseNodeWebProbeSentinelOptions(args: string[]): NodeWebProbeSe && sentinelActionRaw !== "validate" && sentinelActionRaw !== "maintenance" && sentinelActionRaw !== "dashboard" + && sentinelActionRaw !== "error" && sentinelActionRaw !== "report" ) { - throw new Error("web-probe sentinel usage: sentinel plan|status|image|control-plane|publish-current|validate|maintenance|dashboard|report --node NODE --lane vNN [--dry-run|--confirm]"); + throw new Error("web-probe sentinel usage: sentinel plan|status|image|control-plane|publish-current|validate|maintenance|dashboard|error|report --node NODE --lane vNN [--dry-run|--confirm]"); } assertKnownOptions(args, new Set([ "--node", @@ -67,6 +68,7 @@ export function parseNodeWebProbeSentinelOptions(args: string[]): NodeWebProbeSe "--view", "--run", "--run-id", + "--error-id", "--trace-id", "--sample-seq", "--sentinel", @@ -156,6 +158,12 @@ export function parseNodeWebProbeSentinelOptions(args: string[]): NodeWebProbeSe fullPage: args.includes("--full-page") && !args.includes("--no-full-page"), raw: args.includes("--raw"), }; + } else if (sentinelActionRaw === "error") { + const errorAction = args[1]; + if (errorAction !== "get") throw new Error("web-probe sentinel error usage: error get --node NODE --lane vNN --sentinel ID --error-id wbe_..."); + const errorId = requiredOption(args, "--error-id"); + if (!/^wbe_[a-f0-9]{20}$/u.test(errorId)) throw new Error(`web-probe sentinel error get --error-id must look like wbe_<20 hex>, got ${errorId}`); + sentinel = { kind: "error", action: "get", node, lane, sentinelId, errorId, raw: args.includes("--raw"), full: args.includes("--full"), timeoutSeconds }; } else { const view = parseWebProbeSentinelReportView(optionValue(args, "--view") ?? "summary"); const latest = args.includes("--latest"); diff --git a/scripts/verify-web-probe-sentinel-monitor-web.ts b/scripts/verify-web-probe-sentinel-monitor-web.ts index aef86a64..66db40ee 100644 --- a/scripts/verify-web-probe-sentinel-monitor-web.ts +++ b/scripts/verify-web-probe-sentinel-monitor-web.ts @@ -16,6 +16,13 @@ const checks: Array<{ readonly path: string; readonly contains: readonly string[ "data-memory-axis-y", "readMonitorDeepLink", "data-monitor-deep-link-focus", + "/api/errors/", + "data-error-id", + "data-check-deep-link", + "data-copy-detail-link", + "data-copy-error-id", + "data-otel-trace-summary", + "data-sentinel-evidence-summary", "rootCause", ], },