対数犬度関数

140字以上のことを書きます

OpenTelemetryのTracingについてまとめる Node.jsのSpanとContext管理編

これは何

OpenTelemetryのTraceシグナルについて理解するため,Node.jsでのOTel APIの実装について以下を調査する記事です.

  • 1アプリケーション内でContextをどのように管理しているのか
  • どのように現在の処理に対応するSpanを生成,取得するのか

(この記事の内容はopentelemetry-js1.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の計装

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;
  });
}

opentelemetry.io

tracer.startActiveSpanメソッドの第二引数にActiveになったSpanを引数とするCallback関数を渡し,関数内の処理でそのSpanを利用します. このことからtracer.startActiveSpan

  • 新規Spanの生成
    • 現在AcitveになっているSpanをparentとする
  • 新規SpanをContextに追加
  • Callback関数の実行

上記の処理を行っていると考えられます.

次はtracer.startActiveSpanの処理を確認していきます.

tracer.startActiveSpanの実装

open-telemetry.github.io

github.com

順に実装を見ていきます.

ActiveなContextの取得

activeメソッドで現在ActiveなContextを取得しています.

ActiveなContextには実行されているスレッドに対応するSpanなどが格納されており,これを参照することで現在実行されている処理に対応するSpanを取得できます.これによりSpan間の親子関係の紐づけが可能になっています.

opentelemetry.io

この処理で取得したActive Contextをtracer.startSpanメソッドに渡して実行しています.

新規のSpanの開始

tracer.startSpanメソッドで新規のSpanを生成しています.

github.com

ここでは引数で渡されたContext(渡されない場合はActiveなContextを利用)から現在のSpanを取得し,それをParent Spanとして設定しています.

Contextへの登録

trace.setSpanメソッドを用い,新規生成したSpanをContextに追加します.

github.com

Contextに対応するオブジェクトはimmutableである(must)ため,新規に生成されたContextオブジェクトが返されています.

Contextの更新

最終行の処理ではContext APIwithメソッドを上記で生成した新規のContextを引数として呼び出しています.

withメソッドは引数で与えたContextをActiveなContextとして引数で渡されたCallback関数を実行する関数であるため,ここで新規生成されたSpanを含むContextが設定されていると考えられます.

opentelemetry.io

以降ではこのContext.withメソッドの実装について確認していきます.

Context.withメソッドの実装

Context.withメソッドの実装例は以下になります.

github.com

内部ではContextManagerクラスのwithメソッドを呼び出しています.

https://opentelemetry.io/docs/languages/js/context/#context-manager

ContextManagerクラスはAPI実行時に行われる処理を実装しています. 実装をみるとContext APIに対応する処理のほとんどを実装しているため,ほぼContext APIのinterfaceの実装クラスとして見てよさそうです.

ドキュメントよりこのクラスには現在2種類の実装がありますが、今回は推奨されているAsyncLocalStorageクラスを用いた実装を確認します.

github.com

内部では引数で受け取った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インスタンスをここで取得していると考えられます.

github.com

実際にActiveなSpanを取得するtrace.getActiveSpanの実装を追ってみると,最終的には上記のContextManager Classのactiveメソッドの呼び出しにたどり着きます.

github.com

github.com

github.com

github.com

まとめてみる

新規の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