feat: 聚合可观测性收口检查

This commit is contained in:
Codex
2026-07-16 13:00:23 +02:00
parent 8082b2436a
commit 4f9a17d41b
12 changed files with 227 additions and 5 deletions
+3
View File
@@ -11,12 +11,15 @@ Skill(cli-spec)。用于 UniDesk OpenTelemetry/Tempo 链路追踪运维、按 tr
```bash
bun scripts/cli.ts platform-infra observability status
bun scripts/cli.ts platform-infra observability closeout --target <NODE> [--query <PromQL>]... [--trace-id <traceId>] [--service <service>]
bun scripts/cli.ts platform-infra observability trace <traceId>
bun scripts/cli.ts platform-infra observability search --grep <service> --limit 20
```
基本状态、trace 查询、噪声压制、读取窗口与乱序调查见 [references/traces.md](references/traces.md);业务 trace 映射、Code Agent/AgentRun 排障、codex-stdio 追穿和何时先改进 OTel 见 [references/code-agent.md](references/code-agent.md)。
同类运行面复核优先用 `closeout` 一次汇总 status、validate 及显式选择的 PromQL/trace;子检查失败固定为 non-blocking warning,单项细节再走原命令下钻。
## P0 边界
- 微服务契约版本、源码/镜像版本和 `commitId` 漂移只能记录为非阻塞 warning;不得建议或实现线上 admission、dispatch、readiness 或用户业务门禁。业务已成功时,漂移告警不能改写成功结论。
+1
View File
@@ -105,6 +105,7 @@ metricsBackend:
timeoutSeconds: 15
maxSeries: 200
maxResponseBytes: 262144
maxCloseoutQueries: 8
sampling:
mode: parentbased_traceidratio
@@ -0,0 +1,25 @@
# R5 任务报告
## 目标
完成 [UniDesk #2322](https://github.com/pikasTech/unidesk/issues/2322),提供 YAML/参数驱动的 `platform-infra observability closeout` 只读聚合入口,减少同类运行面复核的重复 CLI 调用。
## 实现
- 固定聚合现有 `status``validate`,不复制 Kubernetes、Tempo 或 Prometheus 访问实现。
- 支持可重复 `--query <PromQL>`、可选 `--trace-id <id>``--service <text>`
- 所有子操作并发执行;聚合结果固定 `mutation=false``blocking=false`,可选证据失败只输出 non-blocking warning。
- 默认输出有界表格,`--full`/`--raw` 返回脱敏结构;命令与参数未写死产品、namespace 或 instance。
- query fan-out 上限由 `config/platform-infra/observability.yaml#metricsBackend.query.maxCloseoutQueries` 控制,并在远端访问前校验。
- 同步更新 `unidesk-otel` skill 与 observability 长期参考。
## 验证
- `bun test scripts/src/platform-infra-observability.test.ts`10 pass0 fail。
- `bun scripts/cli.ts platform-infra observability closeout --help`scoped help 正常,无 PikaOA 产品硬编码。
- NC01 真实只读 smoke 以一次 CLI 调用汇总 status、validate、两项 readiness PromQL 与已有 trace5 项检查全部 `ok=true`,输出 `mutation=false blocking=false`
- `git diff --check` 通过。
## 边界
未修改公共运行面、Prometheus/Tempo/Collector、scrape/relabel、CI/CD 或业务 readiness 门禁;PR 仅创建,不合并。
@@ -110,3 +110,7 @@
##### R4.4.2.10 [in_progress]
执行 [HWLAB #2538](https://github.com/pikasTech/HWLAB/issues/2538) P0 架构纠偏:确认 PR #2540 把已工作的 agentrun.event.v1→hwlab.event.v1→实时 SSE/Kafka retention 回放错误替换为强制 PostgreSQL transactional projector,构成重大架构偏移;从最新 v0.3 分析偏移提交并创建新的精确纠偏提交,恢复 direct publish、live Kafka SSE、Kafka refresh replay 及其单一 Kafka authority,删除强制 v8 schema 启动门禁和数据库迁移依赖,保留 canonical duration 修复、feature-config schema、HTTP fallback 禁令及其他无关改动;通过新 PR 和自动 PaC/GitOps/Argo 上线后校对实时、回放、terminal/final 与 canonical duration,完成任务后将详细报告写入[任务报告](./details/observability-trace-reliability/R4.4.2.10_Task_Report.md)。
## R5 [completed]
实现 [UniDesk #2322](https://github.com/pikasTech/unidesk/issues/2322):新增 YAML/参数驱动的 `platform-infra observability closeout` 只读聚合入口,在一次 CLI 调用中有界汇总 status、validate 及可选 PromQL/OTel trace 证据;保持 `mutation=false`、non-blocking,不修改公共运行面,完成任务后将详细报告写入任务报告,完成任务后将详细报告写入[任务报告](./details/observability-trace-reliability/R5_Task_Report.md)。
+5
View File
@@ -4,6 +4,11 @@ UniDesk 的可观测性优先级高于静默成功。CLI、服务日志、Docker
## 共享 Prometheus 指标面
- 同类运行面收口优先使用单次只读聚合入口:
- `bun scripts/cli.ts platform-infra observability closeout --target <NODE>` 固定汇总 status 与 validate
- 可重复 `--query <PromQL>` 增加指标证据,可选 `--trace-id <id>``--service <text>` 增加 trace 证据;
- 子检查失败只产生 `blocking=false` warning,不修改运行面,也不改变业务成功终态;
- 需要单项完整证据时再使用对应 status、validate、metrics-query 或 trace 下钻。
- `config/platform-infra/observability.yaml` 同时拥有 Collector、Tempo 和 Prometheus 的 image、retention、storage、port、目标 `targetIds`、服务发现与查询预算;TypeScript 只校验和渲染。
- Prometheus 仅作用于 `metricsBackend.targetIds` 选中的 observability target;其他 target 的 plan 显示 `disabled-for-target`manifest、status probe/capture 和 `metrics-query` 均不访问 Prometheus。
- Prometheus 使用 Kubernetes 原生 pod 服务发现。YAML 通过结构化 `labelSelector.key/value` 声明稳定 label,并用 `prometheus.io/scrape=true``prometheus.io/path``prometheus.io/port` annotation 声明抓取;显式 path 优先,仅在 annotation 缺失时使用 `defaultPath`,不允许业务专属分支。
@@ -7,6 +7,7 @@ import { resolveTarget } from "./platform-infra-observability/actions";
import { isKubernetesLabelValue, isKubernetesQualifiedName, readObservabilityConfig } from "./platform-infra-observability/config";
import { renderObservabilityPlanFailure } from "./platform-infra-observability/plan-render";
import { prometheusMetaLabel, re2Literal } from "./platform-infra-observability/prometheus";
import { parseCloseoutOptions } from "./platform-infra-observability/options";
import { statusScript } from "./platform-infra-observability/search-script";
import { renderManifest } from "./platform-infra-observability/trace-script";
@@ -123,13 +124,46 @@ describe("platform-infra observability progressive disclosure", () => {
expect(result.status).toBe(0);
const payload = JSON.parse(result.stdout) as Record<string, any>;
expect(payload.data.operations.map((item: Record<string, unknown>) => item.name)).toEqual([
"plan", "apply", "status", "validate", "trace", "search", "diagnose-code-agent", "metrics-query",
"plan", "apply", "status", "validate", "trace", "search", "diagnose-code-agent", "metrics-query", "closeout",
]);
expect(payload.data.scopedHelp).toContain("<operation> --help");
expect(payload.data.usage).toBeUndefined();
expect(result.stdout).not.toContain("platform-infra sub2api");
});
test("closeout accepts repeatable parameter-driven evidence without a product-specific default", () => {
expect(parseCloseoutOptions([
"--target", "NC01",
"--query", "database_ready",
"--query", "migration_ready",
"--trace-id", "a7a8f10dc985505c74fad89eeb1ba39f",
"--service", "example-api",
"--full",
])).toEqual({
targetId: "NC01",
queries: ["database_ready", "migration_ready"],
traceId: "a7a8f10dc985505c74fad89eeb1ba39f",
service: "example-api",
full: true,
raw: false,
});
const help = cli(["platform-infra", "observability", "closeout", "--help"]);
expect(help.status).toBe(0);
const payload = JSON.parse(help.stdout) as Record<string, any>;
expect(payload.data.command).toBe("platform-infra observability closeout");
expect(payload.data.boundary).toContain("non-blocking warning");
expect(help.stdout).not.toContain("pikaoa");
expect(() => parseCloseoutOptions(["--target", "NC01", "--service", "example-api"])).toThrow("--service requires --trace-id");
expect(readObservabilityConfig().metricsBackend.query.maxCloseoutQueries).toBe(8);
});
test("closeout rejects query fan-out above the YAML budget before remote access", async () => {
const args = ["closeout", "--target", "NC01"];
for (let index = 0; index < 9; index += 1) args.push("--query", `metric_${index}`);
await expect(runPlatformObservabilityCommand({} as never, args)).rejects.toThrow("at most 8 --query values");
});
test("configRef failures name the source and a bounded next command", () => {
const rendered = renderObservabilityPlanFailure(
new Error("config/hwlab-node-lanes.yaml#lanes.v03.targets.NC01.runtime.namespace is missing segment namespace"),
@@ -0,0 +1,102 @@
// 只读聚合 status、validate 以及调用者显式选择的 metrics/trace 证据。
import type { UniDeskConfig } from "../config";
import type { RenderedCliResult } from "../output";
import { redactSensitiveUnknown } from "../platform-infra-ops-library";
import { metricsQuery } from "./prometheus";
import { status, validate } from "./actions";
import { trace } from "./render";
import { formatTable, shortenEnd, textValue } from "./manifest";
import { resolveTarget } from "./actions";
import { readObservabilityConfig } from "./config";
import { targetSummary } from "./summary";
import type { CloseoutOptions } from "./types";
type Result = Record<string, unknown>;
export async function closeout(config: UniDeskConfig, options: CloseoutOptions): Promise<Result | RenderedCliResult> {
const observability = readObservabilityConfig();
const target = resolveTarget(observability, options.targetId);
if (options.queries.length > observability.metricsBackend.query.maxCloseoutQueries) {
throw new Error(`observability closeout accepts at most ${observability.metricsBackend.query.maxCloseoutQueries} --query values from config/platform-infra/observability.yaml`);
}
const operations: Array<Promise<Result>> = [
settled("status", () => status(config, { targetId: target.id, full: true, raw: false })),
settled("validate", () => validate(config, { targetId: target.id, full: true, raw: false })),
];
for (const query of options.queries) {
operations.push(settled(`metrics:${query}`, () => metricsQuery(config, { targetId: target.id, query, full: true, raw: false })));
}
if (options.traceId !== null) {
operations.push(settled("trace", () => trace(config, {
targetId: target.id,
traceId: options.traceId,
grep: options.service,
limit: 40,
full: true,
raw: true,
})));
}
const settledResults = await Promise.all(operations);
const statusResult = settledResults[0];
const validateResult = settledResults[1];
const statusOk = statusResult.ok === true;
const validateOk = validateResult.ok === true;
const warnings = settledResults
.filter((item) => item.ok !== true)
.map((item) => ({ code: "observability-closeout-child-failed", operation: item.operation, blocking: false, detail: item.error ?? "child operation returned not-ok" }));
const result: Result = {
ok: statusOk && validateOk,
action: "platform-infra-observability-closeout",
mutation: false,
blocking: false,
target: targetSummary(target),
requested: { queries: options.queries, traceId: options.traceId, service: options.service },
status: redactSensitiveUnknown(statusResult),
validate: redactSensitiveUnknown(validateResult),
optional: settledResults.slice(2).map((item) => redactSensitiveUnknown(item)),
warnings,
};
if (options.full || options.raw) return result;
return renderCloseout(result, target.id, target.namespace);
}
async function settled(operation: string, run: () => Promise<unknown>): Promise<Result> {
try {
const value = await run();
if (typeof value === "object" && value !== null && !Array.isArray(value)) {
return { operation, ...(value as Result) };
}
return { operation, ok: false, error: "child operation returned an unsupported payload" };
} catch (error) {
return { operation, ok: false, error: error instanceof Error ? error.message : String(error) };
}
}
function renderCloseout(result: Result, targetId: string, namespace: string): RenderedCliResult {
const statusResult = asRecord(result.status);
const validateResult = asRecord(result.validate);
const optional = Array.isArray(result.optional) ? result.optional.filter(asRecord) : [];
const rows = [
["status", textValue(statusResult?.ok)],
["validate", textValue(validateResult?.ok)],
...optional.map((item) => [textValue(item.operation), textValue(item.ok)]),
];
const warnings = Array.isArray(result.warnings) ? result.warnings.filter(asRecord) : [];
const lines = [
`platform-infra observability closeout (${result.ok === true ? "ok" : "not-ok"})`,
"",
`target=${targetId} namespace=${namespace} ready=${result.ok === true} mutation=false blocking=false`,
"",
"Checks:",
formatTable(["OPERATION", "OK"], rows.length > 0 ? rows : [["-", "-"]]),
];
if (warnings.length > 0) {
lines.push("", "Warnings:", formatTable(["OPERATION", "BLOCKING", "DETAIL"], warnings.map((item) => [shortenEnd(textValue(item.operation), 28), textValue(item.blocking), shortenEnd(textValue(item.detail), 100)])));
}
lines.push("", "Disclosure:", " status/validate are always collected; metrics and trace are opt-in via --query/--trace-id.", " Optional failures remain non-blocking warnings; use --full for structured child results.");
return { ok: result.ok === true, command: "platform-infra observability closeout", contentType: "text/plain", renderedText: lines.join("\n") };
}
function asRecord(value: unknown): Result | null {
return typeof value === "object" && value !== null && !Array.isArray(value) ? value as Result : null;
}
@@ -124,6 +124,7 @@ export function readObservabilityConfig(): ObservabilityConfig {
timeoutSeconds: integerField(metricsQuery, "timeoutSeconds", "metricsBackend.query"),
maxSeries: integerField(metricsQuery, "maxSeries", "metricsBackend.query"),
maxResponseBytes: integerField(metricsQuery, "maxResponseBytes", "metricsBackend.query"),
maxCloseoutQueries: integerField(metricsQuery, "maxCloseoutQueries", "metricsBackend.query"),
},
},
sampling: {
@@ -152,7 +153,7 @@ export function readObservabilityConfig(): ObservabilityConfig {
if (!isKubernetesQualifiedName(config.metricsBackend.discovery.labelSelector.key)) throw new Error(`${configLabel}.metricsBackend.discovery.labelSelector.key must be a Kubernetes qualified name`);
if (!isKubernetesLabelValue(config.metricsBackend.discovery.labelSelector.value)) throw new Error(`${configLabel}.metricsBackend.discovery.labelSelector.value must be a Kubernetes label value`);
if (config.collector.replicas < 0 || config.traceBackend.replicas < 0 || config.metricsBackend.replicas < 0) throw new Error(`${configLabel} replicas must be >= 0`);
if (config.metricsBackend.query.timeoutSeconds < 1 || config.metricsBackend.query.maxSeries < 1 || config.metricsBackend.query.maxResponseBytes < 1024) throw new Error(`${configLabel}.metricsBackend.query budgets must be positive and maxResponseBytes must be >= 1024`);
if (config.metricsBackend.query.timeoutSeconds < 1 || config.metricsBackend.query.maxSeries < 1 || config.metricsBackend.query.maxResponseBytes < 1024 || config.metricsBackend.query.maxCloseoutQueries < 1) throw new Error(`${configLabel}.metricsBackend.query budgets must be positive and maxResponseBytes must be >= 1024`);
if (config.sampling.ratio < 0 || config.sampling.ratio > 1) throw new Error(`${configLabel}.sampling.ratio must be between 0 and 1`);
if (!config.probes.traceQueryPathTemplate.includes("{{traceId}}")) throw new Error(`${configLabel}.probes.traceQueryPathTemplate must include {{traceId}}`);
return config;
@@ -9,5 +9,6 @@ export * from "./apply-status-scripts";
export * from "./trace-script";
export * from "./search-script";
export * from "./diagnose-code-agent-script";
export * from "./closeout";
export * from "./summary";
export * from "./plan-render";
@@ -19,13 +19,14 @@ import {
capture,
} from "../platform-infra-ops-library";
import type { ApplyOptions, CommonOptions, DiagnoseCodeAgentOptions, MetricsQueryOptions, SearchOptions, TraceOptions } from "./types";
import type { ApplyOptions, CloseoutOptions, CommonOptions, DiagnoseCodeAgentOptions, MetricsQueryOptions, SearchOptions, TraceOptions } from "./types";
import { apply, plan, status, validate } from "./actions";
import { diagnoseCodeAgent, search, trace } from "./render";
import { renderObservabilityPlan, renderObservabilityPlanFailure } from "./plan-render";
import { metricsQuery } from "./prometheus";
import { closeout } from "./closeout";
const observabilityOperations = ["plan", "apply", "status", "validate", "trace", "search", "diagnose-code-agent", "metrics-query"] as const;
const observabilityOperations = ["plan", "apply", "status", "validate", "trace", "search", "diagnose-code-agent", "metrics-query", "closeout"] as const;
export function observabilityHelp(scope: string | null = null): Record<string, unknown> {
const scoped = scope === null ? null : observabilityOperationHelp(scope);
@@ -64,6 +65,7 @@ export async function runPlatformObservabilityCommand(config: UniDeskConfig, arg
if (action === "search") return await search(config, parseSearchOptions(args.slice(1)));
if (action === "diagnose-code-agent") return await diagnoseCodeAgent(config, parseDiagnoseCodeAgentOptions(args.slice(1)));
if (action === "metrics-query") return await metricsQuery(config, parseMetricsQueryOptions(args.slice(1)));
if (action === "closeout") return await closeout(config, parseCloseoutOptions(args.slice(1)));
return { ok: false, error: "unsupported-platform-infra-observability-command", args, help: observabilityHelp() };
}
@@ -113,6 +115,13 @@ function observabilityOperationHelp(scope: string): Record<string, unknown> | nu
usage: ["bun scripts/cli.ts platform-infra observability metrics-query --target <NODE> --query <promql> [--full|--raw]"],
options: ["--target <NODE>", "--query <promql>", "--full", "--raw"],
};
if (scope === "closeout") return {
...common,
command: "platform-infra observability closeout",
usage: ["bun scripts/cli.ts platform-infra observability closeout --target <NODE> [--query <promql>]... [--trace-id <OTel trace id>] [--service <service text>] [--full|--raw]"],
options: ["--target <NODE>", "--query <promql> (repeatable)", "--trace-id <32-hex>", "--service <service text>", "--full", "--raw"],
boundary: "只读聚合 status/validate 及显式 metrics/trace;所有可观测性缺口均为 non-blocking warning。",
};
if (scope === "search") return {
...common,
command: "platform-infra observability search",
@@ -155,6 +164,36 @@ export function parseMetricsQueryOptions(args: string[]): MetricsQueryOptions {
return { ...parseCommonOptions(commonArgs), query };
}
export function parseCloseoutOptions(args: string[]): CloseoutOptions {
const commonArgs: string[] = [];
const queries: string[] = [];
let traceId: string | null = null;
let service: string | null = null;
for (let index = 0; index < args.length; index += 1) {
const arg = args[index];
const value = args[index + 1];
if (arg === "--query") {
if (value === undefined || value.startsWith("--")) throw new Error("--query requires a value");
queries.push(value);
index += 1;
} else if (arg === "--trace-id" || arg === "--service") {
if (value === undefined || value.startsWith("--")) throw new Error(`${arg} requires a value`);
if (arg === "--trace-id") traceId = value;
else service = value;
index += 1;
} else {
commonArgs.push(arg);
if (arg === "--target") {
commonArgs.push(value ?? "");
index += 1;
}
}
}
if (traceId !== null && !/^[0-9a-fA-F]{32}$/u.test(traceId)) throw new Error("trace id must be a 32-character OpenTelemetry trace id encoded as hex");
if (service !== null && traceId === null) throw new Error("--service requires --trace-id");
return { ...parseCommonOptions(commonArgs), queries, traceId, service };
}
export function parseCommonOptions(args: string[]): CommonOptions {
let targetId: string | null = null;
let full = false;
@@ -111,7 +111,7 @@ export interface PrometheusConfig {
scrapeInterval: string;
scrapeTimeout: string;
};
query: { path: string; timeoutSeconds: number; maxSeries: number; maxResponseBytes: number };
query: { path: string; timeoutSeconds: number; maxSeries: number; maxResponseBytes: number; maxCloseoutQueries: number };
}
export interface ImageSpec {
@@ -196,3 +196,9 @@ export interface DiagnoseCodeAgentOptions extends CommonOptions {
export interface MetricsQueryOptions extends CommonOptions {
query: string | null;
}
export interface CloseoutOptions extends CommonOptions {
queries: string[];
traceId: string | null;
service: string | null;
}
+1
View File
@@ -483,6 +483,7 @@ export function platformInfraHelp(): unknown {
"bun scripts/cli.ts platform-infra observability apply --target D601 --confirm",
"bun scripts/cli.ts platform-infra observability status --target D601",
"bun scripts/cli.ts platform-infra observability validate --target D601",
"bun scripts/cli.ts platform-infra observability closeout --target D601 [--query 'up'] [--trace-id <otel-trace-id>] [--service <service>] [--full|--raw]",
"bun scripts/cli.ts platform-infra observability trace <traceId> --target D601 [--grep text] [--limit 40] [--full|--raw]",
"bun scripts/cli.ts platform-infra observability search --target D601 --grep 'no rollout found' [--lookback-minutes 360] [--candidate-limit 80] [--limit 20]",
"bun scripts/cli.ts platform-infra observability diagnose-code-agent --target D601 --business-trace-id <trc_...> [--full|--raw]",