トレースを大量に生成するとコストが高くなるので、部分的なサンプリングして絞るというのは王道である。しかし、サンプリングすることで、実際のリクエスト数やエラー数には実態との差異が生じてしまう。特に「エラーだけは確実に拾いたい」といったルールでテールサンプリングを行うと、結果として得られるエラーレートなどの値に意味がなくなる。SLI(サービスレベル指標)に使おうとしている場合、これは非常に困る。
Webアプリケーションであればnginxやロードバランサーのメトリックを使うという従来の方法もとれるが、今回はスパンメトリックと複数のパイプラインを活用して、記録するトレースはサンプリングしつつ、USEメソッドのためのリクエスト数とエラーレートの精度を保つ安価なメトリックを生成することを試みた。
- 複数パイプラインの構成
- traces/spanmetrics
- traces/main
- traces/signoz
- metrics
- メトリックの確認
- レイテンシーは…?
- メトリックのコスト
- 余談:span.nameの書き換え
- 余談:メモリの消費状況
複数パイプラインの構成
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_codeがERRORのスパンがあれば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のヒストグラムを分割したcount・p90・p95・p99・sumが存在する(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.1:STATUS_CODE_UNSET、STATUS_CODE_ERRORv1.0:STATUS_CODE_UNSETv2.0:STATUS_CODE_UNSETv3.0:STATUS_CODE_UNSET、STATUS_CODE_ERROR
この6パターンを、traces.span.metricsとtraces.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_dimensionsでspan.nameを除外するのが簡潔だろう。
余談:メモリの消費状況
リクエストがさして多くないこともあり、パイプラインの追加やテールサンプリングの有無で大きな差は見られなかった(docker compose statsで観察し、テールサンプリングあり47MB・なし44MB)。