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,9 @@ import { checkExitCode } from "./expect";
import { commandCheckerSchemas } from "./schema";
import { validateCommandConfig } from "./validate";
const STDOUT_PREVIEW_MAX = 1024;
const STDERR_PREVIEW_MAX = 1024;
export class CommandChecker implements CheckerDefinition<ResolvedCommandTarget> {
readonly configKey = "cmd";
@@ -20,6 +23,11 @@ export class CommandChecker implements CheckerDefinition<ResolvedCommandTarget>
readonly type = "cmd";
buildDetail(observation: Record<string, unknown>): null | string {
const exitCode = observation["exitCode"];
return typeof exitCode === "number" ? `exitCode=${exitCode}` : null;
}
async execute(t: ResolvedCommandTarget, ctx: CheckerContext): Promise<CheckResult> {
const timestamp = new Date().toISOString();
const start = performance.now();
@@ -37,10 +45,11 @@ export class CommandChecker implements CheckerDefinition<ResolvedCommandTarget>
} catch (error) {
const durationMs = Math.round(performance.now() - start);
return {
detail: null,
durationMs,
failure: errorFailure("exitCode", "spawn", isError(error) ? error.message : String(error)),
matched: false,
statusDetail: null,
observation: null,
targetId: t.id,
timestamp,
};
@@ -70,10 +79,11 @@ export class CommandChecker implements CheckerDefinition<ResolvedCommandTarget>
} catch {
const durationMs = Math.round(performance.now() - start);
return {
detail: null,
durationMs,
failure: errorFailure("exitCode", "execution", "输出读取失败"),
matched: false,
statusDetail: null,
observation: null,
targetId: t.id,
timestamp,
};
@@ -83,24 +93,33 @@ export class CommandChecker implements CheckerDefinition<ResolvedCommandTarget>
const durationMs = Math.round(performance.now() - start);
const exitCode = proc.exitCode ?? 1;
const stdoutPreview = truncatePreview(outputResult.stdout, STDOUT_PREVIEW_MAX);
const stderrPreview = truncatePreview(outputResult.stderr, STDERR_PREVIEW_MAX);
const observation: Record<string, unknown> = { error: null, exitCode, stderrPreview, stdoutPreview };
if (outputResult.exceeded) {
const message = `输出超过限制 ${t.cmd.maxOutputBytes} 字节`;
observation["error"] = message;
return {
detail: null,
durationMs,
failure: errorFailure("exitCode", "output", `输出超过限制 ${t.cmd.maxOutputBytes} 字节`),
failure: errorFailure("exitCode", "output", message),
matched: false,
statusDetail: `exitCode=${exitCode}`,
observation,
targetId: t.id,
timestamp,
};
}
if (ctx.signal.aborted) {
const message = `命令执行超时 (${t.timeoutMs}ms)`;
observation["error"] = message;
return {
detail: null,
durationMs,
failure: errorFailure("exitCode", "timeout", `命令执行超时 (${t.timeoutMs}ms)`),
failure: errorFailure("exitCode", "timeout", message),
matched: false,
statusDetail: null,
observation,
targetId: t.id,
timestamp,
};
@@ -109,10 +128,11 @@ export class CommandChecker implements CheckerDefinition<ResolvedCommandTarget>
const exitCodeResult = checkExitCode(exitCode, t.expect?.exitCode ?? [0]);
if (!exitCodeResult.matched) {
return {
detail: null,
durationMs,
failure: exitCodeResult.failure,
matched: false,
statusDetail: `exitCode=${exitCode}`,
observation,
targetId: t.id,
timestamp,
};
@@ -125,10 +145,11 @@ export class CommandChecker implements CheckerDefinition<ResolvedCommandTarget>
});
if (!durationResult.matched) {
return {
detail: null,
durationMs,
failure: durationResult.failure,
matched: false,
statusDetail: `exitCode=${exitCode}`,
observation,
targetId: t.id,
timestamp,
};
@@ -138,10 +159,11 @@ export class CommandChecker implements CheckerDefinition<ResolvedCommandTarget>
const stdoutResult = checkContentRules(outputResult.stdout, t.expect.stdout, { path: "stdout", phase: "stdout" });
if (!stdoutResult.matched) {
return {
detail: null,
durationMs,
failure: stdoutResult.failure,
matched: false,
statusDetail: `exitCode=${exitCode}`,
observation,
targetId: t.id,
timestamp,
};
@@ -152,10 +174,11 @@ export class CommandChecker implements CheckerDefinition<ResolvedCommandTarget>
const stderrResult = checkContentRules(outputResult.stderr, t.expect.stderr, { path: "stderr", phase: "stderr" });
if (!stderrResult.matched) {
return {
detail: null,
durationMs,
failure: stderrResult.failure,
matched: false,
statusDetail: `exitCode=${exitCode}`,
observation,
targetId: t.id,
timestamp,
};
@@ -163,10 +186,11 @@ export class CommandChecker implements CheckerDefinition<ResolvedCommandTarget>
}
return {
detail: null,
durationMs,
failure: null,
matched: true,
statusDetail: `exitCode=${exitCode}`,
observation,
targetId: t.id,
timestamp,
};
@@ -269,3 +293,8 @@ async function readOutput(
return { exceeded, stderr: err, stdout: out };
}
function truncatePreview(text: string, maxLen: number): string {
if (text.length <= maxLen) return text;
return text.slice(0, maxLen);
}

View File

@@ -13,6 +13,7 @@ import { dbCheckerSchemas } from "./schema";
import { validateDbConfig } from "./validate";
const PROBE_QUERY = "SELECT 1";
const ROWS_PREVIEW_MAX = 5;
export class DbChecker implements CheckerDefinition<ResolvedDbTarget> {
readonly configKey = "db";
@@ -21,16 +22,27 @@ export class DbChecker implements CheckerDefinition<ResolvedDbTarget> {
readonly type = "db";
buildDetail(observation: Record<string, unknown>): null | string {
const connected = observation["connected"];
if (connected !== true) {
const error = observation["error"];
return typeof error === "string" ? `connection failed: ${error}` : "not connected";
}
const rowCount = observation["rowCount"];
if (typeof rowCount === "number") {
return `${rowCount} rows`;
}
return "connected";
}
async execute(t: ResolvedDbTarget, ctx: CheckerContext): Promise<CheckResult> {
const timestamp = new Date().toISOString();
const start = performance.now();
let db: SQL | undefined;
try {
// 创建连接SQLite 不需要 max 选项)
db = new SQL(t.db.url);
// 监听 abort signal
ctx.signal.addEventListener(
"abort",
() => {
@@ -41,24 +53,30 @@ export class DbChecker implements CheckerDefinition<ResolvedDbTarget> {
{ once: true },
);
// 连接测试Bun SQL 是 lazy 的,首次查询才真正连接)
try {
await db.unsafe(PROBE_QUERY);
} catch (error) {
const durationMs = Math.round(performance.now() - start);
const errorMsg = isError(error) ? error.message : String(error);
return {
detail: null,
durationMs,
failure: errorFailure("connect", "connect", isError(error) ? error.message : String(error)),
failure: errorFailure("connect", "connect", errorMsg),
matched: false,
statusDetail: null,
observation: { connected: false, error: errorMsg, rowCount: null, rowsPreview: null },
targetId: t.id,
timestamp,
};
}
// 无 query 时仅测试连接
if (!t.db.query) {
const durationMs = Math.round(performance.now() - start);
const observation: Record<string, unknown> = {
connected: true,
error: null,
rowCount: null,
rowsPreview: null,
};
const durationResult = checkValueMatcher(durationMs, t.expect?.durationMs, {
message: "durationMs mismatch",
path: "durationMs",
@@ -66,55 +84,60 @@ export class DbChecker implements CheckerDefinition<ResolvedDbTarget> {
});
if (!durationResult.matched) {
return {
detail: null,
durationMs,
failure: durationResult.failure,
matched: false,
statusDetail: "connected",
observation,
targetId: t.id,
timestamp,
};
}
return {
detail: null,
durationMs,
failure: null,
matched: true,
statusDetail: "connected",
observation,
targetId: t.id,
timestamp,
};
}
// 执行用户 SQL
let rows: unknown[];
try {
rows = await db.unsafe(t.db.query);
} catch (error) {
const durationMs = Math.round(performance.now() - start);
const errorMsg = isError(error) ? error.message : String(error);
return {
detail: null,
durationMs,
failure: errorFailure("query", "query", isError(error) ? error.message : String(error)),
failure: errorFailure("query", "query", errorMsg),
matched: false,
statusDetail: null,
observation: { connected: true, error: errorMsg, rowCount: null, rowsPreview: null },
targetId: t.id,
timestamp,
};
}
const durationMs = Math.round(performance.now() - start);
const rowCount = Array.isArray(rows) ? rows.length : 0;
const rowsPreview = Array.isArray(rows) ? rows.slice(0, ROWS_PREVIEW_MAX) : null;
const observation: Record<string, unknown> = { connected: true, error: null, rowCount, rowsPreview };
// 检查是否超时
if (ctx.signal.aborted) {
return {
detail: null,
durationMs,
failure: errorFailure("query", "timeout", `查询超时 (${t.timeoutMs}ms)`),
matched: false,
statusDetail: null,
observation,
targetId: t.id,
timestamp,
};
}
// duration 断言
const durationResult = checkValueMatcher(durationMs, t.expect?.durationMs, {
message: "durationMs mismatch",
path: "durationMs",
@@ -122,39 +145,40 @@ export class DbChecker implements CheckerDefinition<ResolvedDbTarget> {
});
if (!durationResult.matched) {
return {
detail: null,
durationMs,
failure: durationResult.failure,
matched: false,
statusDetail: `${Array.isArray(rows) ? rows.length : 0} rows`,
observation,
targetId: t.id,
timestamp,
};
}
// rowCount 断言
if (t.expect?.rowCount) {
const rowCountResult = checkRowCount(Array.isArray(rows) ? rows.length : 0, t.expect.rowCount);
const rowCountResult = checkRowCount(rowCount, t.expect.rowCount);
if (!rowCountResult.matched) {
return {
detail: null,
durationMs,
failure: rowCountResult.failure,
matched: false,
statusDetail: `${Array.isArray(rows) ? rows.length : 0} rows`,
observation,
targetId: t.id,
timestamp,
};
}
}
// rows 断言
if (t.expect?.rows && t.expect.rows.length > 0) {
const rowsResult = checkRows(rows, t.expect.rows);
if (!rowsResult.matched) {
return {
detail: null,
durationMs,
failure: rowsResult.failure,
matched: false,
statusDetail: `${Array.isArray(rows) ? rows.length : 0} rows`,
observation,
targetId: t.id,
timestamp,
};
@@ -162,14 +186,14 @@ export class DbChecker implements CheckerDefinition<ResolvedDbTarget> {
}
if (t.expect?.result && t.expect.result.length > 0) {
const rowCount = Array.isArray(rows) ? rows.length : 0;
const resultCheck = checkContentRules({ rowCount, rows }, t.expect.result, { path: "result", phase: "result" });
if (!resultCheck.matched) {
return {
detail: null,
durationMs,
failure: resultCheck.failure,
matched: false,
statusDetail: `${rowCount} rows`,
observation,
targetId: t.id,
timestamp,
};
@@ -177,10 +201,11 @@ export class DbChecker implements CheckerDefinition<ResolvedDbTarget> {
}
return {
detail: null,
durationMs,
failure: null,
matched: true,
statusDetail: `${Array.isArray(rows) ? rows.length : 0} rows`,
observation,
targetId: t.id,
timestamp,
};

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));
}

View File

@@ -22,6 +22,35 @@ export class IcmpChecker implements CheckerDefinition<ResolvedPingTarget> {
readonly type = "ping";
buildDetail(observation: Record<string, unknown>): null | string {
const alive = observation["alive"];
const transmitted = observation["transmitted"];
const received = observation["received"];
if (alive !== true) {
const rx = typeof received === "number" ? received : 0;
const tx = typeof transmitted === "number" ? transmitted : 0;
return `unreachable (${rx}/${tx} received)`;
}
const avg = observation["avgLatencyMs"];
const loss = observation["packetLoss"];
const avgStr = typeof avg === "number" ? formatNumber(avg) : "n/a";
const lossStr = typeof loss === "number" ? formatNumber(loss) : "0";
const rx = typeof received === "number" ? received : 0;
const tx = typeof transmitted === "number" ? transmitted : 0;
let detail = `alive, avg ${avgStr}ms, loss ${lossStr}% (${rx}/${tx})`;
if (typeof loss === "number" && loss > 0) {
const max = observation["maxLatencyMs"];
if (typeof max === "number") {
detail = `${detail}, max ${formatNumber(max)}ms`;
}
}
return detail;
}
async execute(t: ResolvedPingTarget, ctx: CheckerContext): Promise<CheckResult> {
const timestamp = new Date().toISOString();
const start = performance.now();
@@ -36,10 +65,11 @@ export class IcmpChecker implements CheckerDefinition<ResolvedPingTarget> {
} catch (error) {
const durationMs = Math.round(performance.now() - start);
return {
detail: null,
durationMs,
failure: errorFailure("ping", "spawn", `ping 命令不可用: ${isError(error) ? error.message : String(error)}`),
matched: false,
statusDetail: "ping command not found",
observation: null,
targetId: t.id,
timestamp,
};
@@ -63,10 +93,11 @@ export class IcmpChecker implements CheckerDefinition<ResolvedPingTarget> {
const durationMs = Math.round(performance.now() - start);
if (ctx.signal.aborted) {
return {
detail: null,
durationMs,
failure: errorFailure("ping", "timeout", `ping 执行超时 (${t.timeoutMs}ms)`),
matched: false,
statusDetail: null,
observation: null,
targetId: t.id,
timestamp,
};
@@ -75,21 +106,42 @@ export class IcmpChecker implements CheckerDefinition<ResolvedPingTarget> {
const stats = parsePingOutput(stdout, process.platform);
if (!stats) {
return {
detail: null,
durationMs,
failure: errorFailure("ping", "parse", "无法解析 ping 输出"),
matched: false,
statusDetail: truncateOutput(stdout),
observation: {
alive: false,
avgLatencyMs: null,
error: "parse failed",
maxLatencyMs: null,
minLatencyMs: null,
packetLoss: 100,
received: 0,
transmitted: 0,
},
targetId: t.id,
timestamp,
};
}
const result = checkStats(stats, t.expect, durationMs);
const observation: Record<string, unknown> = {
alive: stats.alive,
avgLatencyMs: stats.avgLatencyMs,
error: null,
maxLatencyMs: stats.maxLatencyMs,
minLatencyMs: stats.minLatencyMs,
packetLoss: stats.packetLoss,
received: stats.received,
transmitted: stats.transmitted,
};
return {
detail: null,
durationMs,
failure: result.failure,
matched: result.matched,
statusDetail: buildStatusDetail(stats),
observation,
targetId: t.id,
timestamp,
};
@@ -126,17 +178,6 @@ export class IcmpChecker implements CheckerDefinition<ResolvedPingTarget> {
}
}
function buildStatusDetail(stats: PingStats): string {
if (!stats.alive) return `unreachable (${stats.received}/${stats.transmitted} received)`;
const avg = stats.avgLatencyMs === null ? "n/a" : formatNumber(stats.avgLatencyMs);
const loss = formatNumber(stats.packetLoss);
let detail = `alive, avg ${avg}ms, loss ${loss}% (${stats.received}/${stats.transmitted})`;
if (stats.packetLoss > 0 && stats.maxLatencyMs !== null) {
detail = `${detail}, max ${formatNumber(stats.maxLatencyMs)}ms`;
}
return detail;
}
function checkStats(stats: PingStats, expect: PingExpectConfig | undefined, durationMs: number) {
const aliveResult = checkAlive(stats.alive, expect?.alive ?? true);
if (!aliveResult.matched) return aliveResult;
@@ -179,8 +220,3 @@ async function readStream(stream: ReadableStream<Uint8Array>): Promise<string> {
}
return text;
}
function truncateOutput(output: string, maxLen = 80): string {
if (output.length <= maxLen) return output;
return `${output.slice(0, maxLen)}`;
}

View File

@@ -5,7 +5,7 @@ import { isError } from "es-toolkit";
import type { CheckResult, RawTargetConfig } from "../../types";
import type { CheckerContext, CheckerDefinition, CheckerValidationInput, ResolveContext } from "../types";
import type { LlmCheckObservation, LlmExpectConfig, LlmTargetConfig, ResolvedLlmTarget } from "./types";
import type { LlmExpectConfig, LlmTargetConfig, ResolvedLlmTarget } from "./types";
import { errorFailure } from "../../expect/failure";
import { checkValueMatcher } from "../../expect/matcher";
@@ -14,6 +14,7 @@ import {
buildObservationFromApiCallError,
buildObservationFromGenerateText,
buildObservationFromStreamText,
toPersistedObservation,
} from "./observation";
import { createProviderModel } from "./provider";
import { llmCheckerSchemas } from "./schema";
@@ -24,6 +25,43 @@ export class LlmChecker implements CheckerDefinition<ResolvedLlmTarget> {
readonly schemas = llmCheckerSchemas;
readonly type = "llm";
buildDetail(observation: Record<string, unknown>): null | string {
const provider = observation["provider"];
const mode = observation["mode"];
const parts: string[] = [`LLM ${String(provider)} ${String(mode)}`];
const http = observation["http"] as null | Record<string, unknown> | undefined;
if (http && typeof http["status"] === "number") {
parts.push(String(http["status"]));
}
if (typeof observation["finishReason"] === "string") {
parts.push(`finish=${observation["finishReason"]}`);
}
if (typeof observation["rawFinishReason"] === "string") {
parts.push(`raw=${observation["rawFinishReason"]}`);
}
const stream = observation["stream"] as null | Record<string, unknown> | undefined;
if (stream && typeof stream["firstTokenMs"] === "number") {
parts.push(`firstToken=${stream["firstTokenMs"]}ms`);
}
if (typeof observation["outputLength"] === "number") {
parts.push(`output=${observation["outputLength"]} chars`);
}
const usage = observation["usage"] as null | Record<string, unknown> | undefined;
if (usage) {
const inputTokens = typeof usage["inputTokens"] === "number" ? usage["inputTokens"] : 0;
const outputTokens = typeof usage["outputTokens"] === "number" ? usage["outputTokens"] : 0;
parts.push(`usage=${inputTokens}/${outputTokens} tokens`);
}
return parts.join(", ");
}
async execute(t: ResolvedLlmTarget, ctx: CheckerContext): Promise<CheckResult> {
const timestamp = new Date().toISOString();
const expect = t.expect;
@@ -45,10 +83,11 @@ export class LlmChecker implements CheckerDefinition<ResolvedLlmTarget> {
if (observation.http === null) {
return {
detail: null,
durationMs,
failure: errorFailure("request", "request", error.message),
matched: false,
statusDetail: null,
observation: null,
targetId: t.id,
timestamp,
};
@@ -63,10 +102,11 @@ export class LlmChecker implements CheckerDefinition<ResolvedLlmTarget> {
const failure = expectResult.failure ?? durationResult.failure;
return {
detail: null,
durationMs,
failure,
matched: failure === null,
statusDetail: buildStatusDetail(observation),
observation: toPersistedObservation(observation),
targetId: t.id,
timestamp,
};
@@ -74,6 +114,7 @@ export class LlmChecker implements CheckerDefinition<ResolvedLlmTarget> {
const isTimeout = ctx.signal.aborted || (error instanceof DOMException && error.name === "AbortError");
return {
detail: null,
durationMs,
failure: errorFailure(
"request",
@@ -81,7 +122,7 @@ export class LlmChecker implements CheckerDefinition<ResolvedLlmTarget> {
isTimeout ? `请求超时 (${t.timeoutMs}ms)` : isError(error) ? error.message : String(error),
),
matched: false,
statusDetail: null,
observation: null,
targetId: t.id,
timestamp,
};
@@ -222,10 +263,11 @@ export class LlmChecker implements CheckerDefinition<ResolvedLlmTarget> {
const failure = expectResult.failure ?? durationResult.failure;
return {
detail: null,
durationMs,
failure,
matched: failure === null,
statusDetail: buildStatusDetail(observation),
observation: toPersistedObservation(observation),
targetId: t.id,
timestamp,
};
@@ -268,10 +310,11 @@ export class LlmChecker implements CheckerDefinition<ResolvedLlmTarget> {
const failure = expectResult.failure ?? durationResult.failure;
return {
detail: null,
durationMs,
failure,
matched: failure === null,
statusDetail: buildStatusDetail(observation),
observation: toPersistedObservation(observation),
targetId: t.id,
timestamp,
};
@@ -291,33 +334,3 @@ function buildSdkOptions(config: ResolvedLlmTarget["llm"]): Record<string, unkno
if (opts.seed !== undefined) options["seed"] = opts.seed;
return options;
}
function buildStatusDetail(observation: LlmCheckObservation): string {
const parts: string[] = [`LLM ${observation.provider} ${observation.mode}`];
if (observation.http) {
parts.push(String(observation.http.status));
}
if (observation.finishReason) {
parts.push(`finish=${observation.finishReason}`);
}
if (observation.rawFinishReason) {
parts.push(`raw=${observation.rawFinishReason}`);
}
if (observation.stream?.firstTokenMs != null) {
parts.push(`firstToken=${observation.stream.firstTokenMs}ms`);
}
if (observation.outputText !== null) {
parts.push(`output=${observation.outputText.length} chars`);
}
if (observation.usage) {
parts.push(`usage=${observation.usage.inputTokens}/${observation.usage.outputTokens} tokens`);
}
return parts.join(", ");
}

View File

@@ -4,11 +4,15 @@ import type {
LlmCheckObservation,
LlmHttpMetadata,
LlmMode,
LlmPersistedHttpMetadata,
LlmPersistedObservation,
LlmProvider,
LlmStreamObservation,
LlmUsageObservation,
} from "./types";
import { LLM_HEADERS_MAX, LLM_OUTPUT_PREVIEW_MAX } from "./types";
export function buildObservationFromApiCallError(
error: APICallError,
provider: LlmProvider,
@@ -129,3 +133,42 @@ export async function buildObservationFromStreamText(
warnings,
};
}
export function toPersistedObservation(obs: LlmCheckObservation): LlmPersistedObservation {
const outputText = obs.outputText;
const outputPreview =
outputText !== null
? outputText.length <= LLM_OUTPUT_PREVIEW_MAX
? outputText
: outputText.slice(0, LLM_OUTPUT_PREVIEW_MAX)
: null;
const outputLength = outputText !== null ? outputText.length : null;
const http: LlmPersistedHttpMetadata | null = obs.http
? {
headers: truncateHeaders(obs.http.headers, LLM_HEADERS_MAX),
status: obs.http.status,
statusText: obs.http.statusText,
}
: null;
return {
finishReason: obs.finishReason,
http,
mode: obs.mode,
model: obs.model,
outputLength,
outputPreview,
provider: obs.provider,
rawFinishReason: obs.rawFinishReason,
stream: obs.stream,
usage: obs.usage,
warnings: obs.warnings,
};
}
function truncateHeaders(headers: Record<string, string>, maxCount: number): Record<string, string> {
const entries = Object.entries(headers);
if (entries.length <= maxCount) return headers;
return Object.fromEntries(entries.slice(0, maxCount));
}

View File

@@ -15,6 +15,7 @@ export interface LlmCheckObservation {
usage: LlmUsageObservation | null;
warnings: string[];
}
export interface LlmDefaultsConfig {
headers?: Record<string, string>;
ignoreSSL?: boolean;
@@ -33,13 +34,37 @@ export interface LlmExpectConfig {
stream?: LlmStreamExpect;
usage?: LlmUsageExpect;
}
export interface LlmHttpMetadata {
headers: Record<string, string>;
status: number;
statusText: string;
}
export interface LlmPersistedHttpMetadata {
[key: string]: unknown;
headers: Record<string, string>;
status: number;
statusText: string;
}
export interface LlmPersistedObservation {
[key: string]: unknown;
finishReason: null | string;
http: LlmPersistedHttpMetadata | null;
mode: LlmMode;
model: string;
outputLength: null | number;
outputPreview: null | string;
provider: LlmProvider;
rawFinishReason: null | string;
stream: LlmStreamObservation | null;
usage: LlmUsageObservation | null;
warnings: string[];
}
export const LLM_HEADERS_MAX = 20;
export const LLM_OUTPUT_PREVIEW_MAX = 512;
export type LlmMode = "http" | "stream";
export interface LlmOptions {

View File

@@ -15,7 +15,7 @@ const DEFAULT_BANNER_READ_TIMEOUT = 2000;
const DEFAULT_MAX_BANNER_BYTES = 4096;
type ConnectAndBannerResult =
| { banner?: string; bannerExceeded?: boolean; ok: true; socket: { close(): void } }
| { banner?: string; bannerExceeded?: boolean; connectTimeMs: number; ok: true; socket: { close(): void } }
| { error: string; ok: false };
export class TcpChecker implements CheckerDefinition<ResolvedTcpTarget> {
@@ -25,6 +25,21 @@ export class TcpChecker implements CheckerDefinition<ResolvedTcpTarget> {
readonly type = "tcp";
buildDetail(observation: Record<string, unknown>): null | string {
const connected = observation["connected"];
if (connected !== true) {
const error = observation["error"];
return typeof error === "string" ? `connection failed: ${error}` : "not connected";
}
const connectTimeMs = observation["connectTimeMs"];
const banner = observation["banner"];
const parts: string[] = [`connected in ${typeof connectTimeMs === "number" ? connectTimeMs : "?"}ms`];
if (typeof banner === "string" && banner.length > 0) {
parts.push(`banner: ${truncateBanner(banner)}`);
}
return parts.join(", ");
}
async execute(t: ResolvedTcpTarget, ctx: CheckerContext): Promise<CheckResult> {
const timestamp = new Date().toISOString();
const start = performance.now();
@@ -42,36 +57,46 @@ export class TcpChecker implements CheckerDefinition<ResolvedTcpTarget> {
if (!connectResult.ok) {
const durationMs = Math.round(performance.now() - start);
const observation: Record<string, unknown> = {
banner: null,
connected: false,
connectTimeMs: null,
error: connectResult.error,
};
if (expect?.connected === false) {
return {
detail: null,
durationMs,
failure: null,
matched: true,
statusDetail: connectResult.error,
observation,
targetId: t.id,
timestamp,
};
}
return {
detail: null,
durationMs,
failure: errorFailure("connect", "connect", connectResult.error),
matched: false,
statusDetail: null,
observation,
targetId: t.id,
timestamp,
};
}
const socket = connectResult.socket;
const connectTimeMs = connectResult.connectTimeMs;
if (ctx.signal.aborted) {
closeSocket(socket);
const durationMs = Math.round(performance.now() - start);
return {
detail: null,
durationMs,
failure: errorFailure("connect", "connect", `连接超时 (${t.timeoutMs}ms)`),
matched: false,
statusDetail: null,
observation: null,
targetId: t.id,
timestamp,
};
@@ -83,10 +108,11 @@ export class TcpChecker implements CheckerDefinition<ResolvedTcpTarget> {
closeSocket(socket);
const durationMs = Math.round(performance.now() - start);
return {
detail: null,
durationMs,
failure: connectedResult.failure,
matched: false,
statusDetail: "connected",
observation: { banner: null, connected: true, connectTimeMs, error: null },
targetId: t.id,
timestamp,
};
@@ -96,10 +122,11 @@ export class TcpChecker implements CheckerDefinition<ResolvedTcpTarget> {
closeSocket(socket);
const durationMs = Math.round(performance.now() - start);
return {
detail: null,
durationMs,
failure: errorFailure("banner", "banner", `banner 数据超过 ${t.tcp.maxBannerBytes} 字节限制`),
matched: false,
statusDetail: null,
observation: { banner: null, connected: true, connectTimeMs, error: null },
targetId: t.id,
timestamp,
};
@@ -108,15 +135,23 @@ export class TcpChecker implements CheckerDefinition<ResolvedTcpTarget> {
const banner = connectResult.banner ?? "";
closeSocket(socket);
const observation: Record<string, unknown> = {
banner: banner ? truncateBannerForObservation(banner) : null,
connected: true,
connectTimeMs,
error: null,
};
if (expect?.banner) {
const bannerCheck = checkBanner(banner, expect.banner);
if (!bannerCheck.matched) {
const durationMs = Math.round(performance.now() - start);
return {
detail: null,
durationMs,
failure: bannerCheck.failure,
matched: false,
statusDetail: banner ? truncateBanner(banner) : null,
observation,
targetId: t.id,
timestamp,
};
@@ -131,26 +166,29 @@ export class TcpChecker implements CheckerDefinition<ResolvedTcpTarget> {
});
if (!durationResult.matched) {
return {
detail: null,
durationMs,
failure: durationResult.failure,
matched: false,
statusDetail: buildStatusDetail(banner, durationMs),
observation,
targetId: t.id,
timestamp,
};
}
return {
detail: null,
durationMs,
failure: null,
matched: true,
statusDetail: buildStatusDetail(banner, durationMs),
observation,
targetId: t.id,
timestamp,
};
} catch (error) {
const durationMs = Math.round(performance.now() - start);
return {
detail: null,
durationMs,
failure: errorFailure(
"connect",
@@ -158,7 +196,7 @@ export class TcpChecker implements CheckerDefinition<ResolvedTcpTarget> {
ctx.signal.aborted ? `连接超时 (${t.timeoutMs}ms)` : isError(error) ? error.message : String(error),
),
matched: false,
statusDetail: null,
observation: null,
targetId: t.id,
timestamp,
};
@@ -221,12 +259,6 @@ function assembleChunks(chunks: Uint8Array[], totalBytes: number): Uint8Array {
return result;
}
function buildStatusDetail(banner: string, durationMs: number): string {
const base = `connected in ${durationMs}ms`;
if (!banner) return base;
return `${base}, banner: ${truncateBanner(banner)}`;
}
function closeSocket(socket: { close(): void }) {
try {
socket.close();
@@ -292,11 +324,13 @@ async function connectAndMaybeReadBanner(
};
try {
const connectStart = performance.now();
const socket = await Bun.connect({
hostname,
port,
socket: socketHandlers,
});
const connectTimeMs = Math.round(performance.now() - connectStart);
if (signal.aborted) {
closeSocket(socket);
@@ -304,7 +338,7 @@ async function connectAndMaybeReadBanner(
}
if (!readBanner) {
return { bannerExceeded: false, ok: true, socket };
return { bannerExceeded: false, connectTimeMs, ok: true, socket };
}
const timer = setTimeout(() => {
@@ -332,11 +366,11 @@ async function connectAndMaybeReadBanner(
signal.removeEventListener("abort", onAbort);
if (bannerExceeded) {
return { bannerExceeded: true, ok: true, socket };
return { bannerExceeded: true, connectTimeMs, ok: true, socket };
}
const banner = new TextDecoder().decode(assembleChunks(chunks, totalBytes));
return { banner, bannerExceeded: false, ok: true, socket };
return { banner, bannerExceeded: false, connectTimeMs, ok: true, socket };
} catch (error) {
if (signal.aborted) {
return { error: "连接超时", ok: false };
@@ -360,3 +394,8 @@ function truncateBanner(banner: string, maxLen = 80): string {
if (banner.length <= maxLen) return banner;
return `${banner.slice(0, maxLen)}`;
}
function truncateBannerForObservation(banner: string, maxLen = 256): string {
if (banner.length <= maxLen) return banner;
return banner.slice(0, maxLen);
}

View File

@@ -10,6 +10,7 @@ export interface CheckerContext {
}
export interface CheckerDefinition<TResolved extends ResolvedTargetBase = ResolvedTargetBase> {
buildDetail(observation: Record<string, unknown>): null | string;
readonly configKey: string;
execute(target: TResolved, ctx: CheckerContext): Promise<CheckResult>;
resolve(target: RawTargetConfig, context: ResolveContext): TResolved;

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,