OpenTelemetry Node.js 実装ガイド|分散トレーシング設定とJaeger/Tempo連携

(更新: 2026年6月21日 ) OpenTelemetry Node.js 分散トレーシング オブザーバビリティ Jaeger Tempo OTLP バックエンド
結論
  • Node.jsの分散トレーシングは tracing.js--require で最初に読み込み@opentelemetry/sdk-node + auto-instrumentation でHTTP/DB/fetchを自動計測し、サービス間は W3C traceparent ヘッダーでコンテキストを伝播する。
  • エクスポート先は OTLP が共通規格で、Jaeger単体でもGrafana Tempoでも同じExporter設定を環境変数で切り替えられる。
  • ビジネスクリティカルな処理だけ 手動Span を足せば、本番障害時に「どのマイクロサービスで何秒かかったか」を一発で追える。

分散トレーシングとは:マイクロサービス時代の「一本の物語」

マイクロサービスやBFF(Backend for Frontend)構成では、1つのユーザーリクエストが複数サービスを横断する。API Gateway → 認証サービス → 注文サービス → 在庫サービス → 決済ゲートウェイ——各サービスは独立したログを持つが、同じリクエストに属するログを横断的に繋ぐIDがなければ、障害調査は「ログの海で手探り」になる。

分散トレーシング(Distributed Tracing)は、リクエスト全体を Trace(トレース) と呼ぶ単位で捉え、その中の各処理ステップを Span(スパン) として記録する仕組みだ。Spanには開始時刻・終了時刻・サービス名・HTTPステータス・エラー情報・任意の属性(attributes)が載る。

OpenTelemetry(OTel)は、このトレースデータをベンダーニュートラルに生成・収集・エクスポートするためのCNCF標準である。2026年現在、Datadog・New Relic・Honeycomb・Jaeger・Grafana Tempo・AWS X-Rayなど主要ツールがOTel SDKからのデータ受け入れに対応しており、ロックインを避けつつ統一計装できる。ログ基盤(Loki 等)との相関については 構造化ログ JSON + Loki 設計 を併読すると、trace_id をログに載せる実装まで一気通貫で理解できる。

方式 特徴
ログのみ サービスごとに独立。リクエスト横断の相関が困難。trace_idをログに埋め込む運用で補完可能
メトリクス(Prometheus等) QPS・レイテンシP99など集約値は得意。個別リクエストの詳細フローは見えない
分散トレーシング 1リクエストの全Spanを時系列で可視化。ボトルネック特定・依存関係マップに強い
OpenTelemetry トレース・メトリクス・ログの統一API。SDK→Collector→バックエンドの標準パイプライン

OpenTelemetryのアーキテクチャ概要

Node.jsアプリにOpenTelemetryを導入すると、データはおおむね次の経路を辿る。

┌─────────────────┐     ┌──────────────────┐     ┌─────────────────┐
│  Node.js App    │     │  OTel Collector  │     │  Jaeger / Tempo │
│  (SDK + Auto    │────▶│  (optional)      │────▶│  / Datadog etc. │
│   Instrument)   │ OTLP│  batch/retry/    │     │                 │
└─────────────────┘     │  tail sampling   │     └─────────────────┘
                          └──────────────────┘
  1. SDK(@opentelemetry/sdk-node) — TracerProvider・Sampler・Exporterを初期化
  2. Instrumentation — HTTP/Express/pg等のライブラリ呼び出しを自動または手動でSpan化
  3. Context Propagation — サービス間HTTP通信でtrace_id/span_idをヘッダー経由で伝播
  4. Exporter — SpanをOTLP/gRPCまたはHTTPでJaeger・Tempo等へ送信
  5. Collector(任意) — バッチ処理・リトライ・サンプリング・PII除去をアプリ外で実行

小規模構成ではアプリからJaeger/Tempoへ直接エクスポートしてもよい。本番ではCollectorを挟み、アプリ再起動の影響を受けにくくするのが一般的だ。

必要なパッケージのインストール

2026年時点の推奨パッケージ構成は次のとおり。

npm install @opentelemetry/sdk-node \
  @opentelemetry/api \
  @opentelemetry/auto-instrumentations-node \
  @opentelemetry/exporter-trace-otlp-grpc \
  @opentelemetry/exporter-trace-otlp-http \
  @opentelemetry/resources \
  @opentelemetry/semantic-conventions \
  @opentelemetry/sdk-trace-base
パッケージ役割
@opentelemetry/sdk-nodeNode.js向け統合SDK(NodeSDKクラス)
@opentelemetry/apitrace.getTracer() 等のアプリケーションAPI
@opentelemetry/auto-instrumentations-nodeHTTP/Express/pg/redis等の自動計測バンドル
@opentelemetry/exporter-trace-otlp-grpcOTLP/gRPCでSpanを送信(Tempo/Jaeger/Collector向け)
@opentelemetry/exporter-trace-otlp-httpOTLP/HTTPでSpanを送信(プロキシ環境向け)
@opentelemetry/resourcesサービス名・環境・バージョン等のリソース属性
@opentelemetry/semantic-conventionsATTR_SERVICE_NAME 等の標準属性名定数
バージョン整合性

@opentelemetry/* パッケージは同じメジャーバージョンで揃える。混在すると Cannot find module やSpanが空になる不具合が起きやすい。npm ls @opentelemetry/sdk-node で依存ツリーを確認し、 Renovate/Dependabot で一括更新するのが安全だ。

tracing.js:@opentelemetry/sdk-node の完全セットアップ

OpenTelemetryはアプリケーションコードより先に初期化する必要がある。tracing.js をプロジェクトルートに置き、--require で読み込む。

// tracing.js — OpenTelemetry ブートストラップ(Node.js 20+ / ESM・CJS両対応)
'use strict';

const { NodeSDK } = require('@opentelemetry/sdk-node');
const { getNodeAutoInstrumentations } = require('@opentelemetry/auto-instrumentations-node');
const { OTLPTraceExporter } = require('@opentelemetry/exporter-trace-otlp-grpc');
const { Resource } = require('@opentelemetry/resources');
const {
  ATTR_SERVICE_NAME,
  ATTR_SERVICE_VERSION,
  ATTR_DEPLOYMENT_ENVIRONMENT_NAME,
} = require('@opentelemetry/semantic-conventions');
const {
  ParentBasedSampler,
  TraceIdRatioBasedSampler,
} = require('@opentelemetry/sdk-trace-base');
const { diag, DiagConsoleLogger, DiagLogLevel } = require('@opentelemetry/api');

// デバッグ時のみ有効化(本番では OTEL_LOG_LEVEL=none)
if (process.env.OTEL_LOG_LEVEL === 'debug') {
  diag.setLogger(new DiagConsoleLogger(), DiagLogLevel.DEBUG);
}

const serviceName = process.env.OTEL_SERVICE_NAME || 'order-api';
const environment = process.env.NODE_ENV || 'development';
const sampleRatio = parseFloat(process.env.OTEL_TRACES_SAMPLER_ARG || '1.0');

// OTLP gRPC エンドポイント(Jaeger/Tempo/Collector)
const otlpEndpoint = process.env.OTEL_EXPORTER_OTLP_ENDPOINT || 'http://localhost:4317';

const traceExporter = new OTLPTraceExporter({
  url: otlpEndpoint,
  // gRPC の場合、url は host:port 形式。パスは不要
});

const sdk = new NodeSDK({
  resource: new Resource({
    [ATTR_SERVICE_NAME]: serviceName,
    [ATTR_SERVICE_VERSION]: process.env.npm_package_version || '0.0.0',
    [ATTR_DEPLOYMENT_ENVIRONMENT_NAME]: environment,
  }),

  traceExporter,

  // サンプラー: 親Spanがサンプル対象なら子も追従、ルートは比率で判定
  sampler: new ParentBasedSampler({
    root: new TraceIdRatioBasedSampler(sampleRatio),
  }),

  instrumentations: [
    getNodeAutoInstrumentations({
      // 個別instrumentationの細かい設定
      '@opentelemetry/instrumentation-fs': {
        enabled: false, // fs読み込みSpanはノイズになりやすい
      },
      '@opentelemetry/instrumentation-http': {
        ignoreIncomingRequestHook: (req) => {
          // ヘルスチェック・メトリクススクレイプは計測除外
          const url = req.url || '';
          return url === '/health' || url === '/ready' || url.startsWith('/metrics');
        },
      },
      '@opentelemetry/instrumentation-express': {
        enabled: true,
      },
      '@opentelemetry/instrumentation-pg': {
        enabled: true,
        enhancedDatabaseReporting: true,
      },
    }),
  ],
});

sdk.start();

// Graceful shutdown — Spanフラッシュ漏れを防ぐ
const shutdown = async () => {
  try {
    await sdk.shutdown();
    console.log('[otel] SDK shutdown complete');
  } catch (err) {
    console.error('[otel] SDK shutdown error', err);
  } finally {
    process.exit(0);
  }
};

process.on('SIGTERM', shutdown);
process.on('SIGINT', shutdown);

console.log(`[otel] Tracing initialized: service=${serviceName}, endpoint=${otlpEndpoint}, sample=${sampleRatio}`);

package.json の start スクリプト

{
  "scripts": {
    "start": "node --require ./tracing.js src/server.js",
    "start:prod": "NODE_ENV=production OTEL_TRACES_SAMPLER_ARG=0.1 node --require ./tracing.js src/server.js"
  }
}

ESM("type": "module")プロジェクトでは tracing.jstracing.cjs にリネームし、--require ./tracing.cjs とするか、起動専用のCJSラッパーを用意する。

ESMプロジェクトの注意

Node.js 20以降でも --require はCJSモジュールのみ。ESMの import './tracing.js'server.js 先頭に書いても、他モジュールのimportよりに評価される場合があり、auto-instrumentationが効かない。確実なのは --require または --import(Node 20.6+ の register フック)だ。

Auto-Instrumentation:何が自動計測されるか

getNodeAutoInstrumentations()@opentelemetry/auto-instrumentations-node に含まれる計装群を一括有効化する。主要な対象は次のとおり。

Instrumentation 計測内容
@opentelemetry/instrumentation-http Node.js http/https の入出力。メソッド・URL・ステータスコード・レスポンスタイム
@opentelemetry/instrumentation-express Expressルート名・ミドルウェア層。http instrumentationと併用
@opentelemetry/instrumentation-fastify Fastifyルートハンドラ
@opentelemetry/instrumentation-pg PostgreSQLクエリ文・DB名(enhancedDatabaseReporting時)
@opentelemetry/instrumentation-redis-4 Redisコマンド
@opentelemetry/instrumentation-fetch Node.js 18+ 組み込み fetch の発信HTTP
@opentelemetry/instrumentation-grpc gRPCクライアント/サーバー
@opentelemetry/instrumentation-aws-sdk AWS SDK v3(S3/SQS/DynamoDB等)

auto-instrumentationだけでも「HTTPリクエスト → DBクエリ → 外部API呼び出し」のウォーターフォールは可視化できる。ただし「在庫引当処理」「決済オーソリ」といったドメイン単位のSpanは手動追加が必要だ(後述)。

計測除外のベストプラクティス

ヘルスチェック(/health)やPrometheusスクレイプ(/metrics)を計測すると、Trace UIがノイズだらけになる。ignoreIncomingRequestHook で除外する。

'@opentelemetry/instrumentation-http': {
  ignoreIncomingRequestHook: (req) => {
    const ignorePaths = ['/health', '/ready', '/live', '/metrics', '/favicon.ico'];
    const url = req.url?.split('?')[0] || '';
    return ignorePaths.includes(url);
  },
  // 発信リクエストで内部サービス以外を除外したい場合
  ignoreOutgoingRequestHook: (req) => {
    const host = req.hostname || req.host || '';
    return host.includes('metadata.google.internal'); // 例: GCPメタデータ
  },
},

Trace Context Propagation:HTTPヘッダーでSpanを繋ぐ

分散トレーシングの核心は、サービスAのSpanからサービスBのSpanへコンテキスト(trace_id, span_id)を伝えること。これがないと各サービスが独立したTraceになり、横断表示できない。

W3C Trace Context(推奨)

2026年の標準は W3C Trace Context。主要ヘッダーは2つ。

ヘッダー意味
traceparent00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01version-trace_id-parent_span_id-flags
tracestatevendor=value,another=1ベンダー固有の追加状態(任意)

traceparent の各フィールド:

  • version — 現在は 00
  • trace_id — 32hex(128bit)。リクエスト全体で不変
  • parent_span_id — 16hex(64bit)。呼び出し元SpanのID
  • flags01 = sampled(このTraceを記録する)

@opentelemetry/sdk-node はデフォルトでW3C Propagatorを使用する。auto-instrumentationされた fetchhttp.request自動的にこれらのヘッダーを注入する。

GET /api/inventory/123 HTTP/1.1
Host: inventory.internal
traceparent: 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01
tracestate: ot=foo

レガシープロパゲータ(B3 / Jaeger)

既存システムがZipkin B3やJaeger形式を使っている場合、複数Propagatorを合成できる。

// tracing-legacy-propagators.js — レガシー連携が必要な場合のみ
'use strict';

const { NodeSDK } = require('@opentelemetry/sdk-node');
const { getNodeAutoInstrumentations } = require('@opentelemetry/auto-instrumentations-node');
const { OTLPTraceExporter } = require('@opentelemetry/exporter-trace-otlp-grpc');
const { Resource } = require('@opentelemetry/resources');
const { ATTR_SERVICE_NAME } = require('@opentelemetry/semantic-conventions');
const { CompositePropagator, W3CTraceContextPropagator, W3CBaggagePropagator } = require('@opentelemetry/core');
const { B3Propagator } = require('@opentelemetry/propagator-b3');
const { JaegerPropagator } = require('@opentelemetry/propagator-jaeger');

const sdk = new NodeSDK({
  resource: new Resource({ [ATTR_SERVICE_NAME]: process.env.OTEL_SERVICE_NAME || 'order-api' }),
  traceExporter: new OTLPTraceExporter({
    url: process.env.OTEL_EXPORTER_OTLP_ENDPOINT || 'http://localhost:4317',
  }),
  textMapPropagator: new CompositePropagator({
    propagators: [
      new W3CTraceContextPropagator(),
      new W3CBaggagePropagator(),
      new B3Propagator(),
      new JaegerPropagator(),
    ],
  }),
  instrumentations: [getNodeAutoInstrumentations()],
});

sdk.start();
形式代表ヘッダー用途
W3C Trace Contexttraceparent, tracestate新規設計の標準
Zipkin B3X-B3-TraceId, X-B3-SpanId, X-B3-SampledSpring Cloud Sleuth legacy等
Jaegeruber-trace-idJaeger native client連携

新規マイクロサービス間はW3Cのみで十分。GatewayがB3→W3C変換する構成も多い。

Baggage:ビジネスコンテキストの伝播

Baggageはトレースと一緒に任意のキー・バリューをサービス間で運ぶ仕組み。ユーザーID・テナントID・実験フラグなどを載せる。

const { baggage, context, propagation } = require('@opentelemetry/api');

// サービスA: Baggage設定
const { trace } = require('@opentelemetry/api');
const ctx = baggage.set(context.active(), 'tenant.id', 'tenant_abc123');
const span = trace.getTracer('order-api').startSpan('createOrder', {}, ctx);

// HTTP発信時、Baggageもヘッダー(baggage: キー=値)に載る
// auto-instrumentation された fetch/http が自動注入
Baggageに載せてはいけないもの

Baggageは全ダウンストリームサービスに伝播する。PII(メールアドレス・クレジットカード番号)やシークレットを載せない。テナントIDやリクエスト相関ID程度に留める。サイズも大きくしすぎるとヘッダー上限(8KB付近)に抵触する。

手動Instrumentation:ドメインロジックにSpanを追加

auto-instrumentationはHTTP/DB層をカバーするが、「注文作成」「在庫引当」「決済オーソリ」といったビジネスステップは手動Spanで可視化する。

// src/services/orderService.js
const { trace, SpanStatusCode, context } = require('@opentelemetry/api');

const tracer = trace.getTracer('order-api', '1.0.0');

async function createOrder({ userId, items, paymentToken }) {
  return tracer.startActiveSpan('createOrder', async (span) => {
    try {
      span.setAttributes({
        'enduser.id': userId,
        'order.item_count': items.length,
        'order.total_amount': items.reduce((sum, i) => sum + i.price * i.qty, 0),
      });

      // サブステップ1: 在庫確認
      const inventory = await tracer.startActiveSpan('checkInventory', async (childSpan) => {
        try {
          const result = await inventoryClient.reserve(items);
          childSpan.setAttribute('inventory.reserved', result.reserved);
          childSpan.addEvent('inventory_reserved', { sku_count: result.skus.length });
          return result;
        } catch (err) {
          childSpan.recordException(err);
          childSpan.setStatus({ code: SpanStatusCode.ERROR, message: err.message });
          throw err;
        } finally {
          childSpan.end();
        }
      });

      // サブステップ2: 決済
      const payment = await tracer.startActiveSpan('processPayment', async (childSpan) => {
        childSpan.setAttribute('payment.provider', 'stripe');
        try {
          const charge = await paymentGateway.charge(paymentToken, inventory.total);
          childSpan.setAttribute('payment.charge_id', charge.id);
          return charge;
        } catch (err) {
          childSpan.recordException(err);
          childSpan.setStatus({ code: SpanStatusCode.ERROR, message: err.message });
          throw err;
        } finally {
          childSpan.end();
        }
      });

      // サブステップ3: DB保存
      const order = await saveOrderToDb({ userId, items, paymentId: payment.id });

      span.setAttribute('order.id', order.id);
      span.addEvent('order_created', { order_id: order.id });
      return order;
    } catch (err) {
      span.recordException(err);
      span.setStatus({ code: SpanStatusCode.ERROR, message: err.message });
      throw err;
    } finally {
      span.end();
    }
  });
}

module.exports = { createOrder };

startActiveSpan vs startSpan

API用途
tracer.startActiveSpan(name, fn)推奨。Spanを現在のContextに自動設定。子Span・HTTP伝播が正しく繋がる
tracer.startSpan(name) + 手動 context.with()細かい制御が必要な場合。終了忘れに注意

startActiveSpan 内で await してもContextは維持される(AsyncLocalStorageベース)。

Semantic Conventions:属性名の標準

属性名は OpenTelemetry Semantic Conventions に従うと、Jaeger/Tempo/Grafana間で一貫したフィルタが効く。

属性意味
http.request.methodGETHTTPメソッド
http.response.status_code200ステータスコード
db.systempostgresqlDB種別
db.statementSELECT id FROM orders WHERE user_id = $1クエリ(PII注意)
enduser.iduser_123エンドユーザーID
error.typeTypeError例外クラス名

サービス間HTTP通信:fetch/clientの完全例

在庫サービスを呼ぶHTTPクライアント。auto-instrumentationされた fetch ならヘッダー注入は自動だが、カスタムクライアントでは propagation.inject を明示する。

// src/clients/inventoryClient.js
const { trace, context, propagation, SpanStatusCode } = require('@opentelemetry/api');

const INVENTORY_BASE = process.env.INVENTORY_SERVICE_URL || 'http://localhost:3002';
const tracer = trace.getTracer('order-api');

async function reserveItems(items) {
  return tracer.startActiveSpan('inventoryClient.reserve', async (span) => {
    const url = `${INVENTORY_BASE}/api/v1/reserve`;
    span.setAttributes({
      'peer.service': 'inventory-api',
      'http.url': url,
    });

    // fetch は auto-instrumentation で traceparent が自動注入される
    const headers = injectTraceHeaders({
      'Content-Type': 'application/json',
    });

    const response = await fetch(url, {
      method: 'POST',
      headers,
      body: JSON.stringify({ items }),
    });

    span.setAttribute('http.response.status_code', response.status);

    if (!response.ok) {
      const err = new Error(`Inventory service returned ${response.status}`);
      span.recordException(err);
      span.setStatus({ code: SpanStatusCode.ERROR });
      throw err;
    }

    return response.json();
  });
}

/** axios等、auto-instrumentation非対応クライアント向け */
function injectTraceHeaders(carrier = {}) {
  propagation.inject(context.active(), carrier);
  return carrier;
}

module.exports = { reserveItems, injectTraceHeaders };

受信側Expressミドルウェア(extract)

通常、Express auto-instrumentationが traceparent自動extractする。カスタムフレームワークでは:

const { propagation, context, trace } = require('@opentelemetry/api');

function otelContextMiddleware(req, res, next) {
  // 受信ヘッダーから親Contextを復元
  const extractedContext = propagation.extract(context.active(), req.headers);
  context.with(extractedContext, () => {
    const span = trace.getActiveSpan();
    if (span) {
      span.setAttribute('http.route', req.route?.path || req.path);
    }
    next();
  });
}

Jaegerへのエクスポート設定

Jaeger 1.35以降はネイティブOTLP受信に対応。Jaeger all-in-oneをDockerで起動する例。

docker run -d --name jaeger \
  -e COLLECTOR_OTLP_ENABLED=true \
  -p 16686:16686 \
  -p 4317:4317 \
  -p 4318:4318 \
  jaegertracing/all-in-one:1.58
ポート用途
16686Jaeger UI
4317OTLP gRPC
4318OTLP HTTP

Node.js側の環境変数:

export OTEL_SERVICE_NAME=order-api
export OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4317
export OTEL_EXPORTER_OTLP_PROTOCOL=grpc
node --require ./tracing.js src/server.js

Jaeger専用Exporter(レガシー)

OTLP非対応の古いJaeger Agent(UDP 6831)向け。新規はOTLP推奨。

// レガシー: Jaeger Exporter(非推奨だが既存環境向け)
const { JaegerExporter } = require('@opentelemetry/exporter-jaeger');

const jaegerExporter = new JaegerExporter({
  endpoint: 'http://localhost:14268/api/traces', // Jaeger collector HTTP
});

Grafana Tempoへのエクスポート設定

Grafana TempoはOTLPを第一級サポートする。docker-compose例:

# docker-compose.observability.yml
services:
  tempo:
    image: grafana/tempo:2.4.1
    command: ['-config.file=/etc/tempo.yaml']
    volumes:
      - ./tempo.yaml:/etc/tempo.yaml
    ports:
      - '4317:4317'   # OTLP gRPC
      - '4318:4318'   # OTLP HTTP
      - '3200:3200'   # Tempo query

  grafana:
    image: grafana/grafana:11.0.0
    ports:
      - '3000:3000'
    environment:
      - GF_AUTH_ANONYMOUS_ENABLED=true
# tempo.yaml(最小構成)
server:
  http_listen_port: 3200

distributor:
  receivers:
    otlp:
      protocols:
        grpc:
          endpoint: 0.0.0.0:4317
        http:
          endpoint: 0.0.0.0:4318

storage:
  trace:
    backend: local
    local:
      path: /tmp/tempo/blocks

metrics_generator:
  registry:
    external_labels:
      source: tempo
  storage:
    path: /tmp/tempo/generator/wal
  traces_storage:
    path: /tmp/tempo/generator/traces

Node.js → Tempo:

OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4317 \
OTEL_SERVICE_NAME=order-api \
node --require ./tracing.js src/server.js

Grafana UI(http://localhost:3000)で Explore → Tempo を選び、trace_idまたはサービス名で検索する。Lokiと連携していれば「ログからTraceへ」「Traceからログへ」のジャンプも設定できる。

OTel Collector経由(本番推奨)

アプリ → Collector → Tempo/Jaeger の構成。Collector設定例:

# otel-collector-config.yaml
receivers:
  otlp:
    protocols:
      grpc:
        endpoint: 0.0.0.0:4317
      http:
        endpoint: 0.0.0.0:4318

processors:
  batch:
    timeout: 5s
    send_batch_size: 1024
  memory_limiter:
    check_interval: 1s
    limit_mib: 512

exporters:
  otlp/tempo:
    endpoint: tempo:4317
    tls:
      insecure: true
  # デバッグ用
  debug:
    verbosity: basic

service:
  pipelines:
    traces:
      receivers: [otlp]
      processors: [memory_limiter, batch]
      exporters: [otlp/tempo, debug]

アプリ側は OTEL_EXPORTER_OTLP_ENDPOINT=http://collector:4317 を向けるだけ。サンプリング変更・リトライ・複数バックエンドへのファンアウトはCollector側で制御できる。

完全なサンプルアプリケーション

3ファイル構成の最小例。Gateway → Order API → Inventory API。

tracing.js(共通)

前述の tracing.js を各サービスにコピーし、OTEL_SERVICE_NAME だけ変える。

order-api(src/server.js)

'use strict';

const express = require('express');
const { trace, SpanStatusCode } = require('@opentelemetry/api');
const { reserveItems } = require('./clients/inventoryClient');

const app = express();
app.use(express.json());
const tracer = trace.getTracer('order-api');
const PORT = process.env.PORT || 3001;

app.get('/health', (_req, res) => res.json({ status: 'ok' }));

app.post('/api/orders', async (req, res) => {
  // Express auto-instrumentation が親Spanを作成済み
  const activeSpan = trace.getActiveSpan();
  activeSpan?.setAttribute('http.route', '/api/orders');

  try {
    const { userId, items } = req.body;
    if (!userId || !items?.length) {
      return res.status(400).json({ error: 'userId and items required' });
    }

    const order = await tracer.startActiveSpan('createOrder', async (span) => {
      span.setAttributes({
        'enduser.id': userId,
        'order.item_count': items.length,
      });

      const inventory = await reserveItems(items);
      span.setAttribute('inventory.reservation_id', inventory.reservationId);

      const orderId = `ord_${Date.now()}`;
      span.addEvent('order_persisted', { order_id: orderId });
      return { orderId, userId, items, reservationId: inventory.reservationId };
    });

    res.status(201).json(order);
  } catch (err) {
    activeSpan?.recordException(err);
    activeSpan?.setStatus({ code: SpanStatusCode.ERROR, message: err.message });
    console.error(err);
    res.status(503).json({ error: 'Order creation failed' });
  }
});

app.listen(PORT, () => {
  console.log(`order-api listening on :${PORT}`);
});

inventory-api(別サービス、src/inventoryServer.js)

'use strict';

const express = require('express');
const { trace } = require('@opentelemetry/api');

const app = express();
app.use(express.json());
const tracer = trace.getTracer('inventory-api');
const PORT = process.env.PORT || 3002;

app.get('/health', (_req, res) => res.json({ status: 'ok' }));

app.post('/api/v1/reserve', async (req, res) => {
  const { items } = req.body;

  await tracer.startActiveSpan('reserveInventory', async (span) => {
    span.setAttribute('inventory.requested_skus', items.length);

    // DB問い合わせを模擬(pg instrumentation なら自動Span)
    await new Promise((r) => setTimeout(r, 50));

    const reservationId = `rsv_${Date.now()}`;
    span.setAttribute('inventory.reservation_id', reservationId);
    span.addEvent('stock_reserved');

    res.json({ reservationId, reserved: true, skus: items.map((i) => i.sku) });
  });
});

app.listen(PORT, () => {
  console.log(`inventory-api listening on :${PORT}`);
});

動作確認

# ターミナル1: Jaeger
docker run -d -p 16686:16686 -p 4317:4317 -e COLLECTOR_OTLP_ENABLED=true jaegertracing/all-in-one:1.58

# ターミナル2: inventory-api
OTEL_SERVICE_NAME=inventory-api OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4317 \
  node --require ./tracing.js src/inventoryServer.js

# ターミナル3: order-api
OTEL_SERVICE_NAME=order-api OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4317 \
  INVENTORY_SERVICE_URL=http://localhost:3002 \
  node --require ./tracing.js src/server.js

# ターミナル4: リクエスト送信
curl -X POST http://localhost:3001/api/orders \
  -H 'Content-Type: application/json' \
  -d '{"userId":"user_42","items":[{"sku":"SKU-001","qty":2,"price":1500}]}'

Jaeger UI(http://localhost:16686)で order-apiinventory-api のTraceが1本のウォーターフォールとして表示されれば成功。

1

[object Object]

2

[object Object]

3

[object Object]

4

[object Object]

5

[object Object]

6

[object Object]

7

[object Object]

サンプリング戦略:コストと可視性のバランス

全リクエストを100%記録すると、1日数億Spanになりストレージコストが膨らむ。サンプリングで代表的なTraceだけ残す。

方式設定例挙動
AlwaysOnデフォルト(開発)全Span記録
TraceIdRatioBasedOTEL_TRACES_SAMPLER=traceidratio, OTEL_TRACES_SAMPLER_ARG=0.1ルートSpanの10%のみ
ParentBasedSDK内蔵親がサンプル対象なら子も記録。途中参加サービスでも整合
Tail-basedCollector側完了後に「遅い/エラー」のTraceだけ残す。本番向け
// tracing.js — 本番向けサンプラー
const { ParentBasedSampler, TraceIdRatioBasedSampler } = require('@opentelemetry/sdk-trace-base');

sampler: new ParentBasedSampler({
  root: new TraceIdRatioBasedSampler(
    process.env.NODE_ENV === 'production' ? 0.1 : 1.0
  ),
}),

エラーTraceを必ず残したい場合、Collector の tail_sampling プロセッサで status_code = ERROR または latency > 2s 条件を追加する。2026年の本番構成では、Head-based(SDK側の比率サンプリング)+ Tail-based(Collector側の事後フィルタ) の二段構えが主流だ。

# otel-collector-tail-sampling.yaml — エラー・遅延Traceを必ず保持
processors:
  tail_sampling:
    decision_wait: 10s
    num_traces: 100000
    expected_new_traces_per_sec: 1000
    policies:
      - name: errors-policy
        type: status_code
        status_code:
          status_codes: [ERROR]
      - name: slow-traces-policy
        type: latency
        latency:
          threshold_ms: 2000
      - name: probabilistic-policy
        type: probabilistic
        probabilistic:
          sampling_percentage: 10

service:
  pipelines:
    traces:
      receivers: [otlp]
      processors: [memory_limiter, tail_sampling, batch]
      exporters: [otlp/tempo]
2026年の推奨パターン適用場面
開発・ステージング: TraceIdRatioBased(1.0)全Spanを記録し E2E を検証
本番ルートAPI: ParentBased + Ratio(0.05〜0.15)コストと代表サンプルのバランス
決済・認証: Collector tail_sampling で ERROR/Latency 必須クリティカルパスの欠落防止
バッチ・ワーカー: ルートSpan比率をさらに下げる非同期ジョブのSpan爆発を抑制

環境変数 OTEL_TRACES_SAMPLER=parentbased_traceidratioOTEL_TRACES_SAMPLER_ARG=0.1 を Kubernetes Deployment に載せ、比率変更を再デプロイなしで行えるように ConfigMap 化しておくと運用が楽になる。

ログとの統合:trace_idで相関

OpenTelemetryのTrace IDをアプリログに埋め込むと、Grafanaで「ログ ↔ Trace」ジャンプができる。構造化ログ JSON + Loki 設計 で解説した requestId(correlation id) に加え、trace_id / span_id を同一 JSON 行に載せるのが2026年の可観測性の定石だ。Loki では {service="api"} | json | trace_id="..."、Jaeger/Tempo では Trace 詳細画面から逆引きできる。

const { trace } = require('@opentelemetry/api');

function getTraceContext() {
  const span = trace.getActiveSpan();
  const ctx = span?.spanContext();
  if (!ctx) return {};
  return {
    trace_id: ctx.traceId,
    span_id: ctx.spanId,
  };
}

// Pino との統合例
const pino = require('pino');
const logger = pino({ level: 'info' });

function logWithTrace(level, fields) {
  logger[level]({
    ...fields,
    ...getTraceContext(),
  });
}

// 使用例
logWithTrace('info', {
  msg: 'Order created',
  order_id: 'ord_123',
  userId: 'user_42',
  durationMs: 128,
});

Grafana Lokiで {trace_id="4bf92f3577b34da6a3ce929d0e0e4736"} と検索し、Derived FieldsでTempoへリンクする設定が一般的。API エラー時は APIエラーハンドリング設計instance(リクエストID)・error_code と trace_id を同時にログへ書き、サポートが「ユーザー報告 ID → ログ → Trace → 障害 Span」の順でたどれる Runbook を用意する。

本番運用チェックリスト

  1. tracing.js--require で読み込んでいるか — import順序ミスで計測ゼロになっていないか
  2. ヘルスチェックを計測除外しているか/health SpanがTrace UIを埋め尽くしていないか
  3. サンプリング率は適切か — 開発100%、本番10%前後が目安
  4. Graceful shutdown で sdk.shutdown() しているか — デプロイ時のSpan欠落防止
  5. PIIをSpan属性に載せていないかdb.statement にメールアドレス、Baggageにトークンを入れない
  6. OTLPエンドポイントは環境変数化されているか — ステージング/本番でCollector URLを切り替え
  7. Collectorに memory_limiter があるか — OOMでアプリが巻き添えにならない
  8. Trace UIでE2E確認済みか — デプロイ後に代表フローを1本走らせ、全サービスが1 Traceに繋がることを確認

よくあるトラブルと対処

Spanが1サービス分しか表示されない

原因: 下流サービスへのHTTPリクエストに traceparent が載っていない。

対処:

  • 発信側で fetch / http の auto-instrumentationが有効か確認
  • axios等は @opentelemetry/instrumentation-undici または手動 propagation.inject
  • 下流サービスも --require ./tracing.js で起動しているか

auto-instrumentationが効かない

原因: tracing.js の読み込みが遅い(ESM import順序、server.js内require)。

対処: node --require ./tracing.js に変更。OTEL_LOG_LEVEL=debug で計装ログを確認。

Jaeger/TempoにSpanが届かない

原因: エンドポイント・プロトコル不一致、ファイアウォール。

対処:

  • gRPC: 4317、HTTP: 4318 を確認
  • OTEL_EXPORTER_OTLP_PROTOCOL=grpc または http/protobuf
  • Collectorログで received spans を確認
  • 一時的に ConsoleSpanExporter を追加してローカル出力
// デバッグ用(本番では削除)
const { ConsoleSpanExporter, SimpleSpanProcessor } = require('@opentelemetry/sdk-trace-base');
// NodeSDK の spanProcessor オプションに追加
spanProcessor: new SimpleSpanProcessor(new ConsoleSpanExporter()),

Span数が爆発する

原因: fs instrumentation、DNS lookup、短時間ポーリングが全てSpan化。

対処: 不要なinstrumentationを enabled: false に。ignoreOutgoingRequestHook で内部ポーリング先を除外。

Kubernetes / Docker環境変数一覧

コンテナデプロイ時は環境変数でOTel設定を完結させるのが一般的。

# deployment.yaml 抜粋
env:
  - name: OTEL_SERVICE_NAME
    value: order-api
  - name: OTEL_EXPORTER_OTLP_ENDPOINT
    value: http://otel-collector.observability.svc.cluster.local:4317
  - name: OTEL_EXPORTER_OTLP_PROTOCOL
    value: grpc
  - name: OTEL_TRACES_SAMPLER
    value: parentbased_traceidratio
  - name: OTEL_TRACES_SAMPLER_ARG
    value: "0.1"
  - name: OTEL_RESOURCE_ATTRIBUTES
    value: deployment.environment=production,service.namespace=ecommerce
  - name: NODE_OPTIONS
    value: "--require /app/tracing.js"

OTEL_RESOURCE_ATTRIBUTES で追加のリソース属性をカンマ区切りで指定できる。Kubernetes downward APIで k8s.pod.name 等を載せる運用も多い。

OpenTelemetry vs 専用APM SDK

観点OpenTelemetryDatadog/New Relic ネイティブSDK
ベンダーロックイン低(OTLPでどこでも可)
セットアップtracing.js + Collector構成が必要エージェント1本で完結
カスタム計測標準API(@opentelemetry/apiベンダー固有API
コミュニティCNCF、多言語統一ベンダーサポート品質は高い

新規マイクロサービスやマルチクラウド構成ではOpenTelemetryが主流。既存Datadog契約がある場合も OTel SDK → Datadog Agent OTLP intake の構成が2026年の推奨パターン。

まとめ:分散トレーシング導入の優先順位

  1. 今日: tracing.js + auto-instrumentation + Jaeger all-in-one でローカル可視化
  2. 今週: 本番相当のdocker-compose(Collector + Tempo + Grafana)を構築
  3. 今月: ドメインロジックへの手動Span、ログtrace_id相関、サンプリング調整
  4. 継続: Tail-based sampling、SLOダッシュボード、Trace-driven アラート

OpenTelemetry Node.js 実装の要点は「早く・確実にSDKを起動」「W3C traceparentで繋ぐ」「OTLPでエクスポート」「ビジネスステップは手動Span」の4点。分散トレーシング設定を一度整えれば、構造化ログ JSON + Loki 設計 の trace_id 注入と APIエラーハンドリングinstance(リクエストID)を組み合わせ、障害時の「ログ → Trace → メトリクス」の三点セットで根本原因を短時間で特定できる。

次のステップ

メトリクス(@opentelemetry/exporter-metrics-otlp-grpc)とログ(@opentelemetry/sdk-logs)も同じResource・Collectorパイプラインに載せると、Grafanaの統合ダッシュボードで三本柱(Metrics / Logs / Traces)が揃う。ログ側のスキーマ設計は 構造化ログ JSON + Loki 設計 を参照。まずはTraceだけでも、マイクロサービス間のレイテンシ可視化だけで運用効率は大きく改善する。

関連記事

この記事は API・バックエンド テーマの一環です。あわせて読むと理解が深まる関連記事をまとめました。

トピック記事
APIキャッシュ設計ガイドAPIキャッシュ設計ガイド|ETag・Cache-Control・304・CDNキャッシュキーの実務
Web APIのべき等性(Idempotency)設計Web APIのべき等性(Idempotency)設計|二重決済を防ぐ実装ガイド
API設計におけるページネーションの使い分け:オフセット型とカーソル型の比較API設計におけるページネーションの使い分け:オフセット型とカーソル型の比較
APIのレート制限(Rate Limiting)実装ガイドAPIのレート制限(Rate Limiting)実装ガイド|アルゴリズム選定と429エラーの適切なハンドリング
APIレート制限と429対応APIレート制限と429対応|X-RateLimit・Retry-Afterを使ったクライアント実装
WebHook設計のベストプラクティスWebHook設計のベストプラクティス|署名・再試行・べき等性で信頼性を担保する
ZodでAPI境界のランタイム検証を設計するZodでAPI境界のランタイム検証を設計する|z.infer・Expressミドルウェア・zod-to-openapi(2026)
OpenAPI 3.1仕様のCIバリデーション完全ガイドOpenAPI 3.1仕様のCIバリデーション完全ガイド|Spectral Lint・Breaking Change検出・GitHub Actions
GraphQL N+1 問題の解決GraphQL N+1 問題の解決|DataLoader バッチング実装と Apollo Server 完全ガイド
Circuit Breaker + Retry パターン実装ガイドCircuit Breaker + Retry パターン実装ガイド|Node.js opossum/cockatiel・指数バックオフ・Jitter
Sagaパターン完全ガイドSagaパターン完全ガイド|Orchestration vs Choreography と補償トランザクション(Node.js)
Event SourcingとCQRSの基礎Event SourcingとCQRSの基礎|Node.js + PostgreSQL 実装ガイド