🔍

PHP自動計装ライブラリで学ぶOpenTelemetryのTrace

に公開

Social Databank Advent Calendar 2025 の9日目です。

概要

PHPにおける自動計装ライブラリのコードリーディングを通して、PHPにおけるTrace計装の手法を学ぶ。

基礎概念

Observability入門 で説明されている スパン をPHPに導入するのがOpenTelemetryライブラリの主な役割である。
トレース の実現のため、トレーサー を使って、スパンを作成する。
スパンには イベント属性スパン種別 を登録できる。

手動計装

OpenTelemetrySDKを使った手動計装の例 を見よう。

use OpenTelemetry\API\Globals;
use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request;
use Slim\Factory\AppFactory;

// トレーサーの取得
$tracer = Globals::tracerProvider()->getTracer('demo');

$app = AppFactory::create();
// API定義
$app->get('/rolldice', function (Request $request, Response $response) use ($tracer) {
    // スパンの開始
    $span = $tracer
        ->spanBuilder('manual-span')
        ->startSpan();
    // APIの処理
    $result = random_int(1,6);
    $response->getBody()->write(strval($result));
    // スパンへのイベント追加・終了
    $span
        ->addEvent('rolled dice', ['result' => $result])
        ->end();
    // APIレスポンス
    return $response;
});

rolldiceAPIに対して、API処理の前後でスパンを設定し、イベントを追加したことが読み取れる。

PHPの自動計装拡張(ext-opentelemetry)

PHP zero-code instrumentation にて自動計装の原理が説明されている。
自動計装にはpeclなどからの PHP拡張機能としてのopentelemetry を導入する必要がある。
この拡張の説明を引用すると

PHP8で導入された zend_observer を元にした、自動計装用のPHP拡張です。
任意のPHPメソッドへprepostフックを追加し、計測ができます。
アトリビュートによるメソッドへの計測の追加もできます。

この拡張によりhookメソッド、#[WithSpan]アトリビュート、#[SpanAttribute]アトリビュートが追加される。

hookメソッドによる計測例

hookメソッドは以下のインターフェースを持っている。

namespace OpenTelemetry\Instrumentation;

/**
 * @param string|null $class フックするクラス名
 * @param string $function フックするメソッド名
 * @param Closure|null $pre メソッド呼び出し前に行う処理、引数を変更することもできる
 * @param Closure|null $post メソッド呼び出し後に行う処理、返り値を変更することもできる
 * @return bool フック追加が成功したかどうか
 */
function hook(
    string|null $class,
    string $function,
    ?Closure $pre = null,
    ?Closure $post = null,
): bool {}

PHP zero-code instrumentation の測定例を引用すると、

/* 計測するクラス */
class DemoClass
{
    public function run(): void
    {
        echo 'Hello, world';
    }
}

/* 自動計装コード */
OpenTelemetry\Instrumentation\hook(
    class: DemoClass::class,
    function: 'run',
    pre: static function (DemoClass $demo, array $params, string $class, string $function, ?string $filename, ?int $lineno) {
        // トレーサーの取得
        static $instrumentation;
        $instrumentation ??= new CachedInstrumentation('example');
        $tracer = $instrumentation->tracer();
        // スパンの開始
        $span = $tracer->spanBuilder('democlass-run')->startSpan();
        // コンテキストにスパンを追加
        $context = $span->storeInContext(Context::getCurrent());
        $scope = Context::storage()->attach($context);
    },
    post: static function (DemoClass $demo, array $params, $returnValue, ?Throwable $exception) {
        // コンテキストからスパンを取り出す
        $scope = Context::storage()->scope();
        $scope->detach();
        $context = $scope->context();
        $span = Span::fromContext($context);
        if ($exception) {
            // スパンへの例外の記録
            $span->recordException($exception);
            $span->setStatus(StatusCode::STATUS_ERROR);
        }
        // スパンの終了
        $span->end();
    }
);

/* コードを実行すると、トレースが生成される。 */
$demo = new DemoClass();
$demo->run();

rolldiceAPIの場合と同様に、トレーサー経由でスパンを開始していることが読み取れる。

CachedInstrumentation

CachedInstrumentation は、TracerInterfaceへのアクセスを提供している。

namespace OpenTelemetry\API\Instrumentation;

/**
 * キャッシュされたTracerInterfaceへのアクセスを提供します。
 *
 * 自動計装ではGlobalsクラスから都度取得するよりも、このクラスを使ったほうがよいでしょう
 */
final class CachedInstrumentation
{
    /** @var WeakMap<TracerProviderInterface, TracerInterface> */
    private WeakMap $tracers;

    public function __construct(
        private readonly string $name,
        private readonly ?string $version = null,
        private readonly ?string $schemaUrl = null,
        private readonly iterable $attributes = [],
    ) {
        $this->tracers = new \WeakMap();
    }

    public function tracer(): TracerInterface
    {
        $tracerProvider = Globals::tracerProvider();
        return $this->tracers[$tracerProvider] ??= $tracerProvider->getTracer($this->name, $this->version, $this->schemaUrl, $this->attributes);
    }
}

PHPにおけるコンテキストの伝搬

PHP execution context からPHPにおける コンテキストの伝搬 についての説明を引用する、

ContextAPIはグローバルに呼び出すことができます。
アクティブなコンテキストは1つしか存在できず、Context::getCurrent()で取得できます。

コンテキストはスパンなどの値を保持でき、Storageを使用して保持した値を追跡します

コンテキストは $context->activate() でアクティブにできます。
$context->activate() の戻り値はスコープです。
スコープを detach() するとコンテキストが非アクティブ化され、1つ前のコンテキストが再アクティブ化されます。

コンテキストをアクティブ化するくだりが、DemoClassのコードと乖離しているが、
$context->activate()の実態Storageへのattachである。

namespace OpenTelemetry\Context;

final class Context implements ContextInterface
{
    public function activate(): ScopeInterface
    {
        $scope = self::storage()->attach($this);
        assert(self::debugScopesDisabled() || $scope = new DebugScope($scope));
        return $scope;
    }
}

preフックでは新たにスパンを作成し、コンテキストへ追加しアクティブにする。
postフックではコンテキストを取り出し非アクティブにして、スパンを終了することで測定の区間を定義している。

#[WithSpan]アトリビュート

namespace OpenTelemetry\API\Instrumentation;

/**
 * 指定したメソッドへ、
 * opentelemetry.attr_pre_handler_function, opentelemetry.attr_post_handler_function
 * で指定したフックを設定する
 */
#[Attribute(Attribute::TARGET_FUNCTION|Attribute::TARGET_METHOD)]
final class WithSpan
{
    /**
     * @param string|null $span_name スパン名、デフォルトはメソッド名
     * @param int|null $span_kind スパン種別、デフォルトはINTERNAL
     * @param array $attributes スパンに追加する属性
     */
    public function __construct(
        public readonly ?string $span_name = null,
        public readonly ?int $span_kind = null,
        public readonly array $attributes = [],
    ) {
    }
}

/**
 * #[WithSpan]が付与されたメソッドのスパンに、
 * このアトリビュートを付与した引数を属性として追加します。
 */
#[Attribute(Attribute::TARGET_PARAMETER | Attribute::TARGET_PROPERTY)]
final class SpanAttribute
{
    /**
     * @param string|null $name 属性名、デフォルトは引数名
     */
    public function __construct(
        public readonly ?string $name = null,
    ) {
    }
}

opentelemetry.attr_pre_handler_functionにデフォルトで設定されたフックはOpenTelemetry\API\Instrumentation\WithSpanHandler::preである。

OpenTelemetry\API\Instrumentation\WithSpanHandler を見よう。

namespace OpenTelemetry\API\Instrumentation;

class WithSpanHandler
{
    public static function pre(mixed $target, array $params, ?string $class, string $function, ?string $filename, ?int $lineno, ?array $span_args = [], ?array $attributes = []): void
    {
        static $instrumentation;
        $instrumentation ??= new CachedInstrumentation(name: 'io.opentelemetry.php.with-span', schemaUrl: 'https://opentelemetry.io/schemas/1.25.0');

        $name = $span_args['name'] ?? null;
        $kind = $span_args['span_kind'] ?? SpanKind::KIND_INTERNAL;

        $span = $instrumentation
            ->tracer()
            ->spanBuilder($name)
            ->setSpanKind($kind)
            ->setAttribute('code.function', $function)
            ->setAttribute('code.namespace', $class)
            ->setAttribute('code.filepath', $filename)
            ->setAttribute('code.lineno', $lineno)
            ->setAttributes($attributes ?? [])
            ->startSpan();
        $context = $span->storeInContext(Context::getCurrent());
        Context::storage()->attach($context);
    }

    public static function post(mixed $target, array $params, mixed $result, ?Throwable $exception): void
    {
        $scope = Context::storage()->scope();
        $scope?->detach();

        if (!$scope || $scope->context() === Context::getCurrent()) {
            return;
        }

        $span = Span::fromContext($scope->context());
        if ($exception) {
            $span->recordException($exception, ['exception.escaped' => true]);
            $span->setStatus(StatusCode::STATUS_ERROR, $exception->getMessage());
        }

        $span->end();
    }
}

DemoClassの例とほぼ同様の流れでpre/postフックが設定されていることが読み取れる。

Laravelの自動計装

open-telemetry/opentelemetry-auto-laravel を見てみよう。
計装が有効な場合、LaravelInstrumentation を呼び出してLaravelの各メソッドにフックを仕込んでいる。

namespace OpenTelemetry\Contrib\Instrumentation\Laravel;

class LaravelInstrumentation
{
    public const NAME = 'laravel';
    public static function register(): void
    {
        $instrumentation = new CachedInstrumentation(
            'io.opentelemetry.contrib.php.laravel',
            null,
            'https://opentelemetry.io/schemas/1.32.0',
        );

        Hooks\Illuminate\Console\Command::hook($instrumentation);
        Hooks\Illuminate\Contracts\Console\Kernel::hook($instrumentation);
        Hooks\Illuminate\Contracts\Http\Kernel::hook($instrumentation);
        Hooks\Illuminate\Contracts\Queue\Queue::hook($instrumentation);
        Hooks\Illuminate\Foundation\Application::hook($instrumentation);
        Hooks\Illuminate\Foundation\Console\ServeCommand::hook($instrumentation);
        Hooks\Illuminate\Queue\SyncQueue::hook($instrumentation);
        Hooks\Illuminate\Queue\Queue::hook($instrumentation);
        Hooks\Illuminate\Queue\Worker::hook($instrumentation);
        Hooks\Illuminate\Database\Eloquent\Model::hook($instrumentation);
    }
}

アプリの基本的なライフサイクルの他、キューやモデルにもフックを仕込んでいることが読み取れる。

身近な Model::findへのフック を見よう

hook(
    \Illuminate\Database\Eloquent\Builder::class,
    'find',
    pre: function ($builder, array $params, string $class, string $function, ?string $filename, ?int $lineno) {
        $model = $builder->getModel();
        $builder = $this->instrumentation
            ->tracer()
            ->spanBuilder($model::class . '::find')
            ->setSpanKind(SpanKind::KIND_INTERNAL)
            ->setAttribute(TraceAttributes::CODE_FUNCTION_NAME, sprintf('%s::%s', $class, $function))
            ->setAttribute(TraceAttributes::CODE_FILE_PATH, $filename)
            ->setAttribute(TraceAttributes::CODE_LINE_NUMBER, $lineno)
            ->setAttribute('laravel.eloquent.model', $model::class)
            ->setAttribute('laravel.eloquent.table', $model->getTable())
            ->setAttribute('laravel.eloquent.operation', 'find');

        $parent = Context::getCurrent();
        $span = $builder->startSpan();
        Context::storage()->attach($span->storeInContext($parent));

        return $params;
    },
    post: function ($builder, array $params, $result, ?Throwable $exception) {
        $scope = Context::storage()->scope();
        if (!$scope) {
            return;
        }
        $scope->detach();
        $span = Span::fromContext($scope->context());
        if ($exception) {
            $span->recordException($exception);
            $span->setStatus(StatusCode::STATUS_ERROR, $exception->getMessage());
        }
        $span->end();
    }
);

モデルのクラス名や、アクセスするテーブルの情報をスパンの属性として登録以外は、
先述の例たちと全く同様の手順を踏んでいることがわかる。

Watcherによるイベントの記録

LaravelのイベントはWatcherと呼ばれるクラス群によって購読され、OpenTelemetryのイベントとしてスパンに登録される

Laravelの Application の起動にフックしWatcherを登録 する。

namespace OpenTelemetry\Contrib\Instrumentation\Laravel\Hooks\Illuminate\Foundation;

class Application implements LaravelHook
{
    use LaravelHookTrait;

    public function instrument(): void
    {
        hook(
            FoundationalApplication::class,
            '__construct',
            post: function (FoundationalApplication $application, array $_params, mixed $_returnValue, ?Throwable $_exception) {
                $this->registerWatchers($application, new CacheWatcher());
                $this->registerWatchers($application, new ClientRequestWatcher($this->instrumentation));
                $this->registerWatchers($application, new ExceptionWatcher());
                $this->registerWatchers($application, new LogWatcher($this->instrumentation));
                $this->registerWatchers($application, new QueryWatcher($this->instrumentation));
                $this->registerWatchers($application, new RedisCommandWatcher($this->instrumentation));
            },
        );
    }
}

例として CacheWatcher を見よう

namespace OpenTelemetry\Contrib\Instrumentation\Laravel\Watchers;

class CacheWatcher extends Watcher
{
    public function register(Application $app): void
    {
        $app['events']->listen(CacheHit::class, [$this, 'recordCacheHit']);
    }

    public function recordCacheHit(CacheHit $event): void
    {
        $this->addEvent('cache hit', [
            'key' => $event->key,
            'tags' => json_encode($event->tags),
        ]);
    }

    private function addEvent(string $name, iterable $attributes = []): void
    {
        $scope = Context::storage()->scope();
        if (!$scope) {
            return;
        }
        $span = Span::fromContext($scope->context());
        $span->addEvent($name, $attributes);
    }
}

例として CacheHit イベントを購読し、コンテキストから得たアクティブなスパンに対しイベントとして追加していることが読み取れる。

キューにおけるプロセス外へのコンテキスト伝搬

キューへの投入にフックするクラス を見よう

namespace OpenTelemetry\Contrib\Instrumentation\Laravel\Hooks\Illuminate\Queue;

use Illuminate\Queue\Queue as AbstractQueue;
use OpenTelemetry\API\Trace\Propagation\TraceContextPropagator;

class Queue implements LaravelHook
{
    protected function hookAbstractQueueCreatePayloadArray(): bool
    {
        return hook(
            AbstractQueue::class,
            'createPayloadArray',
            post: function (AbstractQueue $_queue, array $_params, array $payload, ?Throwable $_exception): array {
                TraceContextPropagator::getInstance()->inject($payload);
                return $payload;
            },
        );
    }
}

createPayloadArray はジョブのペイロードを定義するメソッドである
たいていの場合 createObjectPayload として CallQueuedHandler@call のペイロードに変換される

TraceContextPropagator という コンテキスト伝搬 用のクラスを使い、ペイロードを加工している。

OpenTelemetryでは W3C TraceContext の形式が用いられて おり、traceparenttracestate の2つのパラメータでコンテキストを伝搬する。

namespace OpenTelemetry\API\Trace\Propagation;

/**
 * W3C TraceContext の形式に則ったコンテキスト伝搬用のクラス
 * (https://www.w3.org/TR/trace-context/)
 *
 * トレースが壊れないように、このクラスはtraceparentとtracestateヘッダを付与します。 
 * このクラスを使用する場合、traceparentとtracestateヘッダを使ったトレースの設計に責任を持つことになります。
 */
final class TraceContextPropagator implements TextMapPropagatorInterface
{
    public const TRACEPARENT = 'traceparent';
    public const TRACESTATE = 'tracestate';

    public function inject(&$carrier, ?PropagationSetterInterface $setter = null, ?ContextInterface $context = null): void
    {
        $setter ??= ArrayAccessGetterSetter::getInstance();
        $context ??= Context::getCurrent();
        $spanContext = Span::fromContext($context)->getContext();

        // traceparentヘッダの作成と注入
        $traceparent = self::VERSION . '-' . $spanContext->getTraceId() . '-' . $spanContext->getSpanId() . '-' . ($spanContext->isSampled() ? '01' : '00');
        $setter->set($carrier, self::TRACEPARENT, $traceparent);

        // tracestateヘッダの注入
        // 値が空の場合はヘッダを作成しないことが決められています
        if (($tracestate = (string) $spanContext->getTraceState()) !== '') {
            $setter->set($carrier, self::TRACESTATE, $tracestate);
        }
    }

    public function extract($carrier, ?PropagationGetterInterface $getter = null, ?ContextInterface $context = null): ContextInterface
    {
        $getter ??= ArrayAccessGetterSetter::getInstance();
        $context ??= Context::getCurrent();

        $spanContext = self::extractImpl($carrier, $getter);
        return $context->withContextValue(Span::wrap($spanContext));
    }

    private static function extractImpl($carrier, PropagationGetterInterface $getter): SpanContextInterface
    {
        $traceparent = $getter->get($carrier, self::TRACEPARENT);
        // traceParent = {version}-{trace-id}-{parent-id}-{trace-flags}
        $pieces = explode('-', $traceparent);
        [$version, $traceId, $spanId, $traceFlags] = $pieces;
        // Tracestate = 'Vendor1=Value1,...,VendorN=ValueN'
        $rawTracestate = $getter->get($carrier, self::TRACESTATE);
        $tracestate = new TraceState($rawTracestate);
        return SpanContext::createFromRemoteParent(
            $traceId,
            $spanId,
            $isSampled ? TraceFlags::SAMPLED : TraceFlags::DEFAULT,
            $tracestate
        );
    }
}

inject では $carrier という配列と配列アクセスのインターフェースを備えたものに対して、traceparenttracestate を追加。
extract では $carrier からtraceparenttracestate を取り出し、

Worker によるキューの取り出し へのフックを見よう

namespace OpenTelemetry\Contrib\Instrumentation\Laravel\Hooks\Illuminate\Queue;

use Illuminate\Queue\Worker as QueueWorker;

class Worker implements LaravelHook
{
    private function hookWorkerProcess(): bool
    {
        return hook(
            QueueWorker::class,
            'process',
            pre: function (QueueWorker $worker, array $params, string $_class, string $_function, ?string $_filename, ?int $_lineno) {
                /** @var Job $job */
                $job = $params[1];
                $parent = TraceContextPropagator::getInstance()->extract(
                    $job->payload(),
                );
                
                $span = $this->instrumentation
                    ->tracer()
                    ->spanBuilder()
                    ->setSpanKind(SpanKind::KIND_CONSUMER)
                    ->setParent($parent)
                    ->startSpan();
                Context::storage()->attach($span->storeInContext($parent));
                return $params;
            },
            post: function (QueueWorker $worker, array $params, $returnValue, ?Throwable $exception) {
                // 略
            },
        );
    }
}

ジョブのペイロードからキュー投入時のコンテキストを親コンテキスト $parent として取得し、新たなコンテキストを作成していることが読み取れる。

まとめ

PHP自動計装ライブラリのコードリーディングを通して、OpenTelemetryのTrace、
つまりコンテキストを使った親子関係を持ったスパンの作成方法を学んだ。
自動計装に対応していないフレームワークや、独自実装のクラスについても、ライブラリを参考に計装を実装できるだろう。

ソーシャルデータバンク テックブログ

Discussion