kmuto’s blog

はてな社でMackerel CREをやっています。料理と旅行といろんなIT技術

OpenTelemetryのRouting Connectorを使って条件分岐とサンプリングをシンプルにしてみた

OpenTelemetryのトレースシグナル取得をする上で、ユーザーの属性に応じてサンプリングを調整したいことがある。たとえば以下のような具合だ。

  • userType=freeの場合は1%サンプリング(1%拾い、99%は廃棄)
  • standardなどほかのuserTypeの場合は全サンプリング(全部拾う)

テールサンプリングかな?と思うところだが、これは少々問題がある。

  tail_sampling:
    decision_wait: 10s
    policies:
      - name: free_sampling
        type: and
        and:
          and_sub_policy:
            - name: match_free
              type: ottl_condition
              ottl_condition:
                span:
                  - attributes["userType"] == "free"
            - name: probabilistic_policy
              type: probabilistic
              probabilistic:
                sampling_percentage: 1
      - name: all_sampling
        type: always_sample

これはうまくいかない。ポリシーで設定したfree_samplingとall_samplingは並列で評価が実行され、残ったものが拾われる挙動になっている。そのため、free_samplingで落としても、all_samplingで拾われてしまう。

回避策のひとつは、all_samplingで明示的にuserTypeがfreeのものを除外することである。

      - name: free_sampling
        type: and
        and:
          and_sub_policy:
            - name: match_free
              type: ottl_condition
              ottl_condition:
                span:
                  - attributes["userType"] == "free"
            - name: probabilistic_policy
              type: probabilistic
              probabilistic:
                sampling_percentage: 1
      - name: all_sampling
        type: and
        and:
          and_sub_policy:
            - name: not_match_free
              type: ottl_condition
              ottl_condition:
                span:
                  - attributes["userType"] != "free"
            - name: always_sample_policy
              type: always_sample

ただ、このような書き方の場合、将来的にuserTypeが増えてくると、そのたびに「拾う条件」と「その否定条件」の2つをメンテナンスすることになってしまう。

何か手立てがないかOpenTelemetry Collector Contribを眺めていたところ、Routing Connectorを使うと、この問題を解決できるのではないかと考えた。

Routing Connectorは、テーブルに設定した条件(属性など)に応じてパイプラインを切り替えるコネクタで、トレース-トレース、メトリック-メトリック、ログ-ログ、と同じシグナル形式の間をつなぐ。「デフォルト」のパイプラインも指定できる。

今回の例で言えば、次のような記述となる。

connectors:
  routing:
    default_pipelines: [traces/all_sampling]
    table:
      - condition: attributes["userType"] == "free"
        pipelines: [traces/free_sampling]

  pipelines:
    traces:
      receivers: [otlp]
      ...
      exporters: [routing]
      
    traces/all_sampling:
      receivers: [routing]
      ...

    traces/free_sampling:
      receivers: [routing]
      ...
  • tracesパイプラインはreceiversのotlpでトレースシグナルを受け、routingコネクタにエクスポートする
  • routingコネクタではデフォルトで渡すパイプラインとしてtraces/all_samplingを設定し、属性userTypeがfreeであればtraces/free_samplingのパイプラインに渡す
  • traces/all_samplingパイプラインはroutingコネクタからのトレースシグナルを受ける
  • traces/free_samplingパイプラインもroutingコネクタからのトレースシグナルを受ける(属性userTypeがfreeのときにここに来る)

これならば、区別したい属性が増えても拾う条件とパイプラインを追加するだけなので、見通しがよくなる。

ただ、ここまで読んで「よし、やってみよう」と飛びつく前に、Routing Connectorを使う上での注意がある。それは、「判定に使われる属性はリソース属性」ということだ。リソース属性はアプリケーション・サービスやトレース全体にかかる属性で、たとえばサービスバージョンやデプロイ環境名など、アプリケーション起動時に通常決定されるものがそれに当たる。一方のスパン属性は、スパンに関する情報で、リクエストや処理単位ごとに変わる。

構成にもよると思うがuserTypeは一般にスパン属性だろうから、これをなんとかする必要がある。アプリケーション側でやるよりはOpenTelemetry Collector側で属性を処理できると楽だろう。

属性を変更するプロセッサとしては、transformプロセッサを使う(attributesプロセッサはリソース属性の操作はできない)。transformプロセッサの中で、スパン属性userTypeの内容をリソース属性userTypeにコピーする。

  transform:
    error_mode: ignore
    trace_statements:
      - set(resource.attributes["userType"], span.attributes["userType"])

...
  pipelines:
    traces:
      receivers: [otlp]
      processors: [..., transform]
      exporters: [routing]

ところで、属性でパイプラインを分けたことで、userTypeがfreeの1%サンプリングは、テールサンプリングではなく、シンプルなヘッドベースの確率サンプリングで済むことにお気付きだろうか。

  probabilistic_sampler:
    sampling_percentage: 1

...
    traces/free_sampling:
      receivers: [routing]
      processors: [probabilistic_sampler, batch]

全体としては次のようになった。

receivers:
  otlp:
    protocols:
      http:
        endpoint: "0.0.0.0:4318"

processors:
  batch:
    timeout: 5s
    send_batch_size: 5000
    send_batch_max_size: 5000

  resource/namespace:
    attributes:
      - key: service.namespace
        value: samplingtest
        action: upsert

  transform:
    error_mode: ignore
    trace_statements:
      - set(resource.attributes["userType"], span.attributes["userType"])

  probabilistic_sampler:
    sampling_percentage: 1

connectors:
  routing:
    default_pipelines: [traces/all_sampling]
    table:
      - condition: attributes["userType"] == "free"
        pipelines: [traces/free_sampling]

exporters:
  otlphttp/mackerel:
    endpoint: https://otlp-vaxila.mackerelio.com
    compression: gzip
    headers:
      Mackerel-Api-Key: ${env:MACKEREL_APIKEY}

  otlphttp/oteltui:
    endpoint: http://localhost:4319

  debug:
    
service:
  telemetry:
    metrics:
      level: none

  pipelines:
    traces:
      receivers: [otlp]
      processors: [resource/namespace, transform]
      exporters: [routing]
      
    traces/all_sampling:
      receivers: [routing]
      processors: [batch]
      exporters: [otlphttp/oteltui, otlphttp/mackerel]

    traces/free_sampling:
      receivers: [routing]
      processors: [probabilistic_sampler, batch]
      exporters: [debug, otlphttp/oteltui, otlphttp/mackerel]

freeなものを200回、standardなものを10回投稿し、Mackerelや、4319ポートで上げているotel-tui(otel-tui --http 4319)で様子を見る。freeのものについてはdebugにもエクスポートすることで、Routing Connectorで確かに分岐して少しだけしか届かないことを確認した。

全体のレイテンシーに基づく分岐などはテールサンプリングでしかできないが、属性値で分岐するようなシンプルなものであれば、Routing Connectorが効果的な解決方法になりそうだ。