Metabase 后端 OpenTelemetry 链路追踪实战:with-span 宏、Trace Groups 与模块边界规范
2026/9/8 22:46:25 网站建设 项目流程

Metabase 后端 OpenTelemetry 链路追踪实战:with-span 宏、Trace Groups 与模块边界规范

【免费下载链接】metabaseThe easy-to-use open source Business Intelligence and Embedded Analytics tool that lets everyone work with data :bar_chart:项目地址: https://gitcode.com/GitHub_Trending/me/metabase

本文以 Metabase 仓库中的add-tracing技能文档为骨架,系统讲解如何为 Metabase 的 Clojure 后端代码添加 OpenTelemetry(OTel)链路追踪:涵盖tracing/with-span宏的完整用法与运行时行为、Trace Groups 注册与启用机制、Span 命名与属性规范、SQL 脱敏、defjob根 Span、循环依赖规避、模块边界(clj-kondo 配置)与MB_TRACING_*环境变量的源码级配置说明。读完本文,你可以按规范独立完成一次合规的埋点改造,并通过 lint 与测试验证。

一、tracing 模块架构:极简 API 表面与命名空间边界

Metabase 的 tracing 模块刻意保持了极小的公开 API 表面。根据 .clj-kondo/config/modules/config.edn 中tracing模块的定义,只有两个命名空间出现在:api集合中:

tracing {:team "DevEx" :api #{metabase.tracing.core metabase.tracing.init} :uses #{config events settings task util}}

各命名空间的职责与可见性如下:

命名空间职责状态
tracing.core主 API:with-span、组注册、SDK 生命周期、Pyroscope 集成、MDC 注入、best-effort-sanitize-sql公开 API
tracing.init加载quartzsettingsevents的副作用入口(init 约定)公开 API
tracing.attributesbest-effort-sanitize-sql实现(经tracing.core重新导出)内部
tracing.settings设置定义(MB_TRACING_*环境变量)内部
tracing.quartzQuartz JDBC 代理 + JobListener内部
tracing.events事件发布路径的埋点(由init加载)内部

对应的硬性规则:

  • 模块外只能require[metabase.tracing.core :as tracing],所有公开函数(包括best-effort-sanitize-sql)都从这个单一命名空间获得(它在 src/metabase/tracing/core.clj 中通过p/import-varstracing.attributes重新导出)。
  • 不得新增 tracing API 命名空间——新的公开函数一律加到tracing.core
  • 不得从模块外 requiretracing.attributestracing.settingstracing.quartz等内部命名空间。
  • 其他模块对core使用:uses :any并不会绕过目标模块的:api检查,内部命名空间依然被强制约束。

从 src/metabase/tracing/init.clj 可以看到,init命名空间仅负责按约定加载metabase.tracing.eventsmetabase.tracing.quartzmetabase.tracing.settings三个命名空间的副作用——这正是 Metabase 全仓库统一的 "init 约定"。

二、核心原语:with-span

所有埋点的入口都是 src/metabase/tracing/core.clj 中定义的宏:

(tracing/with-span group span-name attrs & body)
  • group— 关键字,决定 Span 属于哪个 Trace Group(如:tasks:sync:search);
  • span-name— 字符串,标识该 Span(如"search.execute");
  • attrs— 属性 map(如{:db/id 42});
  • body— 在 Span 内执行的代码。

运行时行为(源码级拆解)

with-span的宏展开(core.clj#L296-L321)可以看到几个关键设计:

  1. 禁用时零开销:宏展开的第一步是(if (group-enabled? group) ... (do ~@body))——仅一次 atom 解引用加布尔判断,body 直接执行,不产生任何对象。
  2. 启用时创建 Span 并注入 MDC:内部调用 clj-otel 的span/with-span!创建 OTel Span,随后inject-trace-id-into-mdc!把当前 Span 的trace_idspan_id写入 Log4j2 的ThreadContext(MDC),实现日志到追踪的关联(Loki 与 Tempo 之间的跳转)。trace_level也会被写入 MDC,供 log4j2 中的DynamicThresholdFilter在 Span 存活期间动态降低该线程的日志阈值。
  3. MDC 保存与恢复:宏会先读取父级的trace_id/span_id,在finally中恢复,保证嵌套 Span 退出时不会把父 Span 的 MDC 值抹掉。
  4. 根 Span 与 Pyroscope 联动:当 MDC 中不存在父span_id(即当前是根 Span)时,宏会调用set-pyroscope-context!,把当前线程的 profiling 采样打上 span_id 标签,实现 Grafana 中 trace 到 profile 的链接。非根 Span 跳过此步——根 Span 的上下文已覆盖所有采样。
  5. 属性去重保护:宏内部通过动态变量*span-attrs*跟踪已写入的属性。add-span-attrs!允许在 Span 执行中途补充属性,但重复写同一个 key 在 dev/test 环境会抛异常、在 prod 会log/warn并丢弃,防止属性被静默覆盖。

手动获取 Tracer

如果宏不够用(例如 Quartz JobListener 这种跨越多个回调的生命周期),可以直接用tracing/get-tracer拿到 OTelTracer实例(core.clj#L157-L163)。注意其 docstring 特意强调:它返回的是 clj-otel 的默认 OTel 实例,而不是GlobalOpenTelemetry——后者在:set-as-global false时是 no-op。

三、Trace Groups:注册、启用与选择原则

内置 Group 列表

所有内置 Group 在 src/metabase/tracing/core.clj 中通过register-group!注册:

:qp ; 查询处理器:preprocess、compile、execute、cache :sync ; 数据库同步:metadata、analysis、fingerprinting、field values :tasks ; Quartz 定时后台任务 :search ; 搜索:全文、语义、索引、摄取 :api ; HTTP 请求/响应生命周期 :db-user ; 客户/用户数据库操作:SQL 执行、连接池 :db-app ; 应用/系统数据库操作:会话、设置、QE 写入 :events ; 事件系统:view log、审计、通知 :quartz ; Quartz 调度器内部:trigger 获取、锁、心跳、JDBC :transforms ; Transform 管道:jobs、stages、inspector

选择原则:按领域而非调用点

Group 跟随领域,而不是调用点。一段代码即使运行在 Quartz 任务里,只要逻辑上是搜索工作,就应该用:search而不是:tasks

新增 Group 的方式是在src/metabase/tracing/core.clj中注册:

(register-group! :my-domain "Description of what this covers")

用户侧通过环境变量启用 Group:MB_TRACING_GROUPS=tasks,search,sync(逗号分隔,或"all"启用全部)。init-enabled-groups!会解析该字符串并缓存结果(core.clj#L79-L90),group-enabled?则做 O(1) 的 set 成员检查;shutdown-groups!用于清除缓存(测试中常用)。

前端强制 Trace ID 机制

core.clj中还实现了一个细节机制(core.clj#L105-L131):前端传来的traceparent头中的 trace ID 可通过force-trace-id!存入 ThreadLocal,SDK 初始化时安装的自定义IdGenerator(core.clj#L331-L343)在创建根 Span 时消费该值并回退到随机生成。这样前端发起的请求与后端 Span 共享同一个 trace ID,又不会形成指向不存在的浏览器 Span 的父子链接。

四、命名规范:Span 名与属性

Span 名:点分层级命名

采用"domain.subsystem.operation"的点分结构,domain 前缀应与 Group 名一致:

search.execute -- `:search` group sync.fingerprint.table -- `:sync` group task.session-cleanup.delete -- `:tasks` group db-app.collection-items -- `:db-app` group

属性:带命名空间的关键字

属性 key 使用命名空间化的关键字,命名空间用于归类相关属性:

:db/id -- 数据库 ID(整数) :db/engine -- 数据库引擎名(字符串) :db/statement -- 脱敏后的 SQL(字符串,经 best-effort-sanitize-sql) :search/engine -- 搜索引擎名(字符串) :search/query-length -- 查询串长度(整数) :sync/table -- 表名(字符串) :sync/step -- 同步步骤名(字符串) :task/name -- 任务名(字符串) :http/method -- HTTP 方法(字符串) :http/url -- 请求 URL(字符串)

需要时可自造新的命名空间化属性(如:pulse/id:transform/count)。值必须是原始类型(字符串、数字、布尔),不允许 map 或集合。

五、埋点实操:六步流程

第 1 步:检查模块边界

在 .clj-kondo/config/modules/config.edn 中查到你所在命名空间所属的模块。如果其:uses集合中没有tracing,添加进去并保持字母序:

my-module {:team "MyTeam" :uses #{analytics config tracing util}}

第 2 步:添加 require

(ns metabase.my-module.thing (:require [metabase.tracing.core :as tracing] ;; 按字母序插入 [metabase.util :as u]))

best-effort-sanitize-sql已从tracing.core可用,无需额外 require。

第 3 步:识别 I/O 边界

只包装有意义的 I/O 边界

应该埋点:

  • 外部 API 调用(embedding API、metabot、webhooks)
  • 数据库查询(应用库与用户库均算)
  • 网络请求(对外的 HTTP 调用)
  • 重量级批处理(批量索引、批量 embedding)
  • 协调多个子操作的顶层编排函数

不应埋点:

  • 纯计算(排序、过滤、map)
  • 单行简单查询(t2/select-one :model/Setting :key k
  • 调用链中的每个函数(只关心边界)
  • 琐碎操作(字符串格式化、哈希计算)

第 4 步:用with-span包装

技能文档给出了六类典型写法,覆盖从简单 Span 到迭代式子 Span 的常见场景:

;; 简单 Span(无需属性) (tracing/with-span :search "search.init-index" {} (do-expensive-thing)) ;; 带静态属性的 Span (tracing/with-span :sync "sync.fingerprint.table" {:db/id (:db_id table) :sync/table (:name table)} (fingerprint-fields! table fields)) ;; 带计算属性的 Span (tracing/with-span :search "search.execute" {:search/engine (name (:search-engine ctx)) :search/query-length (count (:search-string ctx))} (search.engine/results ctx)) ;; 带脱敏 SQL 的 Span(动态 HoneySQL 查询) (let [hsql {:delete-from [(t2/table-name :model/Session)] :where [:< :created_at oldest-allowed]}] (tracing/with-span :tasks "task.session-cleanup.delete" {:db/statement (tracing/best-effort-sanitize-sql hsql)} (t2/query-one hsql))) ;; 用子 Span 把函数拆成多个 I/O 阶段 (let [embedding (tracing/with-span :search "search.semantic.embedding" {:search.semantic/provider (:provider model)} (get-embedding model search-string)) results (tracing/with-span :search "search.semantic.db-query" {} (into [] xform reducible))] (process results)) ;; 逐项迭代的 Span (doseq [e (search.engine/active-engines)] (tracing/with-span :search "search.ingestion.update" {:search/engine (name e)} (search.engine/update! e batch)))

第 5 步:添加测试

在对应的test/路径下创建或更新测试,遵循仓库中既有 tracing 测试的模式(如test/metabase/tracing/下的 quartz 测试、test/metabase/server/middleware/下的 trace 中间件测试)。要点:

  • tracing/init-enabled-groups!/tracing/shutdown-groups!配合try/finally管理 Group 生命周期;
  • 同时测试启用与禁用两条路径(禁用路径要验证零开销语义:代码照常工作、无包装);
  • 对 Java 接口(Connection、PreparedStatement、JobListener 等)使用reify打 mock;
  • (set! *warn-on-reflection* true)并对 proxy/reify 调用做类型提示,避免反射警告。
(deftest my-span-enabled-test (testing "when group is enabled, span is created" (try (tracing/init-enabled-groups! "my-group" "INFO") ;; ... 验证 Span 行为 ... (finally (tracing/shutdown-groups!))))) (deftest my-span-disabled-test (testing "when group is disabled, code runs without tracing" (tracing/shutdown-groups!) ;; ... 验证代码仍正常工作、无包装 ... ))

第 6 步:Lint 与测试

# 对修改的源码与测试文件做 lint —— 期望 0 error、0 warning clj-kondo --lint path/to/modified/file.clj path/to/test/file.clj # 运行测试(需要 Java 21+) clojure -X:dev:test :only my-ns.test-ns

验收标准:所有测试通过、0 失败、0 错误,且你的文件不产生任何反射警告。

六、SQL 脱敏:best-effort-sanitize-sql

当 Span 属性需要携带 SQL 时,必须使用tracing/best-effort-sanitize-sql。其实现位于 src/metabase/tracing/attributes.clj:把 HoneySQL map 经honey.sql/format渲染为参数化 SQL 字符串,所有值变成?占位符;格式化失败时回退为 map 的pr-str(best-effort 语义)。

(let [hsql {:delete-from [:core_session] :where [:< :created_at some-timestamp]}] (tracing/with-span :tasks "task.cleanup.delete" {:db/statement (tracing/best-effort-sanitize-sql hsql)} (t2/query-one hsql))) ;; Trace 属性: db/statement = "DELETE FROM core_session WHERE created_at < ?"

规则:

  • 属性中永远不要放原始 SQL 字符串或用户提供的值;
  • best-effort-sanitize-sql只用于应用库(HoneySQL)查询;
  • 对用户库/外部库查询,只追踪耗时与计数,不追踪 SQL 内容。

七、defjob与根 Span

src/metabase/task/impl.clj 中的defjob宏是 quartzite 原版defjob的受控包装,自动为每个 Quartz 任务补上日志上下文与:tasks根 Span:

(defmacro defjob "Like `clojurewerkz.quartzite.task/defjob` but with a log context and an OpenTelemetry tracing span." [jtype args & body] `(jobs/defjob ~jtype ~args (log/with-context {:quartz-job-type (quote ~jtype)} (tracing/with-span :tasks (str "task." (quote ~jtype)) {:task/name (str (quote ~jtype))} ~@body))))

例如:

(task/defjob ^{DisallowConcurrentExecution true} SessionCleanup [_] (cleanup-sessions!)) ;; 自动创建 Span: "task.SessionCleanup" {:task/name "SessionCleanup"}

因此defjobbody 内不需要再写根 Span,只需为任务内部的 I/O 添加子 Span

对于运行在普通Thread(非 Quartz)上的代码,需手动添加根 Span:

(defn init! [] (tracing/with-span :search "search.task.init" {} (search/init-index!)))

Quartz 内部观测:JobListener 与 JDBC 代理

src/metabase/tracing/quartz.clj 提供了:quartz组的两层内部观测(均在该组启用时才生效):

  1. 生命周期 Spancreate-tracing-job-listener创建的 JobListener 在jobToBeExecuted时打开quartz.job.executeSpan 并makeCurrent,使defjob的 Span 成为其子 Span;jobWasExecuted时记录异常(如有)、关闭 Scope 并结束 Span。Quartz 的调度开销(锁获取、trigger 状态迁移)因此表现为 listener Span 与 defjob 子 Span 之间的间隙。Span 状态存于 ThreadLocal,因为同一执行的回调都发生在同一 Quartz worker 线程上。
  2. JDBC 级 Spantraced-connection用动态代理层层包装 Connection → PreparedStatement/Statement,对execute*方法创建quartz.db.executeSpan 并附带完整 SQL 文本与:db/operation(从 SQL 动词提取)。代理通过task.bootstrap/set-connection-interceptor!安装进 bootstrap 的 ConnectionProvider,拦截函数在调用时检查group-enabled? :quartz,禁用时原样返回连接。

八、架构红线:循环依赖规避与反模式清单

循环依赖规避

tracing/core.clj被代码库中大量模块 require。技能文档为此确立了明确约定:tracing/core.clj不应以编译期 require 的方式依赖tracing.settings,否则会形成传递性的加载循环(文档中给出的示例链路为settings/core -> tracing/settings -> tracing/core -> events/impl -> events/core)。推荐的替代方式是requiring-resolve的惰性运行时解析:

;; 正确 —— 惰性运行时解析,无编译期依赖 ((requiring-resolve 'metabase.tracing.settings/tracing-enabled)) ;; 错误 —— 制造循环加载依赖 (require '[metabase.tracing.settings :as settings]) (settings/tracing-enabled)

外部库命名空间(clj-otel 的 API、SDK、exporter)可以正常 require——它们不参与 Metabase 的命名空间循环。

另一条容易被忽略的约束:requiring-resolve必须使用字面量引号符号。clj-kondo 的 hook 会校验required-namespaces全部是简单符号,动态构造会直接报错:

;; 正确 —— 字面量引号符号 (requiring-resolve 'metabase.tracing.settings/tracing-endpoint) ;; 错误 —— kondo hook 拒绝: "Assert failed: (every? simple-symbol? required-namespaces)" (requiring-resolve (symbol "metabase.tracing.settings" "tracing-endpoint"))

Span 使用反模式

;; 错误 - 纯计算,无 I/O (tracing/with-span :search "search.format-results" {} (map format-result results)) ;; 错误 - 琐碎的单行查询 (tracing/with-span :db-app "db-app.get-setting" {} (t2/select-one :model/Setting :key "my-setting")) ;; 错误 - 原始 SQL 进属性(数据泄漏) (tracing/with-span :tasks "task.cleanup" {:db/statement raw-sql-string} (execute! raw-sql-string)) ;; 错误 - Group 选错(搜索工作应用 :search 而非 :tasks) (tracing/with-span :tasks "search.execute" {} ...) ;; 错误 - 冗余嵌套(do-search 已有 Span) (tracing/with-span :search "search.process" {} (let [results (do-search ctx)] (tracing/with-span :search "search.format" {} (format-results results))))

架构反模式

;; 错误 - 新建 tracing 命名空间 (ns metabase.tracing.my-feature ...) ;; 错误 - 从模块外 require 内部 tracing 命名空间 (ns metabase.my-module.thing (:require [metabase.tracing.attributes :as trace-attrs] ;; 内部! [metabase.tracing.settings :as tracing.settings] ;; 内部! [metabase.tracing.quartz :as tracing.quartz])) ;; 内部! ;; 错误 - 动态符号构造 requiring-resolve(kondo 拒绝) (requiring-resolve (symbol "metabase.tracing.settings" "tracing-enabled"))

九、配置:全部通过环境变量

tracing 模块的所有设置都定义在 src/metabase/tracing/settings.clj 中,且只接受环境变量(每个 defsetting 均声明:setter :none,不写入应用库,也不出现在 API 导出中)。对照源码的默认值:

# 核心 MB_TRACING_ENABLED=true # 启用 tracing(源码默认: false,类型 :boolean) MB_TRACING_ENDPOINT=http://localhost:4318/v1/traces # OTLP collector 端点(源码默认值,:string) MB_TRACING_GROUPS=tasks,search,sync # 逗号分隔的 Group 或 "all"(源码默认: "all") MB_TRACING_SERVICE_NAME=metabase # trace 中的服务名(源码默认: 主机名,getter 中回退 "metabase") MB_TRACING_LOG_LEVEL=DEBUG # 被追踪线程的日志阈值 TRACE/DEBUG/INFO(默认: INFO,getter 会校验,非法值告警并回退 INFO) # Batch span processor 调优 MB_TRACING_MAX_QUEUE_SIZE=2048 # 待导出 Span 的最大队列长度,满则丢弃(默认: 2048) MB_TRACING_EXPORT_TIMEOUT_MS=10000 # 单批导出完成的最大等待(默认: 10000) MB_TRACING_SCHEDULE_DELAY_MS=5000 # 相邻两次批量导出的间隔(默认: 5000)

源码中有几个值得注意的细节:

  • tracing-endpoint声明了:encryption :when-encryption-key-set——设置加密密钥后其值会在应用库中加密存储;
  • tracing-service-name的 getter 在环境变量缺省时尝试InetAddress/getLocalHost取主机名,失败回退字符串"metabase"
  • tracing-log-level的 getter 只接受TRACE/DEBUG/INFO三个值,非法值会log/warn并回退INFO

SDK 生命周期由 core.clj 的init!/shutdown!管理:init!在启动早期调用(无数据库依赖),构造 OTLP HTTP span exporter 并通过otel-sdk/init-otel-sdk!初始化 SDK(:set-as-default true:register-shutdown-hook false,即关闭由 Metabase 自行管理),把MB_TRACING_MAX_QUEUE_SIZEMB_TRACING_EXPORT_TIMEOUT_MSMB_TRACING_SCHEDULE_DELAY_MS传给 batch span processor,随后调用init-enabled-groups!缓存 Group 集合;初始化失败只记 error 日志、tracing 整体降级为禁用。shutdown!在应用关闭时 flush 未发送的 Span。

十、完成自检清单

按技能文档的 checklist,一次合规的埋点改造应满足:

  • 目标模块已在.clj-kondo/config/modules/config.edn:uses中包含tracing
  • ns 的 require 中加入了[metabase.tracing.core :as tracing](字母序)
  • Span 包装在有意义的 I/O 边界(而非纯计算)
  • Group 与领域匹配(对照src/metabase/tracing/core.clj的注册列表,无合适 Group 时新增注册)
  • Span 名遵循点分约定("domain.subsystem.operation"
  • 属性使用命名空间化关键字(:search/query-length:db/id
  • 属性中无敏感数据(HoneySQL 一律经best-effort-sanitize-sql,绝不使用原始 SQL)
  • 未创建新的 tracing 命名空间(新函数加入tracing.core
  • 未违反目标目录的DO_NOT_ADD_NEW_FILES_HERE.txt约束
  • clj-kondo --lint <files>通过:0 error、0 warning
  • 在对应test/路径补充或更新测试(启用/禁用两条路径)
  • clojure -X:dev:test :only <test-ns>全绿且无反射警告

综上,Metabase 的 tracing 体系把"埋点"约束成了一件事:在正确的模块边界内,用唯一的公开命名空间metabase.tracing.corewith-span宏包装 I/O 边界,让 Group 机制、MDC 日志关联、Pyroscope 联动、SQL 脱敏与 SDK 生命周期都由框架自动完成——开发者只需关注领域划分与命名规范,其余的合规性由 clj-kondo 模块边界和测试共同兜底。

【免费下载链接】metabaseThe easy-to-use open source Business Intelligence and Embedded Analytics tool that lets everyone work with data :bar_chart:项目地址: https://gitcode.com/GitHub_Trending/me/metabase

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

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

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

立即咨询