Metabase 后端 OpenTelemetry 链路追踪实战:with-span 宏、Trace Groups 与模块边界规范 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 后端代码添加 OpenTelemetryOTel链路追踪涵盖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主 APIwith-span、组注册、SDK 生命周期、Pyroscope 集成、MDC 注入、best-effort-sanitize-sql公开 APItracing.init加载quartz、settings、events的副作用入口init 约定公开 APItracing.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-vars从tracing.attributes重新导出。不得新增 tracing API 命名空间——新的公开函数一律加到tracing.core。不得从模块外 requiretracing.attributes、tracing.settings、tracing.quartz等内部命名空间。其他模块对core使用:uses :any并不会绕过目标模块的:api检查内部命名空间依然被强制约束。从 src/metabase/tracing/init.clj 可以看到init命名空间仅负责按约定加载metabase.tracing.events、metabase.tracing.quartz、metabase.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、:searchspan-name— 字符串标识该 Span如search.executeattrs— 属性 map如{:db/id 42}body— 在 Span 内执行的代码。运行时行为源码级拆解从with-span的宏展开core.clj#L296-L321可以看到几个关键设计禁用时零开销宏展开的第一步是(if (group-enabled? group) ... (do ~body))——仅一次 atom 解引用加布尔判断body 直接执行不产生任何对象。启用时创建 Span 并注入 MDC内部调用 clj-otel 的span/with-span!创建 OTel Span随后inject-trace-id-into-mdc!把当前 Span 的trace_id、span_id写入 Log4j2 的ThreadContextMDC实现日志到追踪的关联Loki 与 Tempo 之间的跳转。trace_level也会被写入 MDC供 log4j2 中的DynamicThresholdFilter在 Span 存活期间动态降低该线程的日志阈值。MDC 保存与恢复宏会先读取父级的trace_id/span_id在finally中恢复保证嵌套 Span 退出时不会把父 Span 的 MDC 值抹掉。根 Span 与 Pyroscope 联动当 MDC 中不存在父span_id即当前是根 Span时宏会调用set-pyroscope-context!把当前线程的 profiling 采样打上 span_id 标签实现 Grafana 中 trace 到 profile 的链接。非根 Span 跳过此步——根 Span 的上下文已覆盖所有采样。属性去重保护宏内部通过动态变量*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)用户侧通过环境变量启用 GroupMB_TRACING_GROUPStasks,search,sync逗号分隔或all启用全部。init-enabled-groups!会解析该字符串并缓存结果core.clj#L79-L90group-enabled?则做 O(1) 的 set 成员检查shutdown-groups!用于清除缓存测试中常用。前端强制 Trace ID 机制core.clj中还实现了一个细节机制core.clj#L105-L131前端传来的traceparent头中的 trace ID 可通过force-trace-id!存入 ThreadLocalSDK 初始化时安装的自定义IdGeneratorcore.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-strbest-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与根 Spansrc/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组的两层内部观测均在该组启用时才生效生命周期 Spancreate-tracing-job-listener创建的 JobListener 在jobToBeExecuted时打开quartz.job.executeSpan 并makeCurrent使defjob的 Span 成为其子 SpanjobWasExecuted时记录异常如有、关闭 Scope 并结束 Span。Quartz 的调度开销锁获取、trigger 状态迁移因此表现为 listener Span 与 defjob 子 Span 之间的间隙。Span 状态存于 ThreadLocal因为同一执行的回调都发生在同一 Quartz worker 线程上。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-resolvekondo 拒绝 (requiring-resolve (symbol metabase.tracing.settings tracing-enabled))九、配置全部通过环境变量tracing 模块的所有设置都定义在 src/metabase/tracing/settings.clj 中且只接受环境变量每个 defsetting 均声明:setter :none不写入应用库也不出现在 API 导出中。对照源码的默认值# 核心 MB_TRACING_ENABLEDtrue # 启用 tracing源码默认: false类型 :boolean MB_TRACING_ENDPOINThttp://localhost:4318/v1/traces # OTLP collector 端点源码默认值:string MB_TRACING_GROUPStasks,search,sync # 逗号分隔的 Group 或 all源码默认: all MB_TRACING_SERVICE_NAMEmetabase # trace 中的服务名源码默认: 主机名getter 中回退 metabase MB_TRACING_LOG_LEVELDEBUG # 被追踪线程的日志阈值 TRACE/DEBUG/INFO默认: INFOgetter 会校验非法值告警并回退 INFO # Batch span processor 调优 MB_TRACING_MAX_QUEUE_SIZE2048 # 待导出 Span 的最大队列长度满则丢弃默认: 2048 MB_TRACING_EXPORT_TIMEOUT_MS10000 # 单批导出完成的最大等待默认: 10000 MB_TRACING_SCHEDULE_DELAY_MS5000 # 相邻两次批量导出的间隔默认: 5000源码中有几个值得注意的细节tracing-endpoint声明了:encryption :when-encryption-key-set——设置加密密钥后其值会在应用库中加密存储tracing-service-name的 getter 在环境变量缺省时尝试InetAddress/getLocalHost取主机名失败回退字符串metabasetracing-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_SIZE、MB_TRACING_EXPORT_TIMEOUT_MS、MB_TRACING_SCHEDULE_DELAY_MS传给 batch span processor随后调用init-enabled-groups!缓存 Group 集合初始化失败只记 error 日志、tracing 整体降级为禁用。shutdown!在应用关闭时 flush 未发送的 Span。十、完成自检清单按技能文档的 checklist一次合规的埋点改造应满足目标模块已在.clj-kondo/config/modules/config.edn的:uses中包含tracingns 的 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.core的with-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),仅供参考