Ohhnews

分类导航

$ cd ..
foojay原文

从OpenTelemetry到DuckDB:用SQL即时分析遥测数据

#opentelemetry#duckdb#可观测性#sql分析#遥测数据

可观测性平台通常希望你就在平台内部完成分析,使用它们自己的查询语言和仪表盘。对于你已经知道要问的问题,这很好用;但对于临时性的问题,就不那么顺手了:快速算一个百分位数按状态码透视,或者看到数据后突然想问“到底是哪个服务真正吃掉了延迟预算?”。

这篇文章介绍了一条用于回答这类问题的小型流水线。一端是 DuckDB,这是一个进程内分析型数据库,可以直接读取 CSV、JSON 和 Parquet;另一端是 Dash0 CLI,它可以从 Dash0 拉取 OpenTelemetry 的 span、日志、指标和 trace,并以 CSV 或 JSON 格式输出。

两者可以在命令行中用管道连接起来:

dash0 spans query -o csv --limit 100000 | duckdb -c "SELECT ..."

这样就能对生产环境遥测数据直接执行 SQL,无需中间文件,也不需要导入步骤。

本文其余部分会介绍如何完成设置,以及搭建之后可以做哪些事情。

为什么选择 DuckDB

DuckDB 常被形容为“面向分析的 SQLite”。这个类比是合理的:它只有一个依赖、在进程内运行、不需要服务器(除非你确实想要服务器模式)。不过,这里真正关键的特性是,DuckDB 将文件和流都视为表。你可以让它指向磁盘上的 CSV、S3 中的 Parquet 文件,或者标准输入,然后 直接 查询。

这使得它非常适合承接命令行输出。任何能输出 CSV 或 JSON 的工具——kubectlawsghps、日志文件,或者像 dash0 这样的 CLI——都可以用 SQL 查询,而无需自己编写解析器

说明: 对于正在 Foojay 上阅读本文的 JVM 开发者(Java、Kotlin)来说,好消息是 DuckDB 也提供了 JDBC 驱动。本文中的 SQL 可以原封不动地迁移到 Java 服务中。这条管道适合探索性分析;而当你希望把找到的查询固化下来,并在 Java 代码中定期运行时,JDBC 才是合适的载体。

准备工作

你需要在本机安装两个二进制文件。两者都不需要服务器、容器或配置文件。

1. 安装 DuckDB

在 macOS 和 Linux 上,用 Homebrew 最方便:

brew install duckdb

DuckDB 安装页面 也提供了各大平台的独立二进制文件。请确认它能正常工作:

duckdb --version

2. 安装 Dash0 CLI

Dash0 CLI 以 Homebrew cask 的形式分发:

brew install --cask dash0hq/dash0-cli/dash0

这种全限定名称会自动指向对应仓库,因此无需单独执行 brew tap。GitHub Releases 二进制、FROM scratch Docker 镜像以及 Nix flake 都记录在安装指南中。请确认:

dash0 --version

3. 身份验证

交互式登录使用 OAuth:

dash0 login

对于脚本和 CI,可以改用环境变量设置连接信息。每个配置项的解析顺序为:环境变量、命令行标志、当前 profile。

export DASH0_API_URL=https://api.<region>.<cloud>.dash0.com
export DASH0_AUTH_TOKEN=auth_your_token_here
export DASH0_DATASET=default

运行下面的命令,确认凭据和数据集都按预期解析:

dash0 config show

从 Dash0 导出 Span

所有 dash0 命令都支持 JSON 输出,并提供与 Dash0 UI 一致的过滤语法。如果它检测到环境中运行的是 AI 编码代理,就会默认输出 JSON、跳过确认提示并关闭颜色。对本流水线来说,关键的标志是 -o csv

下面是一个查询最近 30 分钟 span 并以 CSV 输出的例子:

dash0 spans query --from now-30m -o csv --limit 100000

输出效果类似这样:

otel.span.start_time,otel.span.duration,otel.span.name,otel.span.status.code,service.name,otel.parent.id,otel.trace.id,otel.span.id,otel.span.links
2026-09-08T09:12:03.456Z,150ms,GET /api/users,OK,frontend,,0af7651916cd...,b7ad6b71...,
...

你可以使用与 UI 相同的过滤器来缩小范围——--filter "service.name is checkout"--filter "otel.span.status.code is ERROR"——不过在探索阶段,通常更简单的做法是拉取一个较宽的时间窗口,然后在 SQL 里做切片。

试试 DuckDB

下面把 span 查询结果通过管道送入 DuckDB,生成每个服务的请求数、错误数和一张小型 ASCII 柱状图:

dash0 spans query --from now-30m -o csv --limit 100000 \
  | duckdb -c "
    SELECT
      \"service.name\"                                          AS service,
      count(*)                                                  AS reqs,
      count(*) FILTER (WHERE \"otel.span.status.code\"='ERROR') AS errors,
      bar(count(*), 0, 30, 25)                                  AS traffic
    FROM read_csv('/dev/stdin')
    GROUP BY 1 ORDER BY reqs DESC;
  "
┌─────────────┬───────┬────────┬──────────────────────────┐
│   service   │ reqs  │ errors │          traffic          │
├─────────────┼───────┼────────┼──────────────────────────┤
│ frontend    │    28 │      0 │ ███████████████████████▍  │
│ postgres    │    25 │      1 │ ████████████████████▎     │
│ api-gateway │    16 │      1 │ █████████████▍            │
│ checkout    │    15 │      3 │ ████████████▌             │
│ auth        │    10 │      2 │ ████████▍                │
└─────────────┴───────┴────────┴──────────────────────────┘

这里有几个值得注意的地方:

  • read_csv('/dev/stdin') 会把管道传入的流当作表读取,并从表头推断列名和类型。这里不需要 CREATE TABLE
  • 带点的列名(如 service.name)来自 OpenTelemetry 的语义约定。在 SQL 中必须用双引号括起来("service.name"),否则点号会被解析为 schema 或表限定符。
  • FILTER (WHERE ...)bar() 都是 DuckDB 的标准功能。

持久化处理

如果要做的不只是一次性查询,更便捷的做法是先导出一次,再把数据加载到 DuckDB 文件中。首先,把 CSV 写下来:

dash0 spans query --from now-30m -o csv --limit 100000 > /tmp/spans.csv

然后创建一个带数值型时长列的表。CLI 会把时长格式化为类似 150ms2.49s 的字符串,因此需要先把它们归一化为毫秒,之后才能进行算术运算:

CREATE TABLE spans AS
SELECT
  "service.name"          AS service,
  "otel.span.name"        AS op,
  "otel.span.status.code" AS status,
  "otel.trace.id"         AS trace_id,
  "otel.span.duration"    AS dur_str,
  CASE
    WHEN "otel.span.duration" LIKE '%ms'                                    THEN CAST(regexp_extract("otel.span.duration",'[0-9.]+') AS DOUBLE)
    WHEN "otel.span.duration" LIKE '%µs' OR "otel.span.duration" LIKE '%us' THEN CAST(regexp_extract("otel.span.duration",'[0-9.]+') AS DOUBLE)/1000.0
    WHEN "otel.span.duration" LIKE '%s'                                     THEN CAST(regexp_extract("otel.span.duration",'[0-9.]+') AS DOUBLE)*1000.0
  END                     AS dur_ms
FROM read_csv('/tmp/spans.csv');

运行 duckdb /tmp/spans.duckdb,然后把上面的 SQL 粘贴进去。下面所有查询都会作用于生成的 spans 表。

五个实用查询

1. 每个服务的黄金指标

每个服务的延迟百分位数和错误率,一条查询就能搞定:

SELECT service, count(*) AS reqs,
  round(quantile_cont(dur_ms, 0.50), 1) AS p50_ms,
  round(quantile_cont(dur_ms, 0.90), 1) AS p90_ms,
  round(quantile_cont(dur_ms, 0.99), 1) AS p99_ms,
  round(max(dur_ms), 1)                 AS max_ms,
  count(*) FILTER (WHERE status='ERROR') AS errors,
  round(100.0 * count(*) FILTER (WHERE status='ERROR') / count(*), 1) AS err_pct
FROM spans GROUP BY service ORDER BY p99_ms DESC;

quantile_cont 返回连续型(插值)百分位数;FILTER (WHERE ...) 则在同一趟扫描中统计错误数。在示例数据中,checkout 的 p99 为 2.46 秒,错误率为 20%。

2. 延迟直方图

把时长分桶,并用 bar() 绘制出来:

WITH bucketed AS (
  SELECT CASE
    WHEN dur_ms < 10   THEN '1  <10ms'
    WHEN dur_ms < 50   THEN '2  10-50ms'
    WHEN dur_ms < 100  THEN '3  50-100ms'
    WHEN dur_ms < 250  THEN '4  100-250ms'
    WHEN dur_ms < 500  THEN '5  250-500ms'
    WHEN dur_ms < 1000 THEN '6  0.5-1s'
    ELSE                    '7  >1s'
  END AS bucket
  FROM spans
)
SELECT regexp_replace(bucket, '^[0-9]  ', '') AS latency,
       count(*)                               AS n,
       bar(count(*), 0, 30, 40)               AS distribution
FROM bucketed GROUP BY bucket ORDER BY bucket;
┌───────────┬───────┬──────────────────────────────────────────┐
│  latency  │   n   │               distribution               │
├───────────┼───────┼──────────────────────────────────────────┤
│ <10ms     │     5 │ ██████▏                                │
│ 10-50ms   │    21 │ ████████████████████████████           │
│ 50-100ms  │    10 │ █████████████▍                           │
│ 100-250ms │    30 │ ████████████████████████████████████████ │
│ 250-500ms │    15 │ ████████████████████                    │
│ 0.5-1s    │     5 │ ██████▏                                │
│ >1s       │     8 │ ██████████▍                              │
└───────────┴───────┴──────────────────────────────────────────┘

这个分布呈双峰形态:大多数请求落在 10 到 500 毫秒之间,另一组则超过 1 秒。无论是平均值还是中位数,都无法展现出这种特征。

3. 按状态进行透视

DuckDB 把 PIVOT 作为关键字支持。下面的查询会把状态值转换为列:

PIVOT spans ON status USING count(*) GROUP BY service ORDER BY service;
┌─────────────┬───────┬───────┐
│   service   │ ERROR │  OK   │
├─────────────┼───────┼───────┤
│ api-gateway │     1 │    15 │
│ auth        │     2 │     8 │
│ checkout    │     3 │    12 │
│ frontend    │     0 │    28 │
│ postgres    │     1 │    24 │
└─────────────┴───────┴───────┘

4. 每个服务占用的总延迟比例

这里对聚合结果使用了窗口函数:sum(sum(dur_ms)) OVER () 可以在分组查询内得到总计,因此只需一趟就能算出每个服务的占比:

SELECT service,
  count(*)                                                 AS spans,
  round(sum(dur_ms), 1)                                    AS total_ms,
  round(100.0 * sum(dur_ms) / sum(sum(dur_ms)) OVER (), 1) AS pct_of_budget
FROM spans GROUP BY service ORDER BY total_ms DESC;
┌─────────────┬───────┬──────────┬───────────────┐
│   service   │ spans │ total_ms │ pct_of_budget │
├─────────────┼───────┼──────────┼───────────────┤
│ checkout    │    15 │  17592.0 │          63.6 │
│ frontend    │    28 │   4744.0 │          17.1 │
│ api-gateway │    16 │   3992.0 │          14.4 │
│ auth        │    10 │    684.0 │           2.5 │
│ postgres    │    25 │    654.0 │           2.4 │
└─────────────┴───────┴──────────┴───────────────┘

checkout 用 94 个 span 中的 15 个,就占了总 span 时间的 63.6%。如果要优化一件事,这就是应该开始的地方。

5. 转换为 Parquet

DuckDB 原生支持读写 Parquet:

COPY (SELECT * FROM read_csv('/tmp/spans.csv'))
TO '/tmp/spans.parquet' (FORMAT parquet, COMPRESSION zstd);

在这样的小样本上,Parquet 文件大约只有 CSV 的一半大小。在真实数据规模下,压缩比会更好,因为遥测数据有大量重复值,而列式存储配合字典编码能很好地压缩这类数据。生成的 Parquet 文件也可以直接查询:

SELECT "service.name" AS service, count(*) AS spans
FROM '/tmp/spans.parquet'
GROUP BY 1 ORDER BY 2 DESC;

把一天的 span 数据归档为 Parquet,可以形成一个廉价的冷存储层;任何 DuckDB 客户端——包括通过 JDBC 连接的 JVM 应用——之后都可以查询这些数据。

为什么不把 DuckDB 嵌入 CLI?

理论上可以把 DuckDB 直接构建进 dash0,让 CLI 自行运行这种分析。但这也许并不是个好主意。

Dash0 CLI 以禁用 CGO 的静态二进制构建,以不含 shell 或 libc 的 FROM scratch Docker 镜像发布,并可交叉编译到五种架构。与此同时,DuckDB 是 C++ 库,而且它的 Go 驱动依赖 CGO。如果内嵌 DuckDB,就意味着必须放弃静态链接,镜像体积会大得多,交叉编译也会更复杂。

而且,如上所示,管道本身已经完成了这项工作。CLI 输出 CSV 和 JSON,DuckDB 负责查询;两者彼此无需了解对方。

这个道理同样适用于任何能输出结构化数据的工具。一旦你习惯了把输出接到 DuckDB 中,就很难再回到 awk 了。

总结

dash0 login 到每个服务的延迟拆分,只需要几分钟的设置和一条管道。

由于 DuckDB 把标准输入、文件和 Parquet 都当作表来读取,它可以充当命令行上几乎所有内容的 SQL 查询层;Dash0 CLI 输出的 OpenTelemetry 数据正好是它的一个很好的应用场景。

文章《From OpenTelemetry to DuckDB》最初发布于 foojay