Java の SPI を活用して OpenTelemetry Java Agent に独自のフィルタリング処理を組み込む

トレースの計装をしている時に頻出の話題として、ヘルスチェックのエンドポイントなど不要なスパンをいかに落とすかという問題がある。今回は Java Agent を使用してゼロコード計装をしている場合に、Java 標準の SPI (Service Provider Interface)を使って特定のスパンを除外する方法を試してみた。

Java 17、OpenTelemetry Java Agent 2.25.0 で動作確認しています。

アプリケーションの構成

Spring Boot アプリケーションに加えて、OpenTelemetry Java Agent に独自の処理を注入するためのプロジェクトを otel-extension というディレクトリに作成する構成。

demo/
├── build.gradle
├── settings.gradle                       # include 'otel-extension' を追加
├── gradle.properties
│
├── src/main/
│   ├── java/com/example/demo/
│   │   ├── controller/
│   │   │   ├── HealthController.java     # 除外したい /health エンドポイント
│   │   │   └── ...
│   │   ├── DemoApplication.java
│   │   └── ...
│   └── resources/
│       └── application.properties
│
├── otel-extension/                       # 新規作成するサブプロジェクト
│   ├── build.gradle
│   └── src/main/
│       ├── java/com/example/extension/
│       │   ├── FilteringSampler.java                # 方法1: Sampler
│       │   ├── FilteringSamplerProvider.java
│       │   ├── FilteringSpanExporter.java           # 方法2: SpanExporter
│       │   └── FilteringSpanExporterProvider.java
│       └── resources/
│           └── META-INF/
│               └── services/
│                   └── io.opentelemetry.sdk.autoconfigure.spi.AutoConfigurationCustomizerProvider
│                     # SPI ファイル(中身は Provider の FQCN)
│
├── opentelemetry-javaagent.jar           # OpenTelemetry Java Agent
│
└── build/libs/
    └── demo-0.0.1-SNAPSHOT.jar           # bootJar で生成される実行可能 JAR

otel-extension/build.gradle

plugins {
    id 'java'
}

java {
    toolchain {
        languageVersion = JavaLanguageVersion.of(17)
    }
}

repositories {
    mavenCentral()
}

dependencies {
    compileOnly 'io.opentelemetry:opentelemetry-sdk:1.54.1'
    compileOnly 'io.opentelemetry:opentelemetry-sdk-extension-autoconfigure-spi:1.54.1'
    compileOnly 'io.opentelemetry.semconv:opentelemetry-semconv:1.39.0'
}

SPI ファイル: otel-extension/src/main/resources/META-INF/services/io.opentelemetry.sdk.autoconfigure.spi.AutoConfigurationCustomizerProvider

SPI ファイルは、Agent が起動時に自動で発見・読み込みするための仕組みで、ファイル名がインターフェースの完全修飾名、中身が実装クラスの完全修飾名になる。今回は、io.opentelemetry.sdk.autoconfigure.spi.AutoConfigurationCustomizerProvider インターフェースの実装クラスの完全修飾名を指定する。

方法1: Sampler による除外

スパンの生成時にサンプリング判定を行い、不要なスパンをそもそも作らない方法。

FilteringSampler.java

package com.example.extension;

import io.opentelemetry.api.common.Attributes;
import io.opentelemetry.api.trace.SpanKind;
import io.opentelemetry.context.Context;
import io.opentelemetry.sdk.trace.data.LinkData;
import io.opentelemetry.sdk.trace.samplers.Sampler;
import io.opentelemetry.sdk.trace.samplers.SamplingResult;
import io.opentelemetry.semconv.UrlAttributes;
import java.util.List;
import java.util.Set;

class FilteringSampler implements Sampler {
  // 除外対象の URL パスを定義
  // https://opentelemetry.io/docs/specs/semconv/registry/attributes/url/#url-path
  private static final Set<String> EXCLUDED_PATHS = Set.of("/health");

  private final Sampler delegate;

  FilteringSampler(Sampler delegate) {
    this.delegate = delegate;
  }

  @Override
  public SamplingResult shouldSample(
      Context parentContext,
      String traceId,
      String name,
      SpanKind spanKind,
      Attributes attributes,
      List<LinkData> parentLinks) {

    String urlPath = attributes.get(UrlAttributes.URL_PATH);
    if (urlPath != null && EXCLUDED_PATHS.contains(urlPath)) {
      return SamplingResult.drop();
    }

    return delegate.shouldSample(parentContext, traceId, name, spanKind, attributes, parentLinks);
  }

  @Override
  public String getDescription() {
    return "FilteringSampler{" + delegate.getDescription() + "}";
  }
}

FilteringSamplerProvider.java

package com.example.extension;

import io.opentelemetry.sdk.autoconfigure.spi.AutoConfigurationCustomizer;
import io.opentelemetry.sdk.autoconfigure.spi.AutoConfigurationCustomizerProvider;
import io.opentelemetry.sdk.trace.samplers.Sampler;

public class FilteringSamplerProvider implements AutoConfigurationCustomizerProvider {

  @Override
  public void customize(AutoConfigurationCustomizer autoConfiguration) {
    autoConfiguration.addTracerProviderCustomizer(
        (tracerProviderBuilder, config) ->
            tracerProviderBuilder.setSampler(
                new FilteringSampler(Sampler.parentBased(Sampler.alwaysOn()))));
  }
}

SPI ファイル

com.example.extension.FilteringSamplerProvider

注意点

Sampler ではスパンが作成される段階(startSpan)で設定された属性(attributes)のみ判定(shouldSample)に使用できる。

SdkSpanBuilder#startSpan

例えば、url.path は以下のようにスパン作成時に属性が設定されるため、Sampler での判定に使用できる。

  1. Instrumenter#doStartImpl が extractor の onStart を呼ぶ
  2. HttpServerAttributesExtractor#onStart が url attributes の extractor を呼ぶ
  3. 最終的に InternalUrlAttributesExtractor#onStart が呼ばれ、url.path が設定される

方法2: SpanExporter による除外

エクスポート時に不要なスパンを除外する方法。スパンの終了後に判定するため、全ての属性が揃った状態でフィルタリングできる。

FilteringSpanExporter.java

package com.example.extension;

import io.opentelemetry.sdk.common.CompletableResultCode;
import io.opentelemetry.sdk.trace.data.SpanData;
import io.opentelemetry.sdk.trace.export.SpanExporter;
import io.opentelemetry.semconv.UrlAttributes;
import java.util.Collection;
import java.util.Set;

class FilteringSpanExporter implements SpanExporter {

  // 除外対象の URL パスを定義
  // https://opentelemetry.io/docs/specs/semconv/registry/attributes/url/#url-path
  private static final Set<String> EXCLUDED_PATHS = Set.of("/health");

  private final SpanExporter delegate;

  FilteringSpanExporter(SpanExporter delegate) {
    this.delegate = delegate;
  }

  @Override
  public CompletableResultCode export(Collection<SpanData> spans) {
    var filtered =
        spans.stream()
            .filter(
                span -> {
                  String path = span.getAttributes().get(UrlAttributes.URL_PATH);
                  return path == null || !EXCLUDED_PATHS.contains(path);
                })
            .toList();
    if (filtered.isEmpty()) {
      return CompletableResultCode.ofSuccess();
    }
    return delegate.export(filtered);
  }

  @Override
  public CompletableResultCode flush() {
    return delegate.flush();
  }

  @Override
  public CompletableResultCode shutdown() {
    return delegate.shutdown();
  }
}

FilteringSpanExporterProvider.java

package com.example.extension;

import io.opentelemetry.sdk.autoconfigure.spi.AutoConfigurationCustomizer;
import io.opentelemetry.sdk.autoconfigure.spi.AutoConfigurationCustomizerProvider;

public class FilteringSpanExporterProvider implements AutoConfigurationCustomizerProvider {

  @Override
  public void customize(AutoConfigurationCustomizer autoConfiguration) {
    autoConfiguration.addSpanExporterCustomizer(
        (exporter, config) -> new FilteringSpanExporter(exporter));
  }
}

SPI ファイル

com.example.extension.FilteringSpanExporterProvider

ビルドと実行

ビルドと Java Agent の準備。

# otel-extension をビルド
./gradlew :otel-extension:build

# Spring Boot アプリをビルド
./gradlew bootJar

# OpenTelemetry Java Agent をダウンロード
curl -L -O https://github.com/open-telemetry/opentelemetry-java-instrumentation/releases/latest/download/opentelemetry-javaagent.jar

logging exporter を使用してコンソールにスパンを出力するようにして実行する。実行時に otel-extension の JAR を指定して起動しているところがポイント。SPI ファイルの中身を書き換えると 2 つの方法を切り替えられる(otel-extension はビルドし直す必要がある点に注意)。

java \
  -javaagent:opentelemetry-javaagent.jar \
  -Dotel.javaagent.extensions=otel-extension/build/libs/otel-extension.jar \
  -Dotel.traces.exporter=logging \
  -Dotel.metrics.exporter=none \
  -Dotel.logs.exporter=none \
  -jar build/libs/demo-0.0.1-SNAPSHOT.jar

この状態でアクセスすると、/health へのリクエストはフィルタリングされてスパンが出ないが、それ以外のエンドポイントは通常通りスパンが出力される。

その他の方法

OpenTelemetry Collector の Filter Processor

Filter Processor なら Collector の設定だけで属性を指定してスパンを除外できる。

Declarative Configuration

環境変数による設定よりも表現力豊かで、言語によらない宣言的な設定方法として Declarative Configuration の仕様策定が進んでいる。ヘルスチェックのエンドポイントを除外する設定は以下のようにシンプルに書ける。

file_format: "1.0-rc.3"

resource:
  attributes:
    - name: service.name
      value: demo

tracer_provider:
  processors:
    - batch:
        exporter:
          otlp_http:
            endpoint: https://otlp-vaxila.mackerelio.com/v1/traces
            headers:
              - name: Accept
                value: "*/*"
              - name: Mackerel-Api-Key
                value: ${MACKEREL_APIKEY}
  sampler:
    rule_based_routing:
      fallback_sampler:
        always_on:
      span_kind: SERVER
      rules:
        - action: DROP
          attribute: url.path
          pattern: /health
java -javaagent:opentelemetry-javaagent.jar \
  -Dotel.experimental.config.file=otel-config.yaml \
  -jar build/libs/demo-0.0.1-SNAPSHOT.jar

現時点では Experimental だけど、将来的には設定方法の主流になっていきそう。