# OpenTelemetry 本番可観測性ガイド：トレース・メトリクス・ログを相関させ、止まった処理を一目で追えるようにする

> OpenTelemetryで本番システムを可観測にする実装ガイド。3シグナル（トレース/メトリクス/ログ）とコンテキスト伝播の考え方から、FastAPI（Python）とNext.js（Node）の計装、OTel Collector、Head/Tailサンプリング、ログとトレースの相関、PIIスクラブ、テレメトリのコスト最適化までを公式準拠の実コードで解説します。

- 公開日: 2026-06-24
- 著者: 友田 陽大
- タグ: 可観測性, OpenTelemetry, アーキテクチャ設計, Python, Next.js
- URL: https://tomodahinata.com/blog/opentelemetry-observability-production-tracing-metrics-logs

## 要点

- メトリクスで異常に気づき、トレースで場所を特定し、ログでなぜかを読む3シグナルの相関が可観測性の核心
- スパン名は低カーディナリティ（種類）に保ち、job.id等の可変値は属性に置く
- 計装はゼロコードで全体に網をかけ、ドメインの関心事だけ手動スパンで名前を付ける二段構え
- OTel Collectorを挟むとベンダー差し替え・サンプリング・PIIスクラブを一元化でき、ロックインを断てる
- テレメトリにPIIを載せず、相関に必要なのは「誰か」でなく「同じ人か」なのでuser.idはハッシュ化する

---

本番で一番こわいのは「落ちること」ではありません。**「動いているのに遅い」「どこかで詰まっているが、どこかが分からない」**——この状態です。エラーログには何も出ていない。なのにユーザーからは「処理が返ってこない」と言われる。ログを `grep` しても、リクエストが複数サービス（フロント → API → ワーカー → 外部API → DB）をどう通ったのかが繋がらない。

この「繋がらなさ」を解くのが**可観測性（Observability）**であり、その業界標準が **OpenTelemetry（OTel）** です。この記事は、OpenTelemetry で本番システムを可観測にするための実装ガイドです。題材として、私が国内大手放送事業者向けに構築した社内AIプラットフォーム（[長時間AIジョブのパイプライン](/case-studies/broadcaster-ai-content-platform)）で、「どのチャンク・どの段で処理が詰まったか」を追えるようにした設計判断も交えます。

> **この記事のルール**：用語・API・設定の出典は **OpenTelemetry 公式ドキュメント（2026年6月時点）** に基づきます。SDK・パッケージ名・設定は更新が速いため、本番投入前に必ず[公式ドキュメント](https://opentelemetry.io/docs/)で最新を確認してください。そして最重要の鉄則——**テレメトリにPII（個人情報）を載せない**。属性・ログ本文・スパン名に個人名や連絡先を入れた瞬間、可観測性基盤そのものが情報漏洩経路になります。

---

## 0. なぜ「ログだけ」では足りないのか

まず頭の中のモデルを正します。「ログを出していれば可観測だ」という誤解が、本番障害の調査を地獄にします。

ログは**点**です。「14:03:12 に payment サービスでエラー」——これは一点の事実でしかありません。しかし実際の障害は、こういう形をしています。

> ユーザーがボタンを押す → Next.js の Route Handler が FastAPI を呼ぶ → FastAPI が外部決済APIを叩く → そのレスポンスを待つ間に DB ロックが詰まる → 結果、フロントには「30秒後にタイムアウト」

このとき知りたいのは「どの一点でエラーが出たか」ではなく、**「1つのリクエストが、どのサービスを、どの順で、それぞれ何ミリ秒かけて通ったか」**です。これは点（ログ）の集合では復元できません。**線（トレース）**が要ります。

OpenTelemetry は「システムの動作を記述する出力＝**シグナル（Signals）**」を扱います。公式が定義する主要シグナルは4つです。

| シグナル | 公式の定義 | 何に答えるか |
| --- | --- | --- |
| トレース（Traces） | アプリケーションを通過するリクエストの経路（the path of a request through your application） | 「このリクエストはどこを通り、どこで時間を食ったか」 |
| メトリクス（Metrics） | 実行時に取得される測定値（a measurement captured at runtime） | 「全体としてエラー率・レイテンシ・スループットは健全か」 |
| ログ（Logs） | イベントの記録（a recording of an event） | 「その瞬間、具体的に何が起きたか」 |
| バゲッジ（Baggage） | シグナル間で受け渡される文脈情報（contextual information that is passed between signals） | 「テナントID等の文脈を、経路全体に持ち回りたい」 |

加えて公式は **Events（特定種のログ）** と **Profiles（コードレベルのリソース使用記録）** を開発中と位置づけています。

役割分担はこう覚えてください——**メトリクスで「異常がある」と気づき、トレースで「どこか」を特定し、ログで「なぜか」を読む。** この3点が相関して初めて、冒頭の「動いているのに遅い」が一目で追えるようになります。

なぜ3つも要るのか、と疑問に思うかもしれません。一言でいえば**「解像度」と「コスト」のトレードオフが3者で違う**からです。メトリクスは最も安く（数値の集約なので）、常時全件を見られるが粒度が粗い。ログは最も詳細だが量が膨大で、単体では文脈（どのリクエストか）が欠ける。トレースはその中間で、リクエスト1本の経路を構造として持つが、全件保存するとコストがかさむ（だから7章のサンプリングが要る）。**この3層を相関させる**のが OpenTelemetry の設計思想であり、どれか1つだけでは本番の障害調査は完結しません。

---

## 1. 用語の最小セット：スパンとトレース

トレースは**スパン（Span）**の木構造です。1スパン＝1つの作業単位（HTTPハンドラ、DBクエリ、外部API呼び出しなど）で、スパンが親子関係を持って繋がり、全体で1本のトレースになります。

公式の操作概念で、本番に必要な最小セットはこれだけです。

- **スパン名**：作業の名前（例 `POST /jobs`、`transcribe_chunk`）。低カーディナリティに保つ（IDを入れない）。
- **属性（Attributes）**：スパンに付ける key-value（例 `job.id`、`chunk.index`、`http.response.status_code`）。検索・絞り込みの軸になる。
- **スパンイベント（Span Events）**：スパン内で起きた時点イベント（例 「リトライ開始」）。
- **スパンステータス（Status）**：`OK` / `ERROR`。エラー時に `ERROR` を立てる。
- **例外記録（record exception）**：例外をスパンに添付し、スタックトレースを残す。
- **スパンコンテキスト**：trace ID と span ID。これが**伝播**され、サービスをまたいでも同じトレースに繋がる（次章）。

ここで早くも設計原則が効きます。**属性はDRYに、スパン名はKISSに。** スパン名に `job.id` のような高カーディナリティ値を埋め込むと、バックエンドが集計できずコストが跳ねます。可変値は**属性**へ、名前は**種類**を表す低カーディナリティに——これが鉄則です。

---

## 2. コンテキスト伝播：サービスをまたいでもトレースが繋がる仕組み

分散トレースの心臓部が**コンテキスト伝播（Context Propagation）**です。ここを理解すると、なぜ別々のサービスのスパンが1本に繋がるのかが腑に落ちます。

公式の定義はこうです。

- **Context**：送信側・受信側が「あるシグナルを別のシグナルと相関させる」ための情報を持つオブジェクト。サービスAがサービスBを呼ぶとき、trace ID と span ID を渡すことで、Bは**同じトレースに属し、Aのスパンを親とする**新しいスパンを作れる。
- **Propagation**：その Context をサービス・プロセス間で動かす仕組み。Context オブジェクトを直列化／復元する。通常は計装ライブラリが透過的に処理する。
- **Propagator**：その移動を実装するもの。**デフォルトの伝播は W3C TraceContext 仕様のヘッダ**を使う。

実体は HTTP ヘッダ1本、`traceparent` です。フォーマットは公式どおり `<version>-<trace-id>-<parent-id>-<trace-flags>` で、具体例は次の形です。

```text
traceparent: 00-a0892f3577b34da6a3ce929d0e0e4736-f03067aa0ba902b7-01
```

サービスAは送信時に自分のコンテキストを `traceparent` ヘッダに**注入（inject）**し、サービスBは受信時にそれを**抽出（extract）**して自分のローカルコンテキストに入れる。これで親子関係が成立し、両サービスのスパンが同一トレースに連結されます。

実務上の重要点：**HTTP クライアント／サーバーの計装ライブラリを入れていれば、この inject/extract は自動で行われます。** あなたが手で書くのは「サービス内の手動スパン」だけ。ただし、**メッセージキュー・SSE・独自プロトコルをまたぐとき**は自動伝播が効かないことがあるので、そこだけ手動で `traceparent` 相当を運ぶ設計を意識します。後述の長時間ジョブで、ここがまさに勘所になりました。

ここで、もう1つの伝播対象**バゲッジ（Baggage）**にも触れておきます。trace ID/span ID が「トレースの骨格」を運ぶのに対し、バゲッジは「アプリ独自の文脈情報」（例 `tenant.id`、`request.priority`）を経路全体に持ち回ります。たとえば入口で決まったテナントIDを、下流のサービスでもメトリクスの次元として使いたい——そういうときバゲッジが効きます。ただし**バゲッジに載せた値は伝播ヘッダ経由で各サービスに流れる**ため、ここにPIIを入れるのは厳禁です（7章のスクラブ以前の問題として、そもそも載せない）。バゲッジは「便利だが、低カーディナリティかつ非機微な値だけ」と覚えてください。

---

## 3. FastAPI（Python）を計装する

Python の計装には**2つの道**があります。両方使うのが正解です。

### 3.1 ゼロコード計装（まず全体に網をかける）

公式の「zero-code」アプローチは、アプリのコードに手を入れずに自動計装を入れます。FastAPI・リクエストライブラリ・DBドライバなどに**実行時のモンキーパッチ**で計装を差し込みます。

```bash
# distro は API/SDK/bootstrap/instrument ツールを含む。OTLP エクスポーターも入れる
pip install opentelemetry-distro opentelemetry-exporter-otlp

# 入っているライブラリを走査し、対応する計装ライブラリを自動インストール
opentelemetry-bootstrap -a install
```

起動はアプリのコマンドを `opentelemetry-instrument` でラップするだけです。

```bash
# 例：uvicorn で動く FastAPI を計装して起動
opentelemetry-instrument \
    --service_name broadcaster-ai-api \
    --traces_exporter otlp \
    --metrics_exporter otlp \
    --exporter_otlp_endpoint http://otel-collector:4317 \
    uvicorn app.main:app --host 0.0.0.0 --port 8000
```

同じことは環境変数でも設定できます（コンテナ運用ではこちらが扱いやすい）。

```bash
export OTEL_SERVICE_NAME=broadcaster-ai-api
export OTEL_TRACES_EXPORTER=otlp
export OTEL_METRICS_EXPORTER=otlp
export OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector:4317
```

> CLI 引数と環境変数は1対1に対応します（`--service_name` ↔ `OTEL_SERVICE_NAME` など）。**シークレットや接続先はコードに焼かず env へ**——可観測性の設定もこの原則の例外ではありません。

これで「HTTPハンドラ・DB・外部HTTP」の自動スパンが出始めます。**まず全体に網をかける**のがゼロコードの価値です。

### 3.2 手動計装（ビジネス上の関心事に名前を付ける）

自動計装は「技術的な境界」しか見えません。「OCRの段」「音声認識の段」「マルウェア検査の段」といった**ドメインの関心事**は、自分でスパンを切って初めて可視化されます。公式の手動計装API（traces は Stable、metrics も Stable、logs は Development）はこう使います。

```python
from opentelemetry import trace
from opentelemetry.trace import Status, StatusCode

# トレーサーはモジュール単位で一度だけ取得して使い回す（SRP: 取得と利用を分離）
tracer = trace.get_tracer("broadcaster.ai.pipeline")


async def transcribe_chunk(job_id: str, chunk_index: int, audio: bytes) -> str:
    # スパン名は「種類」。可変値（job_id 等）は属性へ → 低カーディナリティを死守
    with tracer.start_as_current_span("transcribe_chunk") as span:
        # 属性 = 後で絞り込む軸。IDはここに置く（スパン名には置かない）
        span.set_attribute("job.id", job_id)
        span.set_attribute("chunk.index", chunk_index)
        span.set_attribute("chunk.size_bytes", len(audio))

        try:
            span.add_event("asr.request.start")  # 時点イベント
            text = await call_asr(audio)
            span.add_event("asr.request.done")
            return text
        except Exception as ex:
            # エラーはステータスを立て、例外を添付（スタックトレースが残る）
            span.set_status(Status(StatusCode.ERROR))
            span.record_exception(ex)
            raise
```

ネストすれば親子のスパンになり、処理の入れ子がそのままトレースの木になります。

```python
with tracer.start_as_current_span("process_job") as parent:
    parent.set_attribute("job.id", job_id)
    for i, chunk in enumerate(chunks):
        # この子スパンは自動的に process_job を親に持つ
        await transcribe_chunk(job_id, i, chunk)
```

> **ゼロコード（網）＋手動（要所）の二段構え**が本番のスイートスポットです。自動計装で抜け漏れをなくし、ビジネス的に重要な段にだけ手で名前を付ける。これは「全部手で書く（DRY違反・工数爆発）」でも「自動だけ（ドメインが見えない）」でもない、ちょうどいい中間です。

### 3.3 メトリクスも同じトレーサー観で

「全体の傾向」はメトリクスで取ります。公式のカウンタはこう作ります。

```python
from opentelemetry import metrics

meter = metrics.get_meter("broadcaster.ai.pipeline")

chunk_counter = meter.create_counter(
    "asr.chunks.processed",
    unit="1",
    description="処理済みチャンク数",
)

# 属性で次元を切る（work.type ごとに集計可能になる）
chunk_counter.add(1, {"result": "ok"})
```

「処理時間の分布」はヒストグラム、「同時実行ジョブ数」のような瞬間値は observable gauge（コールバックで観測）を使います。**メトリクスはカーディナリティに最も敏感**です。属性に `job.id` のような無限に増える値を入れると時系列が爆発し、コストが青天井になります。メトリクスの属性は**有限集合**（`result=ok|error`、`stage=ocr|asr` 等）に限る——これがコスト破綻を防ぐ一線です。

---

## 4. Next.js（Node）を計装する

このポートフォリオサイトと同じく、フロント／BFF が Next.js（Node ランタイム）のケースです。Node も「自動計装＋手動」の二段構えで考えます。

### 4.1 NodeSDK で自動計装をブートする

公式の Node セットアップは、SDK を**アプリより先にロード**するのが要点です。

```bash
npm install @opentelemetry/sdk-node \
  @opentelemetry/api \
  @opentelemetry/auto-instrumentations-node \
  @opentelemetry/sdk-metrics \
  @opentelemetry/sdk-trace-node
```

`instrumentation.ts` を作り、`NodeSDK` を起動します（下は公式の構成。本番では Console エクスポーターを OTLP に差し替えます）。

```ts
import { NodeSDK } from "@opentelemetry/sdk-node";
import { ConsoleSpanExporter } from "@opentelemetry/sdk-trace-node";
import { getNodeAutoInstrumentations } from "@opentelemetry/auto-instrumentations-node";
import {
  PeriodicExportingMetricReader,
  ConsoleMetricExporter,
} from "@opentelemetry/sdk-metrics";

const sdk = new NodeSDK({
  traceExporter: new ConsoleSpanExporter(), // 本番では OTLP エクスポーターに置換
  metricReader: new PeriodicExportingMetricReader({
    exporter: new ConsoleMetricExporter(),
  }),
  instrumentations: [getNodeAutoInstrumentations()],
});

sdk.start();
```

実行はアプリより前にこのファイルを読み込ませます。

```bash
# TypeScript（Node v20+）
npx tsx --import ./instrumentation.ts app.ts

# JavaScript（ESM。ファイルは instrumentation.mjs）
node --import ./instrumentation.mjs app.js
```

> **Next.js 固有の注意**：Next.js（App Router）には `instrumentation.ts` という公式フックがあり、Node ランタイムで `register()` が起動時に呼ばれます。OTel の初期化はここに置くのが筋です。**ただし計装が動くのは Node ランタイムのみ**——Edge ランタイムや、ビルド時に静的化されるルートでは挙動が変わります。「どのルートが Node で動いているか」を取り違えると「スパンが出ない」とハマるので、計装したい処理は Node ランタイムに寄せます（このサイトのルールでも、サーバーデータが要るものは RSC/Route Handler 側に置く方針です）。

### 4.2 手動スパンで「自分の関心事」を可視化

公式の Node 手動API はコールバック形式です。`span.end()` を**必ず呼ぶ**のがPythonの `with` との違いで、ここを忘れるとスパンが閉じません。

```ts
import { trace, SpanStatusCode } from "@opentelemetry/api";

const tracer = trace.getTracer("portfolio-bff", "1.0.0");

export async function submitContact(input: ContactInput): Promise<void> {
  return tracer.startActiveSpan("contact.submit", async (span) => {
    try {
      // 属性は「集計・検索の軸」だけ。本文・メール・氏名は載せない（PII禁止）
      span.setAttribute("contact.phase", input.phase);
      await sendViaResend(input);
      span.setStatus({ code: SpanStatusCode.OK });
    } catch (error) {
      span.setStatus({ code: SpanStatusCode.ERROR });
      span.recordException(error as Error);
      throw error;
    } finally {
      span.end(); // Node では明示的に閉じる。忘れるとスパンがリークする
    }
  });
}
```

メトリクスも対称的に書けます。

```ts
import { metrics } from "@opentelemetry/api";

const meter = metrics.getMeter("portfolio-bff", "1.0.0");
const submits = meter.createCounter("contact.submit.count");
submits.add(1, { phase: "discovery" }); // 属性は有限集合に限る
```

ここでも原則は同じ——**属性に氏名・メール・電話・自由記述本文を絶対に入れない。** 入れていいのは「フェーズタグ」のような**集計可能で個人を特定しないラベル**だけです（このサイトの規約でも、問い合わせフォームのPIIはフェーズタグ以上を残さない方針です）。

---

## 5. OTel Collector：ベンダーロックインを断つ要

ここまでのアプリは OTLP でテレメトリを「どこか」に送ります。その「どこか」を**アプリから隠蔽する**のが **OpenTelemetry Collector** です。公式の言葉では「テレメトリを受信・処理・エクスポートする**ベンダー非依存（vendor-agnostic）**の方法」。

なぜアプリから直接バックエンドに送らず、Collector を挟むのか。公式が production で推奨する理由がそのまま設計上の利点です。

- **アプリは素早く手放せる**：アプリは Collector に投げて終わり。リトライ・バッチ・暗号化・機微データのフィルタリングは Collector が担う。
- **ベンダーを差し替えられる**：「Cloud Monitoring → Datadog → Grafana」とバックエンドを変えても、**変えるのは Collector の設定1か所**。アプリのコードは無傷。これが ETC（Easy To Change）そのものです。
- **横断的処理を一元化**：サンプリング・PIIスクラブ・属性整形を、各アプリにバラ撒かず Collector に集約できる（DRY）。

Collector のパイプラインは4種＋拡張で構成されます。

| 構成要素 | 役割 |
| --- | --- |
| Receivers | テレメトリを受け取る（例 OTLP） |
| Processors | 変換・フィルタ・バッチ・サンプリング |
| Exporters | バックエンドへ送る |
| Connectors | パイプライン同士を繋ぐ |
| Extensions | ヘルスチェック等の付加機能 |

最小構成の `config.yaml` はこう書きます。**受信 → 処理 → 送出**の流れがそのまま `service.pipelines` に現れます。

```yaml
receivers:
  otlp:
    protocols:
      grpc:          # 既定の OTLP gRPC（4317）
      http:          # OTLP HTTP（4318）

processors:
  memory_limiter:    # メモリ上限を守り、Collector自身の落下を防ぐ（信頼性）
    check_interval: 1s
    limit_mib: 512
  batch:             # まとめて送り、送出回数とコストを下げる

exporters:
  otlp/backend:
    endpoint: your-backend:4317

service:
  pipelines:
    traces:
      receivers: [otlp]
      processors: [memory_limiter, batch]
      exporters: [otlp/backend]
    metrics:
      receivers: [otlp]
      processors: [memory_limiter, batch]
      exporters: [otlp/backend]
```

デプロイ形態は2つ。**Agent**（各サービスに併置）と **Gateway**（中央集約）です。小さく始めるなら Agent、横断ポリシー（サンプリング・スクラブ）を効かせたいなら Gateway、というのが定石です。多くの本番は「各ノードに Agent → 中央 Gateway → バックエンド」の二段で組みます。

> 放送事業者向けのプラットフォームでは、マルウェア検査の結果を **OpenTelemetry で Cloud Monitoring に送出**しました。Collector を挟む設計の効きどころは、**アプリは GCP を知らなくていい**点です。送出先が Cloud Monitoring でも別ベンダーでも、アプリのコードは「OTLP で Collector に投げる」だけ。可観測性の出口を、ビジネスロジックから切り離せます。

---

## 6. PIIスクラブ：テレメトリを漏洩経路にしない

これは「やった方がいい」ではなく**必須**です。可観測性基盤は全サービスのデータが流れ込むハブなので、ここにPIIが乗ると、漏洩時の被害が最大化します。守りは**二重**にかけます。

**第一の砦＝アプリ側（入れない）。** 4章で繰り返したとおり、属性・スパン名・ログ本文に個人情報を**そもそも書かない**。氏名は `user.id`（不可逆ID）に、メール本文は載せない、自由記述は要約もせず除外。最も安いのは「最初から入れない」ことです。

**第二の砦＝Collector 側（削る）。** それでも漏れる可能性に備え、エクスポート前に Collector のプロセッサで属性を削除・マスクします。公式が「機微データのフィルタリング（sensitive data filtering）」を Collector の役割として挙げているのは、まさにこの第二の砦のためです。

```yaml
processors:
  # 機微属性を削除/ハッシュ化してから外部へ出す（出口での最終防衛）
  attributes/scrub:
    actions:
      - key: enduser.email
        action: delete          # メールはそもそも消す
      - key: http.request.header.authorization
        action: delete          # 認証ヘッダを残さない
      - key: user.id
        action: hash             # 必要なIDは不可逆化して相関だけ残す
```

設計思想は明快です——**相関に必要なのは「同じ人かどうか」であって「誰か」ではない。** だから `user.id` はハッシュ化すれば相関は保てる。氏名やメールは可観測性に一切不要なので、最初から載せない・出口で消す。これが「可観測性とプライバシーの両立」の具体です。

---

## 7. サンプリング：コストと網羅性のトレードオフ

全トレースを保存すれば理想的ですが、高トラフィックでは**コストとストレージが破綻**します。そこでサンプリング（標本抽出）で「全体を代表する一部」だけを残します。公式は「1000トレース/秒を超えるなら検討」を一つの目安にしています。判断は **Head（頭）か Tail（尾）か**の二択が軸です。

| 観点 | Head-based サンプリング | Tail-based サンプリング |
| --- | --- | --- |
| 判断時点 | トレース開始時（早期） | トレース完成後（全スパンを見てから） |
| 代表手法 | Consistent Probability（trace IDベースの確率抽出。例 5%） | エラー・遅延・属性で条件抽出 |
| 「全エラーを残す」 | **保証できない**（完成前に決めるため） | できる（エラーは必ず残す等が可能） |
| 実装・運用 | 簡単・軽量・効率的 | 難しい・ステートフル・リソース集約的 |
| 向く規模 | 小〜中、健全トラフィックが大半 | 大規模、賢い取捨が要る |

公式の核心的トレードオフはこうです。**Head は軽いがエラーを取りこぼし得る。Tail はエラー・遅延を狙い撃ちできるが、全スパンを一旦バッファするためステートフルで重い。**

実務の処方箋：

- **まずは Head（確率サンプリング）から。** SDK 側で trace ID ベースの一貫確率サンプリングをかければ、同一トレースが全サービスで整合的に「採る／採らない」されます（バラバラに採られて木が欠ける事故を防ぐ）。これは KISS で、ほとんどの中規模システムには十分です。
- **「エラーと遅延は1件も逃したくない」段階で Tail を足す。** Collector の **Tail Sampling Processor** で「エラーは100%、正常は1%」のような方針を組みます。ただしステートフルで重いので、入れるのは「Head では取りこぼす重要トレースがある」と確信してから（YAGNI——先回りで重い仕組みを入れない）。

Collector 側の確率サンプリング例：

```yaml
processors:
  probabilistic_sampler:
    sampling_percentage: 10   # 10% を一貫サンプリング（trace IDベースで整合）

service:
  pipelines:
    traces:
      receivers: [otlp]
      processors: [memory_limiter, probabilistic_sampler, batch]
      exporters: [otlp/backend]
```

> **コストはサンプリングだけで決まらない。** メトリクスの属性カーディナリティ、ログの冗長さ、スパンの粒度——この全部が課金に効きます。「全部を最高精度で観測」は YAGNI です。**SLOに関係する経路は厚く、それ以外は薄く**——可観測性も投資配分の問題だと捉えると、コストが制御可能になります。

---

## 8. ログとトレースを相関させる：障害調査が一変する瞬間

ここが、本記事で一番おいしい部分です。**全てのログ行に trace ID を載せる。** これだけで調査体験が劇的に変わります。

可観測性の本番運用フローは、こう回ります。

1. **メトリクス**：「`asr.chunks.processed{result=error}` が急増」とダッシュボードが気づく。
2. **トレース**：該当時間帯の ERROR トレースを開く。`process_job → transcribe_chunk` の木で、**4番目のチャンクで13秒ハングして外部APIタイムアウト**だと一目で分かる。
3. **ログ**：そのスパンの trace ID で全サービスのログを横断検索。FastAPI・ワーカー・外部呼び出しのログが**1リクエスト分だけ**並ぶ。`grep` で点を拾う作業が消える。

実装はシンプルです。OTel のコンテキストから現在のスパンの trace ID を取り、構造化ログに添える。

```python
from opentelemetry import trace

def log_context() -> dict[str, str]:
    """現在のスパンから trace_id / span_id を取り、ログに添えて相関させる。"""
    span = trace.get_current_span()
    ctx = span.get_span_context()
    if not ctx.is_valid:
        return {}
    return {
        "trace_id": format(ctx.trace_id, "032x"),
        "span_id": format(ctx.span_id, "016x"),
    }

# 構造化ログに必ず混ぜる（本文にPIIは入れない）
logger.info("asr chunk failed", extra={**log_context(), "chunk.index": i})
```

これで「トレースで詰まり所を特定 → その trace ID でログに飛ぶ → 原因の例外を読む」が地続きになります。**メトリクス（どこかおかしい）→ トレース（ここだ）→ ログ（これが理由だ）**——3シグナルが trace ID で縫い合わさって初めて、可観測性は「ダッシュボードを眺める」から「障害を解く」道具になります。

---

## 9. 可観測性が信頼性を支える：長時間ジョブはどこで詰まるか

最後に、これら全部が**信頼性（Reliability）**にどう効くかを、実例で締めます。

放送事業者向けプラットフォームの中核は、**長時間のAIジョブ**でした。スタックは FastAPI（async）＋ Cloud Run Jobs ＋ Cloud Workflows。OCRと音声認識を Cloud Workflows で並列化し、**処理時間を約30%短縮（逐次18分 → 並列13分）**しています。進捗は Firestore スナップショット＋SSE で準リアルタイムに配信しました。

この「並列化して13分かかるジョブ」で必ず問われるのが——**「13分のうち、どの段が支配的か」「失敗するなら、どのチャンク・どの段で詰まるか」**です。これは可観測性なしには答えられません。

- スパンの木を見れば、**OCR の段と ASR の段のどちらが律速か**が時間幅で一目で分かる。並列化の効果検証も、推測ではなくトレースで裏が取れる。
- チャンク単位でスパンを切ってあるので、**「4番目のチャンクだけ毎回遅い」**といった偏りが浮かぶ。入力データ側の問題か、外部APIの問題かを切り分けられる。
- マルウェア検査の結果は OpenTelemetry で Cloud Monitoring に送出。**「検査で弾かれたのか、処理で詰まったのか」**をシグナルで判別できる。

ここで効いた設計の勘所が、2章で触れた**伝播の手動補完**です。Cloud Run Jobs／Workflows／SSE をまたぐと、HTTP の自動伝播だけではトレースが切れがちです。段の境界でコンテキスト（trace ID）を意図的に持ち回ることで、**「1つのジョブ＝1本のトレース」**を保ちました。これがあるからこそ「どのチャンク・どの段で止まったか」を一目で追えます。

信頼性の文脈に一言だけ繋げます。**SLO（例 ジョブ成功率99%、p95処理時間15分）を決め、エラーバジェットを設定する**なら、その達成度はメトリクスで測り、逸脱の原因はトレースとログで掘る。**可観測性は SLO を「測れる・守れる」ものに変える土台**です。観測できないものは、約束（SLO）もできません。

---

## 10. まとめ：本番可観測性チートシート

迷ったときの早見表です。

- **3シグナルの役割**：メトリクスで気づく → トレースで場所を特定 → ログで理由を読む。
- **トレースの基本**：スパン名は低カーディナリティ（種類）、可変値（ID）は属性へ。
- **伝播**：HTTPなら計装ライブラリが `traceparent` を自動で inject/extract。MQ・SSE・独自経路だけ手動補完。
- **Python**：`opentelemetry-distro` でゼロコント網をかけ、要所だけ `start_as_current_span` で手動計装。
- **Node/Next.js**：`NodeSDK`＋`getNodeAutoInstrumentations()` で自動、`startActiveSpan` で手動（`span.end()` を忘れない）。Node ランタイムに計装処理を寄せる。
- **Collector**：アプリは OTLP で投げるだけ。ベンダー差し替え・サンプリング・スクラブを集約 → ロックイン回避（ETC）。
- **PII**：第一の砦＝入れない、第二の砦＝Collector で削る／ハッシュ化。相関に必要なのは「誰か」でなく「同じ人か」。
- **サンプリング**：まず Head（確率）。エラー・遅延を1件も逃せない段階で Tail を足す（YAGNI）。
- **相関**：全ログに trace_id を載せる。これだけで調査が `grep` から「トレースを辿る」へ変わる。
- **信頼性**：SLO／エラーバジェットを測る土台。観測できないものは守れない。

可観測性は「ツールを入れること」ではなく、**「障害が起きたとき、どこで何が詰まったかを一目で追える状態を設計すること」**です。私は放送事業者向けの社内AIプラットフォームで、長時間AIジョブを OpenTelemetry で可観測化し、「どのチャンク・どの段で止まったか」をトレースで追える本番システムとして組み上げました。生成AI（Claude Code）で実装を加速しつつ、PIIスクラブやサンプリング方針のような**人間が判断すべき設計ゲート**は自分の手で締める——この組み合わせで、速く・安く・安全に可観測性を入れます。

**「自社のシステムを、止まったときに追える状態にしたい」——その設計から計装・Collector運用まで、一気通貫で伴走できます。** 既存システムへの後付け計装の相談からでも、お気軽にどうぞ。

---

### 参考（公式ドキュメント）

- [Signals（シグナルの概念）](https://opentelemetry.io/docs/concepts/signals/) — Traces / Metrics / Logs / Baggage の定義
- [Context propagation（コンテキスト伝播）](https://opentelemetry.io/docs/concepts/context-propagation/) — Context / Propagation / Propagator と W3C traceparent
- [OpenTelemetry Python](https://opentelemetry.io/docs/languages/python/) / [Python Instrumentation](https://opentelemetry.io/docs/languages/python/instrumentation/) — 手動計装API・メトリクス
- [Python Zero-code instrumentation](https://opentelemetry.io/docs/zero-code/python/) — `opentelemetry-distro` / `opentelemetry-bootstrap` / `opentelemetry-instrument`・環境変数
- [OpenTelemetry JavaScript](https://opentelemetry.io/docs/languages/js/) / [Node.js Getting Started](https://opentelemetry.io/docs/languages/js/getting-started/nodejs/) / [JS Instrumentation](https://opentelemetry.io/docs/languages/js/instrumentation/) — `NodeSDK`・手動計装API
- [Sampling（サンプリング）](https://opentelemetry.io/docs/concepts/sampling/) — Head vs Tail とトレードオフ
- [Collector](https://opentelemetry.io/docs/collector/) — receivers / processors / exporters のパイプラインと運用上の価値
