はじめに
バックエンドで複数のAPIの呼び出しを行っていると、障害発生時など、どこのAPIでエラーになっているのかなどの切り分けが難しくなることがあると思います。
今回は[Next.js → PHP(BFF) → 複数APIの呼び出し]という構成でトレースできる環境を調べながら動かした記録をまとめています。
作ったもの
について確かめていきたいと思います。
- フロントエンドからAPIに
traceparentヘッダーでトレースを繋ぐ - ブラウザ → BFF → APIが1つのTrace IDでJaegerに表示される
OpenTelemetryとは
OpenTelemetry (OTel)はオープンソースのオブザーバビリティのフレームワークです。
ベンダーに依存しない形でテレメトリデータを収集・送信できます。
テレメトリシグナル
OTelは以下のシグナルを定義しています。今回はTracesに絞って扱います。
| シグナル | 記録する内容 | 例 |
|---|---|---|
| Traces | リクエストの流れ | BFF → API-Aの呼び出し経路 |
| Metrics | 数値の集計 | リクエスト数/秒、レスポンスタイム |
| Logs | 個別イベント | 「ユーザー123が注文を作成」 |
用語の整理
| 用語 | 説明 |
|---|---|
| Span | 1つの処理単位。「APIを呼んだ」「DBに問い合わせた」などが1Spanになる |
| Span ID | 各Span固有の識別子。Spanが作られるたびに新規生成される |
| Parent ID | このSpanの親はどれかを示す値。Span生成時にアクティブだったSpanのIDが入る |
| Trace ID | リクエスト全体の識別子。生成されたものを全APIで共有する |
| Context Propagation | サービス間でTrace IDを引き継ぐ仕組み |
| Collector | テレメトリを受信・加工・転送する中継サーバー。PHPプロセスの外で独立して動く |
traceparentヘッダー
サービス間でTrace IDを途切れさせずに引き継ぐ仕組みがContext Propagationです。その具体的な伝搬方法として、W3C Trace Contextで標準化されたtraceparentヘッダーを使います。
traceparent: 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01
│ │ │ │
│ trace-id (32桁) parent-id (16桁) trace-flags
version ※送信時にアクティブなSpanのspan-id
- parent-id: リクエスト送信時にアクティブだったSpanのSpan IDが入ります。受信側はこの値を親として自身のSpanを生成します
このヘッダーはフロントエンド → BFF間、BFF → API間のHTTPリクエストで使います。
使用パッケージと役割
今回は、以下のパッケージの組み合わせでアプリのコードの変更を少なく実現しています。
フロントエンド (Next.js)
| パッケージ | 役割 |
|---|---|
@opentelemetry/sdk-trace-web |
Span生成の本体(WebTracerProvider) |
@opentelemetry/instrumentation-fetch |
fetch()をフックしてSpan生成 + traceparent付与 |
@opentelemetry/exporter-trace-otlp-http |
SpanをCollectorに送信 |
@opentelemetry/context-zone |
非同期処理をまたいだコンテキスト追跡 |
バックエンド (CakePHP)
| パッケージ | 役割 |
|---|---|
| kaz29/cakephp-otel-plugin | ControllerのSpan自動生成 |
| opentelemetry-auto-psr15 | 受信リクエストからtraceparentを抽出(extract) |
| opentelemetry-auto-guzzle | 送信リクエストにtraceparentを注入(inject) |
| ext-opentelemetry | 上記パッケージがPHPメソッドをフックするために必要なPECL拡張 |
トレースの仕組み
Step 1: ブラウザ → BFF
ブラウザのOTel JS SDKがSpanを生成し、fetch()にtraceparentヘッダーを自動付与します。
POST /api/dashboard.json HTTP/1.1
Host: localhost:8080
traceparent: 00-4bf92f3577b34da6a3ce929d0e0e4736-aabb001122334455-01
フロントエンド の Span:
traceId: 4bf92f3577b34da6a3ce929d0e0e4736 (新規生成)
spanId: aabb001122334455 (新規生成)
parentId: なし (起点)
name: HTTP POST
Step 2: BFFでトレースを引き継ぐ
BFFに到達すると、opentelemetry-auto-psr15がtraceparentヘッダーからContextを自動抽出し、サーバースパンを生成します。フロントエンドと同じTrace IDが引き継がれます。
続いてcakephp-otel-pluginがControllerのSpanを自動生成します。
BFF の Span (server span):
traceId: 4bf92f3577b34da6a3ce929d0e0e4736 ← フロントエンドと同じ (引き継いだ)
spanId: 00f067aa0ba902b7 (新規生成)
parentId: aabb001122334455 ← フロントエンドのspanId (traceparentから)
name: RequestHandlerInterface::handle
└─ BFF の Span (internal span):
traceId: 4bf92f3577b34da6a3ce929d0e0e4736
spanId: 1122334455667788 (新規生成)
parentId: 00f067aa0ba902b7 ← handleのspanId (アクティブだったspanId)
name: DashboardController::index
Controller内で下流のAPIを呼び出します。(本来よくないですが、今回はトレースの流れを追いやすくするためControllerに直接書いています)
public function index(): void
{
$apiA = new ApiClient(env('API_A_URL', 'http://api-a'));
$apiB = new ApiClient(env('API_B_URL', 'http://api-b'));
// API呼び出し
$promises = [
'user' => $apiA->getAsync('/users/me.json'),
'products' => $apiB->getAsync('/products.json'),
];
$results = Utils::unwrap($promises);
$this->set('dashboard', [
'user' => json_decode((string)$results['user']->getBody(), true),
'products' => json_decode((string)$results['products']->getBody(), true),
]);
$this->viewBuilder()->setOption('serialize', ['dashboard']);
}
Step 3: BFF → API-A, API-Bを呼ぶ (inject)
下流APIを呼ぶとき、opentelemetry-auto-guzzleがGuzzleのリクエストをフックしてSpan IDを生成し、そのSpan IDをtraceparentヘッダーとしてHTTPリクエストに書き込みます。
BFF の Span (client span):
traceId: 4bf92f3577b34da6a3ce929d0e0e4736
spanId: aabbccdd11223344 (新規生成)
parentId: 1122334455667788 ← ControllerのspanId
name: GET
このCLIENTスパンのSpan IDがtraceparentのparent-idとしてAPI-Aに送られます。
GET /users/me.json HTTP/1.1
Host: api-a
Accept: application/json
traceparent: 00-4bf92f3577b34da6a3ce929d0e0e4736-aabbccdd11223344-01
API-Bにも同じTrace IDでtraceparentが送られます。
Step 4: API-Aが受信 (extract)
API側でもopentelemetry-auto-psr15がサーバースパンを生成し、traceparentのparent-idを親にします。さらにcakephp-otel-pluginがControllerスパンを生成します。
API-A の Span (server span):
traceId: 4bf92f3577b34da6a3ce929d0e0e4736 ← BFF と同じ (引き継いだ)
spanId: 1a2b3c4d5e6f7890 (新規生成)
parentId: aabbccdd11223344 ← BFFのCLIENTスパンのspanId (traceparentから)
name: RequestHandlerInterface::handle
└─ API-A の Span (internal span):
spanId: 5566778899aabbcc (新規生成)
parentId: 1a2b3c4d5e6f7890 ← handleのspanId (アクティブだったため)
name: UsersController::me
Step 5: テレメトリがCollector → Jaegerに届く
フロントエンド・各PHPサービスのSDKがSpanデータをCollectorに送ります。Jaegerは同じTrace IDのSpanを集めてparentIdでツリーを組み立てます。
データの流れ
フロントエンドと各バックエンドのOTel SDKがSpanデータをOTLP (OpenTelemetry Protocol)でCollectorに送信します。
そのため、送り先をJaegerからAzure Monitorなど他のサービスに変える際も、Collectorの設定ファイルを書き換えるだけでアプリのコードは触らなくて済みます
環境構築
Dockerfile
FROM php:8.3-apache
# システム依存パッケージ
RUN apt-get update && apt-get install -y \
git \
unzip \
libzip-dev \
libicu-dev \
libonig-dev \
&& rm -rf /var/lib/apt/lists/*
# PHP 拡張
RUN docker-php-ext-install \
intl \
pdo_mysql \
zip \
mbstring
# ext-opentelemetry (自動計装に必要なPECL拡張)
RUN pecl install opentelemetry \
&& docker-php-ext-enable opentelemetry
# ext-protobuf (protobuf シリアライズの高速化)
RUN pecl install protobuf \
&& docker-php-ext-enable protobuf
# Composer
COPY --from=composer:latest /usr/bin/composer /usr/bin/composer
# Apache mod_rewrite (CakePHP のルーティングに必要)
RUN a2enmod rewrite
# Apache の DocumentRoot を CakePHP の webroot に変更
ENV APACHE_DOCUMENT_ROOT=/var/www/html/webroot
RUN sed -ri -e 's!/var/www/html!${APACHE_DOCUMENT_ROOT}!g' \
/etc/apache2/sites-available/*.conf \
&& sed -ri -e 's!/var/www/!${APACHE_DOCUMENT_ROOT}!g' \
/etc/apache2/apache2.conf /etc/apache2/conf-available/*.conf
# AllowOverride All (.htaccess を有効化)
RUN sed -ri -e 's/AllowOverride None/AllowOverride All/g' \
/etc/apache2/apache2.conf
WORKDIR /var/www/html
OTel Collector設定 (otel-collector-config.yml)
receivers:
otlp:
protocols:
http:
endpoint: 0.0.0.0:4318
cors:
allowed_origins:
- "http://localhost:3000"
allowed_headers:
- "*"
processors:
batch: {}
exporters:
otlphttp/jaeger:
endpoint: http://jaeger:4318
service:
pipelines:
traces:
receivers: [otlp]
processors: [batch]
exporters: [otlphttp/jaeger]
docker-compose.yml (抜粋)
services:
otel-collector:
image: otel/opentelemetry-collector-contrib:latest
volumes:
- ./otel-collector-config.yml:/etc/otelcol-contrib/config.yaml
ports:
- "4318:4318"
jaeger:
image: jaegertracing/all-in-one:latest
ports:
- "16686:16686"
bff:
build: ./php
volumes:
- ./php/bff:/var/www/html
ports:
- "8080:80"
environment:
OTEL_PHP_AUTOLOAD_ENABLED: "true"
OTEL_SERVICE_NAME: bff
OTEL_EXPORTER_OTLP_ENDPOINT: http://otel-collector:4318
OTEL_EXPORTER_OTLP_PROTOCOL: http/protobuf
OTEL_PROPAGATORS: tracecontext,baggage
API_A_URL: http://api-a
API_B_URL: http://api-b
API_C_URL: http://api-c
api-a:
build: ./php
volumes:
- ./php/api-a:/var/www/html
environment:
OTEL_PHP_AUTOLOAD_ENABLED: "true"
OTEL_SERVICE_NAME: api-a-user # ← サービスごとに変える
OTEL_EXPORTER_OTLP_ENDPOINT: http://otel-collector:4318
OTEL_EXPORTER_OTLP_PROTOCOL: http/protobuf
OTEL_PROPAGATORS: tracecontext,baggage
# api-b, api-c も同様(OTEL_SERVICE_NAME だけ変える)
frontend:
image: node:20-alpine
working_dir: /app
volumes:
- ./frontend:/app
ports:
- "3000:3000"
command: sh -c "npm install && npm run dev"
environment:
NEXT_PUBLIC_BFF_URL: http://localhost:8080
NEXT_PUBLIC_OTEL_COLLECTOR_URL: http://localhost:4318
API-A/B/Cは同じDockerイメージを使い回してOTEL_SERVICE_NAMEだけ変えています。
フロントエンドのOTel SDK初期化
lib/tracing.ts
const COLLECTOR_URL =
process.env.NEXT_PUBLIC_OTEL_COLLECTOR_URL ?? "http://localhost:4318";
let initialized = false;
export function initTracing(): void {
if (initialized || typeof window === "undefined") {
return;
}
initialized = true;
const resource = new Resource({
[ATTR_SERVICE_NAME]: "frontend",
});
const exporter = new OTLPTraceExporter({
url: `${COLLECTOR_URL}/v1/traces`,
});
const provider = new WebTracerProvider({
resource,
spanProcessors: [new BatchSpanProcessor(exporter)],
});
provider.register({
contextManager: new ZoneContextManager(),
});
registerInstrumentations({
instrumentations: [
new FetchInstrumentation({
// BFF (localhost:8080) への fetch に traceparent を自動付与
propagateTraceHeaderCorsUrls: [/localhost:8080/],
// Collector への送信自体を計装しない(再帰防止)
ignoreUrls: [/localhost:4318/],
}),
],
});
}
pages/_app.tsx
import { initTracing } from "@/lib/tracing";
initTracing();
export default function App({ Component, pageProps }: AppProps) {
// ...
}
バックエンド
初期設定
composer require kaz29/cakephp-otel-plugin
composer require open-telemetry/opentelemetry-auto-psr15
composer require open-telemetry/opentelemetry-auto-guzzle
// src/Application.php の bootstrap()
$this->addPlugin('OtelInstrumentation');
Jaegerの見え方
API呼び出しを行った時の見え方(API-A/Bを並列で呼び出し)
フロントエンドを起点として、BFF → API-A + API-Bの並列呼び出しがツリー構造で可視化されます。
- 例外が発生した時の見え方(API-Bで例外が発生)
おわりに
今回はフロントエンドからバックエンドまでを1つのTrace IDで繋ぐ環境を構築しました。
実際の運用となると、Spanの粒度の設計などまだまだ考慮すべき事項は多くありますが、分散トレースがどのように動くのかのイメージは掴めたかなと思います。
参考にさせていただいた記事
- kaz29/cakephp-otel-plugin
- CakePHP用のOpenTelemetryプラグインを作った話
- OpenTelemetry公式ドキュメント
- W3C Trace Context仕様
- open-telemetry/opentelemetry-auto-psr15 (Packagist)
- open-telemetry/opentelemetry-auto-guzzle (Packagist)
- OpenTelemetry JavaScript SDK
- OpenTelemetryについてざっくり理解する (Qiita)
- Application Insights OpenTelemetry overview (Microsoft Learn)



