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 { 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,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user