OpenTelemetryのトレースゼロコード計装は、たいていフレームワークの利用を前提としており、PHPも例外ではない。しかし、フレームワークを使わないアプリケーションはいまも広く存在する。そのような状況で、アプリケーションコードにできるだけ手を入れずに計装できるか、しばらく試行錯誤していた。
当初は素朴にNGINXでの計装や一部PHPコードの改変、ライブラリ計装から始めたが、最終的にリクエストに対する「ゼロコード計装っぽいもの」を錬成した気がする。
- 題材とするアプリケーション
- ゼロコード計装っぽいものの仕組み
- 試行錯誤(1):NGINXの計装チャレンジ
- 試行錯誤(2):PHPコードに直接計装
- 試行錯誤(3):データベースアクセスの自動計装
- 試行錯誤(4):auto_prepend_fileとauto_append_fileによる計装の隠蔽
- あとがき
題材とするアプリケーション
構成はシンプルで、NGINXがリクエストを受け付け、PHP-FPMを介してPHPコードを実行する。アプリケーションコード内ではMariaDBへのクエリが発生する。
目的は、リクエストやデータベース操作をOpenTelemetryのスパンとして収集し、Mackerelのようなオブザーバビリティサービスに送信してトレース化すること。ただし、既存のPHPコードの修正は最小限にとどめたい。
ゼロコード計装っぽいものの仕組み
結論から言うと、要点はprepend.phpにある。PHPのauto_prepend_file設定を利用すると、リクエストごとにコード冒頭にこのファイルがあたかも注入れたかのように振る舞うので、アプリケーション側を改変せずに計装できる。

/etc/php/*/fpm/php.iniおよび/etc/php/*/cli/php.ini(PHPエンジン設定):
... auto_prepend_file = <パス>/prepend.php ... [opentelemetry] extension=opentelemetry.so
/etc/php/*/fpm/pool.d/www.conf(OTel Collectorへの送信設定とライブラリ計装パッケージの有効化):
... env[OTEL_SERVICE_NAME] = "php-tutorial" env[OTEL_EXPORTER_OTLP_ENDPOINT] = "http://localhost:4318" env[OTEL_PHP_AUTOLOAD_ENABLED] = "true"
composer.json(OpenTelemetry系とシグナルHTTP送信のライブラリ):
{ "require": { "open-telemetry/api": "^1.5", "open-telemetry/exporter-otlp": "^1.3", "open-telemetry/sdk": "^1.5", "php-http/guzzle7-adapter": "^1.1", "open-telemetry/opentelemetry-auto-psr15": "^1.1", "open-telemetry/opentelemetry-auto-mysqli": "^0.0.3" }, "config": { "allow-plugins": { "php-http/discovery": false, "tbachert/spi": false } } }
prepend.php(ゼロコード計装っぽいものの核心):
<?php use OpenTelemetry\SDK\Trace\TracerProviderFactory; require __DIR__ . '/vendor/autoload.php'; $factory = new TracerProviderFactory(); $tp = $factory->create(); $tracer = $tp->getTracer('trace-tutorial'); $rootSpan = $tracer->spanBuilder($_SERVER['SCRIPT_NAME'])->setSpanKind(OpenTelemetry\API\Trace\SpanKind::KIND_SERVER)->startSpan(); $scope = $rootSpan->activate(); $rootSpan->setAttribute('http.target', $_SERVER['REQUEST_URI']); $rootSpan->setAttribute('http.route', $_SERVER['DOCUMENT_URI']); $rootSpan->setAttribute('http.user_agent', $_SERVER['HTTP_USER_AGENT']); $rootSpan->setAttribute('http.method', $_SERVER['REQUEST_METHOD']); $rootSpan->setAttribute('net.sock.peer.addr', $_SERVER['REMOTE_ADDR']); set_exception_handler(function (Throwable $e) use ($rootSpan) { echo "An unexpected error occurred: " . $e->getMessage(); $rootSpan->setStatus(\OpenTelemetry\API\Trace\StatusCode::STATUS_ERROR, $e->getMessage()); $rootSpan->recordException($e, ['exception.escaped' => true]); throw $e; }); register_shutdown_function(function() use ($rootSpan, $scope, $tp) { $rootSpan->end(); $scope->detach(); $tp->shutdown(); });
center.php(サンプルのアプリケーションコード):
<?php echo "<h1>Hello from PHP</h1>"; sleep(1); $childSpan = $tracer->spanBuilder('child_process')->startSpan(); // Exceptionを出してみたいなら // $a = 1/0; try { $mysqli = new mysqli('localhost', 'myuser', 'mypass', 'mydb'); if ($mysqli->connect_errno) { die("MySQL connection failed: " . $mysqli->connect_error); } $sql = "SELECT id, name FROM fruits"; $result = $mysqli->query($sql); if ($result) { echo "<h1>Fruits Table</h1>"; echo "<ul>"; while ($row = $result->fetch_assoc()) { echo "<li>{$row['id']}: {$row['name']}</li>"; } echo "</ul>"; $result->free(); } else { echo "Query error: " . $mysqli->error; } $mysqli->close(); sleep(2); } finally { $childSpan->end(); sleep(1); }
config.yml(OTel Collectorの設定):
receivers: otlp: protocols: http: grpc: processors: batch: timeout: 5s send_batch_size: 5000 send_batch_max_size: 5000 exporters: otlphttp/mackerel: endpoint: https://otlp-vaxila.mackerelio.com compression: gzip headers: Mackerel-Api-Key: <APIキー> service: pipelines: traces: receivers: [otlp] processors: [batch] exporters: [otlphttp/mackerel]
試行錯誤(1):NGINXの計装チャレンジ
Webフレームワークの場合、フレームワーク自身がHTTPサーバーの機能を備えており、NGINXなどはそこにプロキシ接続するという構成が一般的である。フレームワークのフロントエンド部分が計装されていれば、ルートとなるリクエストのスパンもそこで作られる。
しかし、NGINX・PHP-FPM・PHPコードの構成の場合、PHPコードを加工せずにルートスパンを生み出すということはできない。計装作業内容と「既存のPHPコードの修正は最小限にとどめたい」という制約からすると、ルートスパンをNGINXに持たせたほうがよいのではないかと考えた。
- 利点
- 欠点
すでに運用されている環境では欠点がだいぶ厳しいが、ドキュメントを見ながらDebian 13環境に入れていくことにする。
sudo apt-get install -y curl gnupg2 ca-certificates lsb-release debian-archive-keyring curl https://nginx.org/keys/nginx_signing.key | gpg --dearmor \ | sudo tee /usr/share/keyrings/nginx-archive-keyring.gpg >/dev/null echo "deb [signed-by=/usr/share/keyrings/nginx-archive-keyring.gpg] \ http://nginx.org/packages/debian `lsb_release -cs` nginx" \ | sudo tee /etc/apt/sources.list.d/nginx.list echo -e "Package: *\nPin: origin nginx.org\nPin: release o=nginx\nPin-Priority: 900\n" \ | sudo tee /etc/apt/preferences.d/99nginx sudo apt-get update sudo apt-get install -y nginx nginx-module-otel
/etc/nginx/nginx.confを編集し、ngx_otel_module.soの有効化、およびユーザー名をDebianシステム標準のwww-dataに合わせる(さもないとphp-fpmのほうで面倒になる)。
# 先頭に追加 load_module modules/ngx_otel_module.so; ... # userの値をnginxから変更 user www-data; ...
/etc/nginx/conf.d/default.confでOpenTelemetry計装を有効化する。プロトコルはHTTP/ProtobufではなくgRPCを使うようだ。
otel_exporter {
endpoint localhost:4317;
}
otel_trace on;
otel_trace_context propagate;
otel_service_name nginx;
otel_span_name $request_uri;
server {
...
www-dataユーザーへの変更と設定反映を行う。
sudo systemctl stop nginx sudo chwon www-data /var/log/nginx/*.log sudo systemctl start nginx
NGINX版では/usr/share/nginx/htmlがコンテンツの場所になる。
次に、OpenTelemetryシグナルを受け取るOpenTelemetry Collectorを用意する。
wget https://github.com/open-telemetry/opentelemetry-collector-releases/releases/download/v0.132.0/otelcol-contrib_0.132.0_linux_amd64.deb sudo dpkg -i otelcol-contrib_0.132.0_linux_amd64.deb
本記事範囲ではcontribである必要はないが、将来的にフィルタリングやサンプリングなどをしようとすると結局contribレベルが必要になるだろう(無駄も多いが、かといってocbは誰でも使いやすいというものでもない…)。
/etc/otelcol-contrib/config.yamlが設定ファイルなので、Mackerelに送るよう変更する(設定内容は前掲に同じ)。設定を反映するために再起動する。
sudo systemctl restart otelcol-contrib.service
これでリクエストに応じてスパンを送るようになった。
curl http://localhostを実行し、MackerelのAPM画面のnginxサービスでトレースになっていることを確認する。

試行錯誤(2):PHPコードに直接計装
まずはPHPの環境をOpenTelemetryモジュールのビルドも含めて用意する。
sudo apt-get install -y php-fpm php-pear php-dev
sudo pecl channel-update pecl.php.net
sudo pecl intsall opentelemetry
/etc/php/8.4/fpm/php.iniと/etc/php/8.4/cli/php.iniの末尾に拡張モジュールを登録する(cliのほうは実際には使わないのだが、ここに記入しておかないと、あとでcomposerのインストール時に拡張がないと言われて失敗する)。
... [opentelemetry] extension=opentelemetry.so
/etc/php/*/fpm/pool.d/www.confでOTel Collectorへの送信設定とライブラリ計装パッケージの有効化をしておく。こちらの送信はHTTP/Protobufにしている。
... env[OTEL_SERVICE_NAME] = "php-tutorial" env[OTEL_EXPORTER_OTLP_ENDPOINT] = "http://localhost:4318" env[OTEL_PHP_AUTOLOAD_ENABLED] = "true"
sudo systemctl restart php8.4-fpm.serviceで設定を反映する。
データベースとcomposerをインストールし、コンテンツフォルダ下(本来はセキュリティなどで場所を考えるべきだろうが)にOpenTelemetryのSDKなどを展開する。php-http/guzzle7-adapterはHTTPでシグナルを送るために必要だった。
sudo apt-get install -y mariadb-server mariadb-client php8.4-mysql composer cd /usr/share/nginx/html sudo mkdir phptest sudo chown <自分アカウント> phptest cd phptest composer require open-telemetry/api open-telemetry/exporter-otlp open-telemetry/sdk php-http/guzzle7-adapter
データベースはsudo mysqlして適当に作っておく(適当にしてもひどい権限ではある…)。
sudo mysql
CREATE DATABASE mydb CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE USER 'myuser'@'localhost' IDENTIFIED BY 'mypass';
GRANT ALL PRIVILEGES ON mydb.* TO 'myuser'@'localhost';
FLUSH PRIVILEGES;
USE mydb;
CREATE TABLE fruits (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(50) NOT NULL
);
INSERT INTO fruits (name) VALUES
('Apple'),
('Orange'),
('Grape'),
('Banana'),
('Strawberry');
元のPHPアプリケーションコードapp.phpは以下のとおり。いろいろひどいが、あくまでサンプルなので目をつむってほしい。
<?php require __DIR__ . '/vendor/autoload.php'; echo "<h1>Hello from PHP</h1>"; sleep(1); // ここから子スパンにしたい処理 try { $mysqli = new mysqli('localhost', 'myuser', 'mypass', 'mydb'); if ($mysqli->connect_errno) { die("MySQL connection failed: " . $mysqli->connect_error); } $sql = "SELECT id, name FROM fruits"; $result = $mysqli->query($sql); if ($result) { echo "<h1>Fruits Table</h1>"; echo "<ul>"; while ($row = $result->fetch_assoc()) { echo "<li>{$row['id']}: {$row['name']}</li>"; } echo "</ul>"; $result->free(); } else { echo "Query error: " . $mysqli->error; } $mysqli->close(); sleep(2); // ここまで子スパンにしたい処理 sleep(1); }
既存のPHPコードに手を入れたくはないが、いったん計装をコード内に追加してみる。
<?php use OpenTelemetry\SDK\Trace\TracerProviderFactory; use OpenTelemetry\API\Trace\Propagation\TraceContextPropagator; use OpenTelemetry\Context\Propagation\ArrayAccessGetterSetter; require __DIR__ . '/vendor/autoload.php'; $factory = new TracerProviderFactory(); $tp = $factory->create(); $tracer = $tp->getTracer('trace-tutorial'); $headers = getallheaders(); $propagator = TraceContextPropagator::getInstance(); $getter = ArrayAccessGetterSetter::getInstance(); $ctx = $propagator->extract($headers, $getter); $rootSpan = $tracer->spanBuilder('Hello Mackerel!')->setParent($ctx)->startSpan(); $scope = $rootSpan->activate(); echo "<h1>Hello from PHP</h1>"; sleep(1); $childSpan = $tracer->spanBuilder('child_process')->startSpan(); try { $mysqli = new mysqli('localhost', 'myuser', 'mypass', 'mydb'); if ($mysqli->connect_errno) { die("MySQL connection failed: " . $mysqli->connect_error); } $sql = "SELECT id, name FROM fruits"; $result = $mysqli->query($sql); if ($result) { echo "<h1>Fruits Table</h1>"; echo "<ul>"; while ($row = $result->fetch_assoc()) { echo "<li>{$row['id']}: {$row['name']}</li>"; } echo "</ul>"; $result->free(); } else { echo "Query error: " . $mysqli->error; } $mysqli->close(); sleep(2); } finally { $childSpan->end(); sleep(1); $rootSpan->end(); $scope->detach(); $tp->shutdown(); }
- NGINXのスパンを親とするためにヘッダを展開してスパン(
$rootSpan)の親コンテキスト(setParent($ctx))としている。 - スパンのスコープを有効にしておくことで(
$rootSpan->activate())、その後に$tracerから作ったスパンは自動的に子スパン($childSpan)になる。 - 例外などが起きたときでもスパンの処理は完了するよう、
try〜finallyで囲んでいる。
子スパン部分は仕方がないとはいえ、前後に多めに計装のためのコードが入るので、1つのファイルならまだしも、ほかにもページごとのファイルがあったら辛くなりそうだ。
ともあれ、どうなったか見てみよう。

NGINXのスパンの子スパンとなり、それらしいトレースになった。昨日β版がリリースされたばかりのサービスマップでも見てみよう。

試行錯誤(3):データベースアクセスの自動計装
データベースの箇所の計装をしていなかったので、この段階ではPHPコードのところにメインルーチンと子処理の2つのスパンしかない。実際の運用では、データベースのやりとりがどう発生しているかは、パフォーマンスや障害を確認する際に重要な役割になるだろう。
データベースのやりとりの計装であれば、ようやくゼロコード計装の恩恵を受けられる。open-telemetry/opentelemetry-auto-mysqliライブラリをインストールすると、MariaDB(MySQL)データベースとのやりとりに関する命令に対して、自動で計装が施される。
composer require open-telemetry/opentelemetry-auto-mysqli
www.confではenv[OTEL_PHP_AUTOLOAD_ENABLED] = "true"を設定済みなので、そのまま有効になるはずだ。

計画どおり。データベースのクエリのスパンが自動で作られ、操作に関する情報が属性に含まれている。PHPコードでSQL文字列を雑に叩いているためにクエリもだいぶ大胆に出てしまっているが、まっとうな方法で書けばおそらくまっとうに「?」などでマスクされるだろう。さらに先日言及したRedaction Processorを使ってみるのもよいかもしれない。
同様にライブラリをインストールするだけで計装できるものについては、一覧を確認されたい(とはいえ、データベース、I/O、HTTPアクセスまわり程度にはなってしまうだろうが)。
試行錯誤(4):auto_prepend_fileとauto_append_fileによる計装の隠蔽
PHPコードの計装ができてめでたしめでたし…ではなく、「既存のPHPコードの修正は最小限にとどめたい」が達成できていない。
AIと会話していたところ、auto_prepend_fileとauto_append_fileでそれぞれコードの前・後にその内容を注入できるらしい。
auto_prepend_file = <パス>/prepend.php auto_append_file = <パス>/append.php
試してみたところ、prependファイル側をtry {で終わらせるという書き方はできないようだ(evalを使えばできるという提案をAIにされたがそれはちょっと…)。try〜catchが使えないため、全体で発生した例外を拾うための処理もprependに書くようにしていくと、結果的にappendでやることはなくなった。
この時点でのprepend.phpは以下のとおりだ。
<?php use OpenTelemetry\SDK\Trace\TracerProviderFactory; // use OpenTelemetry\API\Trace\Propagation\TraceContextPropagator; // use OpenTelemetry\Context\Propagation\ArrayAccessGetterSetter; require __DIR__ . '/vendor/autoload.php'; $factory = new TracerProviderFactory(); $tp = $factory->create(); $tracer = $tp->getTracer('trace-tutorial'); $headers = getallheaders(); $propagator = TraceContextPropagator::getInstance(); $getter = ArrayAccessGetterSetter::getInstance(); $ctx = $propagator->extract($headers, $getter); $rootSpan = $tracer->spanBuilder($_SERVER['SCRIPT_NAME'])->setParent($ctx)->setSpanKind(OpenTelemetry\API\Trace\SpanKind::KIND_SERVER)->startSpan(); $scope = $rootSpan->activate(); $rootSpan->setAttribute('http.target', $_SERVER['REQUEST_URI']); $rootSpan->setAttribute('http.route', $_SERVER['DOCUMENT_URI']); $rootSpan->setAttribute('http.user_agent', $_SERVER['HTTP_USER_AGENT']); $rootSpan->setAttribute('http.method', $_SERVER['REQUEST_METHOD']); $rootSpan->setAttribute('net.sock.peer.addr', $_SERVER['REMOTE_ADDR']); set_exception_handler(function (Throwable $e) use ($rootSpan) { echo "An unexpected error occurred: " . $e->getMessage(); $rootSpan->setStatus(\OpenTelemetry\API\Trace\StatusCode::STATUS_ERROR, $e->getMessage()); $rootSpan->recordException($e, ['exception.escaped' => true]); throw $e; }); register_shutdown_function(function() use ($rootSpan, $scope, $tp) { $rootSpan->end(); $scope->detach(); $tp->shutdown(); echo "Finished.\n"; });
app.phpの冒頭の計装設定をおおむねそのまま流用している。- 複数のファイルから使われることを想定して、
spanBuilderでのスパン名はPHPファイル名を取り込むようにしている。 $_SERVER配列に入っている情報をスパン属性にしてみた。PHPのこの手のは昔のイメージでどうにも不安があるのだが…まぁほかの計装ライブラリもそんなに変わらないだろう。setSpanKindでKINDをINTERNALではなくSERVERにしてみる。MackerelのAPM画面のHTTPサーバータブに出そうと思ったのだが、まだ出ておらず、ほかに属性値が必要そうだ。http.status_codeはこの時点では決まっていないので難しい。set_exception_handlerでコード内で発生した例外を集めている。スパンのExceptionにバックトレース込みで入れるようにしてみている。register_shutdown_functionでfinally相当のクロージング作業をさせている。
このprepend.phpを前提としたcenter.phpは以下のようになる。
<?php echo "<h1>Hello from PHP</h1>"; sleep(1); $childSpan = $tracer->spanBuilder('child_process')->startSpan(); try { $mysqli = new mysqli('localhost', 'myuser', 'mypass', 'mydb'); if ($mysqli->connect_errno) { die("MySQL connection failed: " . $mysqli->connect_error); } $sql = "SELECT id, name FROM fruits"; $result = $mysqli->query($sql); if ($result) { echo "<h1>Fruits Table</h1>"; echo "<ul>"; while ($row = $result->fetch_assoc()) { echo "<li>{$row['id']}: {$row['name']}</li>"; } echo "</ul>"; $result->free(); } else { echo "Query error: " . $mysqli->error; } $mysqli->close(); sleep(2); } finally { $childSpan->end(); sleep(1); }
計装前のapp.phpに子スパンの設定を手動で加えた理想形に近いのではないだろうか。
例外(たとえば$a = 1 / 0をどこかに書いてみる)も良い感じに表現できる。

ここまでで必要な情報を属性に持たせたので、NGINXスパンやそれを親にする必要がなくなった。コンテキストの設定を外すことにする。
$rootSpan = $tracer->spanBuilder($_SERVER['SCRIPT_NAME'])->setSpanKind(OpenTelemetry\API\Trace\SpanKind::KIND_SERVER)->startSpan();
そういえば、$_SERVERを眺めていたら、PHPエンジンに設定したOTEL_EXPORTER_OTLP_ENDPOINTなどの環境変数も(当然ながら)そこに出てきていた。OTel Collectorを介さずMackerelに直接送信したいときにはOTEL_EXPORTER_OTLP_HEADERSにAPIキーを含める手順になっているが、$_SERVERがうっかり露出したときにはAPIキー文字列が丸々露出することになるので、やはりOTel Collectorを介するのが安全そうだ。
あとがき
PHP3〜PHP5の頃にはいろいろ濫造していたものじゃ…という経験はあまり役に立たなかったなぁ。