AI 时代,也许你的 Flutter 需要一套 Dartastic OpenTelemetry 监控
先简单说说 OpenTelemetry大家应该不陌生它是一个供应商无关的开源可观测性框架可以用于检测、生成、收集和导出遥测数据比如 traces、metrics和 logs 等因为 OTel 是中立性开源工具所以它可以和各种可观测性后端一起使用 包括 Jaeger 和 Prometheus 这类开源工具或者其他商业化产品。OpenTelemetry 的一个主要目标是不管 App 或者系统采用什么编程语言或者基础设施都可以轻松将收集到的信息仪表化。所以这里的dartastic_opentelemetry其实就是用纯 Dart 实现的一套 OpenTelemetry SDK通过让 Dart 服务端、命令行程序、Flutter、Web 和 Wasm 应用都能生成标准化的 Trace、Metric 和 Log再通过 OTLP 发送给 Grafana、Tempo、Datadog、Elastic、Honeycomb 相关的可观测平台。以前做监控和业务埋点 Flutter 项目一般需要分别接入 Sentry、Firebase Crashlytics、Analytics 和自定义日志系统这种情况下错误、性能、网络请求和服务端链路会分散在不同后台然后现在 Dartastic 把 Dart 和 Flutter 纳入了统一的 OpenTelemetry 数据模型一次 Flutter 请求可以沿着 Dart 客户端、网关、Java 服务、数据库一直保持同一个traceId采集端和后端之间使用标准 OTLP 协议迁移可观测平台时不需要重新改造业务埋点。而且Dartastic 内部已经包含了完整的采样、Context 传播、批处理、资源检测、三类信号、OTLP/gRPC、OTLP/HTTP、W3C Trace Context、Baggage、环境变量配置和生命周期管理。不过目前 Dartastic 已经进入 CNCF/OpenTelemetry 官方 Dart SDK 的捐赠流程暂时还不是官方 SDK。可能一些人会觉得日志、Crashlytics 和性能监控早就有了再高一套 OpenTelemetry SDK 的价值高吗实际上问题在于以前这些工具一般都是各自拥有一套封闭的数据结构Crashlytics 看到一次异常APM 平台看到一条慢请求业务统计系统看到一次支付失败服务端日志又记录了数据库超时它们可能来自同一次用户操作但缺少共同的 Trace Context开发者只能根据时间、用户 ID 和请求参数人工拼接现场这对 AI 来说也是它们没办法直接明白平台之间的业务耦合在哪里。然后现在 OpenTelemetry 给出的基础模型是Trace描述一次操作经过了哪些环节Span描述其中一个具体步骤例如 HTTP 请求、数据库查询或本地计算Metric描述一段时间内的数量和分布例如请求量、错误率和响应耗时Log记录带结构化字段和上下文的事件Resource表示这些数据来自哪个应用、版本、设备和运行环境Context负责让父子 Span 关系穿过异步调用和服务边界实际上这对 AI 场景很重要因为 AI 需要排查问题的时候最需要的就是一条从头到位的证据链路因为 AI 不是人它不知道你的详细业务和真实情况所以这种链路其实特别重要。当然对 Dart 来说这个 SDK 最困难的点其实不在于定义Span之类的实现这里真正麻烦的是让这套上下文在Future、await、callback、Timer、Stream、Isolate和HTTP请求之间可以稳定传播同时还要实现采样、批量导出、失败处理、关闭刷新以及符合 OTLP 线协议的序列化等等。所以 Dartastic 的价值主要就在这些底层部分Dartastic 实际上是通过两个核心仓库组成组件职责dartastic_opentelemetry_api定义 Tracer、Span、Context、Metric、Logger 等公共接口并提供 No-op 实现dartastic_opentelemetry实现采样、处理器、存储、资源检测和 OTLP 导出等真正的数据处理能力这种拆分其实就是 OpenTelemetry 的经典风格一个用于埋点的第三方库例如 Dart HTTP 客户端可以只依赖 API 包就算最终应用没有安装 SDK这些埋点调用也会进入 No-op 实现不会报错和产生数据。然后等到应用调用OTel.initialize()后全局工厂从 API 的 No-op Factory 切换成OTelSDKFactory然后新建的 Tracer、Meter 和 Logger 才开始进入真实处理管线。不过这里有一个容易忽略的细节初始化前已经取得的对象还是 No-op 对象不会被自动升级所以应用可以晚一点初始化 SDK第三方库也能安全依赖 API但业务代码不应该在初始化前缓存 Tracer。而且 Dartastic 这层设计上还做了一个很有 Dart 风格但是也很有争议的实现几乎所有对象都通过静态入口OTel和 Factory 创建许多实现类的构造函数是私有的。所以通常的调用方式是await OTel.initialize(serviceName: checkout-service); final tracer OTel.tracer(); final span tracer.startSpan(create-order);所以增加一种可创建对象时需要沿 着OTel、Factory、*_create.dart三层去修改不能只增加一个公开构造函数。这样做的优点是 API 路径统一可以在 API No-op、真实 SDK、Web 和 Native 实现之间切换但是问题也很明显全局状态更强测试需要调用OTel.reset()同一进程需要多个服务身份或多套端点时要使用 named provider不能再次调用OTel.initialize()。接下来如果从一个请求走完整条链路开始比如一个 Flutter 应用调用支付服务支付服务又查询库存客户端和服务端都接入了 OpenTelemetry这时候在 Flutter 端点击“支付”时创建一个 Spanfinal tracer OTel.tracer(); final span tracer.startSpan( checkout.submit, kind: SpanKind.client, ); try { await tracer.withSpanAsync(span, () async { await paymentClient.createOrder(); }); } finally { span.end(); }这里startSpan()只创建 Span实际上它不会自动把它设置成当前 Span真正把 Span 写入当前异步上下文的是withSpanAsync()。另外 Dartastic 会是 Dart Zone 保存Context.current 进入withSpanAsync()后同一异步调用链里创建的新 Span 会自动找到当前 Span然后将它作为父节点这样跨过多个await后 Context 还能存在。然后 HTTP 客户端会通过 W3C propagator 把上下文编码进请求头典型的traceparent类似00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01服务端收到请求后从请求头提取 Context然后在这个 Context 里创建POST /ordersSpan之后服务端查询库存时再创建database.query子 Span最终三个系统里的 Span 共享同一个traceId但各自拥有不同的spanId和父子关系。这条链路里Dartastic 负责 Dart 进程内部的 Span 生命周期、采样、Context、序列化和导出具体的 HTTP 客户端、服务端框架或数据库库还是需要相应的 instrumentation在请求发出和收到时调用 inject/extractSDK 不可能只靠初始化就知道任意业务请求的含义。也就是dartastic_opentelemetry是底层 SDK不会自动采集 Flutter 路由、点击、生命周期和掉帧上层的 flutterrific_opentelemetry 才是负责将 Navigator、WidgetsBinding、Flutter Error 和交互事件转换成 OTel 信号。所以 Dart 上最棘手的是 Context 问题OpenTelemetry Trace 能否用起来很大程度取决于 Context 是否可靠。在同步代码里父子 Span 很容易传递但是到了 Dart 异步环境函数调用栈就很难完全表示当前请求所以 Dartastic 才用 Zone 传播当前 Contextawait tracer.withSpanAsync(parentSpan, () async { await Futurevoid.delayed(const Duration(milliseconds: 50)); final childSpan tracer.startSpan(child-operation); childSpan.end(); });childSpan会从 Zone 中取得当前 Context然后自动成为parentSpan的子节点。而 Isolate 会更复杂Zone 只能覆盖一个 Isolate普通 Dart 对象也不能随意跨 Isolate 传递所以 Dartastic API 提供Context.current.runIsolate()把可序列化的 Span Context 传到新 Isolate然后在接收端恢复成 remote context新 Isolate 内要重新取得 Tracer不能捕获父 Isolate 中包含处理器、Exporter 等不可发送对象的 SDK 实例。项目自带的isolate_context_example.dart会验证两件事子 Isolate 创建的 Span 与父 Span 拥有相同traceId并且其parentSpanId等于父 Span 的spanId。这项能力对于 Dart 服务端很重要图片处理、大文件解析、压缩和 CPU 密集任务经常被放进 Isolate缺少显式传播机制时Trace 会从任务分发处直接断掉。不过它也没办法让任意Isolate.spawn()自动继承上下文调用方还是需要使用runIsolate()或者自己序列化并恢复 W3C Context自动跨越所有 Isolate 在 Dart 的隔离内存模型下并不现实。这时候可能就有人有疑问了那一个 Span 结束后数据去了哪里实际上 Dartastic 的 Trace 数据管线可以简单分成四层创建 Span 时Sampler 先决定DROP、RECORD_ONLY或者RECORD_AND_SAMPLE而被丢弃的 Span 还可以保留传播所需的 Span Context但对应属性、事件和异常操作都会直接成为空操作结束时也不会通知 Processor。这一点能减少关闭采样后还记录大量对象的成本。项目提供的采样器数量也很完整包括AlwaysOnSampler、AlwaysOffSampler、ParentBasedSampler、TraceIdRatioSampler、概率采样、计数采样、令牌桶限流采样和组合采样。生产环境一般更适合用ParentBasedSampler(TraceIdRatioSampler(...))新 Trace 按 trace ID 稳定抽样下游服务尊重上游决定避免同一条分布式链路只留下中间几段。Span 结束后默认进入BatchSpanProcessor一般源码中的默认配置为参数默认值作用最大队列2048队列满后新 Span 会被丢弃单批最大数量512限制一次导出的数据规模调度间隔5 秒周期性触发导出导出超时30 秒防止 Exporter 无限等待Processor 用内存队列和异步锁Timer 周期性取出一批 Span再交给 Exporter目前三类信号都支持 OTLP/gRPC、HTTP/protobuf 和 HTTP/JSONWeb 环境自然更适合 HTTP 方案。而且这里的背压策略比较直接队列满时丢弃新数据它不会暂停业务请求等待 Collector也不会把遥测数据持久化到磁盘对可观测 SDK 来说这是比较合理的默认值因为监控系统故障不应该拖垮业务但应用需要通过采样率、队列大小和 Collector 容量控制丢失比例。那另外的 Metric 和 Log 能做到了什么程度Metric 部分目前已经覆盖同步和异步仪表包括 Counter、UpDownCounter、Histogram、Gauge 以及对应的 Observable 版本然后数据通过 Metric Storage 聚合再由PeriodicExportingMetricReader周期性收集和导出。还有就是 Exemplar比如 “支付响应耗时” Histogram 的某个桶突然升高普通 Metric 只能告诉你这一段时间很慢但是 Exemplar 可以在指标样本中附带一个相关的traceId和spanId让开发者从指标跳到具体慢请求目前代码已为 Sum、Gauge 和 Histogram 接入ExemplarFilter与ExemplarReservoir。另外项目也提供PrometheusExporter但它目前只负责生成 Prometheus 文本格式SDK 没有内置可供 Prometheus 抓取的 HTTP Server所以设置OTEL_METRICS_EXPORTERprometheus不会自动搭建/metrics接口调用方需要自己暴露prometheusData或者把 OTLP 发送给 Collector再由 Collector 转成 Prometheus。Log 管线包含 LoggerProvider、Logger、Simple/Batch LogRecord Processor、OTLP Exporter 以及package:loggingbridge它还能通过 Zone 拦截print()但需要显式启用和在相应 Zone 中运行应用也要避免把 SDK 自身调试日志重新送回 SDK否则容易形成递归采集。await OTel.initialize( serviceName: my-service, logPrint: true, // Enable print interception logPrintLoggerName: dart.print, // Optional custom logger name ); // Use runWithPrintInterception to capture prints OTel.runWithPrintInterception(() { print(This will be captured as an OTel log); print(So will this); }); // For async code await OTel.runWithPrintInterceptionAsync(() async { print(Async print captured); await someAsyncOperation(); });从实现成熟度看Trace 是核心Metric 和 Log 属于可运行的完整管线而自动 instrumentation 和生产诊断能力还在补齐。而且这个项目还给做了类型化语义属性一般大多数语言的 OpenTelemetry SDK 会用字符串表示语义属性比如span.setStringAttribute(http.request.method, GET);这样相对更很灵活但是比较很容易产生拼写错误或者用了过时字段甚至把原本应该是整数的值写成字符串而 Dartastic API 选择生成了对应 OpenTelemetry Semantic Conventions 的枚举可以写成span.addAttributes( OTel.attributesFromSemanticMap({ Http.requestMethod: GET, Http.responseStatusCode: 200, }), );项目最近还把内部 Resource、异常、Exporter 和环境变量处理中的字符串常量逐步替换成 registry enum。不过对 Flutter 项目来说如果只想获得 Flutter 崩溃上报其实 Crashlytics 或 Sentry 还是更简单直接Dartastic 不负责符号化、崩溃后台、告警规则、用户 Session UI 或 Release Health 页面它提供的是生成与传输标准遥测数据的底座。实际上我感觉它更适合下面这种项目应用包含 Flutter 客户端和 Dart 后端服务端已经采用 OpenTelemetry团队希望统一客户端与服务端 Trace需要把数据发送到自建 Collector希望保留更换 Grafana、Datadog、Elastic 等后端的自由纯 Flutter 应用如果希望自动采集页面、生命周期、点击、错误和帧性能也可以直接用 Flutterrific 和选择相应的 instrumentation 包另外纯 Dart SDK 的 Exporter、Timer 和数据处理主要运行在 Dart 运行时里移动端如果要捕获 native crash、系统级性能、iOS/Android 原生资源或者把遥测处理放到独立原生线程目前单靠 Dartastic 这个仓库到真的不满足它主要还是处理 Dart 场景的。当然它也有原生场景的 SDK 版本。如果感觉还是很抽象那就直接看 Github 的代码吧。链接https://github.com/MindfulSoftwareLLC/dartastic_opentelemetry