跳转到内容

OpenTelemetry 可观测性集成

OpenTelemetry 可观测性集成

学习目标

  • 理解 Goose 的 OpenTelemetry 三支柱架构:Trace、Metric、Log
  • 掌握 tracing-opentelemetry 的 span 映射机制
  • 学会配置 OTLP 导出器与环境变量
  • 识别 span 基数爆炸与 OTLP 端点配置陷阱

项目实践

OpenTelemetry 三支柱架构

Goose 的可观测性基于 OpenTelemetry 标准的三支柱:

Cargo.toml 中的 SDK 依赖

Cargo.toml 中,Goose 声明了完整的 OpenTelemetry SDK 依赖链:

opentelemetry = "0.32"
opentelemetry_sdk = "0.32" # SDK 核心
opentelemetry-http = "0.32" # HTTP 传输
opentelemetry-otlp = "0.32" # OTLP 导出器
opentelemetry-appender-tracing = "0.32" # tracing 桥接
opentelemetry-stdout = "0.32" # 控制台导出
tracing-opentelemetry = "0.33" # span 到 OTel 的映射层

这些依赖覆盖了完整的采集-桥接-导出链路。

OTLP 导出器配置

核心实现位于 crates/goose/src/otel/otlp.rs,提供三个信号的独立配置:

启用检测逻辑

Goose 按以下优先级判断是否启用某个信号的导出:

1. OTEL_SDK_DISABLED = true -> 全部禁用
2. OTEL_TRACES_EXPORTER = none -> traces 禁用
3. OTEL_METRICS_EXPORTER = console -> metrics 导出到控制台
4. OTEL_EXPORTER_OTLP_TRACES_ENDPOINT = http://... -> traces 启用 OTLP
5. OTEL_EXPORTER_OTLP_ENDPOINT = http://... -> 所有信号启用 OTLP(回退)

信号特定导出器变量覆盖通用导出器变量,none 值显式禁用。

三层 Provider 初始化

init_otlp_layers 函数依次创建三个信号的 Layer:

create_otlp_tracing_layer() -> OpenTelemetryLayer (tracing spans -> OTLP traces)
create_otlp_metrics_layer() -> MetricsLayer (tracing events -> OTLP metrics)
create_otlp_logs_layer() -> OpenTelemetryTracingBridge (tracing logs -> OTLP logs)

每个 Layer 支持两种导出模式:

OTLP 模式: 使用 opentelemetry_otlp::XxxExporter::builder().with_http().build()
Console 模式: 使用 opentelemetry_stdout::XxxExporter::default()

Resource 构建

每个 Provider 都绑定 Resource 元数据,用于在监控平台中标识服务:

service.name = "goose"
service.version = env!("CARGO_PKG_VERSION")
service.namespace = "goose"

支持通过环境变量覆盖:

OTEL_SERVICE_NAME -> 覆盖 service.name
OTEL_RESOURCE_ATTRIBUTES -> 添加自定义属性(如 deployment.environment=prod)

tracing-opentelemetry Span 映射

Goose 使用 Rust 的 tracing crate 作为日志抽象层,通过 tracing-opentelemetry 将其映射到 OpenTelemetry:

Tracing Filter 设计

三个信号各有独立的 FilterFn,控制哪些 tracing 事件导出到 OTLP:

Traces Filter:

INFO 及以上级别: 全部导出
DEBUG 级别: 仅 goose:: 和 opentelemetry 开头的 target
其他: 不导出

Metrics Filter:

INFO 及以上级别: 全部导出
DEBUG 级别: 仅 goose::telemetry、goose::metrics 和包含 "metric" 的 target
其他: 不导出

Logs Filter:

级别 <= min_level(RUST_LOG 或 OTEL_LOG_LEVEL,默认 INFO): 导出
target 在抑制列表(rmcp::service): 不导出(避免 MCP 握手日志携带 400KB+ PII)
其他: 导出

Session ID 传播

Logs Bridge 通过 TracingSpanAttributes::allowlist(["session.id"])session.id 属性从 tracing span 传播到 OTLP log record。这使得在监控平台中可以按 Session ID 关联所有日志。

配置到环境变量提升

Goose 配置文件中的 OTel 参数可以通过 promote_config_to_env 提升为环境变量:

config.otel_exporter_otlp_endpoint -> OTEL_EXPORTER_OTLP_ENDPOINT
config.otel_exporter_otlp_timeout -> OTEL_EXPORTER_OTLP_TIMEOUT

环境变量优先级高于配置文件,确保外部注入(如容器编排平台)可以覆盖配置。

优雅关闭

shutdown_otlp 函数在应用退出时按顺序关闭三个 Provider:

TRACER_PROVIDER.shutdown_with_timeout(timeout)
METER_PROVIDER.shutdown_with_timeout(timeout)
LOGGER_PROVIDER.shutdown_with_timeout(timeout)

超时时间由 otel_shutdown_timeout_ms 配置控制,默认 5000ms。这确保未发送完的 traces/metrics/logs 有机会刷新到 OTLP 端点。

企业监控场景

Goose 的 OTel 集成服务于以下企业需求:

需求实现方式
分布式追踪OTLP Traces + session.id 关联
性能分析Metrics Layer 采集工具调用时长
日志聚合OpenTelemetryTracingBridge 统一日志格式
多租户隔离Resource 属性 service.namespace
开发调试Console Exporter 输出到 stdout
敏感数据保护Logs Filter 抑制 rmcp::service(MCP 握手日志含用户记忆)

标准化日志输出

Goose 通过 opentelemetry-appender-tracingtracing 日志桥接到 OTLP Log 格式,实现:

  • 统一的时间戳格式(ISO 8601)
  • 结构化属性(severity、service.name、session.id)
  • 与 traces/metrics 的统一采样策略

日志级别优先级:RUST_LOG > OTEL_LOG_LEVEL > 默认 INFO。如果 RUST_LOG 包含指令格式(如 goose=debug),则回退到 OTEL_LOG_LEVEL 或默认值。

问题与规避

OTLP 端点配置错误

问题: OTLP 端点 URL 格式错误(如缺少 http:// 前缀、路径不对)导致导出静默失败。OTel SDK 默认不打印导出错误,开发者可能长时间不知道监控数据未送达。

规避:

  • 使用 signal_exporter() 函数检查返回值——返回 None 表示信号未启用,返回 Some(Console)Some(Otlp) 表示已配置
  • 开发阶段使用 OTEL_TRACES_EXPORTER=console 先验证 tracing 数据正确性,再切换为 OTLP
  • 在 CI 中使用 clear_otel_env() 清除所有环境变量,确保测试隔离

Span 基数爆炸

问题: 如果 span 的属性值(如 URL 路径、用户 ID、请求体)是高基度的,会导致 OTLP 后端存储爆炸。例如每次工具调用的输入参数不同,作为 span 属性导出后,每个唯一值都创建一个新的时间序列。

规避:

  • 只将低基数属性作为 span attributes(如 session.idtool_namestatus
  • 高基数数据(如请求体、完整 URL)记录为 log message 而非 span attributes
  • Goose 的 Logs Filter 特意抑制了 rmcp::service target,因为 MCP 握手日志可能包含 400KB+ 的扩展指令和用户记忆内容

全局 Provider 状态污染

问题: opentelemetry::global 使用全局单例存储 Provider。在测试环境中,一个测试设置的 Provider 会污染后续测试。

规避: goose-test-support 提供 OtelTestGuard

pub fn clear_otel_env(overrides) -> OtelTestGuard {
let prev_tracer = global::tracer_provider();
let prev_meter = global::meter_provider();
// 清除所有 OTEL_* 环境变量
// 设置 overrides
OtelTestGuard { prev_tracer, prev_meter, ... }
}
// Drop 时恢复之前的 Provider
impl Drop for OtelTestGuard {
fn drop(&mut self) {
global::set_tracer_provider(self.prev_tracer.clone());
global::set_meter_provider(self.prev_meter.clone());
}
}

每个 OTel 测试用例使用 clear_otel_env() 获取 Guard,测试结束后自动恢复全局状态。

Metrics 时序偏好配置

问题: 不同监控后端对 Metrics 的时序类型(Delta vs Cumulative)有不同偏好,硬编码可能导致数据不准确。

规避: Goose 读取 OTEL_EXPORTER_OTLP_METRICS_TEMPORALITY_PREFERENCE 环境变量,支持 deltacumulative(默认)、lowmemory 三种模式,大小写不敏感。

设计取舍

全量 OpenTelemetry vs 仅 Tracing

维度全量 OTel (Trace + Metric + Log)仅 Tracing
覆盖范围完整可观测性三支柱仅分布式追踪
复杂度三层 Provider、三层 Filter、三层关闭单 Tracer
存储成本三支柱数据,成本高仅 traces,成本低
运维需求需要 OTLP Collector、Jaeger/Grafana轻量级
开发体验Console Exporter 可本地调试直接输出到终端

Goose 选择全量 OTel 的定位是企业级 Agent 平台。Agent 的会话追踪、工具调用指标、结构化日志对 SRE 团队至关重要。但同时提供了 Console Exporter 作为开发/轻量级场景的降级方案。

Enterprise 监控 vs 简洁性

Goose 在 OTel 集成上做了以下简洁性取舍:

  • 复用 tracing 生态:不直接调用 OTel SDK,而是通过 tracing + tracing-opentelemetry 桥接。这样应用代码只需写 tracing::info!(),无需关心底层导出方式
  • 环境变量驱动:所有 OTel 配置通过标准环境变量(OTEL_*),与 OpenTelemetry 生态工具(Collector、SDK)一致,无需自定义配置格式
  • 条件启用:不设置 OTLP 端点时,OTel Layer 不初始化,零性能开销
  • 敏感数据抑制:Logs Filter 硬编码抑制 rmcp::service,防止 MCP 握手日志泄露用户数据

参考来源

  • 源码:.op/goose/crates/goose/src/otel/otlp.rs — OTLP 层完整实现,含 600+ 行代码与测试
  • 源码:.op/goose/crates/goose-test-support/src/otel.rs — 测试环境 OTel 隔离工具
  • 源码:.op/goose/Cargo.toml — OpenTelemetry SDK 依赖声明(第 83-89 行)
  • 文档:OpenTelemetry 规范 https://opentelemetry.io/docs/