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,6 +13,7 @@ import { httpCheckerSchemas } from "./schema";
import { validateHttpConfig } from "./validate";
const CHARSET_RE = /charset="?([^";\s]+)"?/i;
const BODY_PREVIEW_BYTES = 1024;
const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]);
const SENSITIVE_HEADERS = new Set(["authorization", "cookie"]);
@@ -23,6 +24,11 @@ export class HttpChecker implements CheckerDefinition<ResolvedHttpTarget> {
readonly type = "http";
buildDetail(observation: Record<string, unknown>): null | string {
const statusCode = observation["statusCode"];
return typeof statusCode === "number" ? `HTTP ${statusCode}` : null;
}
async execute(t: ResolvedHttpTarget, ctx: CheckerContext): Promise<CheckResult> {
const timestamp = new Date().toISOString();
const expect = t.expect;
@@ -39,39 +45,102 @@ export class HttpChecker implements CheckerDefinition<ResolvedHttpTarget> {
});
const statusCode = response.status;
const responseHeaders = Object.fromEntries(response.headers);
const responseHeaders = truncateHeaders(Object.fromEntries(response.headers));
const hasBodyRules = !!(expect?.body && expect.body.length > 0);
const bodyReadResult = await readBodyStream(
response,
hasBodyRules ? t.http.maxBodyBytes : BODY_PREVIEW_BYTES,
!hasBodyRules,
);
let bodyPreview: null | string = null;
let bodyText: null | string = null;
let bodyDecodeFailure: CheckResult["failure"] = null;
if (bodyReadResult.data.byteLength > 0) {
const decodeResult = decodeBody(bodyReadResult.data, response.headers);
if (decodeResult.ok) {
bodyText = decodeResult.text;
bodyPreview = truncateBodyPreview(decodeResult.text);
} else {
bodyDecodeFailure = decodeResult.failure;
}
}
const statusResult = checkStatus(statusCode, expect?.status ?? [200]);
if (!statusResult.matched) {
return makeResult(t, timestamp, performance.now() - start, statusResult.failure, statusCode);
return makeResult(
t,
timestamp,
performance.now() - start,
statusResult.failure,
response,
responseHeaders,
bodyPreview,
);
}
const headersResult = checkHeaders(responseHeaders, expect?.headers);
const headersResult = checkHeaders(Object.fromEntries(response.headers), expect?.headers);
if (!headersResult.matched) {
return makeResult(t, timestamp, performance.now() - start, headersResult.failure, statusCode);
return makeResult(
t,
timestamp,
performance.now() - start,
headersResult.failure,
response,
responseHeaders,
bodyPreview,
);
}
const hasBodyRules = !!(expect?.body && expect.body.length > 0);
const earlyTimeout = hasBodyRules ? checkEarlyTimeout(start, expect?.durationMs) : null;
if (earlyTimeout) {
return makeResult(t, timestamp, earlyTimeout.elapsed, earlyTimeout.failure, statusCode);
return makeResult(
t,
timestamp,
earlyTimeout.elapsed,
earlyTimeout.failure,
response,
responseHeaders,
bodyPreview,
);
}
if (!bodyReadResult.ok) {
return makeResult(
t,
timestamp,
performance.now() - start,
bodyReadResult.failure,
response,
responseHeaders,
bodyPreview,
);
}
if (bodyDecodeFailure) {
return makeResult(
t,
timestamp,
performance.now() - start,
bodyDecodeFailure,
response,
responseHeaders,
bodyPreview,
);
}
if (hasBodyRules) {
const bodyReadResult = await readBodyStream(response, t.http.maxBodyBytes);
if (!bodyReadResult.ok) {
return makeResult(t, timestamp, performance.now() - start, bodyReadResult.failure, statusCode);
}
const decodeResult = decodeBody(bodyReadResult.data, response.headers);
if (!decodeResult.ok) {
return makeResult(t, timestamp, performance.now() - start, decodeResult.failure, statusCode);
}
const bodyResult = checkContentRules(decodeResult.text, expect.body, { path: "body", phase: "body" });
const bodyResult = checkContentRules(bodyText ?? "", expect.body, { path: "body", phase: "body" });
if (!bodyResult.matched) {
return makeResult(t, timestamp, performance.now() - start, bodyResult.failure, statusCode);
return makeResult(
t,
timestamp,
performance.now() - start,
bodyResult.failure,
response,
responseHeaders,
bodyPreview,
);
}
}
@@ -82,15 +151,16 @@ export class HttpChecker implements CheckerDefinition<ResolvedHttpTarget> {
phase: "duration",
});
if (!durationResult.matched) {
return makeResult(t, timestamp, durationMs, durationResult.failure, statusCode);
return makeResult(t, timestamp, durationMs, durationResult.failure, response, responseHeaders, bodyPreview);
}
return makeResult(t, timestamp, durationMs, null, statusCode);
return makeResult(t, timestamp, durationMs, null, response, responseHeaders, bodyPreview);
} catch (error) {
const durationMs = Math.round(performance.now() - start);
const isTimeout = ctx.signal.aborted || (error instanceof DOMException && error.name === "AbortError");
return {
detail: null,
durationMs,
failure: errorFailure(
"request",
@@ -98,7 +168,7 @@ export class HttpChecker implements CheckerDefinition<ResolvedHttpTarget> {
isTimeout ? `请求超时 (${t.timeoutMs}ms)` : isError(error) ? error.message : String(error),
),
matched: false,
statusDetail: null,
observation: null,
targetId: t.id,
timestamp,
};
@@ -155,6 +225,17 @@ export class HttpChecker implements CheckerDefinition<ResolvedHttpTarget> {
}
}
function assembleChunks(chunks: Uint8Array[], totalBytes: number): Uint8Array {
const result = new Uint8Array(totalBytes);
let offset = 0;
for (const chunk of chunks) {
result.set(chunk, offset);
offset += chunk.byteLength;
}
return result;
}
function buildRedirectInit(init: RequestInit, statusCode: number, fromUrl: string, toUrl: string): RequestInit {
let newInit = { ...init };
const method = init.method?.toUpperCase();
@@ -269,13 +350,28 @@ function makeResult(
timestamp: string,
elapsed: number,
failure: CheckResult["failure"],
statusCode: number,
response: Response,
headers: Record<string, string>,
bodyPreview: null | string = null,
): CheckResult {
const contentType = response.headers.get("content-type");
const contentLengthHeader = response.headers.get("content-length");
const contentLength = contentLengthHeader ? Number(contentLengthHeader) : null;
const observation: Record<string, unknown> = {
bodyPreview,
contentLength: Number.isFinite(contentLength) ? contentLength : null,
contentType,
headers,
statusCode: response.status,
};
return {
detail: null,
durationMs: Math.round(elapsed),
failure,
matched: failure === null,
statusDetail: `HTTP ${statusCode}`,
observation,
targetId: t.id,
timestamp,
};
@@ -284,7 +380,8 @@ function makeResult(
async function readBodyStream(
response: Response,
maxBodyBytes: number,
): Promise<{ data: Uint8Array; ok: true } | { failure: CheckResult["failure"]; ok: false }> {
truncateOnLimit = false,
): Promise<{ data: Uint8Array; failure: CheckResult["failure"]; ok: false } | { data: Uint8Array; ok: true }> {
const reader = response.body?.getReader();
if (!reader) {
return { data: new Uint8Array(0), ok: true };
@@ -300,12 +397,19 @@ async function readBodyStream(
totalBytes += value.byteLength;
if (totalBytes > maxBodyBytes) {
const allowedBytes = value.byteLength - (totalBytes - maxBodyBytes);
if (truncateOnLimit && allowedBytes > 0) {
chunks.push(value.slice(0, allowedBytes));
}
try {
await reader.cancel();
} catch {
/* ignore cancel error */
}
const data = assembleChunks(chunks, Math.min(totalBytes, maxBodyBytes));
if (truncateOnLimit) return { data, ok: true };
return {
data,
failure: errorFailure("body", "body", `响应体大小超过限制 ${maxBodyBytes}`),
ok: false,
};
@@ -317,12 +421,16 @@ async function readBodyStream(
reader.releaseLock();
}
const result = new Uint8Array(totalBytes);
let offset = 0;
for (const chunk of chunks) {
result.set(chunk, offset);
offset += chunk.byteLength;
}
return { data: result, ok: true };
return { data: assembleChunks(chunks, totalBytes), ok: true };
}
function truncateBodyPreview(text: string, maxLen = 1024): string {
if (text.length <= maxLen) return text;
return text.slice(0, maxLen);
}
function truncateHeaders(headers: Record<string, string>, maxCount = 20): Record<string, string> {
const entries = Object.entries(headers);
if (entries.length <= maxCount) return headers;
return Object.fromEntries(entries.slice(0, maxCount));
}