Skip to content
BEE
Backend Engineering Essentials

[BEE-19056] OpenTelemetry 可觀測性實作

INFO

OpenTelemetry 是 CNCF 畢業的可觀測性標準,用於從應用程式產生、收集和匯出遙測資料(追蹤、指標、日誌)——提供單一的廠商中立 SDK,取代廠商特定的代理程式,實現與後端無關的可觀測性。

背景

在 OpenTelemetry 出現之前,每個可觀測性廠商都提供其專有的代理程式:Datadog 有自己的追蹤器,New Relic 有自己的 SDK,Jaeger 有自己的客戶端函式庫。選擇某個可觀測性後端的工程團隊,就被鎖定在了該廠商的監控程式碼中。切換後端意味著重新對每個服務進行監控埋點。

兩個相互競爭的開源標準相繼出現:OpenTracing(2016 年,CNCF,僅限 API,專注於追蹤)和 OpenCensus(2018 年,Google,帶有追蹤和指標的 SDK)。兩者都有社群採用,但都不夠完整。2019 年,這兩個專案在 CNCF 旗下合併為 OpenTelemetry,融合了 OpenTracing 的 API 抽象與 OpenCensus 的 SDK,並新增了日誌訊號。OpenTelemetry 於 2024 年 1 月 31 日獲得 CNCF 畢業狀態——成為繼 Kubernetes 之後第二活躍的 CNCF 專案。

結果是一個適用於所有主要語言(Go、Java、Python、Node.js、.NET、Rust、PHP、Ruby、Swift、Erlang)的單一 SDK,透過 OTLP(OpenTelemetry Protocol)向任何後端發出追蹤、指標和日誌。W3C TraceContext 規範(自 2021 年起成為 W3C 推薦標準)定義了在服務邊界傳播追蹤上下文的 traceparenttracestate HTTP 標頭——這是 OTel 實作的標準,所有主要 APM 廠商現在都支援。

實際影響:一次監控埋點,處處匯出。使用 OTel SDK 的應用程式今天可以發送到 Jaeger,明天發送到 Honeycomb,指標發送到 Prometheus/Grafana——只需更改 Collector 設定,而非應用程式程式碼。

設計思考

三種訊號

OpenTelemetry 定義了三種遙測訊號,截至 2023 年底均已正式發佈(GA):

訊號測量內容主要用途
追蹤(Traces)請求跨服務歷程的 Span 樹延遲除錯、服務依賴關係圖
指標(Metrics)隨時間變化的聚合數值測量告警、儀表板、SLO
日誌(Logs)帶時間戳的文字 / 結構化記錄詳細除錯、稽核軌跡

三者都透過共享的 trace_id 相互關聯:在被追蹤的請求期間發出的結構化日誌,可以連結到對應的追蹤,讓你能從慢速追蹤跳轉到解釋原因的日誌行。

SDK vs 自動監控 vs Collector

三個層次協同工作:

SDK:在行程中建立 Span、記錄指標並發出日誌的函式庫。你直接呼叫 SDK API 進行手動監控,或由自動監控自動完成。

自動監控:補丁流行框架(FastAPI、Django、SQLAlchemy、gRPC、Express、Spring Boot、JDBC)的函式庫套件,無需更改程式碼即可自動發出 Span。Django 視圖獲得一個根 Span;每個 SQLAlchemy 查詢成為一個子 Span。使用 opentelemetry-instrument python app.py 進行零程式碼監控。

Collector:一個獨立的代理服務(用 Go 編寫),從應用程式接收 OTLP 遙測資料,處理它(批次、採樣、豐富化、過濾),並匯出到後端。Collector 解耦了應用程式與後端的關係:應用程式始終匯出到 localhost:4317(gRPC)或 localhost:4318(HTTP);Collector 處理重試、批次處理,並同時路由到多個後端。

Span 解析

Span 代表一個工作單位:

  • 名稱:人類可讀的操作名稱(GET /users/{id}db.querykafka.publish
  • Trace ID:分散式追蹤中所有 Span 共享的 128 位識別符
  • Span ID:此 Span 唯一的 64 位識別符
  • Parent Span ID:將此 Span 連結到其父 Span;根 Span 不存在此項
  • 開始 / 結束時間戳:工作的掛鐘時間
  • 狀態:OK、ERROR 或 UNSET
  • 屬性:鍵值對(http.methoddb.systemnet.peer.name
  • 事件:Span 持續時間內帶時間戳的日誌訊息
  • 連結:對其他追蹤中 Span 的引用(對非同步訊息傳遞有用)

最佳實踐

必須(MUST)跨服務邊界傳播追蹤上下文。 上下文傳播是將孤立的 Span 轉換為分散式追蹤的關鍵。對 HTTP 呼叫使用 W3C traceparent 標頭;對 Kafka 和其他訊息系統使用訊息屬性 / 標頭。使用監控過的 HTTP 客戶端時,OTel SDK 的傳播器會自動處理注入(向傳出請求添加標頭)和提取(讀取傳入標頭)。

必須(MUST)在錯誤時記錄 Span 狀態。 status = UNSET 的 Span 在追蹤分析工具中看起來像成功。當捕獲到異常或返回非 2xx 回應時,呼叫 span.set_status(StatusCode.ERROR)span.record_exception(exc)。這使錯誤在追蹤搜索中可見,並允許從追蹤資料計算錯誤率 SLO。

不得(MUST NOT)每個請求建立一個新的 Tracer Tracer 是一個輕量級的工廠;在啟動時使用 tracer = get_tracer(__name__) 建立一次並重複使用。每個請求建立新的 Tracer 會增加開銷並繞過 SDK 快取。

應該(SHOULD)使用語義慣例來命名屬性。 OpenTelemetry 為常見系統定義了標準屬性名稱:http.methodhttp.status_codedb.systemdb.statementrpc.systemmessaging.system。使用標準名稱能讓理解 OTel 語義慣例的廠商的開箱即用儀表板和告警正常運作。當存在標準名稱時,避免自創屬性名稱。

應該(SHOULD)部署 OpenTelemetry Collector 而非直接從應用程式匯出到後端。 從應用程式直接匯出到後端會造成緊密耦合:更換後端需要更改應用程式設定並重新部署。Collector 解決了這個問題:匯出到 localhost:4317,配置 Collector 同時分發到多個後端、添加採樣,或過濾敏感屬性——無需修改應用程式程式碼。

應該(SHOULD)在 Collector 中採樣追蹤,而非在應用程式 SDK 中,以獲得更大的靈活性。 頭部採樣(在追蹤根處決定是否採樣)在根 Span 在錯誤發生之前啟動時,可能會遺失錯誤追蹤。尾部採樣(在完整追蹤組裝後決定)保留所有錯誤追蹤。OTel Collector 的 tailsamplingprocessor 實作了尾部採樣;應用程式端採樣是極端容量情況下的最後手段。

應該(SHOULD)將業務領域屬性添加到根 Span。 通用的 HTTP 屬性(http.methodhttp.route)告訴你在協議層發生了什麼。添加 user.idorder.idtenant.id 作為 Span 屬性,可以透過業務實體搜索追蹤——「找到訂單 12345 的所有追蹤」——這比「找到 POST /orders 的所有追蹤」有用幾個數量級。

可以(MAY)使用 Baggage 跨請求範圍傳播不屬於 Span 屬性的值。 OTel Baggage 跨服務邊界傳播鍵值對(類似分散式的執行緒本地儲存)。用於下游服務需要但與個別 Span 不直接相關的值:tenant.idexperiment.variantuser.tier

視覺化

範例

Python——SDK 設定與手動 Span 建立(FastAPI):

python
# otel_setup.py — 在應用程式啟動時初始化一次
from opentelemetry import trace
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import BatchSpanProcessor
from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter
from opentelemetry.sdk.resources import Resource
from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor
from opentelemetry.instrumentation.sqlalchemy import SQLAlchemyInstrumentor

def configure_otel(service_name: str) -> None:
    resource = Resource.create({"service.name": service_name})
    provider = TracerProvider(resource=resource)
    # 匯出到本機 Collector;Collector 負責路由到後端
    exporter = OTLPSpanExporter(endpoint="http://localhost:4317", insecure=True)
    provider.add_span_processor(BatchSpanProcessor(exporter))
    trace.set_tracer_provider(provider)

    # 自動監控 FastAPI 路由和 SQLAlchemy 查詢
    FastAPIInstrumentor.instrument()
    SQLAlchemyInstrumentor().instrument()

# app.py
from opentelemetry import trace

tracer = trace.get_tracer(__name__)  # 建立一次;到處重複使用

@app.post("/orders")
async def create_order(order: OrderRequest) -> OrderResponse:
    # 自動監控已為此 HTTP 處理器建立了根 Span
    # 添加業務領域屬性,使此追蹤可被搜索
    span = trace.get_current_span()
    span.set_attribute("customer.id", order.customer_id)

    # 為重要子操作手動建立子 Span
    with tracer.start_as_current_span("charge_payment") as payment_span:
        payment_span.set_attribute("payment.method", order.payment_method)
        payment_span.set_attribute("payment.amount", order.amount_cents)
        try:
            result = await payment_service.charge(order)
            payment_span.set_attribute("payment.charge_id", result.charge_id)
        except PaymentDeclinedException as e:
            payment_span.set_status(trace.StatusCode.ERROR, str(e))
            payment_span.record_exception(e)
            raise

    return OrderResponse(order_id=result.order_id)

零程式碼自動監控(無需更改程式碼):

bash
# 安裝自動監控套件
pip install opentelemetry-distro opentelemetry-exporter-otlp
opentelemetry-bootstrap --action=install  # 安裝框架特定套件

# 使用自動監控執行——監控 Django、SQLAlchemy、requests 等
OTEL_SERVICE_NAME=order-service \
OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4317 \
opentelemetry-instrument python manage.py runserver

Go——帶上下文傳播的手動 Span 建立:

go
package main

import (
    "context"
    "go.opentelemetry.io/otel"
    "go.opentelemetry.io/otel/attribute"
    "go.opentelemetry.io/otel/codes"
    "go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc"
    sdktrace "go.opentelemetry.io/otel/sdk/trace"
    "go.opentelemetry.io/otel/sdk/resource"
    semconv "go.opentelemetry.io/otel/semconv/v1.21.0"
)

var tracer = otel.Tracer("order-service")

func initOTel(ctx context.Context) (*sdktrace.TracerProvider, error) {
    exporter, err := otlptracegrpc.New(ctx,
        otlptracegrpc.WithInsecure(),
        otlptracegrpc.WithEndpoint("localhost:4317"),
    )
    if err != nil {
        return nil, err
    }
    res, _ := resource.New(ctx,
        resource.WithAttributes(semconv.ServiceName("order-service")),
    )
    tp := sdktrace.NewTracerProvider(
        sdktrace.WithBatcher(exporter),
        sdktrace.WithResource(res),
    )
    otel.SetTracerProvider(tp)
    return tp, nil
}

func processOrder(ctx context.Context, orderID string) error {
    // 啟動子 Span——ctx 從 HTTP 處理器攜帶父 Span
    ctx, span := tracer.Start(ctx, "process_order")
    defer span.End()

    span.SetAttributes(
        attribute.String("order.id", orderID),
        attribute.String("db.system", "postgresql"),
    )

    if err := chargeAndFulfill(ctx, orderID); err != nil {
        span.SetStatus(codes.Error, err.Error())
        span.RecordError(err)
        return err
    }
    return nil
}

OpenTelemetry Collector 設定(otel-collector-config.yaml):

yaml
receivers:
  otlp:
    protocols:
      grpc:
        endpoint: 0.0.0.0:4317   # 從應用程式接收
      http:
        endpoint: 0.0.0.0:4318

processors:
  batch:                           # 緩衝並批次處理 Span 以提升效率
    timeout: 1s
    send_batch_size: 1024
  resource:
    attributes:
      - key: deployment.environment
        value: production
        action: upsert
  # 尾部採樣:保留 100% 的錯誤追蹤,10% 的成功追蹤
  tail_sampling:
    decision_wait: 10s
    policies:
      - name: errors-policy
        type: status_code
        status_code: {status_codes: [ERROR]}
      - name: probabilistic-policy
        type: probabilistic
        probabilistic: {sampling_percentage: 10}

exporters:
  otlp/jaeger:
    endpoint: jaeger:4317
    tls:
      insecure: true
  prometheusremotewrite:
    endpoint: http://prometheus:9090/api/v1/write

service:
  pipelines:
    traces:
      receivers: [otlp]
      processors: [batch, tail_sampling]
      exporters: [otlp/jaeger]
    metrics:
      receivers: [otlp]
      processors: [batch]
      exporters: [prometheusremotewrite]

實作說明

Pythonopentelemetry-sdk 套件提供核心 SDK。opentelemetry-api 提供 API(可在函式庫程式碼中匯入,無需重量級 SDK 依賴)。生產環境使用 BatchSpanProcessor(非同步、緩衝);SimpleSpanProcessor 僅用於除錯(同步、阻塞)。opentelemetry-instrument 的自動監控支援 Flask、Django、FastAPI、aiohttp、SQLAlchemy、psycopg2、Redis、Celery 等。

Gogo.opentelemetry.io/otel 是 API;go.opentelemetry.io/otel/sdk 是 SDK。透過 otelhttp.NewHandler 包裝 http.Handler 實現 HTTP 監控;透過 otelgrpc.UnaryServerInterceptor 實現 gRPC 監控。始終透過呼叫堆疊傳播 context.Context——OTel Go 依賴它實現 Span 的父子關係。

Java / Spring Bootopentelemetry-spring-boot-starter 透過 Spring Boot 自動配置機制添加自動監控。Java 代理(opentelemetry-javaagent.jar)提供零程式碼監控:java -javaagent:opentelemetry-javaagent.jar -jar app.jar

Node.js@opentelemetry/sdk-node 提供 NodeSDK。存在用於 Express、HTTP、gRPC、pg、MySQL、Redis 和 AWS SDK 的自動監控套件。在匯入應用程式模組之前初始化 SDK——監控必須在框架載入之前注冊。

語義慣例:OTel 在 opentelemetry-semantic-conventions 中定義了一組穩定的屬性名稱。對於常見概念,始終優先使用語義慣例(http.methoddb.systemrpc.service),而非自訂屬性——這確保與期望標準屬性名稱的廠商儀表板和告警規則的相容性。

相關 BEE

  • BEE-14001 -- 三大支柱:日誌、指標、追蹤:涵蓋每種訊號類型是什麼以及為何三者缺一不可;OpenTelemetry 是產生所有三種訊號的 SDK
  • BEE-14003 -- 分散式追蹤:涵蓋追蹤傳播和 Span 關係的理論;OpenTelemetry 是這些概念的實際實作
  • BEE-14002 -- 結構化日誌:OTel 日誌與結構化日誌整合;在被追蹤的請求期間發出的日誌攜帶 Trace ID 以供關聯
  • BEE-14005 -- SLO 與錯誤預算:追蹤資料提供延遲和錯誤率 SLI 的原始素材;OTel 指標可直接用於 SLO 計算

參考資料