OpenTelemetryのTracingについてまとめる Node.jsのSpanとContext管理編
これは何
OpenTelemetryのTraceシグナルについて理解するため,Node.jsでのOTel APIの実装について以下を調査する記事です.
- 1アプリケーション内でContextをどのように管理しているのか
- どのように現在の処理に対応するSpanを生成,取得するのか
(この記事の内容はopentelemetry-jsの1.30.0の内容に基づいています.)
まとめ
AsyncLocalStorage,AsyncHookを用いて1アプリケーション内でのContextの伝搬を行っているAsyncLocalStorage.runメソッドにより個々の非同期処理毎にActiveなContextを分離しているため,Contextを利用する側はContext.activeAPIを実行するだけで実行中の非同期処理に対応したContextを取得できる- Traceシグナルの場合,
tracer.startActiveSpanで新しいSpanの生成とSpanに対応する処理を実行するため,このメソッドの内部で新しいSpanに紐づいたContextを生成 +AsyncLocalStorage.run実行によるContextの更新を行っている
AsyncLocalStorageによる分離ができない処理の場合,Spanの親子関係の設定に失敗すると考えられそう.
以下ではNode.jsアプリケーションでの計装からContext管理の実装まで深ぼる形で調査した結果をまとめます.
- これは何
- まとめ
- Node.jsでのTraceの計装
- tracer.startActiveSpanの実装
- Context.withメソッドの実装
- AsyncLocalStorageの詳細
- まとめてみる
Node.jsでのTraceの計装
Node.jsで実装されたアプリケーションからTraceシグナルを取得できるよう計装する場合,tracer.startActiveSpanメソッドを使用します.
以下はOTel公式ドキュメントで紹介されている計装のサンプルコートです.
function rollOnce(i: number, min: number, max: number) { return tracer.startActiveSpan(`rollOnce:${i}`, (span: Span) => { const result = Math.floor(Math.random() * (max - min + 1) + min); span.end(); return result; }); } export function rollTheDice(rolls: number, min: number, max: number) { // Create a span. A span must be closed. return tracer.startActiveSpan('rollTheDice', (parentSpan: Span) => { const result: number[] = []; for (let i = 0; i < rolls; i++) { result.push(rollOnce(i, min, max)); } // Be sure to end the span! parentSpan.end(); return result; }); }
tracer.startActiveSpanメソッドの第二引数にActiveになったSpanを引数とするCallback関数を渡し,関数内の処理でそのSpanを利用します.
このことからtracer.startActiveSpanは
- 新規Spanの生成
- 現在AcitveになっているSpanをparentとする
- 新規SpanをContextに追加
- Callback関数の実行
上記の処理を行っていると考えられます.
次はtracer.startActiveSpanの処理を確認していきます.
tracer.startActiveSpanの実装
順に実装を見ていきます.
ActiveなContextの取得
activeメソッドで現在ActiveなContextを取得しています.
ActiveなContextには実行されているスレッドに対応するSpanなどが格納されており,これを参照することで現在実行されている処理に対応するSpanを取得できます.これによりSpan間の親子関係の紐づけが可能になっています.
この処理で取得したActive Contextをtracer.startSpanメソッドに渡して実行しています.
新規のSpanの開始
tracer.startSpanメソッドで新規のSpanを生成しています.
ここでは引数で渡されたContext(渡されない場合はActiveなContextを利用)から現在のSpanを取得し,それをParent Spanとして設定しています.
Contextへの登録
trace.setSpanメソッドを用い,新規生成したSpanをContextに追加します.
Contextに対応するオブジェクトはimmutableである(must)ため,新規に生成されたContextオブジェクトが返されています.
Contextの更新
最終行の処理ではContext APIのwithメソッドを上記で生成した新規のContextを引数として呼び出しています.
withメソッドは引数で与えたContextをActiveなContextとして引数で渡されたCallback関数を実行する関数であるため,ここで新規生成されたSpanを含むContextが設定されていると考えられます.
以降ではこのContext.withメソッドの実装について確認していきます.
Context.withメソッドの実装
Context.withメソッドの実装例は以下になります.
内部ではContextManagerクラスのwithメソッドを呼び出しています.
https://opentelemetry.io/docs/languages/js/context/#context-manager
ContextManagerクラスはAPI実行時に行われる処理を実装しています. 実装をみるとContext APIに対応する処理のほとんどを実装しているため,ほぼContext APIのinterfaceの実装クラスとして見てよさそうです.
ドキュメントよりこのクラスには現在2種類の実装がありますが、今回は推奨されているAsyncLocalStorageクラスを用いた実装を確認します.
内部では引数で受け取ったContextクラスのインスタンスを用いてAsyncLocalStorageクラスのメソッドを呼び出していました.
AsyncLocalStorageの詳細
AsyncLocalStorage クラスは、Node.js が提供する非同期コンテキスト管理のための機能です.
非同期処理ごとに独立したコンテキストを紐付けることで、その処理内で一貫したインスタンスを利用できます.
これにより,Context(およびそれに保存されたSpanインスタンス)を非同期処理毎に分離し,かつそこから実行された非同期処理ではおなじContextやSpanを利用することができます.
以下は非同期処理の開始時にMapインスタンスを生成し,それをその非同期処理内で一貫して利用するサンプルです.
import { AsyncLocalStorage } from "async_hooks"; const asyncLocalStorage = new AsyncLocalStorage<Map<string, string>>() const KEY = "key" for (let index = 0; index < 5; index++) { const delayNumSecond = 5 * Math.random() // 非同期処理毎にMapを生成する const mapPerAsyncOpe = new Map<string, string>() mapPerAsyncOpe.set(KEY, `in Operation with loop-${index}`) // AsyncLocalStorage.runに引数として渡すことで,Callback関数の処理内で一貫してgetStoreメソッドによってそのMapを利用できる asyncLocalStorage.run(mapPerAsyncOpe, () => { setTimeout(() => { // Mapインスタンスを取得 // Mapインスタンスが分離されていれば,Loopに対応するValueの値が出力されるはず const mapInsideAsyncOpe = asyncLocalStorage.getStore() as Map<string, string> console.log(mapInsideAsyncOpe.get(KEY)) // Callback関数内から実行された非同期処理内でも同じMapを利用できる setTimeout(() => { const mapInsideAsyncOpe = asyncLocalStorage.getStore() as Map<string, string> // Mapインスタンスが伝搬されていれば,Loopに対応するValueの値が出力されるはず console.log(mapInsideAsyncOpe.get(KEY)) }, 1000) }, delayNumSecond * 1000) }) }
node ➜ /workspaces/otel-in-typescript (main) $ npx ts-node sample.ts in Operation with loop-2 in Operation with loop-3 in Operation with loop-2 in Operation with loop-3 in Operation with loop-4 in Operation with loop-1 in Operation with loop-4 in Operation with loop-1 in Operation with loop-0 in Operation with loop-0 node ➜ /workspaces/otel-in-typescript (main) $
ContextManager実装クラスのactiveメソッドを見てみるとAsyncLocalStorage.getStoreを呼び出す実装になっているため,runメソッドで引数に渡したContextインスタンスをここで取得していると考えられます.
実際にActiveなSpanを取得するtrace.getActiveSpanの実装を追ってみると,最終的には上記のContextManager Classのactiveメソッドの呼び出しにたどり着きます.
まとめてみる
新規のSpan生成 -> Spanの取得までを図にまとめてみます.
graph TD
Tracer["Tracer Class"]
ContextAPI["Context API"]
ContextManager["ContextManager Class"]
AsyncLocalStorage["AsyncLocalStorage"]
TraceAPI["Trace API"]
Tracer -->|startActiveSpan で with を実行| ContextAPI
ContextAPI -->|with で with を実行| ContextManager
ContextManager -->|with で run を実行| AsyncLocalStorage
ContextManager -->|active で getStore を実行| AsyncLocalStorage
ContextAPI -->|active で active を実行| ContextManager
TraceAPI -->|getActiveSpan で active を実行| ContextAPI