OpenTelemetry Node.js 実装ガイド|分散トレーシング設定とJaeger/Tempo連携
- Node.jsの分散トレーシングは
tracing.jsを--requireで最初に読み込み、@opentelemetry/sdk-node+ auto-instrumentation でHTTP/DB/fetchを自動計測し、サービス間は W3Ctraceparentヘッダーでコンテキストを伝播する。 - エクスポート先は 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 │ └─────────────────┘
└──────────────────┘
- SDK(@opentelemetry/sdk-node) — TracerProvider・Sampler・Exporterを初期化
- Instrumentation — HTTP/Express/pg等のライブラリ呼び出しを自動または手動でSpan化
- Context Propagation — サービス間HTTP通信でtrace_id/span_idをヘッダー経由で伝播
- Exporter — SpanをOTLP/gRPCまたはHTTPでJaeger・Tempo等へ送信
- 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-node | Node.js向け統合SDK(NodeSDKクラス) |
@opentelemetry/api | trace.getTracer() 等のアプリケーションAPI |
@opentelemetry/auto-instrumentations-node | HTTP/Express/pg/redis等の自動計測バンドル |
@opentelemetry/exporter-trace-otlp-grpc | OTLP/gRPCでSpanを送信(Tempo/Jaeger/Collector向け) |
@opentelemetry/exporter-trace-otlp-http | OTLP/HTTPでSpanを送信(プロキシ環境向け) |
@opentelemetry/resources | サービス名・環境・バージョン等のリソース属性 |
@opentelemetry/semantic-conventions | ATTR_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.js を tracing.cjs にリネームし、--require ./tracing.cjs とするか、起動専用のCJSラッパーを用意する。
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つ。
| ヘッダー | 例 | 意味 |
|---|---|---|
traceparent | 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01 | version-trace_id-parent_span_id-flags |
tracestate | vendor=value,another=1 | ベンダー固有の追加状態(任意) |
traceparent の各フィールド:
- version — 現在は
00 - trace_id — 32hex(128bit)。リクエスト全体で不変
- parent_span_id — 16hex(64bit)。呼び出し元SpanのID
- flags —
01= sampled(このTraceを記録する)
@opentelemetry/sdk-node はデフォルトでW3C Propagatorを使用する。auto-instrumentationされた fetch や http.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 Context | traceparent, tracestate | 新規設計の標準 |
| Zipkin B3 | X-B3-TraceId, X-B3-SpanId, X-B3-Sampled | Spring Cloud Sleuth legacy等 |
| Jaeger | uber-trace-id | Jaeger 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は全ダウンストリームサービスに伝播する。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.method | GET | HTTPメソッド |
http.response.status_code | 200 | ステータスコード |
db.system | postgresql | DB種別 |
db.statement | SELECT id FROM orders WHERE user_id = $1 | クエリ(PII注意) |
enduser.id | user_123 | エンドユーザーID |
error.type | TypeError | 例外クラス名 |
サービス間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
| ポート | 用途 |
|---|---|
| 16686 | Jaeger UI |
| 4317 | OTLP gRPC |
| 4318 | OTLP 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-api → inventory-api のTraceが1本のウォーターフォールとして表示されれば成功。
[object Object]
[object Object]
[object Object]
[object Object]
[object Object]
[object Object]
[object Object]
サンプリング戦略:コストと可視性のバランス
全リクエストを100%記録すると、1日数億Spanになりストレージコストが膨らむ。サンプリングで代表的なTraceだけ残す。
| 方式 | 設定例 | 挙動 |
|---|---|---|
| AlwaysOn | デフォルト(開発) | 全Span記録 |
| TraceIdRatioBased | OTEL_TRACES_SAMPLER=traceidratio, OTEL_TRACES_SAMPLER_ARG=0.1 | ルートSpanの10%のみ |
| ParentBased | SDK内蔵 | 親がサンプル対象なら子も記録。途中参加サービスでも整合 |
| Tail-based | Collector側 | 完了後に「遅い/エラー」の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_traceidratio と OTEL_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 を用意する。
本番運用チェックリスト
tracing.jsを--requireで読み込んでいるか — import順序ミスで計測ゼロになっていないか- ヘルスチェックを計測除外しているか —
/healthSpanがTrace UIを埋め尽くしていないか - サンプリング率は適切か — 開発100%、本番10%前後が目安
- Graceful shutdown で
sdk.shutdown()しているか — デプロイ時のSpan欠落防止 - PIIをSpan属性に載せていないか —
db.statementにメールアドレス、Baggageにトークンを入れない - OTLPエンドポイントは環境変数化されているか — ステージング/本番でCollector URLを切り替え
- Collectorに memory_limiter があるか — OOMでアプリが巻き添えにならない
- 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
| 観点 | OpenTelemetry | Datadog/New Relic ネイティブSDK |
|---|---|---|
| ベンダーロックイン | 低(OTLPでどこでも可) | 高 |
| セットアップ | tracing.js + Collector構成が必要 | エージェント1本で完結 |
| カスタム計測 | 標準API(@opentelemetry/api) | ベンダー固有API |
| コミュニティ | CNCF、多言語統一 | ベンダーサポート品質は高い |
新規マイクロサービスやマルチクラウド構成ではOpenTelemetryが主流。既存Datadog契約がある場合も OTel SDK → Datadog Agent OTLP intake の構成が2026年の推奨パターン。
まとめ:分散トレーシング導入の優先順位
- 今日:
tracing.js+ auto-instrumentation + Jaeger all-in-one でローカル可視化 - 今週: 本番相当のdocker-compose(Collector + Tempo + Grafana)を構築
- 今月: ドメインロジックへの手動Span、ログtrace_id相関、サンプリング調整
- 継続: 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 実装ガイド |