一次查不到数的错误率告警
2026 年 10 月大促前,arch-lab 订单服务的错误率面板突然空了。值班同学先怀疑采集链路,查了十分钟才发现:过去一周有两次发布,第一次把 span 上的 order_id 改成了 order.id,第二次把 http.status_code 从整数改成了字符串 "500"。面板的查询同时按旧键和新键过滤,两个条件都不匹配,于是返回 0 条;告警规则算不出分母,安静地没有触发。
更隐蔽的问题是有人在指标上加了 order.id 标签。订单号是每单唯一的值,指标的时间序列数一夜之间从几百涨到十几万,采集端开始丢点,而应用日志看起来一切正常。三份遥测各自都能“打出来”,却无法回答同一个问题:订单可观测性契约
里约定的字段,在导出时已经各自变形成了另一套。
语义约定是 schema,不是命名建议
很多团队把语义约定当成“命名规范”,写在 wiki 上,靠评审提醒。真正决定它能否约束系统的,是它有没有像数据库 schema 一样被机器执行:字段名、类型、是否必填、是否允许出现在某类信号里,都要有唯一答案。只要一个字段在日志里叫 order.id、在指标里叫 order_id,跨信号关联就退化成人工猜测。
| 约束层 | 规则 | 违规示例 | 直接后果 |
|---|---|---|---|
| 名字 | 小写、点分、每段语义稳定 | Order.ID、order_id | 查询与看板匹配不到 |
| 类型 | 同一属性在所有信号中同型 | 状态码写成 "500" | 聚合、比较、告警表达式失败 |
| 基数 | 指标禁止订单级、用户级键 | 指标带 order.id | 时间序列爆炸、采集丢点 |
| 敏感度 | 邮箱、卡号导出前必须脱敏 | span 带 user.email | 合规风险与日志泄露 |
这四层里,名字最容易被发现,基数和敏感度最容易被忽略。名字错了通常当天就有人报“看板没数”;基数错了要等到流量上来才暴露;敏感度错了往往在审计时才暴露。校验器必须把四层都变成结构化 finding,而不是只打印一句 warning。
校验要区分信号,而不是一刀切
日志与追踪天然允许高基数:一条日志带订单号是合理的,span 带 order.id 才能从慢请求追到具体订单。指标不行,指标标签会变成时间序列维度,订单号这种无界值必须拒绝。因此同一个 order.id 在 span 上是合法属性,在 metric 上就是违规,这个差异只能由“信号类型 + 属性规格”共同决定。
必填项也随信号变化。span 需要 http.route、http.response.status_code 和 order.status 才能还原一次支付调用;日志需要 log.severity 和 log.message;指标只需要服务名、版本和环境。把这些规则写进注册表后,校验器就不需要在每个埋点处复制判断,新增字段也只改一处。
可运行实现
下面的包只依赖标准库。NewOrderValidator 固定 arch-lab 的注册表,Validate 先按属性名排序,再输出名字、类型、基数、敏感度和枚举五类问题,最后补齐必填项。排序是为了让同一批事件在任何机器上产生完全相同的报告,便于做门禁和 diff。
// file: internal/platform/obs/conventions.go
// tier: L1
// verify: go test ./internal/platform/obs/ -count=1
// Package obs holds chapter 6's observability building blocks: semantic
// convention checks, trace propagation and sampling, and incident correlation.
package obs
import (
"fmt"
"regexp"
"slices"
"sort"
)
// AttrKind is the wire type of one telemetry attribute.
type AttrKind int
const (
StringKind AttrKind = iota
IntKind
)
func (k AttrKind) String() string {
if k == IntKind {
return "int"
}
return "string"
}
// Value keeps the kind explicit so "500" cannot pass as an int status code.
type Value struct {
Kind AttrKind
Str string
Int int64
}
func StringValue(v string) Value { return Value{Kind: StringKind, Str: v} }
func IntValue(v int64) Value { return Value{Kind: IntKind, Int: v} }
// SignalKind decides cardinality rules: a metric cannot carry per-order keys.
type SignalKind string
const (
SignalMetric SignalKind = "metric"
SignalLog SignalKind = "log"
SignalSpan SignalKind = "span"
)
// AttrSpec is one registry entry; RequiredFor differs per signal.
type AttrSpec struct {
Name string
Kind AttrKind
RequiredFor []SignalKind
HighCardinality bool
Sensitive bool
Enum []string
}
// Finding is one violation with a stable code that can be counted and alerted.
type Finding struct {
Attribute string
Code string
Detail string
}
type Report struct {
Signal SignalKind
Checked int
Findings []Finding
}
func (r Report) OK() bool { return len(r.Findings) == 0 }
func (r Report) Codes() map[string]int {
out := make(map[string]int, len(r.Findings))
for _, f := range r.Findings {
out[f.Code]++
}
return out
}
const maxAttrNameLen = 64
var attrNameRE = regexp.MustCompile(`^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)*$`)
// Validator is immutable after construction and safe to share.
type Validator struct {
specs map[string]AttrSpec
required []string
}
// NewOrderValidator returns the arch-lab order system's conventions.
func NewOrderValidator() *Validator {
specs := []AttrSpec{
{Name: "service.name", Kind: StringKind, RequiredFor: []SignalKind{SignalMetric, SignalLog, SignalSpan}},
{Name: "service.version", Kind: StringKind, RequiredFor: []SignalKind{SignalMetric, SignalLog, SignalSpan}},
{Name: "deployment.environment", Kind: StringKind, RequiredFor: []SignalKind{SignalMetric, SignalLog, SignalSpan}, Enum: []string{"dev", "staging", "prod"}},
{Name: "order.id", Kind: StringKind, RequiredFor: []SignalKind{SignalLog, SignalSpan}, HighCardinality: true},
{Name: "order.status", Kind: StringKind, RequiredFor: []SignalKind{SignalSpan}, Enum: []string{"CREATED", "INVENTORY_RESERVED", "PAID", "FULFILLED", "CANCELLED", "REFUNDED"}},
{Name: "http.request.method", Kind: StringKind, RequiredFor: []SignalKind{SignalSpan}, Enum: []string{"GET", "POST", "PUT", "PATCH", "DELETE"}},
{Name: "http.route", Kind: StringKind, RequiredFor: []SignalKind{SignalSpan}},
{Name: "http.response.status_code", Kind: IntKind, RequiredFor: []SignalKind{SignalSpan}},
{Name: "log.severity", Kind: StringKind, RequiredFor: []SignalKind{SignalLog}, Enum: []string{"INFO", "WARN", "ERROR"}},
{Name: "log.message", Kind: StringKind, RequiredFor: []SignalKind{SignalLog}},
{Name: "payment.provider", Kind: StringKind, RequiredFor: []SignalKind{SignalSpan}},
{Name: "user.email", Kind: StringKind, HighCardinality: true, Sensitive: true},
{Name: "payment.card_number", Kind: StringKind, HighCardinality: true, Sensitive: true},
}
v := &Validator{specs: make(map[string]AttrSpec, len(specs))}
for _, s := range specs {
v.specs[s.Name] = s
v.required = append(v.required, s.Name)
}
sort.Strings(v.required)
return v
}
// Validate returns findings in deterministic order: attributes sorted by name,
// then missing-required entries sorted by name.
func (v *Validator) Validate(sig SignalKind, attrs map[string]Value) Report {
rep := Report{Signal: sig, Checked: len(attrs)}
names := make([]string, 0, len(attrs))
for n := range attrs {
names = append(names, n)
}
sort.Strings(names)
for _, name := range names {
val := attrs[name]
if len(name) > maxAttrNameLen {
rep.Findings = append(rep.Findings, Finding{name, "name_too_long", fmt.Sprintf("%d bytes", len(name))})
continue
}
if !attrNameRE.MatchString(name) {
rep.Findings = append(rep.Findings, Finding{name, "invalid_name", "lower-case dot-separated segments required"})
continue
}
spec, ok := v.specs[name]
if !ok {
rep.Findings = append(rep.Findings, Finding{name, "unknown_attribute", "not in registry"})
continue
}
if spec.Kind != val.Kind {
rep.Findings = append(rep.Findings, Finding{name, "type_mismatch", fmt.Sprintf("want %s got %s", spec.Kind, val.Kind)})
continue
}
if spec.Sensitive {
rep.Findings = append(rep.Findings, Finding{name, "sensitive_attribute", "redact before export"})
}
if spec.HighCardinality && sig == SignalMetric {
rep.Findings = append(rep.Findings, Finding{name, "high_cardinality_on_metric", "per-order key on a metric"})
}
if len(spec.Enum) > 0 && !slices.Contains(spec.Enum, val.Str) {
rep.Findings = append(rep.Findings, Finding{name, "invalid_enum_value", fmt.Sprintf("%q not allowed", val.Str)})
}
}
for _, name := range v.required {
required := slices.Contains(v.specs[name].RequiredFor, sig)
if !required {
continue
}
if _, ok := attrs[name]; !ok {
rep.Findings = append(rep.Findings, Finding{name, "missing_required", "required for " + string(sig)})
}
}
return rep
}
测试覆盖八类违规、指标基数与敏感度的叠加、批量门禁和确定性排序。批量门禁故意混入一条合法 span、一条拼错键的 span、一条把状态码写成字符串的 span,以及一条带 order.id 的指标,预期拒绝率 75%。
// file: internal/platform/obs/conventions_test.go
// tier: L1
// verify: go test ./internal/platform/obs/ -count=1
package obs
import (
"reflect"
"strings"
"testing"
)
func validSpanAttrs() map[string]Value {
return map[string]Value{
"service.name": StringValue("order-service"),
"service.version": StringValue("v42"),
"deployment.environment": StringValue("prod"),
"order.id": StringValue("ord-10086"),
"order.status": StringValue("PAID"),
"http.request.method": StringValue("POST"),
"http.route": StringValue("/orders/{id}/pay"),
"http.response.status_code": IntValue(200),
"payment.provider": StringValue("mockpay"),
}
}
func hasCode(rep Report, code string) bool {
for _, f := range rep.Findings {
if f.Code == code {
return true
}
}
return false
}
func TestValidateFindings(t *testing.T) {
v := NewOrderValidator()
cases := []struct {
name string
signal SignalKind
mutate func(map[string]Value)
want string
}{
{"typo key", SignalSpan, func(m map[string]Value) { m["order_id"] = StringValue("o-1") }, "unknown_attribute"},
{"wrong type", SignalSpan, func(m map[string]Value) { m["http.response.status_code"] = StringValue("200") }, "type_mismatch"},
{"metric cardinality", SignalMetric, func(m map[string]Value) { m["order.id"] = StringValue("o-1") }, "high_cardinality_on_metric"},
{"sensitive", SignalSpan, func(m map[string]Value) { m["user.email"] = StringValue("a@b.c") }, "sensitive_attribute"},
{"bad enum", SignalSpan, func(m map[string]Value) { m["order.status"] = StringValue("paid") }, "invalid_enum_value"},
{"missing", SignalSpan, func(m map[string]Value) { delete(m, "order.id") }, "missing_required"},
{"bad name", SignalSpan, func(m map[string]Value) { m["Order.ID"] = StringValue("o-1") }, "invalid_name"},
{"long name", SignalSpan, func(m map[string]Value) { m["a."+strings.Repeat("b", 63)] = StringValue("x") }, "name_too_long"},
}
for _, tc := range cases {
attrs := validSpanAttrs()
if tc.signal == SignalMetric {
for _, k := range []string{"order.status", "http.request.method", "http.route", "http.response.status_code", "payment.provider"} {
delete(attrs, k)
}
}
tc.mutate(attrs)
if rep := v.Validate(tc.signal, attrs); !hasCode(rep, tc.want) {
t.Fatalf("%s: want %s, got %+v", tc.name, tc.want, rep.Findings)
}
}
}
func TestMetricRejectsBoth(t *testing.T) {
rep := NewOrderValidator().Validate(SignalMetric, map[string]Value{
"service.name": StringValue("order-service"), "service.version": StringValue("v42"),
"deployment.environment": StringValue("prod"), "order.id": StringValue("o-1"),
"user.email": StringValue("a@b.c"),
})
codes := rep.Codes()
if codes["high_cardinality_on_metric"] != 2 || codes["sensitive_attribute"] != 1 {
t.Fatalf("codes = %v", codes)
}
t.Logf("codes=%v", codes)
}
func TestBatchGate(t *testing.T) {
v := NewOrderValidator()
events := []struct {
name string
signal SignalKind
attrs map[string]Value
}{
{"good", SignalSpan, validSpanAttrs()},
{"typo", SignalSpan, with(validSpanAttrs(), "order_id", StringValue("o-1"))},
{"string status", SignalSpan, with(validSpanAttrs(), "http.response.status_code", StringValue("500"))},
{"metric order id", SignalMetric, map[string]Value{
"service.name": StringValue("order-service"), "service.version": StringValue("v42"),
"deployment.environment": StringValue("prod"), "order.id": StringValue("o-1"),
}},
}
rejected := 0
for _, e := range events {
if rep := v.Validate(e.signal, e.attrs); !rep.OK() {
rejected++
t.Logf("event=%q codes=%v", e.name, rep.Codes())
}
}
t.Logf("events=%d rejected=%d rate=%.2f", len(events), rejected, float64(rejected)/float64(len(events)))
if rejected != 3 {
t.Fatalf("rejected = %d", rejected)
}
}
func with(base map[string]Value, key string, val Value) map[string]Value {
base[key] = val
return base
}
func TestDeterministicFindings(t *testing.T) {
v := NewOrderValidator()
attrs := validSpanAttrs()
attrs["z.bad"], attrs["a.bad"] = StringValue("x"), StringValue("x")
attrs["service.name"] = IntValue(7)
first, second := v.Validate(SignalSpan, attrs), v.Validate(SignalSpan, attrs)
if !reflect.DeepEqual(first.Findings, second.Findings) || first.Findings[0].Attribute != "a.bad" {
t.Fatalf("not deterministic: %+v", first.Findings)
}
t.Logf("findings=%d first=%s", len(first.Findings), first.Findings[0].Attribute)
}
本机运行输出
在 .local/checks/be-arch-gov-ch06 的 archlab module 中执行。环境是 macOS darwin/arm64、Go 1.26.0;gofmt -l . 和 go vet ./... 均无输出,下面只保留本篇相关测试。
$ go version
go version go1.26.0 darwin/arm64
$ gofmt -l .
$ go vet ./...
$ go test ./internal/platform/obs/ -count=1 -v -run 'TestValidateFindings|TestMetricRejectsBoth|TestBatchGate|TestDeterministicFindings'
=== RUN TestValidateFindings
--- PASS: TestValidateFindings (0.00s)
=== RUN TestMetricRejectsBoth
conventions_test.go:76: codes=map[high_cardinality_on_metric:2 sensitive_attribute:1]
--- PASS: TestMetricRejectsBoth (0.00s)
=== RUN TestBatchGate
conventions_test.go:98: event="typo" codes=map[unknown_attribute:1]
conventions_test.go:98: event="string status" codes=map[type_mismatch:1]
conventions_test.go:98: event="metric order id" codes=map[high_cardinality_on_metric:1]
conventions_test.go:101: events=4 rejected=3 rate=0.75
--- PASS: TestBatchGate (0.00s)
=== RUN TestDeterministicFindings
conventions_test.go:121: findings=3 first=a.bad
--- PASS: TestDeterministicFindings (0.00s)
PASS
ok archlab/internal/platform/obs 0.247s
读数分三组。违规组,八种输入分别命中 unknown_attribute、type_mismatch、high_cardinality_on_metric、sensitive_attribute、invalid_enum_value、missing_required、invalid_name、name_too_long,说明校验没有把“键不存在”和“键存在但值不对”混成同一个错误。叠加组,一条指标同时带 order.id 与 user.email,得到 2 个基数问题和 1 个敏感度问题,说明规则是并列执行而不是命中第一条就返回。门禁组,4 条事件拒绝 3 条,拒绝率 0.75,与预期一致;合法 span 没有被误杀,是这条门禁能否上线的关键。
边界要说明:这是单进程、内存注册表、固定输入的测试,不覆盖真实 SDK 的批量导出、属性截断和采样后的缺字段。0.247 秒只代表本机这组输入,不代表生产校验吞吐;线上应在采集端或 gateway 做抽样校验,避免每条遥测都走一遍完整注册表。
常见错误与边界
- 只在代码评审里约定名字。评审无法覆盖第三方库、历史埋点和异步任务;必须把注册表做成可执行门禁。
- 把所有高基数字段一刀切禁掉。
order.id在 span 和日志上必须保留,否则无法从慢请求定位订单;禁止的是它出现在指标标签里。 - 忽略同一版本内的类型漂移。同一个
http.response.status_code这次是 int、下次是 string,聚合会静默失败;类型必须和名字一起校验。 - 把敏感字段交给下游脱敏。遥测一旦离开进程就可能被多处复制;应在导出前拒绝,而不是指望日志平台统一擦除。
小结
语义约定的价值不在于统一写法,而在于让“这个字段能不能出现在这类信号里”变成机器可判定的规则。名字、类型、基数、敏感度四层要一起校验,且必须按信号区分:span 允许订单号,metric 不允许;span 要求路由与状态码,日志要求级别与消息。
本节用 166 行的 obs 校验器把注册表、finding 和确定性排序固定下来,4 条事件的批量门禁拒绝了 3 条。它是后续两篇的基础:只有字段在同一套语义下可对齐,追踪采样和跨信号排障才不会建立在各自命名的沙地上。
阅读导航:上一节:5.3 动态配置一致性:推送、轮询与回滚 · 下一节:6.2 分布式追踪:采样、上下文传播与成本 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。