Clojure 日志、指标与可观测性:Timbre、OpenTelemetry 与采样

深入 Clojure 服务的可观测性建设:Timbre 结构化日志与上下文、MDC 与请求追踪、日志格式与采集、指标导出(metrics)、OpenTelemetry 埋点与 trace 传播、错误上报与采样策略、生产排错实践,帮你把「日志、指标、链路」三支柱接成一条能定位问题的观测流水线。

服务上线后,「它现在怎么样」「这次请求为什么慢」成了日常问题。可观测性用三支柱回答:日志说「发生了什么」、指标说「整体趋势如何」、链路说「这一次请求经过了哪里」。Clojure 生态里,Timbre 负责结构化日志、OpenTelemetry 负责链路与指标,Ring 中间件把它们串起来。本文从日志讲到 trace 传播与采样,帮你把三支柱接成一条能真正定位问题的观测流水线。

1. 可观测性三支柱

1.1 三支柱的分工

支柱回答的问题典型工具
日志具体发生了什么Timbre、Logback
指标整体趋势与告警metrics、Prometheus
链路这一次请求走了哪OpenTelemetry

1.2 为什么 Clojure 需要结构化日志

;; 非结构化:只能人看,机器难解析
(println "user 42 login failed from 10.0.0.1")

;; 结构化:可查询、可聚合、可告警
(log/info :user-login-failed {:user-id 42 :ip "10.0.0.1" :reason :bad-password})

心智:日志的消费方不只是人,还有日志系统(检索、聚合、告警)。结构化日志让「字段」可被机器索引,是把日志变成数据的前提。

1.3 观测的三层成本

成本从低到高:指标 < 日志 < 链路
采样策略:链路必采样、日志按级别、指标全量

心法:三支柱不是「都要全量」,而是各按特性取舍——指标便宜可全量、日志按级别过滤、链路开销大必须采样。

2. Timbre 结构化日志

2.1 依赖与基本用法

;; deps.edn
{:deps {com.taoensso/timbre {:mvn/version "6.6.1"}}}

(require '[taoensso.timbre :as log])

(log/info "服务启动" {:port 8080})
(log/warn "连接池接近上限" {:active 95 :max 100})
(log/error (ex-info "支付失败" {:order-id 7}) "处理订单出错")

2.2 结构化字段

;; 推荐:事件名 + map,字段可被索引
(log/info :order-created
          {:order-id 1234
           :user-id  42
           :amount   199.00
           :currency :cny})

2.3 级别与输出

(log/set-min-level! :debug)          ;; 全局
(log/merge-config!
  {:min-level [[#{"app.db.*"} :debug]
               [#{"*"} :info]]
   :output-fn :inherit})

心法:日志内容用「事件名 + 字段 map」——事件名稳定、字段可查询。别把变量拼进字符串里,那等于把数据埋进了散文。

3. 日志上下文与 MDC

3.1 请求上下文

一次请求里所有日志都该带上 request-id、user-id,否则排查时无法串联:

(require '[taoensso.timbre :as log])

(defn with-context [f ctx]
  (log/with-context+ ctx (f)))

;; 用法
(with-context handle-request
  {:request-id (str (random-uuid))
   :user-id    42
   :path       "/api/orders"})

3.2 Ring 中间件注入上下文

(defn wrap-request-context [handler]
  (fn [req]
    (let [ctx {:request-id (or (get-in req [:headers "x-request-id"])
                               (str (random-uuid)))
               :method     (:request-method req)
               :path       (:uri req)}]
      (log/with-context+ ctx
        (handler (assoc req :request-context ctx))))))

3.3 MDC 与线程传递

关键:MDC 是「线程局部」的
  go-block 会切换线程 -> 上下文可能丢
  future/线程池     -> 上下文不自动传递
对策:
  显式把 ctx 作为参数传递(推荐)
  或在 go/thread 启动时手动绑定

心法:上下文用「显式传递」而非「隐式线程局部」——core.async 的 go-block 会在线程间跳,MDC 靠不住。把 context 当成数据显式传,跨线程也不会丢。

4. 日志格式与采集

4.1 JSON 输出

(require '[taoensso.timbre :as log]
         '[cheshire.core :as json])

(log/merge-config!
  {:output-fn
   (fn [{:keys [level msg_ context timestamp]}]
     (json/generate-string
       {:ts      (str timestamp)
        :level   (name level)
        :message (force msg_)
        :ctx     @context}))})

输出示例:

{"ts":"2026-10-02T11:00:00Z","level":"info","message":"order-created","ctx":{"request-id":"...","order-id":1234}}

4.2 采集链路

应用 stdout(JSON 行)
  -> 容器运行时收集
  -> 日志代理(Fluent Bit / Vector)
  -> 日志后端(Loki / Elasticsearch)
  -> 查询与告警(Grafana)

4.3 字段规范

字段含义必须
ts时间戳是
level级别是
message事件名是
request-id请求标识是
user-id用户标识视场景
duration-ms耗时视场景

心法:日志格式一旦定,就当成接口对待——字段名、类型、必填项都要有规范,否则日志系统里的查询会变成一场考古。

5. 指标:metrics 与导出

5.1 用 metrics 库

;; deps.edn
{:deps {io.dropwizard.metrics/metrics-core {:mvn/version "4.2.25"}}}

(require '[metrics.core :as m]
         '[metrics.timers :as timers])

(def registry (m/new-registry))
(def req-timer (timers/timer registry "http.requests"))

;; 记录一次请求耗时
(timers/time! req-timer
  (handle-request req))

;; 计数器
(def login-counter (m/counter registry "auth.logins"))
(m/inc! login-counter)

5.2 导出到 Prometheus

;; 用 prometheus 客户端暴露 /metrics
(require '[io.prometheus.client :as prom]
         '[io.prometheus.client.exporter.common :as common])

(def req-duration
  (prom/histogram "http_request_duration_seconds" "请求耗时"
                  ["method" "path" "status"]))

(defn instrument [handler]
  (fn [req]
    (let [t (prom/start-timer req-duration
                              (name (:request-method req))
                              (:uri req))]
      (let [resp (handler req)]
        (prom/observe-duration! t (str (:status resp)))
        resp))))

5.3 该埋哪些指标

黄金四信号(Google SRE):
  Latency   延迟(分位数 p50/p95/p99)
  Traffic   流量(QPS)
  Errors    错误率
  Saturation 饱和度(连接池、队列水位)

心法:指标选「黄金四信号」,别贪多——延迟、流量、错误率、饱和度覆盖了绝大多数告警需求。指标爆炸(几千个)比没有指标更难用。

6. OpenTelemetry 埋点

6.1 依赖与初始化

;; deps.edn
{:deps {io.opentelemetry/opentelemetry-api       {:mvn/version "1.40.0"}
        io.opentelemetry/opentelemetry-sdk         {:mvn/version "1.40.0"}
        io.opentelemetry/opentelemetry-exporter-otlp {:mvn/version "1.40.0"}}}

(require '[clojure.java.io :as io])
(import '[io.opentelemetry.sdk OpenTelemetrySdk]
        '[io.opentelemetry.sdk.trace SdkTracerProvider]
        '[io.opentelemetry.exporter.otlp.trace OtlpGrpcSpanExporter])

(def tracer-provider
  (-> (SdkTracerProvider/builder)
      (.addSpanProcessor
        (-> (io.opentelemetry.sdk.trace.export.BatchSpanProcessor/builder
              (OtlpGrpcSpanExporter/builder
                (.setEndpoint "http://collector:4317") (.build)))
            (.build)))
      (.build)))

(def open-telemetry
  (-> (OpenTelemetrySdk/builder)
      (.setTracerProvider tracer-provider)
      (.build)))

(def tracer (.get (.getTracerProvider open-telemetry) "my-service" "1.0.0"))

6.2 手动创建 span

(defn process-order [order]
  (let [span (.spanBuilder tracer "process-order")
        _    (.setAttribute span "order.id" (str (:id order)))
        _    (.setAttribute span "order.amount" (double (:amount order)))
        span (.startSpan span)]
    (try
      (let [result (do-work order)]
        (.setStatus span io.opentelemetry.api.trace.StatusCode/OK)
        result)
      (catch Exception e
        (.recordException span e)
        (.setStatus span io.opentelemetry.api.trace.StatusCode/ERROR)
        (throw e))
      (finally
        (.end span)))))

6.3 自动埋点 Ring

自动埋点思路:
  1. 从请求头提取 traceparent(上游传播的上下文)
  2. 创建服务端 span,作为父
  3. 处理请求时,后续 span 挂在这个父下
  4. 响应时把新 traceparent 写回头

心法:链路的价值在「跨服务串联」——单个服务内的 span 意义有限,只有把 traceparent 在 HTTP 头里传下去,才能看到「一次下单经过了网关、订单、支付、库存」。手动埋点从入口开始。

7. Trace 传播与跨服务

7.1 W3C traceparent

traceparent: 00-<trace-id>-<span-id>-<flags>
示例:00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01

7.2 提取与注入

(require '[io.opentelemetry.api.trace :as trace])
(import '[io.opentelemetry.context Context]
        '[io.opentelemetry.api.trace.propagation W3CTraceContextPropagator])

(def propagator (W3CTraceContextPropagator/getInstance))

(defn extract-context [req]
  (let [getter (reify io.opentelemetry.context.propagation.TextMapGetter
                 (get [_ carrier key] (get-in carrier [:headers key]))
                 (keys [_ carrier] (keys (:headers carrier))))]
    (.extract propagator Context/root req getter)))

(defn inject-headers [ctx]
  (let [setter (reify io.opentelemetry.context.propagation.TextMapSetter
                 (set [_ carrier key value] (assoc-in carrier [:headers key] value)))]
    (.inject propagator ctx {} setter)))

7.3 跨服务调用

;; 出站请求带上 traceparent
(defn call-downstream [req url]
  (let [ctx     (:otel-context req)
        headers (inject-headers ctx)]
    (http/get url {:headers (merge {"content-type" "application/json"}
                                   (:headers headers))})))

心法:trace 传播是「协议」不是「库功能」——W3C traceparent 是标准头,任何 HTTP 客户端都能带。只要入口提取、出口注入,链路就跨服务连上了。

8. 错误上报与采样

8.1 错误分级

分级处理:
  WARN  —— 可自愈(重试成功、降级命中)
  ERROR —— 需关注(业务失败、依赖异常)
  FATAL —— 需告警(进程级、数据损坏)
上报到 Sentry 之类时只报 ERROR 及以上

8.2 采样策略

策略说明适用
头部采样入口决定是否采高流量、成本敏感
尾部采样完整 trace 后再定需保留错误链路
按错误采错误必采排错优先
按比例采固定比例基线观测
;; 按比例采样(父级无决策时)
(def sampler
  (io.opentelemetry.sdk.trace.samplers.Sampler/traceIdRatioBased 0.1))

;; 尾部采样:始终保留有错误的 trace
;; 由 collector 侧配置(如 otel-collector 的 tail_sampling)

8.3 采样与日志的配合

实践:
  日志:按级别全量(便宜),错误必留
  链路:按比例 + 错误必采
  指标:全量
  三者在同一 request-id 下可关联

心法:采样不是「丢数据」,而是「按价值分配预算」——错误链路必须留,正常链路按比例留。日志里带上 request-id,就能从「一条错误日志」跳到「完整链路」。

9. 生产实践与排错

9.1 一次慢请求的排查路径

1. 指标发现 p99 升高 -> 确认不是整体劣化
2. 按 request-id 检索日志 -> 找到慢请求样本
3. 用 trace-id 看链路 -> 定位慢在哪一跳
4. 看该跳的日志字段 -> 确认是依赖慢还是自己慢

9.2 常见坑

坑现象规避
日志拼字符串字段不可查结构化字段
MDC 跨 go-block 丢上下文缺失显式传递
全量链路成本爆炸采样
指标维度爆炸存储告警限制标签基数
日志级别全 debug磁盘打满生产 info 起

9.3 观测的闭环

发现问题(指标告警)
  -> 定位(日志 + 链路)
  -> 修复
  -> 加监控/加日志(防止复发)

心法:可观测性的目标不是「数据多」,而是「问题来时能定位」——每一次排错都应反哺观测:把这次缺的字段、缺的指标补上,下次同类问题就能秒定位。

10. 速查表与一句话记忆

需求工具或写法
结构化日志Timbre + 字段 map
请求上下文log/with-context+
Ring 注入wrap-request-context 中间件
JSON 日志自定义 output-fn
指标metrics 库 + counter/timer
指标导出Prometheus 客户端
黄金信号延迟/流量/错误/饱和度
链路OpenTelemetry tracer
传播W3C traceparent
采样traceIdRatioBased / 尾部采样
错误上报只报 ERROR 及以上
关联request-id 贯穿三支柱

一句话记忆:可观测性三支柱 = 日志说「发生了什么」(Timbre 结构化、事件名 + 字段、上下文显式传递)→ 指标说「趋势如何」(黄金四信号、Prometheus 导出)→ 链路说「走了哪里」(OpenTelemetry span、W3C traceparent 跨服务传播)→ 采样按价值分配预算(错误必留、正常按比例)→ 三支柱靠 request-id 关联 → 排错后反哺观测形成闭环——目标不是数据多,而是问题来时能定位。

延伸阅读

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「clojure」更多文章

  1. Datalog 查询与 Datomic/Xtdb:数据即事实、pull、时间旅行与架构
  2. Clojure 静态检查与格式化工具链:clj-kondo、cljfmt、zprint 与 CI
  3. Clojure 认证授权与安全实践:Ring 安全链、JWT、密码哈希与审计