三个团队,三份对不上的日志
某电商大促当晚,客服转来一条工单:用户说支付成功,订单却一直显示待支付。值班同学先在 Web 日志里搜订单号,找到一条 checkout_failed;又在 iOS 日志里搜到同一时间段的 pay_callback,但字段叫 uid,Web 用的是 userId;Android 的上报更彻底,只留下一句自由文本:pay callback ok but order not found, uid=...。
三份日志都在,却拼不出一次完整链路:
- 时间戳有的用秒、有的用毫秒,排序后相差三十年。
- 用户标识有三种写法,无法确认是不是同一个人。
- 没有 traceId,无法判断回调、订单查询、页面刷新是不是同一次请求。
- “成功/失败”写在文案里,而不是字段里,无法聚合。
排查花了三个小时,最后发现是回调接口重复消费。问题不在某一行代码,而在端上可观测性没有契约:每个团队都在上报,但没有人规定“一条事件最少要有哪些字段”。
三类信号,一份契约
端上可观测性通常被拆成日志、指标、追踪三件事,分别由三个 SDK、三个团队维护。规模小的时候没问题;端一多,三套字段各长各的,关联就断了。
最小契约的做法是:先定义共享的基础字段,再按事件类型追加必填字段。三类事件共用同一套身份与时间语义,类型只决定额外要求。
| 事件类型 | 用途 | 基础字段 | 追加必填 |
|---|---|---|---|
log | 离散事实,如提交、失败、回调 | type、name、timestamp、sessionId | severity |
metric | 可聚合数值,如耗时、成功率 | 同上 | value、unit |
span | 一次操作的起止与父子关系 | 同上 | traceId、spanId、durationMs |
基础字段里,name 不是给人看的文案,而是稳定的枚举名,例如 checkout.submit、payment.callback。它必须能被聚合,所以不允许把订单号拼进去。timestamp 统一用毫秒整数,sessionId 在应用启动时生成,在同一会话内保持不变。
契约还要定义错误码,否则每个 SDK 都会发明自己的报错方式:
| 错误码 | 含义 | 例子 |
|---|---|---|
missing | 必填字段缺失或为空 | 没有 sessionId |
invalid | 字段存在但格式不对 | severity 不在枚举内 |
too_large | 字段超过上限 | 属性超过 32 个、字符串超过 256 字符 |
分成三类,是为了让接收端做不同处理:missing 通常是接入方漏了字段,invalid 是 SDK 版本不一致,too_large 是调用方把不该上报的内容塞进了属性。三类错误应该进入不同看板,而不是混成一个“上报失败”。
把契约写成代码
下面这个模块把字段集、校验、缺失报告与 trace 关联都做成纯函数,不依赖任何上报 SDK:
// file: src/observability/observability-contract.ts
// tier: L1
// verify: node --test test/observability/observability-contract.test.ts
export type EventType = "log" | "metric" | "span";
export type Severity = "debug" | "info" | "warn" | "error" | "fatal";
export type AttributeValue = string | number | boolean;
export type ObservabilityEvent = {
type: EventType;
name: string;
timestamp: number;
sessionId: string;
severity?: Severity;
value?: number;
unit?: string;
traceId?: string;
spanId?: string;
parentSpanId?: string;
durationMs?: number;
attributes?: Record<string, AttributeValue>;
};
export type EventInput = Partial<ObservabilityEvent> & { type?: string };
export type FieldCode = "missing" | "invalid" | "too_large";
export type FieldError = { field: string; code: FieldCode; detail: string };
export type ValidationResult = { valid: boolean; errors: FieldError[] };
export type TraceStatus = "root" | "child" | "none" | "broken";
export const CONTRACT_VERSION = "1.0";
export const EVENT_TYPES: EventType[] = ["log", "metric", "span"];
export const SEVERITIES: Severity[] = ["debug", "info", "warn", "error", "fatal"];
export const BASE_FIELDS: string[] = ["type", "name", "timestamp", "sessionId"];
export const REQUIRED_FIELDS: Record<EventType, string[]> = {
log: ["severity"],
metric: ["value", "unit"],
span: ["traceId", "spanId", "durationMs"],
};
export const MAX_ATTRIBUTES = 32;
export const MAX_ATTRIBUTE_STRING = 256;
export const MAX_NAME_LENGTH = 64;
const NAME_PATTERN = /^[a-z][a-z0-9]*(?:[._][a-z0-9]+)*$/;
export function isEventType(value: unknown): value is EventType {
return value === "log" || value === "metric" || value === "span";
}
export function isSeverity(value: unknown): value is Severity {
return typeof value === "string" && (SEVERITIES as string[]).includes(value);
}
export function requiredFields(type: EventType): string[] {
return [...BASE_FIELDS, ...REQUIRED_FIELDS[type]];
}
function present(value: unknown): boolean {
return value !== undefined && value !== null && value !== "";
}
function asRecord(event: EventInput): Record<string, unknown> {
return event as Record<string, unknown>;
}
function attributeErrors(attributes: ObservabilityEvent["attributes"]): FieldError[] {
const errors: FieldError[] = [];
if (attributes === undefined) {
return errors;
}
if (typeof attributes !== "object" || attributes === null || Array.isArray(attributes)) {
return [{ field: "attributes", code: "invalid", detail: "attributes 必须是键值对象" }];
}
const keys = Object.keys(attributes);
if (keys.length > MAX_ATTRIBUTES) {
errors.push({ field: "attributes", code: "too_large", detail: `属性数超过 ${MAX_ATTRIBUTES}` });
}
for (const key of keys) {
if (!NAME_PATTERN.test(key)) {
errors.push({ field: `attributes.${key}`, code: "invalid", detail: "属性名不符合小写点分格式" });
}
const value = attributes[key];
if (typeof value === "string" && value.length > MAX_ATTRIBUTE_STRING) {
errors.push({ field: `attributes.${key}`, code: "too_large", detail: `属性值超过 ${MAX_ATTRIBUTE_STRING} 字符` });
} else if (typeof value === "number" && !Number.isFinite(value)) {
errors.push({ field: `attributes.${key}`, code: "invalid", detail: "数字属性必须是有限值" });
} else if (typeof value !== "string" && typeof value !== "number" && typeof value !== "boolean") {
errors.push({ field: `attributes.${key}`, code: "invalid", detail: "属性值只允许字符串、数字或布尔" });
}
}
return errors;
}
export function validateEvent(event: EventInput): ValidationResult {
const errors: FieldError[] = [];
const push = (field: string, code: FieldCode, detail: string) => errors.push({ field, code, detail });
const raw = asRecord(event);
if (!isEventType(event.type)) {
push("type", "missing", "type 必须是 log、metric 或 span");
return { valid: false, errors };
}
for (const field of BASE_FIELDS) {
if (!present(raw[field])) {
push(field, "missing", `${field} 未填写`);
}
}
if (present(event.name)) {
if (typeof event.name !== "string" || !NAME_PATTERN.test(event.name)) {
push("name", "invalid", "name 必须是小写点分或下划线标识");
} else if (event.name.length > MAX_NAME_LENGTH) {
push("name", "too_large", `name 超过 ${MAX_NAME_LENGTH} 字符`);
}
}
if (present(event.timestamp) && (typeof event.timestamp !== "number" || !Number.isInteger(event.timestamp) || event.timestamp <= 0)) {
push("timestamp", "invalid", "timestamp 必须是正整数毫秒");
}
if (present(event.sessionId) && typeof event.sessionId !== "string") {
push("sessionId", "invalid", "sessionId 必须是字符串");
}
if (event.type === "log" && present(event.severity) && !isSeverity(event.severity)) {
push("severity", "invalid", "severity 不在枚举内");
}
if (event.type === "metric") {
if (present(event.value) && (typeof event.value !== "number" || !Number.isFinite(event.value))) {
push("value", "invalid", "value 必须是有限数字");
}
if (present(event.unit) && typeof event.unit !== "string") {
push("unit", "invalid", "unit 必须是字符串");
}
}
if (event.type === "span") {
for (const field of ["traceId", "spanId"]) {
if (present(raw[field]) && typeof raw[field] !== "string") {
push(field, "invalid", `${field} 必须是字符串`);
}
}
if (present(event.durationMs) && (typeof event.durationMs !== "number" || !Number.isFinite(event.durationMs) || event.durationMs < 0)) {
push("durationMs", "invalid", "durationMs 必须是非负有限数字");
}
}
errors.push(...attributeErrors(event.attributes));
return { valid: errors.length === 0, errors };
}
export function missingFields(event: EventInput): string[] {
if (!isEventType(event.type)) {
return [];
}
const raw = asRecord(event);
return requiredFields(event.type).filter((field) => !present(raw[field]));
}
export function missingReport(events: EventInput[]): Record<string, number> {
const report: Record<string, number> = {};
for (const event of events) {
for (const field of missingFields(event)) {
report[field] = (report[field] ?? 0) + 1;
}
}
return report;
}
export function canonicalEvent(event: ObservabilityEvent): string {
const source = event.attributes ?? {};
const attributes: Record<string, AttributeValue> = {};
for (const key of Object.keys(source).sort()) {
attributes[key] = source[key];
}
return JSON.stringify({ ...event, attributes });
}
export function traceStatus(event: ObservabilityEvent): TraceStatus {
if (event.type !== "span") {
return "none";
}
if (!present(event.spanId)) {
return "broken";
}
return present(event.parentSpanId) ? "child" : "root";
}
export function linkSpan(child: ObservabilityEvent, parent: ObservabilityEvent): ObservabilityEvent {
if (child.type !== "span" || parent.type !== "span") {
throw new Error("linkSpan 只接受 span 事件");
}
if (!present(parent.traceId) || !present(parent.spanId)) {
throw new Error("父 span 必须已有 traceId 与 spanId");
}
return { ...child, traceId: parent.traceId, parentSpanId: parent.spanId };
}
export function spansByTrace(events: ObservabilityEvent[]): Record<string, ObservabilityEvent[]> {
const groups: Record<string, ObservabilityEvent[]> = {};
for (const event of events) {
if (event.type !== "span" || !present(event.traceId)) {
continue;
}
const traceId = event.traceId as string;
(groups[traceId] ??= []).push(event);
}
return groups;
}
export function brokenTraces(events: ObservabilityEvent[]): string[] {
const broken: string[] = [];
const groups = spansByTrace(events);
for (const traceId of Object.keys(groups).sort()) {
const spans = groups[traceId];
const known = new Set(spans.map((span) => span.spanId));
if (spans.some((span) => present(span.parentSpanId) && !known.has(span.parentSpanId))) {
broken.push(traceId);
}
}
return broken;
}
export function traceDepth(event: ObservabilityEvent, pool: ObservabilityEvent[]): number {
if (event.type !== "span" || !present(event.traceId)) {
return 0;
}
const byId = new Map<string, ObservabilityEvent>();
for (const span of pool) {
if (span.type === "span" && span.traceId === event.traceId && present(span.spanId)) {
byId.set(span.spanId as string, span);
}
}
let depth = 0;
let cursor = event;
while (present(cursor.parentSpanId) && depth < 64) {
const parent = byId.get(cursor.parentSpanId as string);
if (!parent) {
break;
}
depth += 1;
cursor = parent;
}
return depth;
}
校验函数返回全部错误,而不是遇到第一个就抛异常。接入方第一次接入往往缺好几个字段,一次告诉他全部问题,比修一个跑一次快得多。
canonicalEvent 把属性键按字典序输出。接收端做去重、做指纹、做快照对比时,如果键顺序不稳定,同样的内容会产生不同的哈希。这个函数不改变业务字段,只固定序列化顺序。
trace 关联没有引入完整的 OpenTelemetry 模型,只保留四个判定:
| 判定 | 条件 | 含义 |
|---|---|---|
root | 是 span 且有 spanId、无 parentSpanId | 链路起点 |
child | 有 parentSpanId | 挂在父 span 上 |
broken | 是 span 但缺 spanId | 无法参与关联 |
none | 不是 span | 不参与 trace |
brokenTraces 再往前走一步:它按 traceId 分组,检查每个 parentSpanId 是否能在同一组里找到。找不到就说明链路断了,通常是因为客户端只上报了子 span、父 span 被采样丢弃,或者跨进程传递时丢了上下文。
测试与真实输出
✔ 事件类型只有三种 (1.069333ms)
✔ 基础字段固定 (0.08475ms)
✔ log 必填 severity (0.072292ms)
✔ metric 必填 value 与 unit (0.063291ms)
✔ span 必填 traceId、spanId 与 durationMs (0.075ms)
✔ 完整 log 通过校验 (0.306583ms)
✔ 缺少 sessionId 被标记为 missing (0.085459ms)
✔ 名称中的大写字母被标记为 invalid (0.054042ms)
✔ 名称超过 64 字符被标记为 too_large (0.074625ms)
✔ 未知等级被标记为 invalid (0.12275ms)
✔ metric 的 value 必须是有限数字 (0.080209ms)
✔ span 的 durationMs 不能为负 (0.073166ms)
✔ 属性超过 32 个被标记为 too_large (0.117375ms)
✔ 字符串属性超过 256 字符被标记为 too_large (0.058958ms)
✔ 缺失字段报告按字段累计 (0.121708ms)
✔ 缺失字段只列出未填写的必填项 (0.042ms)
✔ canonicalEvent 按字典序输出属性 (0.100875ms)
✔ traceStatus 区分 root、child、none 与 broken (0.067417ms)
✔ linkSpan 继承父 traceId 并记录 parentSpanId (0.061375ms)
✔ linkSpan 拒绝非 span 事件 (0.2145ms)
✔ spansByTrace 只收集有 traceId 的 span (0.072125ms)
✔ brokenTraces 找出父节点缺失的链路 (0.082708ms)
✔ traceDepth 沿父链计算深度 (0.073292ms)
✔ isEventType 只接受三个字面量 (0.029042ms)
ℹ tests 24
ℹ suites 0
ℹ pass 24
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 129.22975
24 个用例里有两个值得单独说:
- “canonicalEvent 按字典序输出属性”锁定序列化顺序。没有这条测试,某次重构把
Object.keys换成for...in,线上指纹就会整体漂移。 - “brokenTraces 找出父节点缺失的链路”是 trace 关联的核心判据。它不要求链路完整,只要求断链可见。
判据与反例
判断一份端上契约是否可用,不看字段数量,看下面五条:
| 判据 | 通过标准 | 失败表现 |
|---|---|---|
| 可关联 | 同一会话的 log、metric、span 能通过 sessionId 与 traceId 串起来 | 只能按时间猜 |
| 可聚合 | name、severity、unit 是有限枚举 | 看板里出现上万种事件名 |
| 可校验 | 非法事件在客户端就被拒绝或标记 | 脏数据进入数仓后才被发现 |
| 可定位 | 断链、缺字段能按字段统计 | 只知道“上报失败”,不知道缺什么 |
四个反例,任何一个出现都说明契约没有落地:
反例一:用自由文本代替字段。 message: "user 123 pay ok" 看起来信息很全,但无法聚合、无法校验、无法脱敏。文本可以保留,但关键事实必须同时出现在字段里。
反例二:事件名带变量。 order.123456.paid 让每个订单产生一个独立事件名,基数直接爆炸。正确写法是 order.paid,订单号放进属性。
反例三:只在接收端校验。 客户端全量发送,服务端再拒收。这样浪费带宽,而且用户侧的问题要等到服务端才发现。校验应该在上报前做一次,接收端再做一次兜底。
反例四:traceId 只在崩溃日志里出现。 正常事件没有 traceId,出问题时无法把崩溃与之前的操作串起来。traceId 应该在会话开始时就生成,而不是崩溃时才补。
小结
端上可观测性的最小契约,不是一套庞大的 SDK,而是三张表:基础字段、类型追加字段、错误码。
- 三类事件共享身份与时间语义,类型只决定追加字段。
- 校验要一次返回全部错误,缺失字段要能按字段累计。
- trace 关联先做到“断链可见”,再谈完整链路。
- 事件名必须是稳定枚举,变量放进属性。
契约的价值不在文档,而在它能让不同团队的上报在同一条时间轴上对齐。没有这条时间轴,日志越多,排查越慢。
阅读导航:上一节:6.3 发布流水线与灰度回滚 · 下一节:7.2 采样、脱敏与成本 。
常见错误与边界
- 把契约当成校验函数。 契约首先是字段与语义的约定,校验只是它的可执行形式。只有校验、没有字段定义,接入方仍然不知道该怎么填。
- 属性键允许任意字符。 键名带空格、中文或大写字母,会让下游的列式存储和查询语言各自转义,最后对不上。键名统一小写点分。
- 把用户可见文案写进
name。 文案会改,事件名不能跟着改。事件名一旦发布就是接口,改名等于破坏性变更。 - 认为断链一定是客户端 bug。 父 span 被采样丢弃、跨进程上下文未注入、异步任务丢失上下文,都会产生断链。先看断链率,再定位原因。
- 校验失败就静默丢弃。 丢弃会让接入方以为已经接好。至少要本地计数,并把
missing、invalid、too_large分开统计。 - 把 trace 当成日志的附属品。 trace 的结构化程度更高,应该独立上报、独立采样,而不是塞进日志正文里靠正则提取。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。