1
0

feat: 结构化 observation 替代 statusDetail,API 层动态构造 detail

- CheckResult: statusDetail -> observation (持久化) + detail (API 动态派生)
- 存储: status_detail 列 -> observation TEXT (JSON)
- CheckerDefinition: 新增 buildDetail(observation) 方法
- 各 checker 返回结构化 observation,API 层通过 registry 调用 buildDetail
- HTTP: bodyPreview 在 status/header 失败时也提前采集
- UDP: observation 包含 durationMs,未响应归为 error failure
- CMD: 超时/输出超限时保留已收集 observation
- TCP: connectTimeMs 仅含连接建立耗时,不含 banner 等待
- 新增 buildDetail 单测和 mapCheckResult 覆盖测试
- 同步 openspec 主规范,归档 checker-observation 变更
This commit is contained in:
2026-05-19 22:49:00 +08:00
parent 22c06820fa
commit 375dd3492b
64 changed files with 915 additions and 965 deletions

View File

@@ -13,7 +13,6 @@ import { udpCheckerSchemas } from "./schema";
import { validateUdpConfig } from "./validate";
const DEFAULT_MAX_RESPONSE_BYTES = 4096;
const RESPONSE_PREVIEW_MAX = 80;
type UdpExchangeResult =
| {
@@ -35,6 +34,24 @@ export class UdpChecker implements CheckerDefinition<ResolvedUdpTarget> {
readonly schemas = udpCheckerSchemas;
readonly type = "udp";
buildDetail(observation: Record<string, unknown>): null | string {
const responded = observation["responded"];
const durationMs = observation["durationMs"];
const duration = typeof durationMs === "number" ? `${durationMs}ms` : "?ms";
if (responded !== true) {
return `no response in ${duration}`;
}
const responseSize = observation["responseSize"];
const parts: string[] = [
`responded in ${duration}, ${typeof responseSize === "number" ? responseSize : "?"} bytes`,
];
const preview = observation["responsePreview"];
if (typeof preview === "string" && preview.length > 0) {
parts.push(`response: ${preview.length > 80 ? `${preview.slice(0, 80)}` : preview}`);
}
return parts.join(", ");
}
async execute(t: ResolvedUdpTarget, ctx: CheckerContext): Promise<CheckResult> {
const timestamp = new Date().toISOString();
const start = performance.now();
@@ -47,35 +64,21 @@ export class UdpChecker implements CheckerDefinition<ResolvedUdpTarget> {
if (!exchangeResult.ok) {
const durationMs = Math.round(performance.now() - start);
if (expect?.responded === false) {
return {
durationMs,
failure: null,
matched: true,
statusDetail: exchangeResult.error,
targetId: t.id,
timestamp,
};
}
const observation: Record<string, unknown> = {
durationMs,
error: exchangeResult.error,
responded: false,
responsePreview: null,
responseSize: null,
sourceAddress: null,
sourcePort: null,
};
return {
detail: null,
durationMs,
failure: errorFailure("response", "response", exchangeResult.error),
matched: false,
statusDetail: null,
targetId: t.id,
timestamp,
};
}
const expectedResponded = expect?.responded ?? true;
const respondedResult = checkResponded(exchangeResult.responded, expectedResponded);
if (!respondedResult.matched) {
const durationMs = Math.round(performance.now() - start);
return {
durationMs,
failure: respondedResult.failure,
matched: false,
statusDetail: null,
observation,
targetId: t.id,
timestamp,
};
@@ -83,6 +86,31 @@ export class UdpChecker implements CheckerDefinition<ResolvedUdpTarget> {
if (!exchangeResult.responded) {
const durationMs = Math.round(performance.now() - start);
const expectedResponded = expect?.responded ?? true;
const noResponseMessage = "未收到 UDP 响应";
const error = expectedResponded ? noResponseMessage : null;
const observation: Record<string, unknown> = {
durationMs,
error,
responded: false,
responsePreview: null,
responseSize: null,
sourceAddress: null,
sourcePort: null,
};
if (expectedResponded) {
return {
detail: null,
durationMs,
failure: errorFailure("response", "response", noResponseMessage),
matched: false,
observation,
targetId: t.id,
timestamp,
};
}
const durationResult = checkValueMatcher(durationMs, expect?.durationMs, {
message: "durationMs mismatch",
path: "durationMs",
@@ -90,39 +118,69 @@ export class UdpChecker implements CheckerDefinition<ResolvedUdpTarget> {
});
if (!durationResult.matched) {
return {
detail: null,
durationMs,
failure: durationResult.failure,
matched: false,
statusDetail: buildNoResponseDetail(durationMs),
observation,
targetId: t.id,
timestamp,
};
}
return {
detail: null,
durationMs,
failure: null,
matched: true,
statusDetail: buildNoResponseDetail(durationMs),
observation,
targetId: t.id,
timestamp,
};
}
const durationMs = Math.round(performance.now() - start);
const responsePreview = truncateResponsePreview(encodeResponse(exchangeResult.data, t.udp.responseEncoding));
const observation: Record<string, unknown> = {
durationMs,
error: null,
responded: true,
responsePreview,
responseSize: exchangeResult.data.byteLength,
sourceAddress: exchangeResult.sourceAddress,
sourcePort: exchangeResult.sourcePort,
};
const expectedResponded = expect?.responded ?? true;
const respondedResult = checkResponded(true, expectedResponded);
if (!respondedResult.matched) {
return {
detail: null,
durationMs,
failure: respondedResult.failure,
matched: false,
observation,
targetId: t.id,
timestamp,
};
}
if (exchangeResult.flags.truncated) {
const durationMs = Math.round(performance.now() - start);
return {
detail: null,
durationMs,
failure: errorFailure("response", "response", "响应 datagram 被内核截断"),
matched: false,
statusDetail: null,
observation,
targetId: t.id,
timestamp,
};
}
if (exchangeResult.data.byteLength > t.udp.maxResponseBytes) {
const durationMs = Math.round(performance.now() - start);
observation["error"] = `响应超过 ${t.udp.maxResponseBytes} 字节限制 (${exchangeResult.data.byteLength} bytes)`;
return {
detail: null,
durationMs,
failure: errorFailure(
"response",
@@ -130,7 +188,7 @@ export class UdpChecker implements CheckerDefinition<ResolvedUdpTarget> {
`响应超过 ${t.udp.maxResponseBytes} 字节限制 (${exchangeResult.data.byteLength} bytes)`,
),
matched: false,
statusDetail: null,
observation,
targetId: t.id,
timestamp,
};
@@ -139,12 +197,12 @@ export class UdpChecker implements CheckerDefinition<ResolvedUdpTarget> {
if (expect?.responseSize) {
const sizeResult = checkResponseSize(exchangeResult.data.byteLength, expect.responseSize);
if (!sizeResult.matched) {
const durationMs = Math.round(performance.now() - start);
return {
detail: null,
durationMs,
failure: sizeResult.failure,
matched: false,
statusDetail: null,
observation,
targetId: t.id,
timestamp,
};
@@ -155,12 +213,12 @@ export class UdpChecker implements CheckerDefinition<ResolvedUdpTarget> {
const responseText = encodeResponse(exchangeResult.data, t.udp.responseEncoding);
const textResult = checkResponseText(responseText, expect.response);
if (!textResult.matched) {
const durationMs = Math.round(performance.now() - start);
return {
detail: null,
durationMs,
failure: textResult.failure,
matched: false,
statusDetail: null,
observation,
targetId: t.id,
timestamp,
};
@@ -170,12 +228,12 @@ export class UdpChecker implements CheckerDefinition<ResolvedUdpTarget> {
if (expect?.sourceHost) {
const sourceResult = checkSourceHost(exchangeResult.sourceAddress, expect.sourceHost);
if (!sourceResult.matched) {
const durationMs = Math.round(performance.now() - start);
return {
detail: null,
durationMs,
failure: sourceResult.failure,
matched: false,
statusDetail: null,
observation,
targetId: t.id,
timestamp,
};
@@ -185,19 +243,18 @@ export class UdpChecker implements CheckerDefinition<ResolvedUdpTarget> {
if (expect?.sourcePort) {
const sourceResult = checkSourcePort(exchangeResult.sourcePort, expect.sourcePort);
if (!sourceResult.matched) {
const durationMs = Math.round(performance.now() - start);
return {
detail: null,
durationMs,
failure: sourceResult.failure,
matched: false,
statusDetail: null,
observation,
targetId: t.id,
timestamp,
};
}
}
const durationMs = Math.round(performance.now() - start);
const durationResult = checkValueMatcher(durationMs, expect?.durationMs, {
message: "durationMs mismatch",
path: "durationMs",
@@ -205,40 +262,33 @@ export class UdpChecker implements CheckerDefinition<ResolvedUdpTarget> {
});
if (!durationResult.matched) {
return {
detail: null,
durationMs,
failure: durationResult.failure,
matched: false,
statusDetail: buildRespondedDetail(
exchangeResult.data.byteLength,
durationMs,
t.udp.responseEncoding,
exchangeResult.data,
),
observation,
targetId: t.id,
timestamp,
};
}
return {
detail: null,
durationMs,
failure: null,
matched: true,
statusDetail: buildRespondedDetail(
exchangeResult.data.byteLength,
durationMs,
t.udp.responseEncoding,
exchangeResult.data,
),
observation,
targetId: t.id,
timestamp,
};
} catch (error) {
const durationMs = Math.round(performance.now() - start);
return {
detail: null,
durationMs,
failure: errorFailure("response", "response", isError(error) ? error.message : String(error)),
matched: false,
statusDetail: null,
observation: null,
targetId: t.id,
timestamp,
};
@@ -287,20 +337,6 @@ export class UdpChecker implements CheckerDefinition<ResolvedUdpTarget> {
}
}
function buildNoResponseDetail(durationMs: number): string {
return `no response in ${durationMs}ms`;
}
function buildRespondedDetail(size: number, durationMs: number, encoding: string, data: Uint8Array): string {
let detail = `responded in ${durationMs}ms, ${size} bytes`;
if (size > 0 && size <= RESPONSE_PREVIEW_MAX) {
const preview = encodeResponse(data, encoding as "base64" | "hex" | "text");
const truncated = preview.length > RESPONSE_PREVIEW_MAX ? `${preview.slice(0, RESPONSE_PREVIEW_MAX)}` : preview;
detail = `${detail}, response: ${truncated}`;
}
return detail;
}
function simplifyUdpError(message: string): string {
const lower = message.toLowerCase();
if (lower.includes("econnrefused") || lower.includes("connection refused")) return "connection refused";
@@ -311,6 +347,11 @@ function simplifyUdpError(message: string): string {
return message;
}
function truncateResponsePreview(text: string, maxLen = 512): string {
if (text.length <= maxLen) return text;
return text.slice(0, maxLen);
}
async function udpExchange(
hostname: string,
port: number,