弱点補強 — OpenTelemetry 1.x 計装 Bad→Good 7点(TracerProvider × BatchSpanProcessor × record_exception × set_status × structlog trace_id 相関 × Propagator W3C / Baggage × MeterProvider + ヒストグラム)

2026-07-05 (Day 94) 日曜 弱点補強 ★★★★☆ OTel 1.x / Python 3.12 / TracerProvider / BatchSpanProcessor structlog / MeterProvider / CompositePropagator / OTLP gRPC

概要

🔭

TracerProvider を正しく初期化する

trace.get_tracer()set_tracer_provider() に呼ぶ。起動時に ResourceBatchSpanProcessorOTLPSpanExporter を一度だけ設定する。SimpleSpanProcessor は同期エクスポートでリクエストをブロックするため本番禁止。

🚨

record_exception + set_status(ERROR) でエラー span を可視化

span.record_exception(exc) はスタックトレースを span event として記録。span.set_status(Status(StatusCode.ERROR, msg)) を設定しないと Tempo / DataDog でエラー span が赤くならない。両方セットで初めて障害時のトレースが機能する。

📊

MeterProvider でカウンタ + ヒストグラム

PeriodicExportingMetricReader を使い 30 秒間隔で OTLP へ push。campaigns.dispatched(Counter)で成功/エラー数、campaigns.dispatch_duration_ms(Histogram)で p50/p95/p99 レイテンシを取得し SLO 監視に活用する。

🔗

Propagator でマイクロサービス間トレースを繋ぐ

CompositePropagator([TraceContextTextMapPropagator(), BaggagePropagator()]) を設定しないと、Pub/Sub → Cloud Run 間で traceparent ヘッダーが伝わらずトレースが断絶する。structlog の _add_otel_context プロセッサで ログに trace_id を自動付与し、ログ↔トレース相互リンクを実現する。

問題

ECサイト MOps チームでは、キャンペーン配信 API(Cloud Run v2)と Argo Workflows バッチの可観測性が不十分で、「配信エラーが急増したが原因特定に 2 時間かかった」という障害が発生した。以下の「悪いコード」は現在の計装実装である。

問題点を全て洗い出し、OpenTelemetry 1.x(Python SDK)を使って Bad→Good にリファクタリングしてください。

制約・前提条件

  • Python 3.12+、opentelemetry-sdk 1.xopentelemetry-exporter-otlp-proto-grpc
  • OTLP エンドポイント: OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector:4317(環境変数)
  • Propagator: W3C TraceContext + Baggage の両方を設定
  • MeterProvider: campaigns.dispatched カウンタ(属性: campaign_id, status=success|error)と campaigns.dispatch_duration_ms ヒストグラムを計測
  • structlog で JSON ログ出力し、trace_id / span_id をログに自動付与
  • BatchSpanProcessor を使用(SimpleSpanProcessor は本番禁止)
  • record_exception + set_status(StatusCode.ERROR) でエラーを正しく記録

期待する回答形式: (1) 問題点の列挙(番号付き)、(2) 改善後コード(Google スタイル docstring・インラインコメント・名前付き定数含む)、(3) 実行例(input→output)、(4) 適用した OTel パターン名

悪いコード (Before)

❌ BAD — 計装の欠陥 7 箇所
# campaign_dispatcher.py  ── BAD 実装

import logging
import time
import opentelemetry.trace as trace

logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)

# ① グローバルに tracer を直接取得(初期化なし)
#    Provider 未設定 → NoopTracer になってトレース記録なし
tracer = trace.get_tracer("campaign")

def dispatch_campaign(
    campaign_id: str,
    user_ids: list[str],
) -> dict:
    # ② span に set_attribute がない
    #    → campaign_id を検索できない
    with tracer.start_as_current_span("dispatch") as span:
        try:
            results = []
            for uid in user_ids:
                # ③ 子 span がない
                #    → どのユーザーで失敗したか不明
                ok = _send_push(uid, campaign_id)
                results.append(ok)

            success = sum(results)
            # ④ print + 非構造化ログ
            #    → trace_id が付かない
            print(f"done: {success}/{len(user_ids)}")
            return {
                "success": success,
                "total": len(user_ids),
            }

        except Exception as e:
            # ⑤ 例外握りつぶし
            #    → record_exception なし
            #    → set_status(ERROR) なし
            #    → Tempo でエラー span が赤くならない
            logger.error(f"error: {e}")
            return {"success": 0, "total": len(user_ids)}

def _send_push(uid: str, campaign_id: str) -> bool:
    time.sleep(0.01)  # mock
    return True

# ⑥ MeterProvider 未設定
#    → カウンタ / ヒストグラムが計測されない
# ⑦ Propagator 未設定
#    → Pub/Sub → Cloud Run のトレースが切れる
✅ GOOD — 7 点の改善方針
  • configure_otel() を起動時一度だけ呼び set_tracer_provider() 後に get_tracer()
  • span.set_attribute("campaign.id", ...) で contextual attribute 付与
  • ③ ユーザーごとに子 span → user.id attribute → per-user 障害特定
  • structlog JSON ログ + _add_otel_context プロセッサで trace_id 自動付与
  • child_span.record_exception(exc) + set_status(ERROR) でエラーを正式記録
  • MeterProvider + PeriodicExportingMetricReader + カウンタ・ヒストグラム定義
  • CompositePropagator([TraceContextTextMapPropagator(), BaggagePropagator()])

ヒント

ヒント 1 — 方向性(答えを含まない)

計装の「初期化」「計測」「エラー処理」「ログ相関」「伝播」の 5 つの観点で問題を分類する。get_tracer()TracerProvider が設定された に呼ぶことが重要。

ヒント 2 — アプローチ(キーとなる構造)
  • TracerProvider(resource=Resource({...})) + BatchSpanProcessor(OTLPSpanExporter(...))set_tracer_provider()
  • MeterProvider(resource=..., metric_readers=[PeriodicExportingMetricReader(...)])metrics.set_meter_provider()
  • set_global_textmap(CompositePropagator([TraceContextTextMapPropagator(), BaggagePropagator()]))
  • structlog の processors_add_otel_context を追加してトレース ID を注入
  • 子 span: tracer.start_as_current_span("send_push", attributes={"user.id": uid})
ヒント 3 — 誘導(コードの骨格)
def configure_otel(service_name: str) -> None:
    resource = Resource(attributes={
        SERVICE_NAME: service_name,
        "service.version": "1.0.0",
    })
    exporter = OTLPSpanExporter(
        endpoint=os.environ["OTEL_EXPORTER_OTLP_ENDPOINT"],
        insecure=True,
    )
    provider = TracerProvider(resource=resource)
    provider.add_span_processor(BatchSpanProcessor(exporter))
    set_tracer_provider(provider)
    # MeterProvider も同様に初期化 ...
    set_global_textmap(CompositePropagator([
        TraceContextTextMapPropagator(),
        BaggagePropagator(),
    ]))

問題点分析(7 点)

Provider 未初期化で get_tracer()

TracerProvider を設定する前に get_tracer() を呼ぶと NoopTracer が返り、全 span が無視される。configure_otel()set_tracer_provider() 後にモジュール変数として取得する。

親 span に set_attribute なし

Tempo / DataDog で campaign.id を検索できない。span.set_attribute("campaign.id", campaign_id)campaign.target_count を付与する。

ユーザーごとの子 span がない

「u003 だけ失敗」という per-user 問題を特定できない。ループ内で start_as_current_span("campaign.send_push", attributes={"user.id": uid}) として子 span を作成する。

print + 非構造化ログ

Loki 等でのフィルタ不可。structlog JSON + _add_otel_context プロセッサで trace_id / span_id を自動付与し、ログ↔トレース相互リンクを実現する。

record_exception + set_status(ERROR) なし

例外をキャッチしても OTel に記録しないと Tempo でエラー span が赤くならない。child_span.record_exception(exc) でスタックトレースを span event として記録し、set_status(Status(StatusCode.ERROR)) を設定する。

MeterProvider 未設定でメトリクスなし

配信成功率・レイテンシの Prometheus/DataDog 監視が不可能。MeterProvider + PeriodicExportingMetricReader を初期化し、カウンタ(campaigns.dispatched)とヒストグラム(campaigns.dispatch_duration_ms)を定義する。

Propagator 未設定でトレース断絶

Pub/Sub メッセージに traceparent ヘッダーが含まれていても伝播されない。set_global_textmap(CompositePropagator([TraceContextTextMapPropagator(), BaggagePropagator()])) を設定する。

OTel 計装アーキテクチャ図(SVG)

OTel 計装フロー: Pub/Sub → Cloud Run → OTLP Collector → バックエンド Pub/Sub traceparent ヘッダー push Cloud Run v2 dispatch_campaign() configure_otel() 起動時 extract(carrier) で伝播 OTLP gRPC spans ▶ campaign.dispatch (root span) • campaign.id = "camp_2026_summer_sale" • campaign.target_count = 3 ▶ campaign.send_push (child ×3) • user.id = "u001" ✓ • user.id = "u003" ✗ record_exception! set_status(ERROR) OTLP Collector port 4317 (gRPC) OTLP Tempo / DataDog Prometheus Loki Grafana structlog JSON ログ { "event": "campaign_dispatched", "trace_id": "4bf92f3...", ← 自動付与 "span_id": "00f067aa...", ← 自動付与 } fluentbit / DataDog Agent → Loki MeterProvider: 定義するメトリクス campaigns.dispatched (Counter) attrs: campaign_id, status=success|error campaigns.dispatch_duration_ms (Histogram) attrs: campaign_id — p50/p95/p99 で SLO 監視 PeriodicExportingMetricReader: 30s 間隔 push

模範解答(GOOD 実装)

configure_otel() — 初期化(起動時一度だけ)
from __future__ import annotations

import os
from typing import Final

from opentelemetry import metrics, trace
from opentelemetry.exporter.otlp.proto.grpc.metric_exporter import OTLPMetricExporter
from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter
from opentelemetry.propagate import set_global_textmap
from opentelemetry.propagators.composite import CompositePropagator
from opentelemetry.sdk.metrics import MeterProvider
from opentelemetry.sdk.metrics.export import PeriodicExportingMetricReader
from opentelemetry.sdk.resources import SERVICE_NAME, Resource
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import BatchSpanProcessor  # ← SimpleSpanProcessor は本番 NG
from opentelemetry.trace import set_tracer_provider
from opentelemetry.trace.propagation.tracecontext import TraceContextTextMapPropagator
from opentelemetry.baggage.propagation import BaggagePropagator

SERVICE_NAME_VALUE: Final[str] = "campaign-dispatcher"
METRIC_DISPATCHED:  Final[str] = "campaigns.dispatched"
METRIC_DURATION_MS: Final[str] = "campaigns.dispatch_duration_ms"

def configure_otel(service_name: str = SERVICE_NAME_VALUE) -> None:
    """TracerProvider / MeterProvider / Propagator を初期化する(起動時一度)."""
    endpoint = os.environ.get("OTEL_EXPORTER_OTLP_ENDPOINT", "http://localhost:4317")
    resource = Resource(attributes={
        SERVICE_NAME: service_name,
        "service.version": "2.1.0",
        "deployment.environment": os.environ.get("ENV", "development"),
    })

    # ── TracerProvider ─────────────────────────────────────────────
    tracer_provider = TracerProvider(resource=resource)
    tracer_provider.add_span_processor(
        BatchSpanProcessor(OTLPSpanExporter(endpoint=endpoint, insecure=True))
    )
    set_tracer_provider(tracer_provider)  # ← これより後に get_tracer() を呼ぶ

    # ── MeterProvider ──────────────────────────────────────────────
    metrics.set_meter_provider(MeterProvider(
        resource=resource,
        metric_readers=[PeriodicExportingMetricReader(
            OTLPMetricExporter(endpoint=endpoint, insecure=True),
            export_interval_millis=30_000,  # 30 秒ごとに push
        )],
    ))

    # ── Propagator(W3C TraceContext + Baggage)────────────────────
    # Pub/Sub → Cloud Run の traceparent ヘッダーを引き継ぐ
    set_global_textmap(CompositePropagator([
        TraceContextTextMapPropagator(),
        BaggagePropagator(),
    ]))
structlog + _add_otel_context プロセッサ
import logging
import structlog

def _add_otel_context(logger, method_name, event_dict: dict) -> dict:
    """structlog プロセッサ: 現在の trace_id / span_id を付与."""
    span = trace.get_current_span()
    ctx = span.get_span_context()
    if ctx.is_valid:
        event_dict["trace_id"] = format(ctx.trace_id, "032x")  # Loki ↔ Tempo リンク用
        event_dict["span_id"]  = format(ctx.span_id, "016x")
    return event_dict

def configure_structlog() -> None:
    structlog.configure(
        processors=[
            structlog.contextvars.merge_contextvars,
            structlog.stdlib.add_log_level,
            structlog.processors.TimeStamper(fmt="iso"),
            _add_otel_context,       # ← trace_id / span_id を自動注入
            structlog.processors.format_exc_info,
            structlog.processors.JSONRenderer(),  # JSON 出力 → Loki でフィルタ可能
        ],
        wrapper_class=structlog.make_filtering_bound_logger(logging.INFO),
        logger_factory=structlog.PrintLoggerFactory(),
    )
dispatch_campaign() — 計装済み実装
from opentelemetry.trace.status import Status, StatusCode

# ── Provider 初期化後にモジュール変数として取得 ──────────────────────────
_tracer  = trace.get_tracer(__name__)
_meter   = metrics.get_meter(__name__)
_log     = structlog.get_logger(__name__)

# カウンタ: success / error 別に集計
_dispatched_counter = _meter.create_counter(
    name=METRIC_DISPATCHED,
    description="キャンペーン配信数",
    unit="1",
)
# ヒストグラム: p50/p95/p99 レイテンシ
_duration_histogram = _meter.create_histogram(
    name=METRIC_DURATION_MS,
    description="配信所要時間",
    unit="ms",
)

def dispatch_campaign(campaign_id: str, user_ids: list[str]) -> dict[str, int]:
    """キャンペーンを指定ユーザーにディスパッチする."""
    import time
    start_ms = time.monotonic() * 1000

    with _tracer.start_as_current_span("campaign.dispatch") as span:
        # ② contextual attribute: Tempo で campaign_id 検索可能になる
        span.set_attribute("campaign.id", campaign_id)
        span.set_attribute("campaign.target_count", len(user_ids))

        success, errors = 0, []

        for uid in user_ids:
            # ③ 子 span で per-user レイテンシ + エラー特定
            with _tracer.start_as_current_span(
                "campaign.send_push",
                attributes={"user.id": uid, "campaign.id": campaign_id},
            ) as child:
                try:
                    _send_push(uid, campaign_id)
                    success += 1
                    child.set_attribute("send.status", "success")
                except Exception as exc:
                    errors.append(uid)
                    child.record_exception(exc)                          # スタックトレースを event 記録
                    child.set_status(Status(StatusCode.ERROR, str(exc))) # ⑤ Tempo でエラー赤表示
                    _log.warning("push_send_failed", uid=uid, campaign_id=campaign_id, error=str(exc))

        duration_ms = time.monotonic() * 1000 - start_ms
        error_count = len(errors)

        span.set_attribute("campaign.success_count", success)
        span.set_attribute("campaign.error_count", error_count)
        if error_count:
            span.set_status(Status(StatusCode.ERROR, f"{error_count} users failed"))

        # ⑥ メトリクス記録
        _dispatched_counter.add(success,      {"campaign_id": campaign_id, "status": "success"})
        _dispatched_counter.add(error_count,  {"campaign_id": campaign_id, "status": "error"})
        _duration_histogram.record(duration_ms, {"campaign_id": campaign_id})

        # ④ 構造化ログ(trace_id が _add_otel_context で自動付与)
        _log.info("campaign_dispatched",
                  campaign_id=campaign_id, success=success,
                  total=len(user_ids), error_count=error_count,
                  duration_ms=round(duration_ms, 2))

        return {"success": success, "total": len(user_ids), "error": error_count}

# ── エントリーポイント ────────────────────────────────────────────────────
if __name__ == "__main__":
    configure_otel()
    configure_structlog()
    result = dispatch_campaign("camp_2026_summer_sale", ["u001", "u002", "u003"])
    print(result)
    # → {"success": 3, "total": 3, "error": 0}
実行例(input → output)
Input:  campaign_id="camp_2026_summer_sale", user_ids=["u001","u002","u003"]
Output: {"success": 3, "total": 3, "error": 0}

JSON log (structlog):
{
  "event": "campaign_dispatched",
  "level": "info",
  "timestamp": "2026-07-05T10:30:00.123456Z",
  "trace_id": "4bf92f3577b34da6a3ce929d0e0e4736",  ← OTel 自動付与
  "span_id": "00f067aa0ba902b7",
  "campaign_id": "camp_2026_summer_sale",
  "success": 3,
  "total": 3,
  "error_count": 0,
  "duration_ms": 15.42
}
適用した OTel パターン:
  • Resource-attached Provider: service.name / service.version を Resource に付与
  • BatchSpanProcessor: バッファリング非同期エクスポート(本番必須)
  • Parent-Child Span: ルート span → per-user 子 span でトレース階層化
  • Exception Recording: record_exception + set_status(ERROR) ペア
  • Log-Trace Correlation: structlog プロセッサで trace_id を JSON ログに注入
  • Composite Propagator: W3C TraceContext + Baggage でマイクロサービス間伝播
  • RED Metrics: Rate(campaigns.dispatched)+ Error(status=error)+ Duration(dispatch_duration_ms

ポイント解説

  1. configure_otel() を起動時一度だけ呼ぶ: get_tracer() / get_meter() はモジュール変数として一度だけ取得。呼ぶたびに新しいプロバイダーを作らない。
  2. BatchSpanProcessor が必須: SimpleSpanProcessor は同期エクスポートでリクエストをブロックする。本番では BatchSpanProcessor でバッファリング + 非同期送信。
  3. record_exception + set_status(ERROR): 例外を OTel に記録しないと、Tempo 等のトレースバックエンドでエラー span が赤くならない。record_exceptionevents にスタックトレースを記録する。
  4. Propagator の設定: CompositePropagator を設定しないと Pub/Sub → Cloud Run 間でトレース文脈が切れる。W3C traceparent ヘッダーを Pub/Sub メッセージ属性に入れることでトレースが繋がる。
  5. structlog + _add_otel_context プロセッサ: trace_id をログに埋め込むことで、Grafana Loki 等でログ↔トレースを相互リンクできる(ログ→トレースのドリルダウン)。
  6. 子 span で user.id attribute: 「u003 だけ失敗」のような per-user 問題を Tempo のスパン一覧から即座に特定できる。
  7. RED メトリクス設計: Rate(dispatched counter)+ Error(status=error counter)+ Duration(dispatch_duration_ms histogram)の 3 指標で SLO を設計し、p99 > 500ms をアラート条件にする。

実務への応用

  • MOps キャンペーン配信 API (Cloud Run v2): configure_otel()lifespan ハンドラで呼び、dispatch_campaign の親 span は Pub/Sub push subscription から伝播された traceparent ヘッダーで自動リンク
  • DataDog APM + OTLP Native Ingestion: OTLPSpanExporter → DataDog Agent v7(OTLP receiver 有効化)へ送信し、DataDog Service Map でサービス依存関係を可視化
  • Argo Workflows バッチ: Step 単位で span を作成し、workflow_run_id / step_name を attribute に付与 → バッチ障害のステップ特定が数分で完了
  • SLO 監視: campaigns.dispatch_duration_ms の p99 > 500ms をアラート条件に設定し、Error Budget 消費率を週次レビューで確認
  • コスト観点: OTLP Collector の sampling rate を調整(Tail-based sampling)することで DataDog のトレース取り込みコストを 60〜80% 削減可能

今日のまとめ

OTel 計装の核心は「初期化(一度だけ)」「計測(span/metric に attribute 付与)」「エラー記録(record_exception + set_status)」「ログ相関(trace_id 自動付与)」「伝播(CompositePropagator)」の 5 点。BatchSpanProcessorrecord_exception を省略すると、本番で「ログはあるがトレースが切れている」状態になり障害対応コストが跳ね上がる。

自己評価(あとで記入)