以下の内容はhttps://kmuto.hatenablog.com/entry/2025/05/26/102000より取得しました。


OpenTelemetry CollectorでトレースサンプリングとUSEメソッド指標を両立させようとしてみた

トレースを大量に生成するとコストが高くなるので、部分的なサンプリングして絞るというのは王道である。しかし、サンプリングすることで、実際のリクエスト数やエラー数には実態との差異が生じてしまう。特に「エラーだけは確実に拾いたい」といったルールでテールサンプリングを行うと、結果として得られるエラーレートなどの値に意味がなくなる。SLI(サービスレベル指標)に使おうとしている場合、これは非常に困る。

Webアプリケーションであればnginxやロードバランサーのメトリックを使うという従来の方法もとれるが、今回はスパンメトリックと複数のパイプラインを活用して、記録するトレースはサンプリングしつつ、USEメソッドのためのリクエスト数とエラーレートの精度を保つ安価なメトリックを生成することを試みた。

複数パイプラインの構成

apm-demoを使って実験を進める。locustのアクセスはデフォルトでは控え目なので、実行ロボット数を増やした(compose.yaml)。

command: --headless -u 36 --loglevel ERROR

また、サンプリングしていない状態のリクエスト数やエラーレートが正しいか検証するため、SigNozもホストで起動し、ポート4318でトレースを受け取れるようにした。

次に、OpenTelemetry Collectorを設定する。実験に余計な結果が混入しないよう、apm-demoのデフォルトのotel-collector-config.yamlからはかなり変えている。

receivers:
  otlp:
    protocols:
      http:
        endpoint: 0.0.0.0:4319

processors:
  batch:
    timeout: 1m
    send_batch_size: 5000
    send_batch_max_size: 5000
  resource/namespace:
    attributes:
      - key: service.namespace
        value: apm-demo
        action: upsert
  resource/namespacemetric:
    attributes:
      - key: service.name
        value: sample-app
        action: insert
      - key: service.namespace
        value: apm-demo
        action: upsert
  tail_sampling:
    policies:
      - name: error_traces
        type: status_code
        status_code:
          status_codes: [ERROR]
      - name: non_error_traces
        type: probabilistic
        probabilistic:
          sampling_percentage: 1
  filter/http_server_spans:
    error_mode: ignore
    traces:
      span:
        - kind != SPAN_KIND_SERVER
        - attributes["http.status_code"] == nil
  transform/remove_spanname:
    error_mode: ignore
    trace_statements:
      - context: span
        statements:
          - set(name, "http_request")

exporters:
  otlphttp/mackerel:
    endpoint: https://otlp-vaxila.mackerelio.com
    compression: gzip
    headers:
      Mackerel-Api-Key: ${env:MACKEREL_APIKEY}
  otlp/mackerel:
    endpoint: otlp.mackerelio.com:4317
    headers:
      Mackerel-Api-Key: ${env:MACKEREL_APIKEY}
  otlphttp/signoz:
    endpoint: http://host.docker.internal:4318

connectors:
  spanmetrics:
    exclude_dimensions: ["span.name"]

service:
  pipelines:
    traces/spanmetrics:
      receivers: [otlp]
      processors: [filter/http_server_spans, resource/namespace]
      exporters: [spanmetrics]
    traces/main:
      receivers: [otlp]
      processors: [tail_sampling, resource/namespace, batch]
      exporters: [otlphttp/mackerel]
    traces/signoz:
      receivers: [otlp]
      processors: [resource/namespace, batch]
      exporters: [otlphttp/signoz]
    metrics:
      receivers: [spanmetrics]
      processors: [resource/namespacemetric, batch]
      exporters: [otlp/mackerel]

OtelBinでビジュアライズしたものを以下に示す。

トレースのパイプラインとしては次の4つとなる。

  • traces/spanmetrics:スパンメトリック生成パイプライン
  • traces/main:トレースをサンプリングしてMackerelに送るパイプライン
  • traces/signoz:トレースをサンプリングせずにSigNozに送るパイプライン
  • metrics:スパンメトリックをMackerelに送るパイプライン

OTLP Receiverがアプリケーションから受け取ったトレースシグナルは、tracesの全部にコピーで渡される。

traces/spanmetrics

spanmetricsはスパンからREDメソッドのメトリックを生成するコネクタである。今回のターゲットとなるリクエスト数やエラー数もここでメトリック化している。

filterプロセッサを使い、SERVER以外のスパンやHTTPステータスコードがないスパンを除外することでノイズを減らしている。

exclude_dimensions: ["span.name"]でスパン名のspan.nameをメトリックのラベルから削除した。ここにはHTTPリクエストのスパンではGET /{:products}のようにリクエストルート情報が入っているが、この次元でメトリックを分割すると、ルートごとに細分化され、データポイント数が増えてコストになってしまう。今回はあくまでも概要を目的としているので、この次元で分けないようにする。

resourceプロセッサで一貫したネームスペースを付けるようにしている(このあとメトリック側でも付けるので、これはなくてもよい)。

ここで生成したメトリックは、metricsのパイプラインに送られる。また、このメトリックはサンプリングされていない精度の高い値である。

traces/main

Mackerelに送るトレースのためのパイプラインでは、最初にtail_samplingプロセッサを使ったテールサンプリングを以下のポリシーで実行している。

  • status_codeERRORのスパンがあれば100%取得
  • それ以外は1%確率サンプリング

これにより、エラーを確実に記録しつつ、全体のトレース量を抑制する。

resourceプロセッサで一貫したネームスペースを付け、最後にbatchプロセッサで1分間隔のバッチでMackerelへ送信する。

traces/signoz

メトリックでのREDメソッドの値がトレースから計算した値と同等になっているかどうかを確認するために、トレースをサンプリング処理せずにそのままSigNozに引き渡すパイプラインである。

metrics

このパイプラインでは、spanmetricsで生成したメトリックについて加工する。

resourceプロセッサではほかと同様に一貫したネームスペースを付けているほか、サービス名が失われているのでそれを付けるようにしている。

最後はバッチでMackerelへ送信する。現時点でMackerelはメトリックとトレースでエンドポイントもプロトコルも異なるので分けている。

メトリックの確認

サンプリングしていないSigNozのほうの様子を見る。

MackerelのAPMサービス画面でテールサンプリングの影響を見てみる。13:37で切り替えたが、記録されているリクエスト数が減るとともに、エラーレートが大幅に上昇するという予想どおりの結果になっている。

Mackerelのメトリックエクスプローラーでservice.name = apm-demoを指定すると、スパンメトリックとして、traces.span.metrics.callsと、traces.span.metrics.durationヒストグラムを分割したcountp90p95p99sumが存在する(Mackerelではヒストグラム代表値を保存する仕様)。

今回はリクエスト数・エラー数・エラーレートのグラフを作りたいので、traces.span.metrics.callsの値を使ってカスタムダッシュボードにそれぞれのPromQLグラフを作る。式監視と違って普通に定数や四則演算を利用できるのは便利。せっかく渡されているdeployment.environment.name属性を無視して合計してしまっているが、実際に環境を分けて表現したいときには、それぞれ明示指定してグラフを分ければよいだろう。

  • リクエスト数sum(irate(traces.span.metrics.calls{service.name="sample-app"}[1m])) * 60
  • エラー数sum(irate(traces.span.metrics.calls{service.name="sample-app", status.code="STATUS_CODE_ERROR"}[1m])) * 60
  • エラーレート(sum(irate(traces.span.metrics.calls{service.name="sample-app", status.code="STATUS_CODE_ERROR"}[1m])) / sum(irate(traces.span.metrics.calls{service.name="sample-app"}[1m]))) * 100

SigNozと相似したグラフの形になり、数値も妥当なものが得られた。

今回はおおよそ1分あたり平均35リクエストくらいだが、億単位のような大量のリクエストがあったときにこれでうまくいくのかは確信がない。とはいえ、スパンを全部トレースとして記録する高価な手段をとらずに、リクエスト数・エラー数、そしてエラーレートをメトリックグラフとすることができた。

レイテンシーは…?

レイテンシーについては知識不足でまだ作れていない。sumではないし、avg/min/maxでもなさそうだし、一体何が出ているんだろうかこれは…。

メトリックのコスト

apm-demoの場合、deployment.environment.nameラベルの環境値が4つ存在し、status_codeラベルの値のバリエーションもある。

  • v0.1STATUS_CODE_UNSETSTATUS_CODE_ERROR
  • v1.0STATUS_CODE_UNSET
  • v2.0STATUS_CODE_UNSET
  • v3.0STATUS_CODE_UNSETSTATUS_CODE_ERROR

この6パターンを、traces.span.metricstraces.span.metrics.durationの2本のメトリックが持つので、1分あたりのデータポイント数は最大12。ただし、Mackerelにおいては、上記のとおりヒストグラムはメトリックに分解され、spanmetricsのヒストグラムでは5つのメトリックになるので、6 × (1 + 5) = 36データポイントが最大値となる。1分ごとに投稿していると36メトリックの課金になる。

environment.nameが1つだけであれば、STATUS_CODE_OKが含まれる可能性も含めても3 × (1 + 5) = 16データポイント。実際4つも環境を見るケースはないだろうから、リーズナブルだろうか。

余談:span.nameの書き換え

spanmetricsでspan.nameを次元から外せることがわかったが、当初はほかのプロセッサで何かやるのだと思って、試行錯誤していた。

span.nameは属性(attribute)ではないため、attributeプロセッサでは削除できない。

spanプロセッサは属性値からスパン名を作る・スパン名から属性値を作る、というもので、固定値を指定するのには不向きである(リソース属性などからできなくはないが…)。

transformプロセッサを使うと名前を固定値にすることができた。

  transform/unify_spanname:
    error_mode: ignore
    trace_statements:
      - context: span
        statements:
          - set(name, "http_request")

とはいえ、今回の用途ではspanmetricsのexclude_dimensionsspan.nameを除外するのが簡潔だろう。

余談:メモリの消費状況

リクエストがさして多くないこともあり、パイプラインの追加やテールサンプリングの有無で大きな差は見られなかった(docker compose statsで観察し、テールサンプリングあり47MB・なし44MB)。




以上の内容はhttps://kmuto.hatenablog.com/entry/2025/05/26/102000より取得しました。
このページはhttp://font.textar.tv/のウェブフォントを使用してます

不具合報告/要望等はこちらへお願いします。
モバイルやる夫Viewer Ver0.14