从 Trace 到时间序列:Grafana Tempo TraceQL Metrics 指标查询的设计与实现
2026/9/18 5:19:09 网站建设 项目流程

从 Trace 到时间序列:Grafana Tempo TraceQL Metrics 指标查询的设计与实现

【免费下载链接】tempoGrafana Tempo is a high volume, minimal dependency distributed tracing backend.项目地址: https://gitcode.com/GitHub_Trending/tempo1/tempo

导读:TraceQL 指标查询(TraceQL Metrics)是 Grafana Tempo 在 TraceQL 语言基础上扩展出的时序指标能力,它让用户可以像 LogQL 从日志中算指标那样,直接从 Trace 数据中聚合出错误率、请求速率、P95 延迟等时间序列,而无需引入独立的 metrics-generator 派生指标管线。本文以 Tempo 仓库中的设计提案 docs/design-proposals/2023-11 TraceQL Metrics.md 为主体,结合仓库源码逐层剖析其"Span 查询 + 指标管线"的核心模型、六个第一阶段的_over_time聚合函数、分组与过滤、算术运算、与 PromQL/LogQL 的设计差异,以及查询 API 与前端配置等工程化落地细节。读完本文,你将能够理解并编写{status=error} | rate() by (resource.service.name)这类查询背后的完整执行链路,并知道如何在 Tempo 中配置与调优指标查询。

一、为什么要做 TraceQL 指标查询

Tempo 的常规 TraceQL 查询解决的是"找到匹配的 Span"这类问题:输入一段 Span 查询,返回一批独立的 Trace 数据点(时间、耗时、服务名、状态码等)。但在可观测性实践中,用户常常还需要"从这些 Trace 里算出指标",例如:

  • 某个服务在最近 1 小时内的错误率随时间的变化;
  • 某个接口的 P95 延迟曲线;
  • 某个服务的每秒请求数是否超过阈值。

设计提案 docs/design-proposals/2023-11 TraceQL Metrics.md(下称"提案")提出的解决方案是:在语言层面为 TraceQL 增加指标管线阶段(metrics pipeline stages),让一个常规 TraceQL 查询的返回结果继续被聚合为时间序列。这样既不需要预先为每个维度组合落盘指标,也能复用 Tempo 已有的 Trace 存储与查询能力。

从源码结构看,该能力已经完整落地于pkg/traceql包:词法器 pkg/traceql/lexer.go 注册了ratecount_over_timemin_over_timemax_over_timeavg_over_timesum_over_timequantile_over_timehistogram_over_timecomparetopkbottomk等关键字,语法文件 pkg/traceql/expr.y 定义了完整的指标聚合语法规则,执行引擎 pkg/traceql/engine_metrics.go 与 pkg/traceql/ast_metrics.go 实现了从 Span 观测到时间序列输出的全链路。

二、核心模型:Span 查询 + 指标管线

提案给出的核心结构非常简洁:

<span query...> | <metrics query...>

前半段是任意合法的 TraceQL Span 查询,负责"找到匹配的 Span";后半段是指标管线阶段,负责把匹配到的 Span 按时间聚合为时间序列。两者通过管道符|连接。

一个重要推论是:任何合法的 TraceQL 查询都可以被改写成指标查询。提案中的示例是在 Span 查询中使用结构化关系运算符后接rate()

{resource.service.name="A"} >> {resource.service.name="B"} | rate()

这条查询先找出"由服务 A 发起、指向服务 B"的调用 Span,再对这些 Span 计算每秒调用速率——即"服务 A 对服务 B 的调用速率"。

在源码层面,这个模型体现为 pkg/traceql/ast_metrics.go 中定义的三类处理器接口:

  • spanProcessor:处理单个 Span 观测(第一阶段,直接把 Span 聚合成原始时间序列);
  • seriesProcessor:处理预聚合的时间序列(用于跨 job/pod 合并结果);
  • secondStageElement:对第一阶段产出的 SeriesSet 做二次加工(topk/bottomk、比较过滤、算术等)。

与之配套的AggregateMode枚举(见 pkg/traceql/enum_aggregates.go)把指标查询的执行分为三档:

模式含义典型场景
AggregateModeRaw直接在 Span 上运行,产出第一层原始时间序列每个执行 job/分片内部
AggregateModeSum对多个 job/pod 的中间结果做合并(rate/count 是简单相加,min/max 再做一层 min/max)分布式执行时汇总分片
AggregateModeFinal必须在单点完成的最终计算(分位数、平均值等)前端最终聚合

这种"Raw → Sum → Final"的分级设计,正是为了支撑指标查询在多分片/多 job 下的大规模并行执行。

三、第一阶段:把 Span 变成时间序列

第一阶段的职责是"把 Span 变成时间序列",这是 Trace 数据与指标数据唯一的交汇点。提案用_over_time后缀区分这些专用聚合函数与其(如 PromQL 中的)同名概念。

3.1 聚合函数一览

提案给出的第一阶段函数如下:

函数说明
rate()每秒的 Span 速率
count_over_time()Span 的总计数
avg_over_time(<field>)数值字段的平均值,如durationhttp.request.body.size等语义约定属性
max_over_time(<field>)数值字段的最大值
min_over_time(<field>)数值字段的最小值
quantile_over_time(<field>, q1, q2, ...)数值字段的分位数(如 P95)。可一次请求多个分位数,每个分位数生成一条时间序列

在仓库的最终实现中,函数集合进一步扩充,MetricsAggregateOp枚举(pkg/traceql/enum_aggregates.go)还包括了提案未列出的sum_over_timehistogram_over_time。语法规则(pkg/traceql/expr.y 第 330-349 行)完整定义了这些函数的形参:

  • rate()count_over_time():无参数;
  • min_over_time(<attr>)max_over_time(<attr>)sum_over_time(<attr>)avg_over_time(<attr>):一个属性参数;
  • quantile_over_time(<attr>, <numericList>):属性 + 一个或多个分位数;
  • histogram_over_time(<attr>):一个属性参数(内部用对数桶实现)。

3.2 源码视角:聚合器如何工作

第一阶段的执行核心在 pkg/traceql/engine_metrics.go:

  • CountOverTimeAggregator:统计 Span 数量;NewRateAggregator(rateMult)复用同一结构,在Sample()时乘上rateMult = 1.0 / step 秒数,从而把"每个时间桶的计数"换算成"每秒速率"(见 pkg/traceql/ast_metrics.go 第 223-227 行);
  • OverTimeAggregator:针对某个属性执行 min/max/sum 聚合,对duration等内在字段或任意属性使用FloatizeAttribute提取数值;
  • StepAggregator:按查询的 step 把时间窗切成多个桶,每个桶内跑一个VectorAggregator,从而产出"每个时间点一个值"的序列;
  • InstantAggregator:用于瞬时查询(instant query),只有一个桶,输出单个数据点。

分组(by())则由GroupingAggregator实现:它按分组属性组合把 Span 路由到不同系列,并为每个系列维护独立的内部聚合器;未分组时退化为UngroupedAggregator,输出单条无标签序列(并自动补一个__name__=rate之类的指标名标签,与 Prometheus 行为对齐)。

3.3 关于 Interval(聚合间隔)的演进说明

提案设想所有聚合函数都接受一个可选的 interval 参数(如rate(5m)quantile_over_time(duration, 0.95, 5m)),未指定时自动匹配查询的 step interval。

需要注意:从当前仓库语法(pkg/traceql/expr.y)看,这些聚合函数的括号内并未接收独立的 interval 参数——输出的时间分辨率统一由查询范围的step参数决定。这与提案 Notes 中"Step Interval"一节的思想一脉相承:step 是查询的显式分辨率step=1m意味着每 60 秒返回一个数据点。提案举例说明 step 与聚合 interval 是两回事:"rate(1h)配合step=1m仍然每 60 秒返回一个点,但每个点是前 1 小时窗口内平滑后的速率。"在实现中,该语义收敛为:StepAggregator按 step 分桶、rate用 step 换算每秒速率,而"滑动窗口平滑"这类更复杂的窗口语义属于后续演进方向。

Tempo 还提供了自动步长选择逻辑DefaultQueryRangeStep(pkg/traceql/engine_metrics.go):按时间窗长度尽量取约 240 个数据点,步长在 1 小时、5 分钟、1 分钟、15 秒、5 秒、1 秒等粒度中向下取整;时间窗小于 1 分钟时则允许毫秒级步长(最小 50ms)。

3.4 分组(Grouping)

所有第一阶段聚合都支持by (<attr1>, <attr2>, ...):按一个或多个属性分组,为每种属性值组合生成一条独立时间序列。

提案中的两个示例:

{ span.http.path = "/myapi" } | rate() by (span.user_id, span.http.status_code)

按用户 ID 和 HTTP 状态码绘制/myapi的请求速率。

{ resource.service.name = "myservice" } | quantile_over_time(duration, 0.95) by (span.http.path)

按 HTTP 路径绘制myservice的 P95 延迟。

从源码看,分组有几个值得注意的实现细节:

  • 分组数量上限maxGroupBys = 5(pkg/traceql/engine_metrics.go 第 708 行),MetricsAggregate.validate()会拒绝超过上限的分组;其中quantile_over_time/histogram_over_time内部需要为桶标签预留一个槽位,因此分组数上限再减 1(见 pkg/traceql/ast_metrics.go 的validate())。
  • 无作用域属性的双端查找:未限定作用域的属性(不带span./resource.前缀)会先查 span 级再查 resource 级。
  • nil 值的处理:分组值缺失时对应标签会被丢弃;若全部为 nil,则强制补一个值为"nil"的标签,避免下游 Prometheus 侧因零标签出错。

3.5 过滤(Filtering)

聚合结果可以继续接比较运算符来过滤输出。提案示例:

{ } | rate() by (resource.service.name) > 1000

只保留速率超过 1000 req/s 的服务序列。

这在源码中由MetricsFilter实现(pkg/traceql/ast_metrics.go 第 540-630 行):它把不满足条件的数据点置为 NaN,若整条序列过滤后全为 NaN 则整条丢弃;支持的运算符包括>>=<<===!=validate()会拒绝其他运算符)。另外,过滤时还会同步过滤掉不满足条件的 Exemplar,保证下游展示的示例点与曲线语义一致。

四、附加阶段(Additional Stages):对时间序列再加工

第一阶段之后还可以继续追加指标阶段,对时间序列做进一步变换。提案强调:这些阶段接收时间序列并产出新的时间序列,因此与第一阶段不同——它们本质上是"在每个时间点上跨输入工作的瞬时函数",没有自己的 interval,也不会改变数据点的频率(如果原聚合每 60 秒产出一个点,这些阶段同样每 60 秒输出一个点)。

提案给出的函数表:

函数说明
... \| max() [by(...)]每个时间点上的最大值
... \| min() [by(...)]每个时间点上的最小值
... \| avg() [by(...)]每个时间点上的平均值
... \| stddev() [by(...)]每个时间点上的标准差
... \| quantile(q) [by(...)]每个时间点上的分位数
... \| topk(N)返回每个时间点上的前 N 条序列(分组方式待定)

提案示例——找出每个集群内单 Pod 故障率最高的那条:

{ status = error } | rate() by (cluster, pod) | max() by (cluster)

当前仓库已经落地了其中一类重要操作:topk(N)bottomk(N)。语法规则(pkg/traceql/expr.y 第 356-358 行)定义了TOPK OPEN_PARENS INTEGER CLOSE_PARENS,实现类TopKBottomK(pkg/traceql/ast_metrics.go)要求limit > 0,按序列总和对候选排序后取前 N/后 N 条。多个第二阶段元素可以链式组合:ChainedSecondStage依次执行,例如{status=error} | rate() | topk(5) > 10这种"先取 TopK、再过滤"的组合是合法的。

五、算术运算:算错误率

算术运算是 TraceQL 指标查询最实用的能力之一:两个时间序列之间做*/+-,从而把"错误请求速率 / 总请求速率"这类比值算成错误率。

提案中的 5xx 错误率示例:

({ span.http.path = "/myapi" && span.http.status_code >= 500 } | rate()) / ({ span.http.path = "/myapi" | rate())

分子是满足"路径为 /myapi 且状态码 >= 500"的 Span 速率,分母是 /myapi 的全部请求速率,两者逐时间点相除即得 5xx 错误率曲线。

该能力在源码中由 pkg/traceql/ast_metrics_math.go 实现:

  • MathExpression:以二叉树结构表示(A) op (B),叶子节点通过内部的__query_fragment标签从共享的 SeriesSet 中提取属于自己子查询的时间序列;
  • applyBinaryOp:按标签匹配左右两侧的序列,逐点执行运算(applyArithmeticOp中除法遇到除数为 0 时输出 NaN);
  • MetricsScalarOp:额外支持"时间序列与标量常量"之间的运算,例如100 * ({} | rate())({} | rate()) / 1000,标量可在左也可在右。

运算结果的指标名会被合并为形如(a / b)的形式;标签合并时会去重并跳过指标名标签,与 Prometheus 的向量匹配语义对齐。提案也指出,系列与系列之间算术需要明确定义join 语义(参考 PromQL 的 vector-matching),当前实现采取"按标签精确匹配、无标签序列可扇出(fan-out)到所有序列"的默认行为,并预留了on()group_left()等 Prometheus 式修饰符作为后续演进方向。

六、与 PromQL/LogQL 的设计差异

提案用专门一节阐述了为什么选择"管线式语法 + 专用聚合函数",而不是照搬 PromQL/LogQL。这些取舍直接塑造了如今的语言形态。

6.1 标签(Labels):没有流(stream),只有一片 Span

Tempo 的底层架构与 Prometheus/Loki 不同:后者天然存在"流/序列"概念,标签由埋点与指标管线共同定义;而 Tempo 中没有流定义,只有大量离散的 Span。提案用一个例子说明问题:

要按服务绘制速率,PromQL 式写法是:

sum(rate({}[5m])) by (resource.service.name)

rate({}[5m])在 PromQL 语义下返回的是"每个流一条序列",流的标签从何而来?在 Tempo 中只有两种可能:

  1. 返回一条无标签的总速率,除非显式给出分组标签——这正是rate() by(...)语法的由来;
  2. 用可用数据(resource/span 属性)模拟流——但这不可行,因为没有规则决定该选哪些属性;全选会带来灾难性的高基数(一个普通 HTTP 请求的 path、method、status_code 适合做流定义,但 content length、headers、带 query 参数的完整 URL 会令基数失控)。

结论很直白:{} | rate()在方案 1 下是一条时间序列,在方案 2 下可能是数十亿条。

6.2 Rate:没有 range vector

PromQL/LogQL 的速率依赖 range vector selector,如metric[5m]。这在 Tempo 中无法平移:{}合法(找 Span),但{}[5m]在缺少聚合时没有意义。于是 interval 被简化成聚合函数的一个参数(在实现中进一步统一为查询级 step),而不是专门语法。

6.3 管线(Pipeline):单向流动,易于增删

提案选择管线语法而非函数嵌套语法,理由有三:

  • 计算单向流动,更容易理解;
  • 修改更容易——增删一个阶段即可,而无需小心翼翼地增删嵌套函数及其括号;
  • 把"找 Span 的现有查询"升级为指标查询,只需在管道末尾追加一个阶段,改动集中在一处。

例如把{status=error}变成错误率曲线,只需追加| rate(),原查询完全不动。

七、工程化落地:API、查询参数与配置

7.1 查询 API 与参数

TraceQL 指标查询通过指标查询端点暴露,前端 HTTP 处理器位于 modules/frontend/metrics_query_handler.go:

  • /api/metrics/query_range:范围查询,返回一段时间内的序列;
  • /api/metrics/query_range_instant(及 query_range 单步形态):瞬时查询,内部会被改写成单步的 query_range 请求。

请求解析在 pkg/api/http.go 的ParseQueryRangeRequest中完成,主要参数包括:

参数说明
qTraceQL 指标查询语句,如{status=error} \| rate() by (resource.service.name)
start/end查询时间范围(unix 时间戳)
step步长(如1m),决定输出数据点频率;不传时由 Tempo 按范围自动计算
exemplars是否在结果中携带 Exemplar

值得注意:范围查询的start/end会被AlignRequest对齐到 step 的整数倍,使得"最近 1 小时"这类查询在每次刷新时得到稳定一致的时间分桶。序列输出时(SeriesSet.ToProto)会自动跳过 NaN 数据点与空序列,Exemplar 也会做严格的时间桶边界校验(见 pkg/traceql/engine_metrics.go 的ToProto)。

7.2 前端配置与调优

指标查询是计算密集型的,官方配置文档 docs/sources/tempo/metrics-from-traces/metrics-queries/configure-traceql-metrics.md 给出了工程实践建议:

  • 超时:这类查询可能耗时较长,需要检查链路各处的超时——Grafana 前的代理、指向 Tempo 的 Prometheus 数据源、以及 Tempo 配置中的querier.search.query_timeoutserver.http_server_read_timeoutserver.http_server_write_timeout

  • query_frontend.metrics配置块:控制所有 TraceQL 指标查询的执行参数:

    • concurrent_jobs:并发执行 job 数;
    • target_bytes_per_job:单个 job 的目标数据量(字节),例如2.25e+08(约 225MB)或1.25e+09(约 1.25GB);
    • interval:job 切分的时间间隔;
    • max_duration:指标查询允许的最大时间范围,默认 24 小时(注意这与常规 TraceQL 查询默认 168 小时/7 天不同);
    • query_backend_after:查询后端存储与 live-store 的分界,默认15m,比该值更老的数据只查对象存储/后端,更新的数据查 live-store。
    query_frontend: metrics: concurrent_jobs: 1000 target_bytes_per_job: 2.25e+08 # ~225MB interval: 30m0s

    文档给出的经验法则是:云环境适合"更多更小的 job"(高并发、小数据量);本地部署(on-prem)则相反,可降低并发并增大 job 体积以获得更好的查询吞吐。

7.3 采样与性能

大规模数据集上,指标查询还支持采样提示(sampling hints):每个 Span 在写入时会携带采样倍数信息(源码中的IntrinsicSpanMultiplier,即 1/采样概率),聚合器在计数/求和时按该倍数外推(SetExtrapolate),从而在采样数据上依然得到接近真实总量的指标;而 min/max 等极值聚合不会被外推缩放(极值不随采样成比例放大)。相关实现在 pkg/traceql/engine_metrics.go 的spanExtrapolationCountOverTimeAggregator/OverTimeAggregator中。启用采样后,单 job 处理时间缩短,可以支撑更高的并发。

八、未来方向

提案展望了两个演进方向,其中部分已被实现或部分落地:

8.1 时间序列间的 Join

算术运算引出了 join 语义的需求。当前实现采取"标签精确匹配 + 无标签序列扇出"的默认行为,后续计划提供与 PromQL vector-matching 相当的能力,并支持 one-to-many / many-to-one 等专门化场景。

8.2 单查询多指标

在同一查询里计算多个指标(如同时画错误率与总速率)是常见诉求,Tempo 只需一次扫描即可算完,更高效。quantile_over_time已迈出第一步(一次请求可算 P50 和 P95 两条序列);更完整的方案需要 pipeline-splitting 之类的高层构造。源码中batchSeriesProcessor(按__query_fragment内部标签把序列路由到不同子处理器)已经为"一条查询包含多个算术分支"提供了基础设施,可以视为这一方向的初步落地。

8.3 额外提及:compare()

除提案内容外,仓库还实现了compare()函数(pkg/traceql/engine_metrics_compare.go):compare(<spansetFilter>, [topN], [start, end])对任意属性的取值做"基线 vs 选择"的对比,输出带__meta_type(baseline/selection 及其 total)标签的系列,并对超出topN(上限 1000)的基数打上__too_many_values__错误标记——这体现了 TraceQL 指标家族持续扩展的态势。

结语

TraceQL Metrics 把"查询 Trace"与"计算指标"统一进了同一种语言:前半段沿用强大的 Span 查询能力筛选数据,后半段通过_over_time系列聚合函数、by()分组、比较过滤、二阶变换与序列算术,把匹配到的 Span 在线聚合成错误率、速率、分位数延迟等时间序列。整套能力已在 Tempo 的pkg/traceql包中完整落地,并配套了/api/metrics/query_range查询端点与query_frontend.metrics配置块,配合采样外推与自动步长等机制,为大规模 Trace 数据上的指标观测提供了一条"无需预写指标"的便捷路径。想要深入探索的读者,可以从 pkg/traceql/ast_metrics.go 与 pkg/traceql/engine_metrics.go 的聚合器实现入手,再到 pkg/traceql/engine_metrics_test.go(含 avg 加权均值等数值精度测试)与 modules/frontend/metrics_query_handler_test.go 中查看完整的测试用例,理解每一类聚合在边界条件下的行为。

【免费下载链接】tempoGrafana Tempo is a high volume, minimal dependency distributed tracing backend.项目地址: https://gitcode.com/GitHub_Trending/tempo1/tempo

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询