TraceStateによる確率的サンプリング

この記事は英語の原文を日本語に翻訳したものです。原文: https://opentelemetry.io/docs/specs/otel/trace/tracestate-probability-sampling/

翻訳元: open-telemetry/opentelemetry-specification v1.60.0(コミット 29ae8c7

ステータス: Development

Overview

サンプリングは、テレメトリーデータの収集と処理に伴うコストを削減するための重要な手段です。サンプリングによって、全体の母集団から代表的な項目の集合を選ぶことができます。

トレーシングデータのサンプリングには、2つの重要な側面があります。1つ目は、サンプリングの判断が、トレース内の各スパンについてそれぞれ独立に行われうることです。2つ目は、サンプリングの判断がテレメトリーパイプライン内の複数の地点で行われうることです。例えば、あるスパンについてスパン作成時に行われたサンプリング判断が**維持(keep)であったとしても、同じスパンについて後の段階(例えばデータ収集パイプライン内の外部プロセス)で行われる下流のサンプリング判断が破棄(drop)**であることがあります。

上記のいずれの側面についても、一貫した(consistent)サンプリング判断を行わなければ、独立したサンプリング判断のために、整合したスパンの集合を含まない使い物にならないトレースができてしまいます。そうではなく、トレースについて効果的に推論できるように、サンプリングの判断は一貫した方法で行われる必要があります。

本仕様書は、Consistent Probability Samplingと呼ばれる仕組みを使って一貫したサンプリング判断を実現する方法を説明します。これを実現するために、2つの重要な構成要素を使います。1つ目は、トレーサーとCollectorの集合を含む、すべての参加者が利用できる共通のランダム性の源(R)です。これは、OpenTelemetryのTraceState rvサブキーで表現される明示的なランダム性の値か、TraceIDの末尾7バイトから取られる値のいずれかです。2つ目は、拒否閾値(T)という概念です。これは参加者のサンプリング率から直接導出され、サンプリングされたスパンについてはOpenTelemetryのTraceState thサブキーで表現されます。この提案は、これら2つの値がどのように伝搬されるべきか、また参加者がそれらをどのように使ってサンプリング判断を行うべきかを説明します。

本仕様書の詳細については、OTEP 235OTEP 250を参照してください。

OpenTelemetryのトレーシングデータにおける確率的サンプリング情報のエンコーディング、特にOpenTelemetryのTraceState rvthの値の詳細については、Tracestate handlingを参照してください。以降、サンプリング判断においてこれらの量を表す変数としてRTを使います。

Definitions

Sampling Probability

サンプリング確率は、あるスパンが維持される可能性です。各参加者は、スパンごとに異なるサンプリング確率を選べます。例えば、サンプリング確率が0.25であれば、およそ25%のスパンが維持されます。

OpenTelemetryでは、サンプリング確率は2^-56から1の範囲で有効です。この式に現れる56という値は、W3C Trace Context Level 2のTraceIDについて規定されている7バイトのランダム性(すなわち56ビット)に対応しています。ゼロという値は定義されておらず、「決してサンプリングしない」ことは確率的サンプリングの一形態ではないことに注意してください。

Consistent Sampling Decision

一貫したサンプリング判断とは、確率p1で行われたある特定のスパンについての正のサンプリング判断が、同じトレースに属し、p2 >= p1の確率で判断が行われる任意のスパンについても、必然的に正のサンプリング判断を意味することです。

Rejection Threshold (T)

これは、サンプリング確率から直接導出される56ビットの値です。これは、2^56個の考慮されたスパンのうち破棄されるスパンの数と考えることができます。

拒否閾値は、以下のようにサンプリング確率から導出できます。

Rejection_Threshold = (1 - Sampling_Probability) * 2^56.

例えば、サンプリング確率が100%(すべてのスパンを維持する)であれば、拒否閾値は0です。

同様に、サンプリング確率が1%(99%のスパンを破棄する)であれば、5桁の精度での拒否閾値は(1-0.01) * 2^56 ≈ 71337018784743424 = 0xfd70a400000000となります。

この拒否閾値をTと呼びます。あるSpanまたはContextがサンプリングされる場合、そのサンプラーの実効的なTは、そのサンプリング確率を示すためにOpenTelemetryのTraceState thサブキーにエンコードされます。この例で1%のサンプリングを受けたスパンは、OpenTelemetryのTraceStateの値ot=th:fd70a4を持ちます。これは、エンコード時に末尾のゼロが除去されるためです。

さらに多くの例については、tracestate handlingを参照してください。

Randomness Value (R)

(すべての参加者に知られている、あるいは伝搬されている)共通のランダムな値は、一貫した確率的サンプリングを可能にするための主要な構成要素です。各参加者はこの値(R)を自身の拒否閾値(T)と比較することで、トレース全体(あるいはトレースのグループ全体)にわたって一貫したサンプリング判断を行えます。

この提案は、2つのランダム性の源をサポートします。

  • 明示的なランダム性の源: OpenTelemetryは、明示的トレースランダム性として知られるランダムな(あるいは疑似ランダムな)56ビットの値をサポートします。これは、OpenTelemetryのTraceState rvサブキーを通じて伝搬でき、SpanのTraceStateフィールドに格納されます。
  • ランダム性の源としてのTraceIDの利用: OpenTelemetryは、W3C Trace Context Level 2で規定されている、TraceIDの下位56ビットをランダム性の源として使うことをサポートします。これは、ルートSpanのTrace SDKが、そのTraceIDがランダムまたは疑似ランダムな方法で生成されたことを知っている場合に行えます。

ランダム性の値のエンコーディングの詳細については、tracestate handlingを参照してください。

Approach and Terminology

Decision algorithm

上記の構成要素を踏まえ、参加者がどのように一貫したサンプリング判断を行えるかを見てみましょう。そのためには、以下の2つの値が存在しなければなりません(MUST)。

  1. SpanContext内の、共通のランダム性の源である56ビットのランダム性の値(R)。
  2. サンプラーの設定から得られる、56ビットの閾値の値である拒否閾値(T)。

R >= Tであればそのスパンを維持し、そうでなければ破棄します

Sampling stages

概要で触れた2つのサンプリングの側面は、サンプリング戦略の段階として言及されます。

  1. Parent/Childサンプリング。これらの判断は、トレースの生存期間中、SDK内部のスパンについて同期的に行われます。これらの判断は、生きているContextに基づきます。
  2. Downstreamサンプリング。これらのサンプリング判断は、収集パス上のスパンについて、それらが終了した後に発生し、収集パイプライン内の複数の地点で発生することがあります。これらの判断は、過去のContextに基づきます。

これらの段階は、サンプリング判断が行われる2つの次元として認識されます。Parent/Childサンプリングでは、時間的に親から子へと因果関係の方向に進行が起こります。Downstreamサンプリングでは、時間的にCollectorからCollectorへと進行が起こります。ある時点で終了したスパンを考えると、それはParent/Childサンプラーによって1回、Downstreamサンプラーによって0回以上サンプリングされたことになります。

いずれの場合も、「前段の」サンプラーとは、時間的にそれに先行するサンプリング段階(すなわち親またはupstreamのサンプラー)を指します。

graph TD
    subgraph "parent/child samplers"
        direction LR
        Root["root span"] --> Child1["child span"]
        Child1 --> Child2["child span"]
    end

    subgraph "downstream samplers"
        Root --> LC1["frontend agent"]
        LC1 --> LC2["frontend gateway"]

        Child1 --> RC1["backend agent"]
        Child2 --> RC1
        RC1 --> RC2["backend gateway"]
    end

    LC2 --> FC["destination service"]
    RC2 --> FC

    classDef span fill:#a7a,stroke:#333,stroke-width:2px;
    classDef collector fill:#77a,stroke:#33c,stroke-width:1px;

    class Root,Child1,Child2 span;
    class LC1,LC2,RC1,RC2,FC collector;

Sampling base cases

CompositeSamplerは、組み込みのComposableSamplerを基底ケースとして使い、複数のサンプリングルールを1つに組み合わせることをサポートするためのものです。

Parent/Childサンプリングという分類の中では、以下が主要な基底ケースです。

  • Root: Rootのサンプリング判断は、事前の閾値なしで行われる、両方のサンプリング次元における最初の判断です。Rootのサンプリング判断は、Contextの明示的トレースランダム性の値を変更できる唯一のケースです。
  • Parent-based: 子でparent-basedサンプリングを使う場合、子の判断は親の判断と一致することが期待されます。親と子は一致する閾値の値を持ちます。
  • Consistent probability: 確率的サンプラー(例えばTraceIDRatio)は独立したサンプリング判断を行います。一貫した確率的サンプリングの判断は、(存在する場合の)親のサンプリング閾値を無視します。このケースは、常時オン・常時オフのサンプリング挙動を包含します。

一部の用語は特定のサンプラーについてではなく、アプローチ全体について述べています。

  • Head: **注意: この用語は複数の意味で使われます。**これは、SDKが新しいスパンを開始する際に行うサンプリング判断を指す場合があります。例えば「head samplingの判断は、スパン作成時に存在する情報のみを考慮できる」(Span Linkに関するAPI仕様より)といった具合です。また、すべての子サンプラーがparent-basedサンプラーを使う分散トレーシングのセットアップを指す場合もあります。例えば「分散トレーシングの構成のほとんどはhead samplingを使う」といった具合です。
  • Tail: このモードのサンプリングは、Downstreamサンプラーが判断に関与することを意味します。個々のスパンについて収集パス上で行われるサンプリング(「intermediate」サンプリングとして知られる)を指すこともありますが、通常は、複数のスパンを単一のデータセットに組み立てた後にdownstreamで行われる、トレース全体に対するサンプリング判断を指します。もちろんTail samplingはHead samplingと組み合わせられ、トレース内のスパンにまたがって不均等な確率をもたらすこともあれば、もたらさないこともあります。

**調整済みカウント(adjusted count)**という用語は、サンプリング確率の数学的な逆数(逆数)を指し、母集団を代表するために、ある項目が数えられるべき期待回数を表します。調整済みカウントは、サンプリングがなかった場合のスパンの数の推定値として有用です。

span-to-metricsという用語は、テレメトリーシステムにおいて、調整済みカウントの情報を使ってトレーシングのスパンからメトリクスを導出するために調整済みカウントを利用することを指します。tracestateの取り扱いとサンプラー内の閾値情報の管理に関する仕様は、この目的のために調整済みカウントが主として信頼できるものであることを保証します。

Tracestate handling requirements

これは補足的なガイドラインです。各組み込みサンプラーの具体的な要件については、SDK仕様を参照してください。

拒否閾値は、Parent/ChildサンプラーとDownstreamサンプラーを含む、それまでのすべての一貫したサンプリング段階で適用された最大の閾値を表します。

Contextへの明示的トレースランダム性の挿入の可能性を扱う、トレースランダム性についてのSDKの要件を参照してください。

thrvがどのようにエンコードされるかを示す例については、TraceState handlingを参照してください。

General requirements

実効的なサンプリング閾値を変更するすべてのサンプリング段階は、正しい統計的解釈を維持するため、OpenTelemetryのTraceStateを再エンコードすることでサンプリング閾値を更新しなければなりません(MUST)。

条件付きのサンプリング段階(例えばルールベースのサンプラー)が一貫性を保つためには、提供された閾値をランダム性の値や、それに依存するランダム性の源に条件付けてはなりません(独立したランダム性は使ってもかまいません)。

未知のサンプリング確率を持つスパンを生成するサンプリング段階(親の閾値を持たないContextに遭遇したparent-basedサンプラーを含む)は、その出力においてOpenTelemetryの閾値の値を消去しなければなりません(MUST)。

サンプリング段階は、単純な検査で済む場合には一貫性を確認すべきであり(SHOULD)、明らかに矛盾している場合には閾値を消去すべきです(SHOULD)。例えば、閾値付きでサンプリングされたContextを受け取り、それを伝搬するparent-basedサンプラーは、sampledフラグが式rv >= thと一致するかどうかを確認すべきであり、一致しない場合は閾値を消去すべきです。

ランダム性が明示的なランダム性の値から導出されていた場合、送出するOpenTelemetryのTraceStateには同一のrvの値を設定しなければなりません(MUST)。

Parent/Child threshold

Parent/Childサンプラーは、一貫した確率的サンプリングの閾値を初期化します。これは、組み込みのサンプラーの1つを基底ケースとして、またはComposableSamplerのロジックを使って実現できます。

Independent parent/child threshold

親の閾値が存在しないため、rootサンプラーは常に独立した判断を行います。子サンプラーは独立したサンプラーで構成されることがありますが、それは不完全なトレースにつながる可能性があります。

独立したParent/Childサンプラーの基底ケース(すなわちAlwaysOn、TraceIDRatio)は、そのサンプリング確率に基づく固定の閾値をエンコードします。

Parent-based threshold

Parent-basedサンプラーは、以下の一般要件に従って、受信した閾値を送出する閾値としてそのまま伝搬します。

  • サンプリングされた受信閾値が明らかに矛盾している場合、それを消去する。
  • サンプリングされた受信閾値が存在しない場合、送出する閾値も存在しないようにする。

Downstream threshold

Downstreamサンプラーは、適用された閾値を上げることはできますが、下げることはできません。Downstreamサンプラーが閾値を下げることは、以前の段階のサンプリング確率を遡って上げることと同義になります。言い換えると、downstreamサンプラーはサンプリング確率を下げることは許されますが、サンプリング確率を上げることは統計的な誤差をもたらします。

2つのDownstream確率的サンプリングには標準的な用語があり、次に説明します。

Equalizing downstream sampler

equalizingなdownstreamサンプラーは、この段階を通過した後にすべてのスパンが等しい閾値を持つようにすることを目指します。この段階は、以前の段階の選択性が低かった場合に、より選択的になり、結果として等しい確率を持つスパンになります。

閾値T_dで設定されたequalizingなdownstreamサンプラーが、ランダム性の値Rを持つ閾値T_sのスパンを検討する場合は以下のとおりです。

  • T_s > T_dであれば、送出する閾値は変更されずT_sのままです。equalizingな確率的サンプラーは閾値を下げられないため、この場合は確率を均等化できません。
  • R >= T_dであれば、そのスパンは送出する閾値T_dを使って選択されます。
  • そうでない場合、R < T_dはそのスパンがサンプリングされないことを示します。

Proportional downstream sampler

proportionalなdownstreamサンプラーは、受信した確率に設定済みの倍率を掛けることを目指します。閾値を一律に上げることで、この段階のサンプラーは、到着時の閾値にかかわらず、トラフィックを一定量削減することを約束します。

確率pで設定されたproportionalなdownstreamサンプラーが、ランダム性の値Rを持つ閾値T_sのスパンを検討する場合、まず積の閾値T_oを計算します。

T_o = ProbabilityToThreshold(p * ThresholdToProbability(T_s))
  • T_oが最小の確率閾値より小さい場合、そのスパンはサンプリングされません。
  • R >= T_oであれば、そのスパンは送出する閾値T_oを使って選択されます。
  • そうでない場合、R < T_oはそのスパンがサンプリングされないことを示します。

Migration to consistent probability samplers

TraceIdRatioBasedサンプラーについてのOpenTelemetry仕様は、SDK仕様が安定版として宣言された後になってから完成し、そのサンプラーの正確な挙動は未規定のまま残されていました。現行の仕様はこの挙動に対処しています。

OpenTelemetryのTraceIdRatioBasedサンプラーの定義が変わるため、利用者は、古いロジックと新しいロジックの間の移行期間中に、一貫性のないサンプリングによって不完全なトレースが生じることを避ける方法を検討する必要があります。

元のTraceIdRatioBasedサンプラーの仕様は、この未規定の挙動に対する回避策として、rootスパンでの利用は安全であると述べていました。「異なる言語のSDK、あるいは同じ言語のSDKの異なるバージョン間でも、同じ入力に対して一貫性のない結果を生成する可能性があるため、このサンプラーアルゴリズムはrootスパンについてのみ(ParentBasedと組み合わせて)使うことが推奨されます。」

この移行期間中の不整合を避けるため、利用者は、システム内のすべてのTrace SDKがW3C Trace Context Level 2に基づく最新のTraceランダム性の要件へ更新されるまで、このガイダンスに従うべきです(SHOULD)。利用者は、システム内のすべてのSpanでTrace randomフラグが(Spanフラグ内で)設定されていることを確認することで、すべてのTrace SDKが更新済みであることを検証できます。この移行を支援するため、TraceIdRatioBasedサンプラーは、Trace randomフラグが設定されていないContextに対して初めてTraceIDのランダム性を仮定した際に、警告を発します。

Algorithms

TRの値は、プロセッサーの能力や実装のニーズに応じて、さまざまな形式で表現・操作できます。56ビットの値であるため、バイト配列や64ビット整数と互換性があり、精度の損失が実質的に無視できる程度であれば64ビット浮動小数点数でも操作できます。

以下の例はPython3で示されています。これらは明確さのための例に過ぎず、実装として推奨するものではありません。

Converting floating-point probability to threshold value

閾値の値は、可変精度を可能にするため、末尾のゼロを除去してエンコードされます。これは丸めによって実現できますが、組み込みの文字列フォーマットライブラリを使った実用的な方法がいくつかあります。

最大56ビットの精度が利用可能な場合、組み込みの浮動小数点数サポートを使う実装は、その基盤となる数値表現の精度によって制限されます。閾値をエンコードする1つの方法として、IEEE 754-2008標準の16進浮動小数点表現を単純な解として使う方法があります。

import math

# ProbabilityToThresholdWithPrecision assumes the probability value is in the range
# [2^-56, 1] and precision is in the range [1, 13], which is the maximum for a
# IEEE-754 double-width float value.
def probability_to_threshold_with_precision(probability, precision):
    if probability == 1:
        # Special case
        return "0"

    # Raise precision by the number of leading 'f' digits.
    _, exp = math.frexp(probability)
    # Precision is limited to 12 so that there is at least one digit of precision
    # in the final [:precision] statement below.
    precision = max(1, min(12, precision + exp // -4))

    # Change the probability to 1 + rejection probability = 1 + 1 - probability,
    # i.e., modify the range (0, 1] into the range [1, 2).
    rejection_prob = 2 - probability

    # To ensure rounding correctly below, add an offset equal to half of the
    # final digit of precision in the corresponding representation.
    rejection_prob += math.ldexp(0.5, -4 * precision)

    # The expression above technically can't produce a number >= 2.0 because
    # of the compensation for leading Fs. This gives additional safety for
    # the hex_str[4:][:-3] expression below which blindly drops the exponent.
    if rejection_prob >= 2.0:
        digits = "fffffffffffff"
    else:
        # Use float.hex() to get hexadecimal representation
        hex_str = rejection_prob.hex()

        # The hex representation for values between 1 and 2 looks like '0x1.xxxxxxxp+0'
        # Extract the part after '0x1.' (4 bytes) and before 'p' (3 bytes)
        digits = hex_str[4:][:-3]

    assert len(digits) == 13
    # Remove trailing zeros
    return digits[:precision].rstrip('0')

math.frexp(probability)の利用は、確率引数の底2の指数を使って精度を調整するために使われていることに注意してください。これにより、設定された精度が、ゼロに近い確率についての閾値の有効数字に適用されます。100%近くの確率については対称的な調整が行われていないことに注意してください。100%に極めて近い精度でのサンプリングに実用的な用途があるとは考えていないためです。

浮動小数点の確率からmath.Round()とシフト操作を使って直接56ビットの符号なし整数表現へ変換する方法については、OpenTelemetry Collector-Contribのpkg/samplingパッケージを参照してください。このパッケージは、確率から整数の閾値を直接計算する方法を示しています。

OpenTelemetryのSDKは、既定で4桁の精度を使うことが推奨されます。以下の表は、1-in-Nの確率的サンプリングについて、精度3、4、5で上記の方法により計算された値を示します。

1-in-N入力確率閾値(精度3、4、5)実際の確率(精度3、4、5)正確な調整済みカウント(精度3、4、5)
110
0
0
1
1
1
1
1
1
20.58
8
8
0.5
0.5
0.5
2
2
2
30.3333333333333333aab
aaab
aaaab
0.333251953125
0.3333282470703125
0.33333301544189453
3.0007326007326007
3.00004577706569
3.0000028610256777
40.25c
c
c
0.25
0.25
0.25
4
4
4
50.2ccd
cccd
ccccd
0.199951171875
0.1999969482421875
0.19999980926513672
5.001221001221001
5.0000762951094835
5.0000047683761295
80.125e
e
e
0.125
0.125
0.125
8
8
8
100.1e66
e666
e6666
0.10009765625
0.100006103515625
0.10000038146972656
9.990243902439024
9.99938968568813
9.999961853172863
160.0625f
f
f
0.0625
0.0625
0.0625
16
16
16
1000.01fd71
fd70a
fd70a4
0.0099945068359375
0.010000228881835938
0.009999990463256836
100.05496183206107
99.99771123402633
100.00009536752259
10000.001ffbe7
ffbe77
ffbe76d
0.0010004043579101562
0.0009999871253967285
0.000999998301267624
999.5958055290753
1000.012874769029
1000.0016987352618
100000.0001fff972
fff9724
fff97247
0.00010001659393310547
0.00010000169277191162
0.00010000006295740604
9998.340882002383
9999.830725674266
9999.99370426336
1000000.00001ffff584
ffff583a
ffff583a5
9.998679161071777e-06
1.00000761449337e-05
1.0000003385357559e-05
100013.21013412817
99999.238556461
99999.96614643588
10000000.000001ffffef4
ffffef39
ffffef391
9.98377799987793e-07
1.00000761449337e-06
9.999930625781417e-07
1.0016248358208955e+06
999992.38556461
1.0000069374699865e+06

Converting integer threshold to a T-value

56ビットの整数の拒否閾値の値をT表現に変換するには、それを16進数の値として(先頭の'0x’なしで)、末尾のゼロは省略してもよいものとして出力します。

if tvalue == 0:
  add_otel_trace_state('th:0')
else:
  h = hex(tvalue).rstrip('0')
  # remove leading 0x
  add_otel_trace_state('th:'+h[2:])

Testing randomness vs threshold

ランダム性と閾値を64ビット整数として与えたとき、ランダム性が閾値以上であればサンプリングされるべきです。

shouldSample = (randomness >= threshold)

Converting threshold to a sampling probability

サンプリング確率は0.0から1.0の値であり、2^56で割ることで浮動小数点数として計算できます。

# embedded _ in numbers for clarity (permitted by Python3)
maxth = 0x100_0000_0000_0000  # 2^56
prob = float(maxth - threshold) / maxth

Converting threshold to an adjusted count (sampling rate)

調整済みカウントは、このサンプルが母集団のうちどれだけの量の項目を表しているかの近似値を示します。これは1/probabilityに等しくなります。

maxth = 0x100_0000_0000_0000  # 2^56
adjCount = maxth / float(maxth - threshold)

調整済みカウントは、非確率的サンプリング(thの値を持たないサンプリングされたスパン)で得られたスパンについては定義されません。