OpenTelemetry for PHPでディープヘルスチェックがトレースされないようSamplerを自作してみた

OpenTelemetry for PHPでディープヘルスチェックがトレースされないようSamplerを自作してみた

Clock Icon2025.02.19

リテールアプリ共創部@大阪の岩田です。Opentelemetryでトレースを仕込む際、ヘルスチェック用エンドポイントへのアクセスはトレース対象外にしたいという要件はあるあるだと思います。以前関わった案件ではOTEL CollectorのFilter Processorを利用してこの要件を実現していました。しかし、今関わっている案件ではDBアクセスを伴ういわゆるディープヘルスチェックが実装されており、このディープヘルスチェック用のエンドポイントに対して定期的なアクセスが発生します。単にFilter Processorを使ってHTTPのエンドポイントだけでフィルタすると、子スパンであるDBアクセス処理は依然としてトレース対象となってしまいます。

Tail Sampling Processorを使ってサンプリングしても良いのですがOTEL Collectorが複数サービスに紐づいている場合、システムAのヘルスチェック用エンドポイントは/healthでシステムBは/health-checkでシステムCは/healthcheckで...

となると設定がカオスになっていきます。また、微々たるレベルとはいえOTEL Collector側に負荷をかけることになるのも気になります。

そこで今回はディープヘルスチェックに関して親のスパン・子のスパン共にトレース対象外とするようSDK側でSamplerを自前実装してみました。

環境

今回利用した環境は以下の通りです。

  • PHP: 8.1.15
  • 各種ライブラリ
    • laravel/framework: v10.48.28
    • open-telemetry/api: 1.2.2
    • open-telemetry/exporter-otlp: 1.2.0
    • open-telemetry/opentelemetry-auto-laravel: 1.0.1
    • open-telemetry/sdk: 1.2.2
    • OpenTelemetry auto-instrumentation extension: 1.1.2
  • Dockerイメージ
    • jaegertracing/all-in-one: 1.66.0
    • otel/opentelemetry-collector: 0.119.0

やってみる

今回対象となるのはLaravel製のアプリケーションです。Laravelアプリにトレース処理を導入する手順は以下のブログでも紹介しているので参考にしてください。

https://dev.classmethod.jp/articles/trace-laravel-on-ec2-by-opentelemetry/

まずは普通にトレース処理を実装

まずはinstrument.phpにトレース関連の処理を記述します。

instrument.php
<?php

use OpenTelemetry\API\Trace\Propagation\TraceContextPropagator;
use OpenTelemetry\Contrib\Otlp\SpanExporter;
use OpenTelemetry\SDK\Common\Attribute\Attributes;
use OpenTelemetry\SDK\Common\Export\Http\PsrTransportFactory;
use OpenTelemetry\SDK\Resource\ResourceInfo;
use OpenTelemetry\SDK\Resource\ResourceInfoFactory;
use OpenTelemetry\SDK\Sdk;
use OpenTelemetry\SDK\Trace\SpanProcessor\BatchSpanProcessor;
use OpenTelemetry\SDK\Trace\TracerProvider;
use OpenTelemetry\SemConv\ResourceAttributes;

require __DIR__.'/../vendor/autoload.php';

$resource = ResourceInfoFactory::emptyResource()->merge(ResourceInfo::create(Attributes::create([
    ResourceAttributes::SERVICE_NAMESPACE => 'demo',
    ResourceAttributes::SERVICE_NAME => 'test-application',
    ResourceAttributes::SERVICE_VERSION => '0.1',
    ResourceAttributes::DEPLOYMENT_ENVIRONMENT_NAME => 'development',
])));
$spanExporter = new SpanExporter(
    (new PsrTransportFactory())->create('http://host.docker.internal:4318/v1/traces', 'application/json')
);

$tracerProvider = TracerProvider::builder()
    ->addSpanProcessor(
        BatchSpanProcessor::builder($spanExporter)->build()
    )
    ->setResource($resource)
    ->build();

Sdk::builder()
    ->setTracerProvider($tracerProvider)
    ->setPropagator(TraceContextPropagator::getInstance())
    ->setAutoShutdown(true)
    ->buildAndRegisterGlobal();

index.phpから上記のinstrument.phpをrequireします。

public/index.php
<?php

use Illuminate\Contracts\Http\Kernel;
use Illuminate\Http\Request;

define('LARAVEL_START', microtime(true));
require_once './instrument.php';
...

Docker Composeを使ってOTEL CollectorとJaegerを起動します。docker-compose.yamlは以下の通りです。

docker-compose.yaml
services:
  jaeger:
    image: jaegertracing/all-in-one
    ports:
      - '16686:16686'
    # 開発中にトレースデータを確認したい場合はdocker-composeの--profileにtraceを指定する
    profiles:
      - trace
  otel-collector:
    image: otel/opentelemetry-collector
    ports:
      - '4318:4318'
    command: ['--config=/etc/otel-collector-config.yaml']
    volumes:
      - ./otel-collector-config.yaml:/etc/otel-collector-config.yaml
    # 開発中にトレースデータを確認したい場合はdocker-composeの--profileにtraceを指定する
    profiles:
      - trace

本題から逸れますが、実際のdocker-compose.yamlには他にも色々なサービスを定義しているのでOTEL関連のサービスにはtraceというプロファイルを設定して必要なときだけ起動するようにしています。

バインドマウントしている設定ファイルotel-collector-config.yamlの中身は以下の通りです。

otel-collector-config.yaml
receivers:
  otlp:
    protocols:
      http:
        endpoint: 0.0.0.0:4318
processors:
  batch/traces:
    timeout: 1s
    send_batch_size: 50
  filter:
    traces:
      span:
        - 'attributes["url.path"] == "api/health-check"'
exporters:
  otlp/jaeger:
    endpoint: 'jaeger:4317'
    tls:
      insecure: true
service:
  pipelines:
    traces:
      receivers: [otlp]
      processors: [batch/traces]
#      processors: [filter,batch/traces]
      exporters: [otlp/jaeger]

docker compose --profile trace up でOTEL CollectorとJaegerを起動したらヘルスチェック用エンドポイントにアクセスし、JaegerのUIを確認してみましょう。

特に何も設定していない状態でのトレース結果

普通にHTTPアクセスとDBアクセスの両方がトレースされています。

Filter Processorでヘルスチェック用エンドポイントへのアクセスをフィルタ

今度はOTEL CollectorのFilter Processorでヘルスチェック用エンドポイントへのアクセスをフィルタしてみましょう。processorsのコメントアウト箇所を入れ替えFilter Processorを通すようにします。

#      processors: [batch/traces]
      processors: [filter,batch/traces]

OTEL Collectorを再起動してヘルスチェック用エンドポイントにアクセスした後、JaegerのUIを確認します。

Filter Processor設定後のトレース結果

ヘルスチェック用エンドポイントへのHTTPアクセスはトレースされていませんが、子スパンのDBアクセス処理がトレースされています。これはノイズになるのでトレース対象外としたいところです...

Samplerを実装する

ということでPHP側にSamplerを実装してみます。ブログ執筆時点ではOTELの公式ドキュメントでPHPのSDKにはSamplingのセクションが存在しませんが普通に実装できました。

OTELの公式ドキュメント

まずSamplerInterfaceを実装する独自のクラスを定義します。クラスの全体は以下の通りです。

class MySampler implements SamplerInterface {
    public function shouldSample(
        ContextInterface    $parentContext,
        string              $traceId,
        string              $spanName,
        int                 $spanKind,
        AttributesInterface $attributes,
        array               $links,
    ): SamplingResult {
        $parentSpan = Span::fromContext($parentContext);
        $parentSpanContext = $parentSpan->getContext();
        $traceState = $parentSpanContext->getTraceState();

        if (str_starts_with($attributes->get('url.path'), 'api/health-check')) {
            return new SamplingResult(
                SamplingResult::DROP,
                [],
                $traceState
            );
        }

        return new SamplingResult(
            SamplingResult::RECORD_AND_SAMPLE,
            [],
            $traceState
        );
    }

    public function getDescription(): string
    {
        return 'MySampler';
    }
}

スパンごとにshouldSampleが呼び出されるので、$attributesからurl.pathを取得し、URLが'api/health-check'から始まる場合はSamplingResultのコンストラクタ引数にSamplingResult::DROPを渡してインスタンスを生成&returnします。これでヘルスチェック用エンドポイントへのアクセスはサンプリング対象外となります。

このMySamplerクラスのインスタンスをParentBasedのコンストラクタ引数に渡してParentBasedクラスのインスタンスを生成、生成したインスタンスをSdkBuilderクラスのsetSamplerに渡します。

$tracerProvider = TracerProvider::builder()
    ->addSpanProcessor(
        BatchSpanProcessor::builder($spanExporter)->build()
    )
    ->setResource($resource)
    ->setSampler(new ParentBased(new MySampler()))
    ->build();

サンプリング用のクラスとしてParentBasedクラスを指定することで親スパンのサンプリング有無を子スパンに継承できます。これによって、サンプリング対象外であるヘルスチェック用エンドポイントへのアクセスの子スパンであるDBアクセス処理もサンプリング対象外となります。

修正後のinstrument.phpの全体像です。使い捨てなのでMySamplerクラスも詰め込んでますが、実際に利用する際は適宜整理してください。

instrument.php
<?php

use OpenTelemetry\API\Trace\Propagation\TraceContextPropagator;
use OpenTelemetry\Context\ContextInterface;
use OpenTelemetry\Contrib\Otlp\SpanExporter;
use OpenTelemetry\SDK\Common\Attribute\Attributes;
use OpenTelemetry\SDK\Common\Attribute\AttributesInterface;
use OpenTelemetry\SDK\Common\Export\Http\PsrTransportFactory;
use OpenTelemetry\SDK\Resource\ResourceInfo;
use OpenTelemetry\SDK\Resource\ResourceInfoFactory;
use OpenTelemetry\SDK\Sdk;
use OpenTelemetry\SDK\Trace\Sampler\ParentBased;
use OpenTelemetry\SDK\Trace\SamplerInterface;
use OpenTelemetry\SDK\Trace\SamplingResult;
use OpenTelemetry\SDK\Trace\Span;
use OpenTelemetry\SDK\Trace\SpanProcessor\BatchSpanProcessor;
use OpenTelemetry\SDK\Trace\TracerProvider;
use OpenTelemetry\SemConv\ResourceAttributes;

require __DIR__.'/../vendor/autoload.php';

class MySampler implements SamplerInterface {
    public function shouldSample(
        ContextInterface    $parentContext,
        string              $traceId,
        string              $spanName,
        int                 $spanKind,
        AttributesInterface $attributes,
        array               $links,
    ): SamplingResult {
        $parentSpan = Span::fromContext($parentContext);
        $parentSpanContext = $parentSpan->getContext();
        $traceState = $parentSpanContext->getTraceState();

        if (str_starts_with($attributes->get('url.path'), 'api/health-check')) {
            return new SamplingResult(
                SamplingResult::DROP,
                [],
                $traceState
            );
        }

        return new SamplingResult(
            SamplingResult::RECORD_AND_SAMPLE,
            [],
            $traceState
        );
    }

    public function getDescription(): string
    {
        return 'MySampler';
    }
}

$resource = ResourceInfoFactory::emptyResource()->merge(ResourceInfo::create(Attributes::create([
    ResourceAttributes::SERVICE_NAMESPACE => 'demo',
    ResourceAttributes::SERVICE_NAME => 'test-application',
    ResourceAttributes::SERVICE_VERSION => '0.1',
    ResourceAttributes::DEPLOYMENT_ENVIRONMENT_NAME => 'development',
])));
$spanExporter = new SpanExporter(
    (new PsrTransportFactory())->create('http://localhost:4318/v1/traces', 'application/json')
);

$tracerProvider = TracerProvider::builder()
    ->addSpanProcessor(
        BatchSpanProcessor::builder($spanExporter)->build()
    )
    ->setResource($resource)
    ->setSampler(new ParentBased(new MySampler()))
    ->build();

Sdk::builder()
    ->setTracerProvider($tracerProvider)
    ->setPropagator(TraceContextPropagator::getInstance())
    ->setAutoShutdown(true)
    ->buildAndRegisterGlobal();

これで準備が整ったので、再度ヘルスチェック用エンドポイントにアクセスしてJaegerのUIを確認してみましょう。

独自Sampler実装後のトレース結果

何も表示されません。無事に子スパン含めてヘルスチェック用エンドポイントへのアクセスがサンプリング対象外となったようです。

まとめ

サンプリングのロジックを自前で実装する必要はありますが、OTEL Collector側でサンプリングするよりも柔軟に制御できそうなところは良いですね。要件に合わせてうまく使い分けていきたいです。

参考

Share this article

facebook logohatena logotwitter logo

© Classmethod, Inc. All rights reserved.