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 だけど、将来的には設定方法の主流になっていきそう。

go-check-css-selector — CSS セレクタで要素の存在をチェックする Mackerel チェックプラグインを作った

2 年前くらいに作っていたけどどこにも書いてなかったのでちょっと整備してブログに書いておく。CRE になって数ヶ月した頃に「そういえば仕組みは理解してるけど自分で Mackerel のチェックプラグインを作ったことがなかったな」と思い立ち作ってみたもの。

github.com

go-check-css-selector は指定された URL にアクセスし、HTML 内に指定した CSS セレクタにマッチする要素が存在するかをチェックするシンプルなプラグイン。セレクタで指定した要素が見つかれば OK、見つからなければ CRITICAL、URL へのアクセスや HTML のパースでエラーが発生したら UNKNOWN を返す。

使い方は以下のような感じ。

[plugin.checks.check-css-selector-sample]
command = ["go-check-css-selector", "-U", "https://example.com", "-S", "div.content"]

実装としては素朴で GET した HTML をパースして要素を探しているだけ。もうちょっと高度なことをやろうとするとヘッドレスブラウザを使う必要が出てきそう。というのはすでにチームメンバーの id:kmuto が Playwright と mkr wrap の組み合わせでやっていた。

Go のアプリケーションとしてチェックプラグインを書く場合は checkers パッケージを使うと本当に簡単に書ける。チェック結果のステータスやメッセージの生成、標準出力への出力も全部やってくれるので、実装はチェックロジックに集中できる。

年末にリポジトリの棚卸しをしていてこのプラグインの存在を思い出したので記事を書いてみている。久しぶりに見返してみたらコードに TODO コメントが残っていたり、GitHub のリリース周りの設定もそういえばしたことがなかったのでついでに整備した。goreleaser はドキュメントを見つつ設定したら特に詰まるところもなかった。今回は tag は手で打ったけど、リリースワークフローをもうちょっと整備すればさらに楽になりそう。Immutable Releases ってやつを有効にするといいと見たのでついでに有効にしてみた。

ものづくりと AI

プログラミングを覚えたてのころ、狂ったように Greasemonkey スクリプトを書いていた。自分の生活を便利にするためのアイデアをシュッと形にできて、ブログに公開するとフィードバックをもらえるというのがうれしかった。greasemonkey カテゴリ の記事を見ると当時の様子が伺える(はてなダイアリーの時のログもそのまま残っていて便利ですね)

現代では AI と一緒にコードを書くことが当たり前になり、高速にアイデアを形にできるようになった。AI にとっかかりのアイデアを形にしてもらってそのコードを読み書きして改良したり、そこからさらに AI に手伝ってもらってアイデアを膨らませつつ物を作っていくという工程を楽しんでいるうちに、これは冒頭に書いたアイデアを形にしていく楽しさに近いなと感じるようになった。いわゆる Vibe Coding と呼ばれるようなアプローチで、AI が書いたコードを読んで理解し手直しする対話の中にも学びがあるし、何より技術的な壁が下がって「作りたいもの」に集中できるのがうれしい。

そんな風にアイデアを形にして作ったものを紹介する。

Mackerel Pricing Calculator

Mackerelの料金を計算するためのツール1。Mackerel の課金体系は、基本部分は従量課金性でわかりやすいのだけど細かい部分で無料枠があったり最低利用料金があったりする。もともとスプレッドシートで料金計算ツールを作っていたりしたけど、より手軽に使えるように Web アプリにしてみたもの。

最初のロジックを作るところから Claude Code に料金ページを読ませて全部任せてから、実際に触ってみて計算ロジックに間違いがないかを確認しつつ手直しを続けている。細かいところが色々間違っていたのだけど、AI の解釈に問題があるという可能性がある一方で、料金ページの方にも曖昧なところがあるということだとも思うので、それはフィードバックしたい。ペライチ HTML かつライブラリを使わずに作るという方針にしているので本当に素朴なコードになっている。

はてなブログ アイキャッチビルダー

はてなブログには有料プランの機能として、自動生成されるアイキャッチ画像を HTML を書くことでカスタマイズできる機能がある。この時に設定する HTML は、はてなブログの管理画面からもプレビューができるのだけど、Web 上に埋め込まれたエディタを使わないといけない。これをローカルでプレビューできるようにするためのツールを作っている。

とりあえず動くところまではできているけど、そもそもの構成が回りくどくなっていたり追加したい機能もあるので改善していきたい。

おまけ: Radar Chart as a Service

任意の軸と点数を設定してレーダーチャートを共有できるサービス。昔同僚の誰かが欲しいと言ってたよなというのを思い出して作ったんだけど、それ以上は特になし。

***

特にアイキャッチビルダーなどは、気づいたら遅い時間までコードをいじっていたという体験を久しぶりにするくらいアイデアを形にすることが楽しかった。新しい技術を学ぶことも好きだが、それは手段であって、作りたいものを形にすることが出発点だ。とりあえず動くものを作って改良していく。AI エージェントのおかげでものづくりに集中できるようになった。


この記事ははてなエンジニア Advent Calendar 2025 33日目の記事です。


  1. 筆者は Mackerel の CRE をしているが、これは個人活動で作った非公式のツール

Cloudflare AI Search で Mackerel ドキュメントを検索するチャットボットを作った

この記事は Mackerel Advent Calendar 2025 20日目の記事です。


最近は日々の生活を便利にするサイトやスクリプトを動かすために Cloudflare の Workers や Cron Triggers を使っている。CLI ツールである Wrangler を使うと気軽にデプロイやリソースの操作ができて便利だし、いい感じにエコシステムが整っていて KV(Key-Value ストア)、D1(SQL データベース)、R2(オブジェクトストレージ)あたりとの連携も簡単にできる。そんな Cloudflare を使い今回は Mackerel のドキュメントを検索できるチャットボットを作ってみた。

コードは以下のリポジトリにありますが、Cloudflare と Slack での Web コンソール上の設定も必要なため、そのまますぐに動作する状態にはなっていません。

github.com

仕組みを簡単に説明するとこんな感じ。

  • Mackerel のドキュメントを R2 に保存
    • 加工などはせず公開されている Markdown をそのまま同期。お試しということで定期実行の仕組みまでは作らず素朴に手元から同期している
  • AI Search のデータソースとして上記 R2 を指定
    • ベクトル化やインデックス作成は AI Search が自動的にやってくれる。こちらもお試しということでベクトル化や回答生成のモデルはデフォルトのものを使っている
  • Workers でエンドポイントを実装し Slack の slash command からのリクエストを受け、AI Search にクエリを投げて回答を生成

構成
構成

動作イメージはこんな感じ。

サバオ

モデル選定やシステムプロンプトの調整は行っていないため、回答品質の評価はまだできていない。一方で、未使用だった Cloudflare の機能を試す良い機会にはなった。ただし、ドキュメント検索と回答生成は AI Search に任せきりで、その部分は素振りになっていないので精度のチューニングをしつつ学んでいきたい。

Claude Code CLI と Mackerel MCP サーバーで Web アプリケーションの課題を解決してみる

Mackerel の MCP サーバーがリリースされたので、Mackerel のトレーシング機能のハンズオンの Web アプリケーションを題材に、課題の分析とコードの修正ができるか試してみた。

目次

Claude Code CLI の MCP サーバー設定

MCP サーバーの設定

まず Claude Code CLI に MCP サーバーを追加する。これは claude mcp add コマンドでできる。設定は --scope オプションによって保存される場所が変わる。また、下記のようなオプションにすると、MACKEREL_APIKEY が展開されて保存されることに注意。

claude mcp add mackerel --scope local  --env MACKEREL_APIKEY="${MACKEREL_APIKEY}" -- npx -y @mackerel/mcp-server

動作確認

Claude Code CLI を起動して、MCP サーバーが正しく設定されているか確認する

> /mcp
╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
│ Manage MCP servers                                                                                                               │
│                                                                                                                                  │
│ ❯ 1. mackerel            ✔ connected · Enter to view details                                                                     │
│                                                                                                                                  │
│ MCP Config locations (by scope):                                                                                                 │
│  • User config (available in all your projects):                                                                                 │
│    • /Users/kga/.claude.json                                                                                                     │
│  • Project config (shared via .mcp.json):                                                                                        │
│    • /Users/kga/ghq/github.com/mackerelio/mackerel-handson/tracing/demo/sample-app/ruby/.mcp.json (file does not exist)          │
│  • Local config (private to you in this project):                                                                                │
│    • /Users/kga/.claude.json [project: /Users/kga/ghq/github.com/mackerelio/mackerel-handson/tracing/demo/sample-app/ruby]       │
│                                                                                                                                  │
│ For help configuring MCP servers, see: https://docs.claude.com/en/docs/claude-code/mcp                                           │
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯

Claude が Mackerel API を呼び出せていれば成功。

> Mackerel に登録されているホストの数を教えて

⏺ Mackerel に登録されているホストの数を確認します。
  ⎿  {
       "hosts": [
         {
     … +750 lines (ctrl+o to expand)

⏺ 4台

トレーシングハンズオンの事前準備

準備のドキュメントを参考に、起動のための事前準備や Mackerel にトレースをエクスポートするための API キーの設定を済ませる。

また、本来のハンズオンでは、コード中に課題の改善のためのコメントが含まれているがカンニングされないようにこれを削除しておく。

Mackerel MCP サーバーを使った課題の解決

ハンズオンのアプリケーションには以下のような課題が含まれている。

  • エラーが発生する
  • 処理時間が遅い
  • N+1 問題が起きている

これを解決させてみる。

手順

2 と 3 を繰り返す。

  1. ハンズオンアプリケーションを立ち上げる
  2. エンドポイント(/products)にアクセスし、Mackerel にトレースをエクスポートする
  3. Mackerel のトレース詳細画面からトレースIDをコピーし Claude に課題解決を依頼する

課題解決の様子

Claude に以下のように依頼する

このディレクトリに含まれるアプリケーションにはいくつか問題が含まれています。
トレースID 1c8263e7dd735d7b68dcb22f62a37a81 のトレースを Mackerel から取得しその問題を修正してください

エラーが起きているとか、エンドポイントが遅いのような具体的な課題については触れなかったが、まずはエラーを発見し直してくれた。あと、typo も見つけてくれた(試したハンズオンのバージョンが古かった。既に直っています)。

トレースデータから、以下の問題を検出しました:

#### 検出された問題

**問題1: RuntimeError が発生**
- ファイル: `app/controllers/product_controller.rb:32`
- エラー内容: `RuntimeError: Sleep time is too long: 3`
- 原因: `method_sample` メソッドで `n > 2` の場合に例外を発生させている

**問題2: JSON レスポンスのキーにタイポ**
- ファイル: `app/controllers/product_controller.rb:8`
- 誤: `prirce`
- 正: `price`

次のイテレーション

修正を入れたトレースはこれです。さらに改善案を提案してください
a5be924c50c529fbf887596fb3a15c14
**問題1: N+1 クエリ問題(重大)**
- 100個の商品それぞれに対して、個別にカテゴリを取得
- トレースに100個の `SELECT "categories".* FROM "categories" WHERE "categories"."id" = ? LIMIT ?` クエリが存在
- 影響: データベースへの不要なクエリが大量発生し、パフォーマンスが大幅に低下

**問題2: 不要な sleep 処理(中)**
- `method_sample` メソッドで1秒、2秒、3秒の sleep を実行(合計6秒)
- 影響: レスポンス時間が6秒以上増加

**レスポンス時間の内訳**
- リクエスト全体: 約6.3秒
  - 初期クエリ: 約0.12秒
  - Category N+1 クエリ: 約0.15秒
  - method_sample sleep: 6秒

まとめ

**✅ 改善された点**
1. **N+1 クエリが解消**
   - カテゴリクエリが1回のみ: `SELECT "categories".* FROM "categories" WHERE "categories"."id" IN (...)`
   - 100個のIDをまとめて取得(IN句使用)

2. **レスポンス時間が大幅改善**
   - 全体: 約 **46ms**
   - 以前: 6.3秒 → **約137倍の高速化!**

3. **DBクエリ数の削減**
   - 商品クエリ: 1回
   - カテゴリクエリ: 1回
   - 合計: **2回**(以前は102回)

ということで、Claude Code CLI と Mackerel MCP サーバーを組み合わせることで、トレースの内容を取得し様々な課題解決を LLM にやってもらうことができた。トレースの一覧やエラーDBパフォーマンスHTTPパフォーマンスなども MCP サーバーから取得できるようになるとさらに夢が広がりそう。