《互联网产品的客户端架构·规模化与架构演进》7.1 日志、指标与追踪的最小契约

一次支付故障排查花掉三小时,根因不是代码,而是四个团队的上报字段各说各话。本节把日志、指标、追踪收敛成一份可校验的最小契约:三类事件的必填字段、三类错误码、缺失字段报告,以及 span 的父子关联、断链检测与链路深度,并给出契约评审时的判据与反例。

三个团队,三份对不上的日志

某电商大促当晚,客服转来一条工单:用户说支付成功,订单却一直显示待支付。值班同学先在 Web 日志里搜订单号,找到一条 checkout_failed;又在 iOS 日志里搜到同一时间段的 pay_callback,但字段叫 uid,Web 用的是 userId;Android 的上报更彻底,只留下一句自由文本:pay callback ok but order not found, uid=...。

三份日志都在,却拼不出一次完整链路:

  • 时间戳有的用秒、有的用毫秒,排序后相差三十年。
  • 用户标识有三种写法,无法确认是不是同一个人。
  • 没有 traceId,无法判断回调、订单查询、页面刷新是不是同一次请求。
  • “成功/失败”写在文案里,而不是字段里,无法聚合。

排查花了三个小时,最后发现是回调接口重复消费。问题不在某一行代码,而在端上可观测性没有契约:每个团队都在上报,但没有人规定“一条事件最少要有哪些字段”。

三类信号,一份契约

端上可观测性通常被拆成日志、指标、追踪三件事,分别由三个 SDK、三个团队维护。规模小的时候没问题;端一多,三套字段各长各的,关联就断了。

最小契约的做法是:先定义共享的基础字段,再按事件类型追加必填字段。三类事件共用同一套身份与时间语义,类型只决定额外要求。

事件类型用途基础字段追加必填
log离散事实,如提交、失败、回调type、name、timestamp、sessionIdseverity
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 的结构化程度更高,应该独立上报、独立采样,而不是塞进日志正文里靠正则提取。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「books」更多文章

  1. 《互联网产品的 AI 架构·平台化与治理》附录 C 平台化评审清单
  2. 《互联网产品的 AI 架构·平台化与治理》附录 B 能力目录与配额字段速查
  3. 《互联网产品的 AI 架构·平台化与治理》附录 A 治理角色与职责速查