我们用自家遥测栈替换了 PostHog 和 Sentry:iOS SDK 采集 events、性能 histogram 和 crash;Connect-RPC collector 写入 redpanda;Go worker 消费进 ClickHouse。events 和 crash 两条流是通的。最后接入的性能 metric 流,在真机生产环境上连续数月落库零行,且没有任何报错。
服务已经注册,构建是绿的,dashboard 是空的。「服务注册成功」这件事,对「数据是否真在流」不携带任何信息。
从 record() 到一行数据库记录
一个 metric 要落到 ClickHouse,四个相互独立的环节必须都完好:
- register —— assembly 注册进 MicroKernel 服务容器,服务得以存在。
- emit —— 真的有人调
record();注册了却没人调用,产生不了任何数据。 - start ——
start()跑起来,snapshot timer 起转,内存里的样本才会被上传。 - consent —— consent gate 放行,而不是把记录 drop 掉。
这四个环节分属不同文件、不同关注点、由不同层持有。任一断开都是零数据且无报错。它们是串联坏的,所以每修好一个就暴露下一个。
gap 1:根本没注册
MetricCollectionService、它的 assembly、uploader、生成的 RPC client、emitter,全部实现了。bootstrap 从未调用 MetricCollectionAssembly.registerIfConfigured(...)。MicroKernel 解析不到这个服务,emitter 也就无从实例化。
唯一值得看的是终点症状:
redpanda metrics.histogram / metrics.sparse / metrics.tail HW = 0
ClickHouse metrics_histogram / metrics_sparse / metrics_tail 0 行
collector 零 IngestHistograms 调用
修复就是 registerPlatformServices() 里加一行,且必须排在 TelemetryContextService 注册之后(install ID 从那个 SSOT 取)。commit ea368a8a2。
gap 2:注册了,但没人产生 metric
接好 assembly 让服务注册了,但没接上任何生产者。LaunchPerformanceEmitter 实现了却从未实例化,于是 record() 从未被调用。
修复在服务注册之后立刻实例化 emitter,再用 CATransaction.setCompletionBlock(在启动那次提交上屏后触发)记录首帧:
if let metricService = MicroKernel.shared.getService(MetricCollectionServiceProtocol.self) {
launchEmitter = LaunchPerformanceEmitter(service: metricService)
launchEmitter?.recordDidFinishLaunching()
}
gap 3:产生了,但从未上传
MetricCollectionService.start() 负责起周期 snapshot timer、replay 持久化记录、监听 consent stream。而 assembly 的 assemble() 只构造了服务,从未调 start()。record() 写进内存 store,store 永远不被 snapshot、不被上传。
参考范式就在同一个 module 里。AnalyticsReporterServiceAssembly.assemble() 在 return 前调了 service.start(configuration:),消除了「服务存在但休眠」这种半装配态。metric assembly 改成对齐:
let service = MetricCollectionService(/* ... */)
// 不显式 start,record() 只写内存 store,永不 snapshot/upload,
// metrics topic 维持 0 流量。
Task { await service.start() }
return service
gap 4:起转了,但 consent 把一切 drop 掉
consent 状态默认 .undecided,host app 从未调过 setMetricConsent(.granted)。而 record 路径的判定是 == .granted:
guard consent.metricConsent == .granted else { return }
每一条 record 都被 drop。这是 convenient-default 陷阱:一个 opt-in 的默认值(undecided)撞上 == .granted 的判定,结果是一次静默的、彻底的丢弃。
性能 histogram 是匿名的时延与计数,无 PII,适用 opt-out 模型。gate 改成 .undecided 也采集,仅显式 .declined 短路:
guard consent.metricConsent != .declined else { return }
一个测试把语义钉死,防止回归:
func testRecord_consentUndecided_recordsAnyway() {
let (s, _, _, _, _, _, store) = makeService(consent: .undecided)
s.record(Metric.appLaunchToFirstFrame, value: 1_000_000_000,
tags: Metric.AppLaunchToFirstFrameTags(launchType: .cold))
XCTAssertEqual(store.snapshot().histogramCount, 1)
}
commit ea368a8a2(gap 1)和 414dba729(gap 2–4)。
「验证过」到底指什么
这四个修复不是因为构建通过就算完成,而是在终点验证的 —— 看的是数据库里那一行,不是注册那次调用。WidgetCraft 真机冷启动,等满 60 秒 snapshot interval,链路全通:
collector IngestHistograms status=ok
redpanda metrics.histogram HW=1
ClickHouse metrics_histogram:
metric_name = app_launch_to_first_frame
metric_count = 1
metric_sum = 2.34e9 ns (≈2.34 秒冷启动到首帧)
可迁移的约束
每个 gap 从它被改动的那个位置看都「接好了」。这个模式是通用的:
- 注册的服务不等于运行中的服务。 lifecycle 服务必须在
assemble()里就start()。把 start 留给调用方会造出半装配态 —— 存在但休眠 —— 编译器抓不到。 - 在终点验证,不在源头。 「注册成功」和「没返回 error」都不是数据在流的证据。判据是终点 store:ClickHouse 有行、redpanda HW > 0。读到零,就往前一环查。
- convenient default 加上 silent gate 会合谋。
undecided默认撞上== .granted判定,就是无声地全丢。先把 opt-in / opt-out 显式定下来,再把 gate 的语义写成测试而非注释。 - 正确范式往往已在仓库里。 events reporter 在自己的 assembly 里 start 了,metric 服务没有。新接一个遥测服务,应当逐环照抄现有那条的 lifecycle,而不是重新推导一遍。