本記事では、RustにおけるOpenTelemetry Tracerの設定をOpenTelemetryの仕様に照らして理解することを目指します。
仕様を確認しながら、どうして各種crateが現在の型や構成になっているのかを説明します。
Rust特有の話とOpenTelemetry共通の話が最初は分かりづらかったのですが、共通の概念を理解できれば他言語でも同様に設定できるようになります。
前提の確認
もろもろの前提を確認します。
まず、traceの生成についてはtracing
crateを利用します。それに伴い、tracingとopentelemetryを連携させるために、tracing-opentelemetry
crateも利用します。
なお、tracing-opentelemetryなのですが、以前はtokio-rs/tracing配下のworkspace memberとして管理されていましたが、こちらのPRで、tokio-rs/tracing-opentelemetryに移されました。
次にtraceのexportはopentelemetry-otlp
crateを利用して、gRPCでremoteにexportします。(基本的にはopentelemetry-collectorになるかと思います)
Opentelemetry-collectorやobservability-backendの立ち上げについては詳しくは触れません。
対象とするOpenTelemetryの仕様はv1.23.0
です。
仕様の参照元としては、公式のwebsiteとopentelemetry-specification
repositoryがあります。
versionを固定したlinkを貼りやすいので、本記事ではrepositoryを参照します。
仕様の範囲ですが、概ね、Traceと関連する共通項目についてみていきます。
そもそも、Opentelemetryとは、という話は以前のRustでOpenTelemetryをはじめようの方に書いたのでよければ読んでみてください。
具体的なcodeの概要
まずは本記事で扱う、traceをexportするための設定を行うcodeを確認します。のちほど詳しく見ていくので、ここで理解できなくても大丈夫です。
関連する依存crateは以下です。
[]
= { = "=0.19.0", = ["rt-tokio"] }
= "=0.12.0"
= "=0.11.0"
= "0.1.38"
= "=0.19.0"
= "0.3.17"
# ...
async
まずmain.rs
では、2つのことを行います。
- opentelemetry関連の初期化処理
- tracing-subscriberの設定
/// Guard to perform opentelemetry termination processing at drop time.
;
init_opentelemetry()
では、opentelemetry関連の初期化処理を実施し、終了処理をdrop時に行うOtelInitGuard
を返します。
この型は外部crateの型ではなくapplication側の実装です。opentelemetry::global::set_text_map_propagator()
はcontext propagation関連の準備を行っています。
Context propagationについては別の記事で詳しく触れたいと思います。
ここでやりたいのは、application終了時に、opentelemetry::global::shutdown_tracer_provider()
が確実に呼ばれるようにすることです。
tracing subscriberの設定
次にtracing subscriberの設定を行います。
ここでは、tracing subscriberを設定し、tracing_opentelemetry::OpenTelemetryLayer
を設定します。
tracing subscriberやlayerについてはtracing/tracing-subscirberでログが出力される仕組みを理解するで書いたのでここでは特に説明しません。
概要としては、tracing::info!()
すると、そこで生成されたlog(event)がOpenTelemetryLayer
に渡され、OpenTelemetryの仕様に定められたtraceに変換されたのちに、exportされるといった流れです。
要点としては、tracing/tracing-subscriberの仕組みのおかげで、ここで見ているapplication初期化時以外では、traceがopentelemetryの仕組みで処理されるといったことを意識しなくてよくなっているということです。
逆にいうと、opentelemetryにおけるtraceのデータが具体的にどうなるかは、tracing::info!()
といった、tracing apiとtracing_opentelemetryによる変換処理次第となります。
もっとも、tracing/tracing_opentelemetryによるopentelemetryの抽象化は徹底されているわけではなく、他サービスをnetwork越しに呼んだ場合にもtraceを連携させるcontext propagation等の仕組みを利用する上では、opentelemetryを利用していることを意識する必要がでてきます。
Tracerの設定
ここがメインとなる、Tracerの設定です。
どの型がどのcrateから来ているかわかりやすくするために、長くなりますが、crateから書いています。 まず、以下のcrateが出てきます。
opentelemetry
opentelemetry_otlp
opentelemetry_semantic_convensions
opentelemetry
crateはopentelemetry_api
とopentelemetry_sdk
をre-exportしているだけのcrateです。
これらのcrateはそれぞれ対応する仕様やその実装に対応しているので、この設定項目の仕様を理解することを目指します。
crate間の関係
まずはこれらのcrate間の関係を整理します。
まずApplicationはtracingのapiを利用して計装します。
具体的には、tracing::instrument
や、info_span!()
, info!()
等を組み込みます。
Applicationが実行されるとtracingの情報はtracing_subscriberによって処理されるので、subscriberの処理にopentelemetryを組み込むためにtracing_opentelemetryを利用します。
tracing_opentelemetryはopentelemetry_apiに依存しており、実行時にその実装をinjectする必要があります。
opentelemetry_sdkがその実装を提供してくれているので、それを利用するのですが、opentelemetry_sdkはplugin構造になっており、protocol依存な箇所はsdkには組み込まれていません。
今回はgRPCを利用してtraceをexportするのですが、そのgRPC関連の実装はopentelemetry_otlpによって提供されます。
そして、opentelemetry_otlpはopentelemetry_sdkの設定を行うhelper関数を公開してくれているので、applicationではそれを利用します。
概ねこのような役割分担となっています。
Traceの生成からexportまでの流れ
メンタルモデルとして、traceが生成されてからexportされるまでの流れを確認します。
それぞれのcomponentの詳細はのちほど確認しますが、全体の流れとしては上記のようになっております。
spanがdropされたり、#[tracing::instrument]
が付与された関数を抜けると、tracing apiによって、subsriberのon_close()
が呼ばれます。 subscriberはlayered構成になっているので、各layerのspan終了処理が走ります。OpenTelemetryLayer
はここで、tracingのspan情報をopentelemetryのspanに変換を行い、TracerProvider
を経由して、SpanProcessor
にexportするspanを渡します。
SpanExporter
はbatchやtimeout,retry処理等を行い、serializeやprotocolの処理を担うSpanExporter
がnetwork越しのopentelemetry collectorにspanをexportします。
OpenTelemetryLayer
から先のTracer
についてはrustの独自実装ではなく、opentelemetryの仕様に定められています。
このSDKがどのように実装されているかの仕様というのがopentelemetry projectの特徴だと思っています。
APIとSDK
OpenTelemetryの仕様にはAPIとSDKという用語がよくでてきます。
どちらもpackageです。packageもotel用語で各種プログラミング言語のlibraryを抽象化したものです。
ですので、rustにも、opentelemetry_apiとopentelemetry_sdk crateがあります。
ただ、opentelemetry_apiとopentelemetry_sdkは基本的に同一versionで利用されることが想定されているので、opentelemetry crateが両者をre-exportしています。
pub use *;
pub use runtime;
SDKはopentelemetry::sdk
で参照できますが、APIはopentelemetry
直下なので、最初は混乱してしまいました。(かといって、opentelemetry::api
にするとtoplevelになにもitemがなくなってしまうので結果的に今の状態が良いとは思います)
次にAPIとSDKの関係なのですが、公式の以下の図がわかりやすいです。
図自体は、Opentelemetryの各種packageのmajor versionのsupport期間についてなのですが、登場人物とpackageの関係がわかりやすいので引用しました。
まずOpenTelemetryに関わる開発者は以下の3つのroleに分類されます。
- Application Owner
- Instrumentation Author
- Plugin Author
まずApplication Ownerですが、そのままapplicationのmaintainer(開発者)です。SDKを設定する責務を持ちます。
Instrumentation AuthorはAPIを利用して、traceやmetricsを設定する人を指します。library(package)にOpenTelemetryを組み込んでいるlibraryのmaintainerやapplication ownerもinstrumentation authorに含まれます。
Plugin AuthorはSDKのpluginのmaintainerです。本記事でいうと、opentelemetry_otlpがpluginにあたります。
Application Ownerから、SDKのConstructorとPluginのConstructorに矢印が伸びています。
// 👈 Plugin Constructor
new_pipeline .tracing
.with_trace_config
Plugin AuthorからSDKのPlugin Interfaceに矢印が伸びていますが、これはPluginがSDKのtraitを実装しているということです。具体例はのちほど見ていきます。
Instrumentation AuthorからAPIへ伸びている矢印そのままはAPIを利用していることを指しています。
また、APIがSDKをwrapしているような絵になっているところはAPIの実装はSDKへのdelegateになっており、初期状態だとなにもしない実装(Noop)が利用されるようになっています。
この点も公式にわかりやすい図があるので引用します。
例えば、初期状態では以下のようにNoopTracerProvider
が返るようになっていたりします。
/// The global `Tracer` provider singleton.
static GLOBAL_TRACER_PROVIDER: = new;
opentelemetry_api::global::trace
このnoop実装を切り替える処理はSDKの初期化処理によって行なわれます。
APIとSDKの関係の概要を説明したので、次からはいよいよcodeを見ていきます。
opentelemetry_api::trace::Trace
Applicationが実行するOpenTelemetry関連の初期化処理のゴールはtracing_opentelemetry::layer().with_tracer()
を利用して、tracing subscriberにtracing_opentelemetry::OpenTelemetryLayer
を渡すことです。 そして、そのconstruct処理では以下のようにtrait boundとして、opentelemetry_api::trace::Trace
が指定されています。(PreSampledTracer
の説明は割愛)
use trace as otel;
このopentelemetry::trace::Trace
traitは仕様で定義されたものの、rustにおける実装となっています。
仕様では
The tracer is responsible for creating Spans. Note that Tracers should usually not be responsible for configuration. This should be the responsibility of the TracerProvider instead.
Tracerの責務はSpanの生成であり、設定に関してはTracerProviderが担うとあります。
OpenTelemetryにおけるtraceは実体としてはSpanのtreeなので、OpenTelemetryLayer
がSpanの生成のみの責務をもつTracerを要求するのは自然に思えます。
そして、SDKのTracerはAPIのTracer traitをimplしています。
なので、今回の例では以下のようにSDKのTracerを生成しています。
opentelemetry_otlp
によるTracerの設定
ということで、SDKのTracerをconstructすることがゴールとわかりました。
さきほどAPIとSDKの関係で述べたようにSDKを設定してconstructする処理はSDKのPluginが提供してくれるのがOpenTelemetryのdesignのようです。
opentelemetry_otlp
も設定を行った後install_batch()
を呼ぶと、Result<sdk::trace::Tracer,_>
を返すようになっています。
ここで、最後のconstruct処理がbuild()
ではなく、installというどことなく副作用を感じさせる命名になっているのは副作用があるからです。
Normally, the TracerProvider is expected to be accessed from a central place. Thus, the API SHOULD provide a way to set/register and access a global default TracerProvider.
と、APIはdefaultのTracerProviderへのアクセスをglobal変数で提供すべきとされています。
これをうけて上記の処理の中で、SDK Tracer生成の際に作成したTracerProviderをglobal変数にセットする処理があります。
opentelemetry_sdk::trace
の各種struct
各種設定の詳細を確認する前にtraceのexport時に登場するstructの概要を把握しておきます。
概ね上記のstructが、trace(span)の生成からexportに関与します。
これからみていく設定はこれらのcomponentに関する設定を行います。
実装にはasync runtime(tokio,async-std)の抽象化や、futures::stream::FuturesUnordered
を利用したtask管理等参考になる処理が多くあるのですが今回は割愛します。
最初にopentelemetry_otlp::new_pipeline()
を見たとき、パイプラインとは?となりました。
今は、上記の図でいう、TracerProvider -> BatchSpanProcessor -> SpanExporterをpipelineと表現しているのだなと考えています。
opentelemetry::sdk::trace::Config
new_pipeline
.tracing
.with_trace_config
with_trace_config()
にsdk::trace::Config
を渡します。
まず仕様を確認します。さきほどみたように、Tracerは状態をもたず状態(=設定)の責務はTracerProviderにあります。
そこで、今回はTracerProviderのSDKに関する仕様をみてみると、Tracer Provider Configurationに
Configuration (i.e., SpanProcessors, IdGenerator, SpanLimits and Sampler) MUST be owned by the the TracerProvider. The configuration MAY be applied at the time of TracerProvider creation if appropriate.
Traceに関する設定(SamplerやIdGeneraor)はTracerProviderの作成時に適用させるといった記述があります。
これをうけて、opentelemetry_sdk::trace::Config
ではSDKの仕様に定められた設定をConfig
で実装しています。
IdGenetaror
default
.with_id_generator
ここでは、TraceId,SpanIdの生成の責務をもつIdGeneratorを作成します。
これらのIdはその名の通りtraceやspanの識別子です。仕様では
TraceId A valid trace identifier is a 16-byte array with at least one non-zero byte. SpanId A valid span identifier is an 8-byte array with at least one non-zero byte.
と定義されています。
また、OpenTelemetryはW3C TraceContext specificationに準拠しており、たびたびこちらの仕様が参照されます。
これをうけて、SDKの仕様では
The SDK MUST by default randomly generate both the TraceId and the SpanId.
と定義されており、Idの実装を提供してくれています。
opentelemetry_sdk::trace
はRandomIdGenetor
を提供しているのですが、今回の例では利用していません。
代わりにopentelemetry_sdk::trace::id_generator::aws::XrayIdGenerator
という、唐突にAWSのXrayIdGeneratorを利用しています。 これは、AWS XRayのtrace idにはtrace idにtimestampを載せるという仕様があるために、randomに生成されたidだとXRay上で利用できないためです。
個人的にはこの制約には非常に不満をもっています。
といいますのも、OpenTelemetryを利用したいモチベーションのひとつにapplication(計装されたコード)にobservability backendやvendorの概念を持ち込まないというものもがあります。
このおかげで、applicationはopentelemetry collectorへのexportだけを考慮して、あとの事情はcollector側で吸収できるという設計です。
しかしながら、XRayがtrace idに独自の制約が設定されているために、application側でtraceがどこにexportされるかを意識しなくならなければならなくなりました。
ただ、XRayIdgeneratorを利用しても、OpenTelemetryのtrace idの仕様には反していないので、現状ではXRayを利用するかに関わらず、XRayIdGeneratorを利用しています。
SDKの仕様には
Additional IdGenerator implementing vendor-specific protocols such as AWS X-Ray trace id generator MUST NOT be maintained or distributed as part of the Core OpenTelemetry repositories.
とあり、"Core OpenTelemetry repositories"にはvendor依存な処理はいれてはならないとあります。
が、今のところは、XRayIdGeneratorはopentelemetry_sdk::trace::id_genrarator::aws
に定義されています。
issueでもこの点は議論されていました。
Sampler
次はSamplerについてです。
default
.with_sampler
Samplingについてはcontext propagationと関連するので、別で詳しく扱いたいと思っています。
ここでは、traceをすべて取得して、export、永続化するとruntimeのcost、network cost, storage costといろいろ大変なので、全体のn%を取得できるようにする機構という程度の理解でいきます。
SDKの仕様で、いくつかbuiltinの実装が提供されています。
ParentBased
の意味ですが、既にSampleするかの判定がなされていた場合はそれに従い、初めてsampleするかの判定を行う場合は、引数のsampling_ratioに従うという設定を実施しています。
具体的には、graphqlのようなserver側のcomponentを設定している場合にgraphqlのrequestを実施するclient側で既にopentelemetryのsampling判定が実施され、それがhttp header等でserverにpropagate(context propagagation)されていた場合には、graphql serverはそれに従います。
Resource
次はResourceについてです。
default
.with_resource
Resourceとは何かというと、traceに付与するkey valutのmetadataです。
仕様には
A Resource is an immutable representation of the entity producing telemetry as Attributes.
と定義されています。key valutのデータ構造も仕様で定義されており、こちらはAttributeと言われます。
Resourceはtraceだけでなく、metricsでも利用されます。また、AttributeはResourceに限らずSpan eventsであったり、key valueの情報を付与するcontextで参照されるデータ構造です。最大サイズであったり、空文字のkeyが禁止されていたりといったことが規定されています。
Resourceで表現する情報ですが、traceがどこから来たかについてを表現します。
また、その際に用いるkeyにはsemantic conventionsを利用します。
Semantic conventionsはResourceに限らず、OpenTelemetry全体に適用される命名規則です。
例えば、traceを生成したapplicationを指したい場合はservice.name
を利用します。
他にも様々なattributeが規定されています。traceにはrequestしたuserの識別子を載せたくなるのが一般的かと思いますがその際のkeyはどうされているでしょうか。
user.id
を使ってしまいそうですが、General identity attributesとして、enduser.id
が指定されています。
ちなみにAWS XRayでは、enduser.id
をXRay側のuserの識別子に変換してくれたりするので、semantic conventionsに従っておくと、ecosystemの恩恵に預かれるメリットがあります。
productionやstagingといった、deployする環境についても、deployment.environment
と定められています。
このmetadataのkeyが仕様で定められているのは非常に重要だと考えています。
この仕様のおかげで、logやtraceをqueryする際に、query ... condition deployment.environment = "production"
のような条件が書けます。
Rustではsemantic conventionsはopentelemetry_semantic_conventions
に定義されているので、できるだけこちらを参照すると良いと思います。
Schema URL
from_schema_url,
)
Resouce生成時に指定している、schema urlについて。
Telemetry Schemasとして仕様に定められている仕組みです。
概要としては、semantic conventionsのversionのようなもので、これによって、semantic conventionsに破壊的変更があっても既存のecosystemが壊れないような仕組みと理解しています。
基本的には、Resource作成時に指定できるようなapiになっているので、自分が参照したsemantic conventionsのversionをいれておけば、関連するecosystem側でよしなにしてくれることが期待できます。
具体例としては、production,stagingといった情報をenvironmentで表現したが、deployment.environmentに変更しても、query側が壊れないような例が挙げられていました。
QueryはAlertで利用されると思うので、Alertingを壊してしまうと、実質的にapplication側で最新のsemantic conventionsを利用できなくなってしまいます。
OpenTelemetryでは最新のversionへfearlessにupgradeできることが重視されていることがこういった機構からも伝わってきます。
Export
最後にtraceのexport関連の設定を確認します。
new_pipeline
.tracing
.with_trace_config
.with_batch_config
.with_exporter
BatchConfig
traceはexportされる際に一定程度、まとめてからexportされます。この挙動を実装しているのが、BatchSpanProcessorで、SDK側で実装されています。
TracerProvider -> SpanProcessor(BatchSpanProcessor) -> SpanExporterのように、export前にSpanProcessorというlayerを用意している理由ですが、Plugin側を薄くするためだと考えています。
仮にSpanProcessorがなく、traceが直接SpanExporterに渡されたとすると、Plugin(SpanExporter)は担当するprotocol処理以外にもbatch処理を時前で実装する必要があります。
Batch処理において指定できる設定もSDKの仕様で定められています。
OpenTelemetryに仕様の特徴として、設定できる項目のdefault値も仕様で定められています。
例えば、batch処理の際に用いられるmaxQueueSize
のdefault値は2048です。
これはRust特有ということでなく、すべての言語に当てはまります。これによって、言語ごとに、微妙に挙動が違うということがなくなってきます。(もちろん実装次第ですが)
仕様のdefault値はRustらしく、Default::default
で表現されているので、上の例では特に設定せずに利用しています。
また、Rust(opentelemetry_sdk
)の場合、max_concurrent_exports
も指定でき、同時にtokio::task::span
する最大taskも指定できます。(defaultは1)
ExporterConfig
最後にgRPCとして、exportする設定を行っています。
gRPCの実装にはtonicを利用しました。tonic以外にも、grpcioやhttpも選択できます。
まとめ
簡単にですが、rustでOpenTelemetryを利用する際のTracer関連の設定についてみてきました。
最初は、なんでこんないろんなcrateでてくるんだと思ったりしたのですが、仕様を知ると、確かに実装するならこうなるなといろいろ納得できました。
次はMetricsについて書く予定です。