OpenTelemetryで始める分散トレーシング - 完全実装ガイド
OpenTelemetry(OTel)は、分散システムのオブザーバビリティを実現するためのオープンソース標準です。トレース、メトリクス、ログを統一的に扱い、ベンダーロックインを回避できます。
本記事では、OpenTelemetryの基礎から実践的な実装パターン、本番環境での運用まで詳しく解説します。
OpenTelemetryとは
OpenTelemetryは、CNCF(Cloud Native Computing Foundation)の卒業プロジェクトで、可観測性データの標準化を目指しています。
主要コンポーネント
OpenTelemetryは3つの柱で構成されています。
// Traces(トレース)
// リクエストの処理フローを追跡
const span = tracer.startSpan('database-query');
span.setAttribute('db.system', 'postgresql');
span.end();
// Metrics(メトリクス)
// システムの測定値を記録
const counter = meter.createCounter('http_requests_total');
counter.add(1, { method: 'GET', status: 200 });
// Logs(ログ)
// 構造化されたログ出力
logger.emit({
severityText: 'INFO',
body: 'User logged in',
attributes: { userId: '123' },
});
OpenTelemetryの利点
ベンダー中立性: 任意のバックエンド(Jaeger、Zipkin、Datadog等)に送信可能 標準化: 業界標準のAPI・SDKで学習コストを削減 自動計装: 多くのライブラリ・フレームワークを自動計装 拡張性: カスタム計装でビジネスメトリクスも収集
セットアップ
Node.jsでの基本セットアップ
まず、必要なパッケージをインストールします。
npm install @opentelemetry/sdk-node \
@opentelemetry/auto-instrumentations-node \
@opentelemetry/exporter-trace-otlp-http \
@opentelemetry/exporter-metrics-otlp-http
基本的な設定ファイルを作成します。
// instrumentation.ts
import { NodeSDK } from '@opentelemetry/sdk-node';
import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http';
import { OTLPMetricExporter } from '@opentelemetry/exporter-metrics-otlp-http';
import { getNodeAutoInstrumentations } from '@opentelemetry/auto-instrumentations-node';
import { Resource } from '@opentelemetry/resources';
import { SEMRESATTRS_SERVICE_NAME, SEMRESATTRS_SERVICE_VERSION } from '@opentelemetry/semantic-conventions';
const sdk = new NodeSDK({
resource: new Resource({
[SEMRESATTRS_SERVICE_NAME]: 'my-api-service',
[SEMRESATTRS_SERVICE_VERSION]: '1.0.0',
}),
traceExporter: new OTLPTraceExporter({
url: 'http://localhost:4318/v1/traces',
}),
metricReader: new OTLPMetricExporter({
url: 'http://localhost:4318/v1/metrics',
}),
instrumentations: [
getNodeAutoInstrumentations({
'@opentelemetry/instrumentation-fs': {
enabled: false, // ファイルシステムは除外
},
}),
],
});
sdk.start();
process.on('SIGTERM', () => {
sdk.shutdown()
.then(() => console.log('Tracing terminated'))
.catch((error) => console.log('Error terminating tracing', error))
.finally(() => process.exit(0));
});
アプリケーションの起動時に読み込みます。
// package.json
{
"scripts": {
"start": "node -r ./instrumentation.js app.js"
}
}
Expressアプリケーションでの実装
// app.ts
import express from 'express';
import { trace, SpanStatusCode } from '@opentelemetry/api';
const app = express();
const tracer = trace.getTracer('express-app', '1.0.0');
// カスタムミドルウェアでトレース追加
app.use((req, res, next) => {
const span = tracer.startSpan('http-request', {
attributes: {
'http.method': req.method,
'http.url': req.url,
'http.user_agent': req.get('user-agent'),
},
});
res.on('finish', () => {
span.setAttribute('http.status_code', res.statusCode);
if (res.statusCode >= 400) {
span.setStatus({ code: SpanStatusCode.ERROR });
}
span.end();
});
next();
});
app.get('/api/users/:id', async (req, res) => {
const span = trace.getActiveSpan();
span?.setAttribute('user.id', req.params.id);
try {
const user = await fetchUser(req.params.id);
res.json(user);
} catch (error) {
span?.recordException(error as Error);
span?.setStatus({ code: SpanStatusCode.ERROR });
res.status(500).json({ error: 'Internal Server Error' });
}
});
async function fetchUser(id: string) {
return tracer.startActiveSpan('fetch-user', async (span) => {
span.setAttribute('db.system', 'postgresql');
span.setAttribute('db.operation', 'SELECT');
try {
// データベースクエリの実行
const user = await db.query('SELECT * FROM users WHERE id = $1', [id]);
span.setStatus({ code: SpanStatusCode.OK });
return user;
} catch (error) {
span.recordException(error as Error);
span.setStatus({ code: SpanStatusCode.ERROR });
throw error;
} finally {
span.end();
}
});
}
app.listen(3000, () => {
console.log('Server running on port 3000');
});
実践パターン
パターン1: マイクロサービス間のトレーシング
複数のサービスをまたがるリクエストを追跡します。
// service-a/api.ts
import { context, propagation, trace } from '@opentelemetry/api';
import axios from 'axios';
async function callServiceB(userId: string) {
const tracer = trace.getTracer('service-a');
return tracer.startActiveSpan('call-service-b', async (span) => {
span.setAttribute('service.name', 'service-b');
span.setAttribute('user.id', userId);
// トレースコンテキストをHTTPヘッダーに注入
const headers: Record<string, string> = {};
propagation.inject(context.active(), headers);
try {
const response = await axios.get(`http://service-b:3001/api/data/${userId}`, {
headers,
});
span.setStatus({ code: SpanStatusCode.OK });
return response.data;
} catch (error) {
span.recordException(error as Error);
span.setStatus({ code: SpanStatusCode.ERROR });
throw error;
} finally {
span.end();
}
});
}
// service-b/api.ts
import express from 'express';
import { context, propagation } from '@opentelemetry/api';
const app = express();
app.get('/api/data/:userId', (req, res) => {
// HTTPヘッダーからトレースコンテキストを抽出
const extractedContext = propagation.extract(context.active(), req.headers);
context.with(extractedContext, () => {
const tracer = trace.getTracer('service-b');
const span = tracer.startSpan('process-data');
span.setAttribute('user.id', req.params.userId);
// データ処理
const data = processData(req.params.userId);
span.end();
res.json(data);
});
});
パターン2: Next.js App Routerでのトレーシング
// instrumentation.ts(Next.js 15+)
export async function register() {
if (process.env.NEXT_RUNTIME === 'nodejs') {
const { NodeSDK } = await import('@opentelemetry/sdk-node');
const { OTLPTraceExporter } = await import('@opentelemetry/exporter-trace-otlp-http');
const { Resource } = await import('@opentelemetry/resources');
const { SEMRESATTRS_SERVICE_NAME } = await import('@opentelemetry/semantic-conventions');
const sdk = new NodeSDK({
resource: new Resource({
[SEMRESATTRS_SERVICE_NAME]: 'nextjs-app',
}),
traceExporter: new OTLPTraceExporter({
url: process.env.OTEL_EXPORTER_OTLP_ENDPOINT || 'http://localhost:4318/v1/traces',
}),
});
sdk.start();
}
}
// app/api/users/[id]/route.ts
import { trace, SpanStatusCode } from '@opentelemetry/api';
export async function GET(
request: Request,
{ params }: { params: { id: string } }
) {
const tracer = trace.getTracer('nextjs-api');
return tracer.startActiveSpan('api-get-user', async (span) => {
span.setAttribute('user.id', params.id);
span.setAttribute('http.method', 'GET');
try {
const user = await fetchUserFromDB(params.id);
if (!user) {
span.setAttribute('error', 'User not found');
span.setStatus({ code: SpanStatusCode.ERROR });
return Response.json({ error: 'Not found' }, { status: 404 });
}
span.setStatus({ code: SpanStatusCode.OK });
return Response.json(user);
} catch (error) {
span.recordException(error as Error);
span.setStatus({
code: SpanStatusCode.ERROR,
message: (error as Error).message,
});
return Response.json({ error: 'Internal error' }, { status: 500 });
} finally {
span.end();
}
});
}
async function fetchUserFromDB(id: string) {
const tracer = trace.getTracer('nextjs-api');
return tracer.startActiveSpan('db-query-user', async (span) => {
span.setAttribute('db.system', 'postgresql');
span.setAttribute('db.operation', 'SELECT');
span.setAttribute('db.statement', 'SELECT * FROM users WHERE id = $1');
try {
const result = await db.query('SELECT * FROM users WHERE id = $1', [id]);
span.setStatus({ code: SpanStatusCode.OK });
return result.rows[0];
} catch (error) {
span.recordException(error as Error);
span.setStatus({ code: SpanStatusCode.ERROR });
throw error;
} finally {
span.end();
}
});
}
パターン3: Cloudflare Workersでのトレーシング
// worker.ts
import { trace, context, SpanStatusCode } from '@opentelemetry/api';
import { BasicTracerProvider, BatchSpanProcessor } from '@opentelemetry/sdk-trace-base';
import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http';
export default {
async fetch(request: Request, env: Env): Promise<Response> {
// トレーサーの初期化
const provider = new BasicTracerProvider();
const exporter = new OTLPTraceExporter({
url: env.OTEL_ENDPOINT,
headers: {
'Authorization': `Bearer ${env.OTEL_TOKEN}`,
},
});
provider.addSpanProcessor(new BatchSpanProcessor(exporter));
provider.register();
const tracer = trace.getTracer('cloudflare-worker');
return tracer.startActiveSpan('http-request', async (span) => {
span.setAttribute('http.method', request.method);
span.setAttribute('http.url', request.url);
try {
const url = new URL(request.url);
if (url.pathname === '/api/data') {
const data = await fetchData(tracer);
span.setStatus({ code: SpanStatusCode.OK });
return Response.json(data);
}
span.setStatus({ code: SpanStatusCode.ERROR });
return new Response('Not Found', { status: 404 });
} catch (error) {
span.recordException(error as Error);
span.setStatus({ code: SpanStatusCode.ERROR });
return new Response('Internal Error', { status: 500 });
} finally {
span.end();
await provider.forceFlush();
}
});
},
};
async function fetchData(tracer: any) {
return tracer.startActiveSpan('fetch-external-api', async (span: any) => {
span.setAttribute('http.url', 'https://api.example.com/data');
try {
const response = await fetch('https://api.example.com/data');
const data = await response.json();
span.setStatus({ code: SpanStatusCode.OK });
return data;
} catch (error) {
span.recordException(error as Error);
span.setStatus({ code: SpanStatusCode.ERROR });
throw error;
} finally {
span.end();
}
});
}
パターン4: カスタムメトリクスの収集
// metrics.ts
import { metrics } from '@opentelemetry/api';
const meter = metrics.getMeter('my-app', '1.0.0');
// Counter(カウンター)
const requestCounter = meter.createCounter('http_requests_total', {
description: 'Total number of HTTP requests',
});
// Histogram(ヒストグラム)
const requestDuration = meter.createHistogram('http_request_duration_ms', {
description: 'HTTP request duration in milliseconds',
unit: 'ms',
});
// UpDownCounter(増減カウンター)
const activeConnections = meter.createUpDownCounter('active_connections', {
description: 'Number of active connections',
});
// ObservableGauge(監視可能ゲージ)
const memoryUsage = meter.createObservableGauge('memory_usage_bytes', {
description: 'Current memory usage in bytes',
});
memoryUsage.addCallback((observableResult) => {
const usage = process.memoryUsage();
observableResult.observe(usage.heapUsed, { type: 'heap_used' });
observableResult.observe(usage.heapTotal, { type: 'heap_total' });
observableResult.observe(usage.rss, { type: 'rss' });
});
// 使用例
export function recordRequest(method: string, path: string, status: number, duration: number) {
requestCounter.add(1, {
method,
path,
status: status.toString(),
});
requestDuration.record(duration, {
method,
path,
status: status.toString(),
});
}
export function trackConnection(delta: number) {
activeConnections.add(delta);
}
// app.ts
import express from 'express';
import { recordRequest, trackConnection } from './metrics';
const app = express();
app.use((req, res, next) => {
const startTime = Date.now();
trackConnection(1);
res.on('finish', () => {
const duration = Date.now() - startTime;
recordRequest(req.method, req.route?.path || req.path, res.statusCode, duration);
trackConnection(-1);
});
next();
});
本番環境での運用
バックエンドの選択
OpenTelemetryのデータを受け取るバックエンドを選択します。
# docker-compose.yml(Jaegerを使用)
version: '3.8'
services:
jaeger:
image: jaegertracing/all-in-one:latest
ports:
- "16686:16686" # UI
- "4318:4318" # OTLP HTTP
environment:
- COLLECTOR_OTLP_ENABLED=true
app:
build: .
environment:
- OTEL_EXPORTER_OTLP_ENDPOINT=http://jaeger:4318
depends_on:
- jaeger
サンプリング設定
本番環境では、すべてのトレースを記録すると負荷が高くなるため、サンプリングを設定します。
// instrumentation.ts
import { NodeSDK } from '@opentelemetry/sdk-node';
import { TraceIdRatioBasedSampler, ParentBasedSampler } from '@opentelemetry/sdk-trace-base';
const sdk = new NodeSDK({
// 10%のトレースをサンプリング
sampler: new ParentBasedSampler({
root: new TraceIdRatioBasedSampler(0.1),
}),
// その他の設定...
});
環境変数での設定
# .env
OTEL_SERVICE_NAME=my-api-service
OTEL_EXPORTER_OTLP_ENDPOINT=https://api.honeycomb.io
OTEL_EXPORTER_OTLP_HEADERS=x-honeycomb-team=YOUR_API_KEY
OTEL_TRACES_SAMPLER=traceidratio
OTEL_TRACES_SAMPLER_ARG=0.1
OTEL_LOG_LEVEL=info
パフォーマンス最適化
// 効率的なバッチ処理
import { BatchSpanProcessor } from '@opentelemetry/sdk-trace-base';
const processor = new BatchSpanProcessor(exporter, {
maxQueueSize: 2048,
maxExportBatchSize: 512,
scheduledDelayMillis: 5000,
exportTimeoutMillis: 30000,
});
トラブルシューティング
トレースが表示されない
// デバッグ用の設定
import { diag, DiagConsoleLogger, DiagLogLevel } from '@opentelemetry/api';
// 詳細なログを出力
diag.setLogger(new DiagConsoleLogger(), DiagLogLevel.DEBUG);
メモリリークの防止
// スパンの適切なクリーンアップ
async function processData() {
const span = tracer.startSpan('process-data');
try {
// 処理
return result;
} catch (error) {
span.recordException(error);
throw error;
} finally {
// 必ずendを呼ぶ
span.end();
}
}
まとめ
OpenTelemetryは、分散システムのオブザーバビリティを実現する強力なツールです。
主な利点
- 統一された標準: トレース、メトリクス、ログを一元管理
- ベンダー中立: バックエンドを自由に選択可能
- 自動計装: 多くのライブラリに対応
- 本番対応: サンプリング、バッチ処理などの最適化
導入のステップ
- 基本セットアップ: SDK導入と自動計装の有効化
- カスタム計装: ビジネスロジックのトレース追加
- メトリクス収集: 重要な指標の測定
- バックエンド選定: Jaeger、Honeycomb、Datadogなど
- 本番最適化: サンプリング、バッチ処理の調整
OpenTelemetryを活用することで、システムの可視化が劇的に向上し、問題の早期発見と迅速な対応が可能になります。