可观测性联动
排障很少是"打开一个系统看到答案"。真实过程更像接力:监控告警说 checkout 服务错误率涨了,你点开曲线上的一个点,跳到一条具体的 trace,看到 340ms 里 300ms 卡在某个 SQL span 上,再从这条 span 跳到同一个服务同一时刻的日志,读到那条慢查询的具体参数——修完之后回到曲线确认回落。指标、链路、日志三个信号(可观测性"三支柱")各自都不完整,把它们缝起来的那根线,是一个 32 位十六进制的 trace_id。
本章就是讲这些"通道"怎么修。口径:Jaeger v2.22.0(2026-10-06)、OTel Collector spanmetrics connector(v0.163.0)、Grafana v13.2 数据源文档(2026-08-11 复核)。Prometheus/Loki/Grafana 自身的深入用法不在本章范围,只讲与 Jaeger 衔接的配置面和容易断掉的环节。
排障动线:三种信号怎么互相找到对方
先明确各支柱单独回答什么问题:
- 指标(Metrics):出事了吗、哪个服务哪个接口、恶化到什么程度。便宜、可聚合、适合告警和趋势,但它把千万次请求压成一个数字,单请求细节全丢了。
- 链路(Traces):这一次请求的时间花在调用链的哪一跳、哪一跳报错。按请求记录、细节丰富,但太贵,通常要采样,而且不适合直接做告警。
- 日志(Logs):这个 span 对应的那行代码里,到底发生了什么(异常栈、参数、上游返回值)。细节最全,但跨服务对时间戳拼线索效率最低。
三者的关系是"逐级下钻 + 原路返回":
告警响起(Prometheus:错误率/延迟越过阈值,已知 service+operation+时间窗)
| 从数据点取 exemplar: trace_id=<32位hex>
v
Jaeger: 按 trace_id 展开调用树,定位最慢/出错的 span
| Trace to logs:按 span 的 tag 与时间窗过滤日志
v
Loki: 日志行里带着同一条 trace_id,顺藤摸到上下文相邻日志
| 修复、发布
v
回到指标曲线确认回落;留存 trace_id 便于复盘反方向(从日志/指标跳进 Jaeger)靠的机制各有名字,先建立地图,后面逐一落地:
| 跳转方向 | 机制 | 落地位置 |
|---|---|---|
| 指标 → 链路 | exemplar(数据点上附原始 trace_id) | spanmetrics connector 开启 exemplars + Prometheus 开 exemplar-storage |
| 日志 → 链路 | derived fields(正则从日志行提取生成跳转链接) | Grafana Loki 数据源 derivedFields |
| 链路 → 日志 | Trace to logs(按 tag 匹配日志标签 + 时间平移) | Grafana Jaeger 数据源配置 |
| 链路 → 指标 | Trace to metrics(拼接 PromQL 跳转) | Grafana Jaeger 数据源配置 |
| 链路 → 指标(聚合) | SPM:从 span 派生 RED 指标 | spanmetrics connector 或 ES/OS 直查,见下文 |
贯穿这一切的 ID 只有一种:W3C/OTel 格式的 trace_id,128-bit、32 位小写十六进制(格式见 01-核心概念.md,埋点侧怎么拿到它见 05-Go客户端埋点.md)。exemplar 里带的是它,日志行里打的是它,Grafana 跳 Jaeger 的 URL 参数还是它。任何一环格式或字段名对不上,通道就断。
Jaeger 在联动里的边界
OTLP 协议(4317/4318)本身同时承载 traces/metrics/logs 三种信号,但 Jaeger 的定位始终是 tracing 后端:落到 Jaeger 存储里的只有 spans,它不存储、也不查询指标和日志。所以别指望"把 metrics 也发到 Jaeger 就全有了"——Jaeger 与另外两支柱的接口只有两个:
- 从 trace 派生指标(SPM,Monitor 页签 + 供告警用的 RED 时间序列),本质是"traces 是另一种形态的指标数据源";
- 成为跳转的终点/起点:Grafana 里作为 trace 数据源被日志/指标面板链过去,以及 Trace to logs/metrics 链出去。
因此"联动工程"的配置分摊在两边:Jaeger/Collector 侧负责把 trace_id 和由它派生的指标送进 Prometheus/Grafana 能看到的地方;Prometheus/Loki/Grafana 侧负责认这串 ID(exemplar 存储、derived fields、数据源关联)。Jaeger 自身的自监控指标(/metrics,8888 端口)只在本章排查环节用到一句,完整口径见 09-性能与运维.md。
SPM:从 trace 派生 RED 指标
Jaeger v2 的 SPM(Service Performance Monitoring)替代了 v1 时代独立部署的组件思路,由官方文档给出两条路线,产出的都是按 service+operation 聚合的 RED 指标(Rate/Errors/Duration,P50/P75/P95),驱动 UI 的 Monitor 页签,也天然适合接 Prometheus 告警。
指标的形状
按 v2.22 文档口径,SPM 关注两个指标名(可带命名空间前缀 [namespace_]):
calls_total:counter,span 总数。错误与否由标签区分:status_code="STATUS_CODE_ERROR"的序列即错误数;duration_[units]:histogram,span 时长直方图。落到 Prometheus 里会展开成duration_milliseconds_count / _sum / _bucket三族时间序列,_bucket按le分桶,默认 17 个桶。
注意标签名的两次变换:OTel 侧维度叫 status.code、service.name(点号),经 Prometheus 导出惯例清洗后变成 status_code、service_name(下划线)。v2.22 文档故障排查一节给出的真实 Prometheus 序列名带 connector 默认命名空间,形如 traces_span_metrics_calls_total 和 traces_span_metrics_duration_milliseconds_bucket,Monitor 页签对 Prometheus 发的查询也长这样:
sum(rate(traces_span_metrics_calls_total{service_name =~ "redis",
span_kind =~ "SPAN_KIND_SERVER"}[10m])) by (service_name, span_name)写自己的告警表达式前,以自己采集端的 /metrics 实际输出为准,不要照抄任何年代教程猜名字;名字对不上时用后文的 normalize_calls/normalize_duration 或显式 namespace 对齐。
路线一:spanmetrics connector + Prometheus 后端(可告警的路线)
链路:span 流经 Collector 时,一部分进 Jaeger 存储,另一部分被 spanmetrics connector 聚合成指标,经 prometheus exporter 开一个采集端点,外部 Prometheus 来 scrape;Jaeger Query 再连回这个 Prometheus 供 UI 查询。v2 的 jaeger 二进制本身就是 OTel Collector 定制发行版(见 02-v2架构与组件.md),所以 connector 可以直接配在 jaeger 进程里,也可以放在独立的 Collector 上——官方 config-spm 示例就是把 connector 写进 jaeger 的配置,演示用,生产拆分更常见。
Collector/Jaeger 配置骨架(按 v2.22 文档整理;connector 组件名见下文"改名"一节,旧名 spanmetrics 仍可用):
exporters:
prometheus:
endpoint: "0.0.0.0:8889" # 给 Prometheus 采集用的端点,别与 8888 自监控混淆
connectors:
span_metrics:
exemplars:
enabled: true # 默认 false,想要指标到链路的一跳就打开它
service:
pipelines:
traces:
receivers: [otlp]
processors: [batch]
exporters: [jaeger_storage_exporter, span_metrics]
metrics/span_metrics:
receivers: [span_metrics]
exporters: [prometheus]Jaeger 侧声明指标后端并接进查询扩展:
extensions:
jaeger_storage:
backends:
some_trace_storage: # trace 存储,配置见 04-存储后端.md
...
metric_backends:
some_metrics_storage:
prometheus:
endpoint: http://prometheus:9090
normalize_calls: true # 让 Jaeger 按 calls_total 形态找指标
normalize_duration: true # 按 duration_milliseconds_bucket 形态找
jaeger_query:
storage:
traces: some_trace_storage
metrics: some_metrics_storagenormalize_* 这对开关专治命名错位:v2.22 文档记录过真实事故——OTel 侧导出的计数器带 _total 后缀、直方图带单位后缀(duration_milliseconds_bucket),而 Jaeger 默认找 calls/duration_bucket,名字对不上时 Monitor 有数据源却没图,打开 normalize 即可。
Prometheus 侧常规 scrape:
scrape_configs:
- job_name: spanmetrics
scrape_interval: 15s
static_configs:
- targets: ["jaeger:8889"]UI 侧打开 Monitor 页签:Jaeger UI 配置里设置 monitor.menuEnabled=true(v2 的挂载方式见 03-部署与Kubernetes.md)。
{
"monitor": {
"menuEnabled": true
}
}用 SPM 指标写告警
Monitor 页签是给人在面板里点着玩的;进告警系统的是同一批 Prometheus 时间序列。两个最常用的表达式:
# checkout 服务 server span 的 5 分钟错误率
sum(rate(traces_span_metrics_calls_total{service_name="checkout",
span_kind="SPAN_KIND_SERVER", status_code="STATUS_CODE_ERROR"}[5m]))
/
sum(rate(traces_span_metrics_calls_total{service_name="checkout",
span_kind="SPAN_KIND_SERVER"}[5m]))
# 按接口聚合的 P95 延迟,单位毫秒(默认单位,阈值按 ms 写)
histogram_quantile(0.95,
sum by (le, service_name, span_name) (
rate(traces_span_metrics_duration_milliseconds_bucket[5m])))告警路由、静默、SLO 化是告警系统的话题,本章不展开;要提前知道的只有下一节的基数账。
注意 时间序列基数按 v2.22 文档公式估算:
status_code 数 × span_kind 数 × (1 + 直方图桶数) × 操作数。默认配置下典型值为2 × 2 × 18 = 72条序列每操作(上限按 3 种 status、6 种 kind 算,324×操作数)。一个 30 服务、平均 20 个接口的系统就是约 4.3 万条序列——直方图桶数改大、dimensions 加宽都会线性放大这个数。另外 SPM 查询层不支持自定义维度(多余的维度会被聚合掉),自定义桶也需计入公式。
路线二:Elasticsearch/OpenSearch 直查(省组件的路线)
如果 trace 存储本身就是 ES/OS,Jaeger Query 可以在查询时直接对它聚合出 RED 指标,不需要 connector、不需要独立 metrics pipeline、不需要额外指标库:
extensions:
jaeger_storage:
backends:
elasticsearch_trace_storage: &es_config
elasticsearch:
endpoint: http://elasticsearch:9200
...
metric_backends:
elasticsearch_trace_storage: *es_config # 指标后端指回同一个 ES 存储
jaeger_query:
storage:
traces: elasticsearch_trace_storage
metrics: elasticsearch_trace_storage # 同一个后端OpenSearch 同构(官方另有 config-spm-opensearch 示例,ClickHouse 直查为实验性支持)。同样只需 monitor.menuEnabled=true。
取舍很直接:直查路线组件最少、没有独立存储和采集链的运维负担,代价是聚合查询压力全部落在 trace 存储上,且指标不落到 Prometheus,等于没有这条告警通道——想从 SPM 数据报警,要么用路线一,要么在 ES 侧自建 watcher,后者已脱离 Jaeger 联动范畴。中小规模 ES 存储用直查,有成熟 Prometheus 告警体系用路线一,是多数团队的选择。
Monitor 页签的数据也能程序化取:v2.22 提供 jaeger.api_v2.metrics.MetricsQueryService gRPC 端点与 UI 内部使用的 HTTP JSON 接口,查询 API 全貌见 07-查询UI与API.md。
SPM 不通时的自查
Monitor 空白、报错,按 v2.22 文档的排查顺序:
- Jaeger 读指标是否成功:开内部 telemetry(
service.telemetry.metrics,0.0.0.0:8888),curl http://jaeger:8888/metrics | grep jaeger_metricstore。关注jaeger_metricstore_requests_total,operation 标签有get_call_rates/get_error_rates/get_latencies/get_min_step_duration,正常时result="ok"递增、result="err"静止。注意端口账:8888 是 Jaeger 自监控,8889 是 connector 导出给 Prometheus 采集的端点,两者必须错开。 - 指标真的进了 Prometheus 没有:直接在 Prometheus 查
traces_span_metrics_calls_total、traces_span_metrics_duration_milliseconds_bucket这类序列是否随流量增长。有数据但 UI 空白,多半是命名错位,把telemetry.logs.level: debug打开,看 Jaeger 实际发出的 PromQL 里用的指标名,再决定 normalize 开关。 - 有错误 span 却没有错误指标:检查指标序列上有没有
status.code标签——Jaeger 靠它判定错误。若没有,检查 connector 配置里是不是写了exclude_dimensions: ['status.code']把它排除了。 - 某 service/operation 在 Monitor 消失但搜索下拉里有:SPM 查询默认只取
serverspan kind(避免 client/server 双向重复计数)。该服务的埋点没设 span kind 或全是 client/unspecified,就查不到——这是埋点质量问题,回05-Go客户端埋点.md修。 - Prometheus 返回 403:指标后端配置支持
token_file_path(bearer token 文件)与token_override_from_context。
spanmetrics connector 深读
路线一的引擎是这个 connector,几个直接影响联动的配置事实(均按 v0.163.0 模块 README 核实):
组件改名。connector 的组件类型已从 spanmetrics 更名为 span_metrics(snake_case,与 Collector 命名规范对齐);旧名 spanmetrics 仍可用但已标记 deprecated,将在未来版本移除。新配置一律写 span_metrics;Jaeger 官方 SPM 文档的示例目前仍写作 spanmetrics,两者解析到同一组件。
默认指标与维度。生成的指标名默认带命名空间 traces.span.metrics,即 traces.span.metrics.calls(counter)、traces.span.metrics.duration(histogram)、开启后 traces.span.metrics.events。所有序列至少带这些公共维度:service.name、span.name、span.kind、status.code、collector.instance.id。可通过 dimensions 追加维度,每个条目是 name(可带 default,属性缺失时用默认值,不给 default 则属性缺失时整条维度省略)或 glob 模式(. 为分隔符,k8s.*.name 匹配单级、db.**.name 匹配任意多级)。calls_dimensions/histogram.dimensions 可分别只给 calls 或 duration 加维度。
桶与单位。histogram 支持 explicit(默认 16 个边界:2ms 4ms 6ms 8ms 10ms 50ms 100ms 200ms 400ms 800ms 1s 1400ms 2s 5s 10s 15s)与 exponential(max_size 默认 160)两种。单位 histogram.unit 默认 ms。
注意 feature gate
connector.spanmetrics.useSecondAsDefaultMetricsUnit打开后 duration 指标默认单位改为秒,官方已预告将以秒为默认值(对齐 OTel 语义约定)。你的告警阈值(P95 = 800 这种毫秒数)在单位切换后会整体错一千倍——升级 Collector 组件时把这件事列进检查项。
exemplars。exemplars.enabled 默认 false;打开后每个数据点最多附 max_per_data_point(默认 5)条原始 span 作为 exemplar,且 exemplar 只保留一个 flush 周期(metrics_flush_interval 默认 60s)。这两个参数直接决定"从指标点跳到 trace"的体验,详见下文 exemplar 通路一节。
temporality 与基数。aggregation_temporality 默认 AGGREGATION_TEMPORALITY_CUMULATIVE,可改 ..._DELTA,两种都支持进 Prometheus。aggregation_cardinality_limit 默认 0(不限),超限后新维度组合会被并入 otel.metric.overflow="true" 序列。dimensions_cache_size 已废弃,同样用 cardinality limit 管理。系列过期用 series_expiration/metrics_expiration 控制,低峰期可回收长期不再出现的维度组合。
collector.instance.id 与单写者原则。默认维度里的 collector.instance.id 是每个 Collector 实例的 UUID,目的是多副本部署时不违反 Single Writer 原则(各自累计各自的序列)。副作用是同样的 service+operation 会按实例分裂成多条序列,PromQL 聚合时用 sum by (...) 抹掉它即可。若确认单实例或接受重启归零,可用 exclude_dimensions: ['collector.instance.id'] 或 feature gate --feature-gates=-connector.spanmetrics.includeCollectorInstanceID 去掉。
一份把常用键排齐的配置(可直接改成生产用,键名均以 v0.163.0 README 为准):
connectors:
span_metrics:
namespace: traces.span.metrics # 默认值;改短它会改变 Prometheus 侧序列名前缀
metrics_flush_interval: 15s # 默认 60s;越短 exemplar 存活窗口越短但更实时
aggregation_temporality: AGGREGATION_TEMPORALITY_CUMULATIVE
histogram:
unit: ms # 注意上文"默认将改为 s"的预警
explicit:
buckets: [2ms, 4ms, 6ms, 8ms, 10ms, 50ms, 100ms, 200ms, 400ms, 800ms, 1s, 1400ms, 2s, 5s, 10s, 15s]
dimensions:
- name: http.method
default: GET
- name: http.status_code
- glob: "k8s.*.name"
exclude_dimensions: ['collector.instance.id']
aggregation_cardinality_limit: 100000
series_expiration: 30m
exemplars:
enabled: true
max_per_data_point: 5
events:
enabled: true
dimensions:
- name: exception.type另有一条历史线索可解释大量旧教程:span_metrics 的前身是 span-to-metrics processor,connector 化时做了破坏性改名——维度 operation 改为 span.name、histogram 的 latency 改为 duration、生成名不再带 _total、不再做 Prometheus 式标签清洗(_total/单位后缀是之后 Prometheus exporter 导出时按惯例补的)。看到 latency_bucket、traces_spanmetrics_calls_total 这类名字,基本可断定素材来自 connector 化之前,先对照 2.22 文档重校再抄。
日志关联:让日志带上 trace_id
三个方向里最常用也最容易断的是日志通道:日志里没有 trace_id(或有但注入不上),后面一切跳转都是空谈。
在 Go 里注入:slog 的 handler 包装
做法是包装一层 slog.Handler,在 Handle 里从 ctx 取 span context,有 trace 就把 trace_id/span_id 追加进日志属性。这样业务代码不用关心追踪系统,没接 tracing 时日志照常输出:
package main
import (
"context"
"log/slog"
"os"
"go.opentelemetry.io/otel/trace"
)
// TraceHandler 包装任意 slog.Handler:凡 ctx 中带有效 span 的记录,
// 自动追加 trace_id / span_id 两个属性。
type TraceHandler struct {
slog.Handler
}
func NewTraceHandler(h slog.Handler) *TraceHandler {
return &TraceHandler{Handler: h}
}
func (h *TraceHandler) Handle(ctx context.Context, r slog.Record) error {
if sc := trace.SpanContextFromContext(ctx); sc.HasTraceID() {
r.AddAttrs(
slog.String("trace_id", sc.TraceID().String()),
slog.String("span_id", sc.SpanID().String()),
)
}
return h.Handler.Handle(ctx, r)
}
func (h *TraceHandler) WithAttrs(attrs []slog.Attr) slog.Handler {
return &TraceHandler{Handler: h.Handler.WithAttrs(attrs)}
}
func (h *TraceHandler) WithGroup(name string) slog.Handler {
return &TraceHandler{Handler: h.Handler.WithGroup(name)}
}
func main() {
log := slog.New(NewTraceHandler(slog.NewJSONHandler(os.Stdout, nil)))
// 真实服务里,ctx 应来自埋点中间件之后携带 span 的上下文(见 05-Go客户端埋点.md)。
// 用 context.Background() 演示"无 span"路径:不会注入 trace_id,日志正常输出。
log.InfoContext(context.Background(), "order created", slog.String("order_id", "A-1024"))
}ctx 里有 span 时,同一行输出:
{"time":"2026-10-20T14:32:07.112+08:00","level":"INFO","msg":"order created","order_id":"A-1024","trace_id":"4bf92f3577b34da6a3ce929d0e0e4736","span_id":"00f067aa0ba902b7"}注意 两个高频失血点。其一,必须用
logger.InfoContext/WarnContext/...这一族带 ctx 的方法——裸的logger.Info()传给 handler 的是空 ctx,永远注入不上;其二,后台 goroutine 里若直接context.Background()起 ctx 也注入不上,想把请求链路的 span 带过去,用context.WithoutCancel(ctx)(Go 1.21+)保留值、剥离取消信号,或干脆为异步任务新建 span 再手动记录父 trace_id。这两条坑在10-常见陷阱与最佳实践.md亦有汇总。
Loki 侧的字段设计:trace_id 进日志行,不进标签
Loki 只索引标签(labels),标签设计原则是"少而可枚举"。trace_id 每行都不同,把它做成标签等于给 Loki 制造无限基数,是明确的反模式。正确姿势:采集时标签只放 service(或 job)、namespace、pod 这类低基数维度,trace_id 留在日志正文里,由 Grafana 查询时用 derived fields 正则提取。这与 OTel 语义约定、Jaeger span 的 service.name 属性形成对应,也为下文 Trace to logs 的标签匹配打下基础。
Grafana derived fields:日志一键跳 trace
Loki 数据源的 Derived fields 支持从日志行(Regex in log line)或标签(Label)提取字段生成链接:标签模式可用 trace[_]?id 这类正则匹配 traceid/trace_id 变体;正文模式写捕获组,Grafana 取第一个捕获组作为字段值。链接可以走内部(指向本 Grafana 的 trace 数据源)或外部(直接拼 Jaeger UI 的 /trace/<id> URL,格式见 07-查询UI与API.md)。
provisioning YAML 形态(官方文档示例的同构写法,注意 YAML 里 $ 要写成 $$ 转义,且 Jaeger 数据源的 uid 必须与 datasourceUid 一致):
apiVersion: 1
datasources:
- name: Loki
type: loki
uid: loki-prod
access: proxy
url: http://loki:3100
jsonData:
derivedFields:
- name: TraceID
datasourceUid: jaeger-prod # 对上文的 Jaeger 数据源 uid
matcherRegex: "trace_id=([0-9a-f]{32})" # 正文形态,等价于 trace_id: 前缀也行
url: '$${__value.raw}' # 对 trace 数据源而言,这就是 trace 查询参数
urlDisplayLabel: 'View Trace'
- name: Jaeger
type: jaeger
uid: jaeger-prod
access: proxy
url: http://jaeger-query:16686要点与限制:每条日志行只能出一个 derived field 值(多个捕获组不能拼成一个字段);复杂正则在海量日志上有浏览器性能开销,够用即可;内部链接只是"把提取值交给目标数据源",目标里没有这条 trace 时,链接照常渲染、点开却是空结果。
最后一句值得单独说,因为它和采样直接相关:head 采样下未命中的 trace 根本没进 Jaeger(见 06-采样.md),日志里的 trace_id 是真的,跳转目标却不存在,“跳空"不是通道坏了,而是采样预算的体现。两种管理预期的做法:对错误率敏感的关键服务用 tail-based 采样保错误全采;或在告警/日志文案上接受"部分链接会空"的现实,别为了链接必中把采样率拉满。
Grafana 的 Jaeger 数据源:trace 向外跳
Jaeger 数据源在 Grafana OSS/Enterprise 中预装(Grafana v13.2 起作为独立插件随 catalog 自动更新)。能力矩阵很克制:只支持 Traces,Metrics/Logs/Alerting/Annotations 全为 No——它的全部价值在"关联”:把 trace 视图里的信息变成通往日志和指标面板的链接。
Trace to logs:从 span 跳到 Loki
配置两种模式。简单模式填目标日志数据源 + 标签映射:Tags 里的键必须存在于 span 的属性或资源中,匹配到的值会拼成日志查询的标签过滤;默认尝试的标签是 cluster、hostname、namespace、pod、service.name、service.namespace。目标数据源不允许点号标签时要做重映射,例如 span 属性 http.status 映到 Loki 的 http_status——这正是"span tag 名与日志标签名对齐"这件事的具象。Span start/end time shift 用 5s/1m/3h(可为负)平移或放宽查询时间窗,应对日志时间与 span 时间不完全一致;可勾选 Filter by trace ID / Filter by span ID 把 ID 也拼进查询。
自定义查询模式用模板变量拼任意 LogQL:${__tags} 插入已映射标签,${__span.traceId}、${__span.tags["http.status"]}(带点号的标签用下标写法)、${__trace.duration} 等变量按需组合。规则:所有变量都解析出非空值,链接才会出现——引用了 span 上并不存在的 tag,链接就整个消失,这是"我的跳转按钮怎么没了"的第一排查点。
provisioning 形态(jsonData 节选,$$ 转义同理):
jsonData:
tracesToLogsV2:
datasourceUid: loki-prod
spanStartTimeShift: '-5m'
spanEndTimeShift: '5m'
tags:
- key: 'service.name'
value: 'service'
- key: 'k8s.pod.name'
value: 'pod'
filterByTraceID: true
filterBySpanID: false
customQuery: falseTrace to metrics:从 span 跳到指标面板
选目标指标数据源,Tags 把 span 属性名映射为指标标签名(如 k8s.pod 映到 pod),每条关联查询 = Link Label + Query,查询里用 $__tags 展开成标签选择器。官方文档的例子:requests_total{$__tags} 配上映射后实际发出 requests_total{pod="nginx-554b9", cluster="us-east-1"}。给 SPM 用户最实用的两条是"看这个 service+operation 的 P95 曲线"和"看错误率曲线"——查询里的指标名/标签名就按本章前文的 Prometheus 口径写。
jsonData:
tracesToMetrics:
datasourceUid: prom
spanStartTimeShift: '-2m'
spanEndTimeShift: '2m'
tags:
- key: 'service.name'
value: 'service'
queries:
- name: 'Call rate'
query: 'sum(rate(traces_span_metrics_calls_total{$$__tags}[5m]))'指标 → trace:exemplar 通路
反向通道是 Grafana 文档明说的组合:“从日志用 derived fields、从指标用 exemplars 链回 Jaeger trace”。三处配置缺一不可:
- connector 侧:
exemplars.enabled: true(默认关,这是最常见的"曲线上没有可点的小圆点"原因); - Prometheus 侧:启动参数
--enable-feature=exemplar-storage。不开,scrape 到的 exemplar 直接丢弃;开了,exemplar 存在内存固定大小环形缓冲里(一条带trace_id的 exemplar 约 100 字节),缓冲容量由配置文件storage.exemplars块控制,并随 WAL 做本地持久化; - Grafana 侧:面板/查询编辑里启用 exemplar 并把 trace 数据源指到 Jaeger。
时序上要有预期:connector 的 exemplar 只活一个 flush 周期,Prometheus 侧是环形缓冲、新数据挤掉旧数据——exemplar 是"正在响的告警"这种现场的工作方式,隔天复盘十有八九已经取不到,那时靠的是写进日志行的 trace_id。
端到端演练:从告警到修复的一条完整动线
用一个 30 分钟能走完的剧本把本章串起来。前提:三条通道都已配好(SPM 路线一的指标进 Prometheus、日志带 trace_id、Grafana 数据源双向关联)。
- 告警响起:Alertmanager 通知错误率表达式越过阈值,标注里带着
service_name="checkout"、span_name="POST /order"、时间窗。这些标签来自 SPM 指标,天然可读。 - 取 trace_id:在 Grafana 对应曲线面板上点开告警时间窗内的 exemplar 点,拿到
trace_id=4bf92f3577b34da6a3ce929d0e0e4736。若 exemplar 已被冲掉(超出一个 flush 周期/缓冲),退化方案:Jaeger 搜索页按 service+operation+时间窗过滤(见07-查询UI与API.md)。 - 看链路:trace 展开,发现
POST /order的 340ms 里一个SQL SELECT子 span 占 300ms,再往下是某依赖服务的 client span 超时重试。记下出错 span 的 service 与时间戳。 - 跳日志:在该 span 上触发 Trace to logs 链接(tag 匹配 + 时间窗已含前后 5 分钟),落到 Loki 上下文;顺日志行的
trace_id=用 derived field 反查同请求在其他服务的全部日志,确认是缓存失效引发的雪崩。 - 修复并验证:发布修复;回到指标面板看错误率与 P95 回落;把这条 trace 的 ID 记进事故单——trace 存储的保留期内随时能原样调出(存储与过期见
04-存储后端.md)。
三支柱 ID 一致性检查清单——任何一条不满足,上面剧本就会在对应环节断掉:
- 日志里的 trace_id 是 32 位小写 hex,与 Jaeger URL/查询参数完全同值(注入用带 span 的 ctx,调用用
InfoContext族); - span 资源属性
service.name与日志标签(如 Loki 的service)、指标标签(service_name)三者语义一致,点号/下划线差异有明确的映射表; - 指标序列名按你部署的实际 connector 命名与 Prometheus 抓取形态核对,告警表达式和 Jaeger SPM 的
normalize_*用同一套结论; - exemplars 三处开关(connector /
--enable-feature=exemplar-storage/ Grafana 面板)全开,且接受"只活一个 flush 周期"的时效; - 采样策略与跳转预期匹配:低 head 采样率意味着日志→trace 的链接会按比例跳空,关键服务考虑 tail 采样或全采短保留;
- duration 指标单位(ms 或即将改为 s)与告警阈值的量纲对过账。
实战建议
- 联动的最小可用集是两件事:日志行里稳定注入
trace_id,SPM 走路线一(指标进 Prometheus)。前者解决 90% 的"从日志找链路",后者解决"从告警进现场"。ES/OS 直查路线留作 UI 便利功能,不承担告警职责。 - 命名口径每半年查一次岗:
span_metrics改名、_total/单位后缀、traces_spanmetrics_*旧名 processor 素材,都说明这块是变动密集区。写查询前看一眼自己/metrics端点的真实输出,升级 Collector 组件时重读 connector README 的 deprecation 与 feature gate 段落。 - 单位从 ms 改 s 是这颗延迟引信:它同时打穿直方图桶义、告警阈值、SLO 表达式。改桶、改单位都要按
status × kind × (1+桶数) × 操作数重新过基数账,基数失控在 Prometheus 侧的症状通常是内存曲线而非 UI 报错。 - 标签对齐是跨团队约定:埋点规范里写明"哪些 span 属性会被当作日志匹配键"(通常就是 service.name/pod/cluster),日志采集规范里写明"Loki 标签白名单、trace_id 只进正文"。两边各自随机演化,Trace to logs 就会悄悄失效——它失效的形式是链接不出现,而不是报错。
- exemplar 用于现场,不用于取证:它的保留只有一个 flush 周期外加 Prometheus 环形缓冲,复盘永远以日志行和已入库 trace 为准。
- 在告警文案里直接嵌 Jaeger 深链(
/search?service=...或/trace/<id>)能把"看到告警"到"进入现场"压到一次点击;URL 参数形态见07-查询UI与API.md,深链依赖保留期,过期 trace 跳过去同样是空。 - 演练常态化:每个季度走一遍本章剧本(触发一次测试错误→从告警走到日志),三个跳转方向各验一次。通道坏掉最常见的原因是配置漂移(数据源 uid 改名、标签重映射丢失),这类故障只有在演练时才会现形,而不是在真正的凌晨告警里。