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:
@@ -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));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user