OTEP-0066: コンテキスト伝搬:階層化アプローチ

図

OpenTelemetryを、共有のコンテキスト伝搬メカニズム上で動作する一連の独立したクロスカッティングコンサーンにリファクタリングするための提案です。

動機

本RFCは以下のトピックを扱います。

関心の分離

  • パッケージレイアウトがすっきりすることで、システムが学びやすくなります。 Observabilityを理解する必要なく、コンテキスト伝搬を理解することができます。
  • 複数の種類のコンテキスト伝搬を可能にし、それぞれが異なるルールを持つ自己完結したものにできます。 たとえば、TraceContextはサンプリングされることがありますが、CorrelationContextは決してサンプリングされません。
  • Observabilityとコンテキスト伝搬が異なるデフォルトを持てるようにします。 Observabilityシステムはno-op実装とプラガブルなSDKを提供し、コンテキスト伝搬システムは正典的で動作する実装を提供します。

拡張性

  • クリーンな分離により、コンテキスト伝搬メカニズムを単体で使用できるようになるため、非observabilityな関心事についてobservabilityツールに依存したくない他のシステムからも利用できます。
  • 開発者がコンテキスト伝搬の新しい用途を作成できるようにします。 たとえば、A/Bテスト、認証、ネットワーク切り替えなどです。

OpenTelemetryの階層アーキテクチャ

OpenTelemetryの設計は、分散システムのニーズに合わせて採用されたアスペクト指向プログラミングの原則に基づいています。

一部の関心事は、プログラム内の複数の抽象化を「横断」します。 ロギングは、ロギング戦略が必然的にシステムのログ出力されるすべての部分に影響を与えるため、アスペクト指向の典型例です。 そのため、ロギングはログ出力されるすべてのクラスとメソッドを「クロスカット」します。 分散トレーシングはこの戦略をさらに一段階進め、トランザクション全体にわたるすべてのサービスのすべてのクラスとメソッドを横断します。 これをクリーンに実装するには、同じアスペクト指向プログラミングの原則の分散版が必要になります。

OpenTelemetryは、この設計を2つのレイヤーに分けることでこれに対処します。 上位のレイヤーには、プログラムのアプリケーションロジックと絡み合っており、きれいにカプセル化できない、独立した一連のクロスカッティングコンサーンが含まれます。 すべてのコンサーンは、分散トランザクションの生存期間全体にわたって状態を保存しデータにアクセスするための、基盤となる分散コンテキスト伝搬レイヤーを共有します。

クロスカッティングコンサーン

Observability API

分散トレーシングは、クロスカッティングコンサーンの一例です。 トレーシングのコードは通常のコードと絡み合っており、そうでなければカプセル化されたままであるはずの独立したコードモジュールを結びつけます。 トレーシングはまた分散されており、正しく実行するためにトランザクションレベルのコンテキスト伝搬を必要とします。

さまざまなobservability APIについてはここでは直接説明しません。 ただし、この新しい設計では、すべてのobservability APIが、現在使用しているトレーシング固有の伝搬システムではなく、以下で説明する汎用のコンテキスト伝搬メカニズムを使用するように変更されます。

OpenTelemetry APIの呼び出しには、単一キーの値のようなコンテキストの一部だけではなく、常にコンテキストオブジェクト全体へのアクセスが与えられるべきであることに注意してください。 これにより、すべての呼び出し箇所を変更することなく、SDKが改善を行い、利用可能な追加のデータを活用できるようになります。

以下はAPIに関するメモであり、最終的なものではありません。

StartSpan(context, options) -> context スパンが開始されると、新しいスパンが現在のスパンとして設定された新しいコンテキストが返されます。

GetSpanPropagator() -> (HTTP_Extractor, HTTP_Injector) スパンが抽出されると、抽出された値は現在のスパンとは別にコンテキストに格納されます。

Correlations API

トレースの伝搬に加えて、OpenTelemetryはインデックスを伝搬するためのシンプルなメカニズムを提供します。 これはCorrelations APIと呼ばれます。 Correlationsは、同一トランザクション内の先行するサービスによって提供された属性を用いて、あるサービスでのobservabilityイベントにインデックスを付けることを目的としています。 これにより、これらのイベント間の因果関係を確立するのに役立ちます。 たとえば、特定のブラウザバージョンが画像処理サービスにおける障害と関連していることを判断する場合などです。

Correlations APIはW3C Baggage仕様に基づいており、そのワーキンググループで定義されているプロトコルを実装しています。 このAPIを最終的なものにすることは本OTEPの範囲外であるため、ここで提供する詳細はわずかです。

Correlationsは他のクロスカッティングコンサーンのプロトタイプ作成に使用できますが、このメカニズムは主にOpenTelemetryのobservabilityシステムのために値を伝えることを目的としています。

後方互換性のために、OpenTracingのBaggageはOpenTracingブリッジを使用する際にCorrelationsとして伝搬されます。 異なる基準を持つ新しいコンサーンは、同じ基盤となるコンテキスト伝搬レイヤーを構成要素として使い、別々にモデル化されるべきです。

以下は例示的なAPIであり、最終的なものではありません。

GetCorrelation(context, key) -> value 先行するイベントによって設定されたラベルの値にアクセスするために、Correlations APIはコンテキストとキーを入力として受け取り、値を返す関数を提供します。

SetCorrelation(context, key, value) -> context ラベルの値を記録するために、Correlations APIはコンテキスト、キー、値を入力として受け取り、新しい値を含む更新されたコンテキストを返す関数を提供します。

RemoveCorrelation(context, key) -> context ラベルを削除するために、Correlations APIはコンテキストとキーを入力として受け取り、選択したキーバリューペアを含まない更新されたコンテキストを返す関数を提供します。

ClearCorrelations(context) -> context ラベルを信頼できないプロセスに送信しないようにするために、Correlations APIはコンテキストからすべてのCorrelationsを削除する関数を提供します。

GetCorrelationPropagator() -> (HTTP_Extractor, HTTP_Injector) 先行するプロセスによって設定された以前のラベルをデシリアライズし、現在のラベルの合計セットをシリアライズして次のプロセスに送信するために、Correlations APIはPropagation APIにあるHTTPExtract関数とHTTPInject関数のCorrelations固有の実装を返す関数を提供します。

コンテキスト伝搬

Context API

クロスカッティングコンサーンは、同じ共有コンテキストオブジェクトを使用してプロセス内でデータにアクセスします。 各コンサーンは、そのクロスカッティングコンサーンのすべてのデータを含む、コンテキスト内の名前空間で区切られた独自のキーセットを使用します。

以下は例示的なAPIであり、最終的なものではありません。

CreateKey(name) -> key コンサーンがそれぞれのデータへのアクセスを制御できるようにするために、Context APIは、キーを明示的に渡されていないサードパーティが推測できないキーを使用します。 コンサーンは、キーへの直接的なパブリックアクセスを提供するのではなく、API経由でデータアクセスを仲介することが推奨されます。

GetValue(context, key) -> value コンサーンのローカルな状態にアクセスするために、Context APIはコンテキストとキーを入力として受け取り、値を返す関数を提供します。

SetValue(context, key, value) -> context クロスカッティングコンサーンのローカルな状態を記録するために、Context APIはコンテキスト、キー、値を入力として受け取り、新しい値を含む新しいコンテキストを返す関数を提供します。 新しい値は古いコンテキストには存在しないことに注意してください。

RemoveValue(context, key) -> context RemoveValueは、キーがクリアされた新しいコンテキストを返します。 削除された値は、古いコンテキストには依然として存在することに注意してください。

オプション:自動コンテキスト管理

可能な場合、OpenTelemetryのコンテキストはプログラムの実行コンテキストに自動的に関連付けられるべきです。 一部の言語には、現在のコンテキストを設定・取得するための機能が提供されていないことに注意してください。 このような場合、現在のコンテキストを管理する責任はユーザーにあります。

GetCurrent() -> context プログラムの実行に関連付けられたコンテキストにアクセスするために、Context APIは引数を取らずContextを返す関数を提供します。

SetCurrent(context) コンテキストをプログラムの実行に関連付けるために、Context APIはContextを引数に取る関数を提供します。

Propagation API

クロスカッティングコンサーンは、propagatorを介して自身の状態を次のプロセスに送信します。 propagatorとは、RPCリクエストへコンテキストを読み書きする関数です。 各コンサーンは、サポートされている媒体の種類ごとに一連のpropagatorを作成します。 現在サポートされているのはHTTPリクエストのみです。

以下は例示的なAPIであり、最終的なものではありません。

Extract(context, []http_extractor, headers) -> context トランザクションの早い段階で注入されたデータの送信を継続するために、Propagation APIはコンテキスト、一連のHTTP_Extractor、一連のHTTPヘッダーを入力として受け取り、先行するプロセスから送信された状態を含む新しいコンテキストを返す関数を提供します。

Inject(context, []http_injector, headers) -> headers トランザクション内の次のプロセスへすべてのコンサーンのデータを送信するために、Propagation APIはコンテキスト、一連のHTTP_Injectorを入力として受け取り、コンテキストの内容をHTTPヘッダーに追加してコンテキストのHTTPヘッダー表現を含める関数を提供します。

HTTP_Extractor(context, headers) -> context 各コンサーンは、HTTP形式のデータを含むヘッダーを特定し、その内容をインメモリ表現に変換して、返されるコンテキストオブジェクト内に設定するHTTP_Extractorを実装しなければなりません。

HTTP_Injector(context, headers) -> headers 各コンサーンは、指定されたコンテキストオブジェクトから自身のデータのインメモリ表現を取得し、それを既存のHTTPヘッダーのセットに追加するHTTP_Injectorを実装しなければなりません。

オプション:グローバルPropagator

プログラムの初期化時にpropagatorのリストを作成し、プログラムの後半でこれらのpropagatorにアクセスできるようにすると便利な場合があります。 これを容易にするために、グローバルなinjectorとextractorがオプションで利用可能です。 ただし、この機能を使用する必要はありません。

GetExtractors() -> []http_extractor グローバルなextractorにアクセスするために、Propagation APIはextractorを返す関数を提供します。

SetExtractors([]http_extractor) グローバルなextractorを更新するために、Propagation APIはextractorを引数に取る関数を提供します。

GetInjectors() -> []http_injector グローバルなinjectorにアクセスするために、Propagation APIはinjectorを返す関数を提供します。

SetInjectors([]http_injector) グローバルなinjectorを更新するために、Propagation APIはinjectorを引数に取る関数を提供します。

プロトタイプ

Erlang: https://github.com/open-telemetry/opentelemetry-erlang-api/pull/4 Go: https://github.com/open-telemetry/opentelemetry-go/pull/381 Java: https://github.com/open-telemetry/opentelemetry-java/pull/655 Python: https://github.com/open-telemetry/opentelemetry-python/pull/325 Ruby: https://github.com/open-telemetry/opentelemetry-ruby/pull/147 C#/.NET: https://github.com/open-telemetry/opentelemetry-dotnet/pull/399

擬似コードで書かれたいくつかの例を見てみると理解の助けになるでしょう。 この擬似コードは、単純な関数とイミュータブルな値のみを使用していることに注意してください。 ミュータブルなオブジェクト指向言語のほとんどは、多くの場合コンテキストオブジェクトをカプセル化しユーザーから隠すために、Spanオブジェクトのようなオブジェクトを使用します。

service AclientからのHTTPリクエストに対して、service Bへのリクエストの結果で応答する、シンプルなシナリオを説明しましょう。

client -> service A -> service B

さて、上記のシステムにおけるclientがバージョン1.0であるとします。 clientのバージョンv2.0では、service Aは正しいデータを返すために、service Bの代わりにservice Cを呼び出さなければなりません。

client -> service A -> service C

この例では、service Aにクライアントバージョンに基づいてどちらのバックエンドサービスを呼び出すかを決定してほしいと考えています。 また、service Cへのリクエストがservice Bよりも遅いのか速いのかを理解するために、システム全体をトレースしたいとも考えています。 service Aはどのようになるでしょうか。

グローバル初期化

まず、プログラムの初期化時に、service Aはcorrelationとtracingの伝搬を設定し、それらをinjectorとextractorのグローバルリストに含めます。 このトレーシングシステムはB3を使用するように設定されており、その形式専用のpropagatorを持っているとします。 propagatorの初期化は次のようになります。

func InitializeOpentelemetry() {
  // create the propagators for tracing and correlations.
  bagExtract, bagInject = Correlations::HTTPPropagator()
  traceExtract, traceInject = Tracer::B3Propagator()
  
  // add the propagators to the global list.
  Propagation::SetExtractors(bagExtract, traceExtract)
  Propagation::SetInjectors(bagInject, traceInject)
}

HTTPヘッダーからのextractとinject

これらのpropagatorは、service Aのリクエストハンドラーで使用できます。 tracingとcorrelationsのコンサーンは、コンテキストオブジェクトを使用して、それらが組み込まれている関数のカプセル化を壊すことなく状態を扱います。

func ServeRequest(context, request, project) -> (context) {
  // Extract the context from the HTTP headers. Because the list of
  // extractors includes a trace extractor and a correlations extractor, the
  // contents for both systems are included in the  request headers into the
  // returned context.
  extractors = Propagation::GetExtractors()
  context = Propagation::Extract(context, extractors, request.Headers)

  // Start a span, setting the parent to the span context received from
  // the client process. The new span will then be in the returned context.
  context = Tracer::StartSpan(context, [span options])
  
  // Determine the version of the client, in order to handle the data
  // migration and allow new clients access to a data source that older
  // clients are unaware of.
  version = Correlations::GetCorrelation( context, "client-version")

  switch( version ){
    case "v1.0":
      data, context = FetchDataFromServiceB(context)
    case "v2.0":
      data, context = FetchDataFromServiceC(context)
  }

  context = request.Response(context, data)

  // End the current span
  Tracer::EndSpan(context)

  return context
}

func FetchDataFromServiceB(context) -> (context, data) {
  request = NewRequest([request options])
  
  // Inject the contexts to be propagated. Note that there is no direct
  // reference to tracing or correlations.
  injectors = Propagation::GetInjectors()
  request.Headers = Propagation::Inject(context, injectors, request.Headers)

  // make an http request
  data = request.Do()

  return data
}

自動コンテキスト伝搬によるAPIの簡素化

上記の擬似コードのこのバージョンでは、コンテキストオブジェクトが明示的であり、通常のパラメーターとしてすべての関数から渡され、返されると仮定しています。 これは煩雑であり、多くの言語では、コンテキストを自動的に伝搬できるメカニズムが存在します。

この擬似コードのバージョンでは、現在のコンテキストがスレッドローカルとして保存でき、すべての関数に暗黙的に渡され、返されると仮定します。

func ServeRequest(request, project) {
  extractors = Propagation::GetExtractors()
  Propagation::Extract(extractors, request.Headers)
  
  Tracer::StartSpan([span options])
  
  version = Correlations::GetCorrelation("client-version")
  
  switch( version ){
    case "v1.0":
      data = FetchDataFromServiceB()
    case "v2.0":
      data = FetchDataFromServiceC()
  }

  request.Response(data)
  
  Tracer::EndSpan()
}

func FetchDataFromServiceB() -> (data) {
  request = newRequest([request options])
  
  injectors = Propagation::GetInjectors()
  Propagation::Inject(request.Headers)
  
  data = request.Do()

  return data
}

propagatorの実装

トレーシングシステムの詳細を掘り下げると、スパンコンテキストpropagatorの内部はどのようになっているのでしょうか。 以下は、明示的なコンテキストを使用してB3ヘッダーをextractおよびinjectする大まかな例です。

  func B3Extractor(context, headers) -> (context) {
    context = Context::SetValue( context,
                                 "trace.parentTraceID",
                                 headers["X-B3-TraceId"])
    context = Context::SetValue( context,
                                "trace.parentSpanID",
                                 headers["X-B3-SpanId"])
    return context
  }

  func B3Injector(context, headers) -> (headers) {
    headers["X-B3-TraceId"] = Context::GetValue( context, "trace.parentTraceID")
    headers["X-B3-SpanId"] = Context::GetValue( context, "trace.parentSpanID")

    return headers
  }

コンサーンの実装

次に、StartSpanがコンテキストをどのように利用するかについての大まかな例を見てみましょう。 このコードは、上記のpropagatorがデータを格納するコンテキストキーに関する内部の詳細を知っていなければならないことに注意してください。 この擬似コードでは、再びコンテキストがスレッドローカルとして暗黙的に渡されると仮定します。

  func StartSpan(options) {
    spanData = newSpanData()

    spanData.parentTraceID = Context::GetValue( "trace.parentTraceID")
    spanData.parentSpanID = Context::GetValue( "trace.parentSpanID")

    spanData.traceID = newTraceID()
    spanData.spanID = newSpanID()

    Context::SetValue( "trace.parentTraceID", spanData.traceID)
    Context::SetValue( "trace.parentSpanID", spanData.spanID)

    // store the spanData object as well, for in-process propagation. Note that
    // this key will not be propagated, it is for local use only.
    Context::SetValue( "trace.currentSpanData", spanData)

    return
  }

現在のコンテキストのスコープ

自動コンテキスト伝搬に関連する他のいくつかのシナリオを見てみましょう。

現在のコンテキストの値はいつ利用可能になるのでしょうか。 スコープ管理は言語ごとに異なる場合がありますが、(たとえばスレッドを切り替えるなどして)スコープが変わらない限り、現在のコンテキストはプログラムの実行に追従します。 これには関数がリターンした後も含まれます。 コンテキストオブジェクト自体はイミュータブルであるため、現在のコンテキストが変更されても、以前のコンテキストへの明示的なハンドルは更新されないことに注意してください。

func Request() {
  emptyContext = Context::GetCurrent()
  
  Context::SetValue( "say-something", "foo")
  secondContext = Context::GetCurrent()
  
  print(Context::GetValue("say-something")) // prints "foo"
  
  DoWork()
  
  thirdContext = Context::GetCurrent()
  
  print(Context::GetValue("say-something")) // prints "bar"

  print( emptyContext.GetValue("say-something") )  // prints ""
  print( secondContext.GetValue("say-something") ) // prints "foo"
  print( thirdContext.GetValue("say-something") )  // prints "bar"
}

func DoWork(){
  Context::SetValue( "say-something", "bar")
}

複数コンテキストの参照

コンテキスト伝搬が自動である場合、ユーザーがコンテキストオブジェクトを直接参照する必要が生じることはあるのでしょうか。 時にはあります。 自動コンテキスト伝搬が利用可能な選択肢である場合でも、コンサーンが常に現在のコンテキストにのみアクセスしなければならないという制約はありません。

たとえば、あるコンサーンが2つのコンテキスト間でデータをマージしたい場合、少なくとも一方は現在のコンテキストではないことになります。

mergedContext = MergeCorrelations( Context::GetCurrent(), otherContext)
Context::SetCurrent(mergedContext)

明示的なコンテキストへのフォールバック

エッジケースを処理するために、明示的なコンテキストを使用する関数の追加バージョンを提供することが必要な場合があります。 たとえば、場合によっては、extractされたコンテキストが現在のコンテキストとして設定されることを意図していないことがあります。 これを処理するために、代替のextractメソッドをAPIに追加できます。

// Most of the time, the extract function operates on the current context.
Extract(headers)

// When a context needs to be extracted without changing the current
// context, fall back to the explicit API.
otherContext = ExtractWithContext(Context::GetCurrent(), headers)

内部の詳細

図

パッケージレイアウトの例

  Context
    ContextAPI
  Observability
    Correlations
      CorrelationAPI
      HttpInjector
      HttpExtractor
    Metrics
      MetricAPI
    Trace
      TracerAPI
      HttpInjector
      HttpExtractor
  Propagation
    Registry
    HttpInjectorInterface
    HttpExtractorInterface

エッジケース

暗黙の親を操作するtracerの操作のために、ワイヤーからextractされたスパンコンテキストとプロセス内のスパンを管理する際に生じ得るいくつかの複雑な問題があります。 コンテキストキーが期待される型のオブジェクトを参照していること、そして適切な暗黙の親が使用されることを保証するために、以下の規則が定められています。

Extract

リモートコンテキストをextractする場合、extractされたスパンコンテキストは現在のスパンとは別に格納されなければなりません(MUST)。

デフォルトのスパンの親子関係

コンテキストから新しいスパンが作成されるとき、そのスパンにはデフォルトの親を割り当てることができます。 割り当ての順序は次のとおりです。

  • 現在のスパン。
  • extractされたスパン。
  • ルートスパン。

Inject

ワイヤー越しに送信するためにスパンをinjectする場合、デフォルトの割り当ての順序は次のとおりです。

  • 現在のスパン。
  • extractされたスパン。

デフォルトのHTTPヘッダー

OpenTelemetryは現在、コンテキスト伝搬のために2つの標準的なヘッダー形式を使用しています。 それらのプロパティと要件は、OpenTelemetryのAPIに統合されています。

スパンコンテキスト - OpenTelemetryのSpan APIは、W3C Trace Context仕様で定義されているtraceparentヘッダーとtracestateヘッダーをモデルにしています。

Correlationコンテキスト - OpenTelemetryのCorrelations APIは、W3C Baggage仕様で定義されているBaggageヘッダーをモデルにしています。

コンテキスト管理とプロセス内伝搬

Contextが機能するためには、それが表すコードの実行に常にバインドされたままである必要があります。 デフォルトでは、これはプログラマがコンテキストを関数パラメーターとしてコールスタックに渡さなければならないことを意味します。 しかし、多くの言語はスレッドローカルなどの自動化されたコンテキスト管理機能を提供しています。 OpenTelemetryは、自動的なコンテキスト管理を提供するために、利用可能な場合はこれらの機能を活用すべきです。

既存のコンテキスト実装

一部の言語には、広く使われている単一のコンテキスト実装が存在します。 他の言語では、実装が多すぎたり、まったく存在しなかったりします。 たとえば、Goにはcontext.Contextオブジェクトがあり、それをコールスタックに渡す方法について広く普及した規約があります。 JavaにはMDCや他のいくつかのコンテキスト実装がありますが、その存在を保証あるいは前提とできるほど広く使われているものはありません。

非常に明確な既存の選択肢が存在しない場合、OpenTelemetryは独自のコンテキスト実装を提供すべきです。

FAQ

複雑な伝搬の振る舞いについて

一部のOpenTelemetry提案では、より複雑な伝搬の振る舞いが求められてきました。 たとえば、W3C Trace-Contextヘッダーが見つからない場合にB3ヘッダーのextractにフォールバックする、といったものです。 「フォールバックpropagator」やその他の複雑な振る舞いは、Propagatorインターフェースの背後にある実装の詳細としてモデル化できます。 したがって、伝搬システム自体は、propagatorを連結するためのメカニズムやその他の追加機能を提供する必要はありません。

先行技術と代替技術

先行技術:

  • OpenTelemetryの分散コンテキスト
  • OpenCensusのpropagator
  • OpenTracingのスパン
  • gRPCコンテキスト

リスク

Correlations APIはW3C Baggage仕様に関連しています。 この仕様に関する作業は始まっていますが、まだ完了していません。 可能性は低いものの、このW3C仕様がCorrelations APIに必要な設計や保証から乖離する可能性はあります。

将来の可能性

OpenTelemetryをAspectとコンテキスト伝搬レイヤーにクリーンに分割することで、コンテキスト伝搬レイヤーを独立したスタンドアロンのプロジェクトへと移行できるようになるかもしれません。 これにより、コンテキスト伝搬をgRPCや他のプロジェクトと共有できるようになり、採用が促進される可能性があります。