0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

LLM 呼び出しを OpenTelemetry span に載せて request_id と usage だけ見た

0
Posted at

LLM API の呼び出しログを増やしたいとき、私がいつも迷うのが「どこまで残すか」です。

障害調査では request_id が欲しいです。請求や上限の確認では usage.total_tokens も欲しいです。モデル名と成功失敗も欲しいです。ただ、prompt や request body や response body を observability backend に流すのは、だいぶ怖いです。

今回は LLM wrapper の外側に OpenTelemetry span を 1 枚足して、属性は allowlist でかなり絞りました。やったこと自体は小さいのですが、後から消す設計にすると事故りそうだったので、最初に「入れていい属性」だけを決める形にしました。

3行まとめ

  • LLM 呼び出しごとに llm.chat span を作る
  • span 属性は request_id, model, status, usage_total_tokens 相当だけにする
  • prompt, messages, request.body, response.body, authorization, api_key は fixture で混入チェックする

背景

LLM API は失敗したときの調査が少し独特です。普通の REST API なら HTTP status と endpoint と latency だけでもかなり追えますが、LLM ではモデル、ルーティング、usage、provider 側の request ID も見たくなります。

一方で、LLM 呼び出しの入力にはユーザーの本文、社内文書、tool arguments、長い system prompt が入りがちです。これを span attribute に雑に突っ込むと、trace backend の検索対象や長期保存対象になってしまうかもしれません。

なので今回の方針はこうしました。

残す 残さない
request_id prompt
model messages
status request body
usage_total_tokens response body
credentials
raw user text

少し物足りないくらいですが、最初の運用としてはこのくらいが扱いやすいと思います。

環境

手元では Python の最小 fixture で確認しました。実 provider には投げず、fake client が返す response を wrapper が span に載せるだけです。

python -m venv .venv
. .venv/bin/activate
pip install opentelemetry-api opentelemetry-sdk
python llm_otel_span_fixture.py

実際の SDK では request ID の取り方が provider や client library で少し違います。OpenAI API では x-request-id が API request の識別子として使われ、公式 client library では top-level response object から参照できる、と API reference に書かれています。ここでは provider 固有コードに寄せすぎないように、response adapter が request_id, model, status, usage を返す前提にしています。

最小の実装

span に入れる属性を allowlist する

最初に allowlist を作ります。ポイントは denylist から始めないことです。messages を消す、prompt を消す、という形にすると、新しいキーが増えたときに漏れる可能性があります。

ALLOWED_ATTRIBUTE_KEYS = {
    "llm.request_id",
    "llm.model",
    "llm.status",
    "llm.usage.total_tokens",
}

FORBIDDEN_ATTRIBUTE_KEYS = {
    "prompt",
    "messages",
    "request.body",
    "response.body",
    "llm.prompt",
    "llm.request.messages",
    "llm.response.text",
    "authorization",
    "api_key",
    "user_text",
    "email",
}

def allowed_span_attributes(response):
    return {
        "llm.request_id": response.request_id,
        "llm.model": response.model,
        "llm.status": response.status,
        "llm.usage.total_tokens": response.usage["total_tokens"],
    }

OpenAI Chat Completions の通常 response には usage.total_tokens があります。streaming の場合は別で、stream_options: {"include_usage": true} を指定したときに最終 chunk に usage が来る扱いで、stream が中断されると最後の usage chunk を受け取れないことがあります。なので私は usage_missingusage_zero を別の状態として扱うほうがよいと思っています。

LLM wrapper の外側で span を作る

fixture では FakeLLMClient を使っています。実 API を呼ばないので、記事を読んだ人が token や API key を用意しなくても再現できます。

from dataclasses import dataclass

from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import SimpleSpanProcessor
from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter
from opentelemetry.trace import Status, StatusCode


@dataclass(frozen=True)
class FakeLLMResponse:
    request_id: str
    model: str
    status: str
    usage: dict[str, int]
    content: str


class FakeLLMClient:
    def chat(self, *, model: str, messages: list[dict[str, str]]) -> FakeLLMResponse:
        _ = messages
        return FakeLLMResponse(
            request_id="req_fixture_20260801_001",
            model=model,
            status="ok",
            usage={"prompt_tokens": 21, "completion_tokens": 16, "total_tokens": 37},
            content="sanitized fixture response",
        )


def call_llm_with_span(*, tracer_provider, client, model, messages):
    tracer = tracer_provider.get_tracer("app.llm")
    with tracer.start_as_current_span("llm.chat") as span:
        response = client.chat(model=model, messages=messages)
        for key, value in allowed_span_attributes(response).items():
            span.set_attribute(key, value)
        span.set_status(Status(StatusCode.OK))
        return response

ここで messages は client には渡しています。でも span attribute には入れません。実装上は小さな差ですが、レビューではここをかなり見ます。

fixture で forbidden key を落とす

次に、in-memory exporter で finished span を取り出して検査します。ここで allowlist ぴったりになっていることと、危ないキーや文字列が入っていないことを確認します。

def collect_fixture():
    exporter = InMemorySpanExporter()
    provider = TracerProvider()
    provider.add_span_processor(SimpleSpanProcessor(exporter))

    client = FakeLLMClient()
    response = call_llm_with_span(
        tracer_provider=provider,
        client=client,
        model="gpt-4o-mini",
        messages=[
            {"role": "system", "content": "internal policy that must not leave the app"},
            {"role": "user", "content": "private user question that must not become a span attr"},
        ],
    )

    spans = exporter.get_finished_spans()
    attributes = dict(spans[0].attributes)

    assert set(attributes) == ALLOWED_ATTRIBUTE_KEYS
    assert not (set(attributes) & FORBIDDEN_ATTRIBUTE_KEYS)

    serialized = json.dumps(attributes, ensure_ascii=False, sort_keys=True)
    for secret_fragment in ["internal policy", "private user question", response.content]:
        assert secret_fragment not in serialized

    return attributes

実運用では exporter は OTLP exporter などに変わると思いますが、テストでは in-memory にしておくと「何を送るつもりか」を手元で固定できます。

出力された span

fixture の出力はこうなりました。

{
  "span_name": "llm.chat",
  "attribute_count": 4,
  "attributes": {
    "llm.model": "gpt-4o-mini",
    "llm.request_id": "req_fixture_20260801_001",
    "llm.status": "ok",
    "llm.usage.total_tokens": 37
  },
  "verification": "passed"
}

この状態なら、trace を見たときに「どの LLM call が、どの model で、成功して、何 token 使い、provider 側の request ID は何だったか」までは追えます。一方で、入力本文や出力本文は span attribute にはありません。

ハマったポイント

prompt/body を後から消す設計にしない

最初は「とりあえず response を dict にして、あとで不要なキーを消せばいいか」と思ったのですが、これはやめました。

LLM response は provider や SDK の version で形が変わります。tool call が入ることもあります。画像や音声などの modality が増えることもあります。後段で消す方式だと、知らないキーが増えた瞬間に保存される可能性があります。

なので、span attribute を作る関数は response object から必要な値だけを手で抜き出す形にしました。面倒ですが、この面倒さが安全側に働くと思います。

usage が取れないケースを別扱いにする

もう一つ気をつけたいのは usage です。通常 response なら usage.total_tokens を読めることが多いですが、streaming や timeout では app 側で usage を持てないケースがあります。

このときに 0 として記録すると、あとで「使っていない」と誤読します。私は次のように分けるほうが好きです。

状態 意味
llm.usage.total_tokens = 37 app 側で usage を確認できた
llm.status = usage_missing response は得たが app 側では usage を確認できなかった
llm.status = error call 自体が失敗した

この記事の fixture は成功系だけですが、実装するときは missing と zero を分けたほうが、請求確認で変な推測をしにくいです。

属性名をどう切るか

ここは少し迷いました。OpenTelemetry には HTTP や DB などの semantic conventions がありますが、LLM provider の request ID や usage の持ち方は、まだプロダクトごとの差が大きい印象です。なので今回は、無理に一般化しすぎず、アプリ内で読める名前に寄せました。

llm.request_id は provider や gateway 側の request ID を入れる場所にしました。client 側で自分が発行した trace ID とは別物として扱います。自分が発行した ID も入れたくなるのですが、今回の記事では request_id と usage だけを見る趣旨なので外しました。

llm.model は呼び出し時に指定した model を入れます。router を挟む場合、最終的な upstream model と指定 model がずれる可能性もあります。ただ、そこまで span に入れ始めると routing の実装詳細まで app trace に混ざるので、最初は「アプリが指定した model」に限定しました。

llm.statusok, error, usage_missing くらいの粗い状態に留めます。HTTP status code や provider error code も欲しくなりますが、error message には入力由来の断片が混ざることがあります。最初の allowlist には入れず、必要になったら error code だけを別の allowlist として足すのがよさそうです。

llm.usage.total_tokens は数値だけです。prompt token と completion token を分ける案もありましたが、この記事では「請求前に急増に気づく入口」くらいの目的なので total だけにしました。細かい内訳は dashboard や provider response の安全な保存先で見る、という割り切りです。

今回は入れなかったもの

実装していると、だんだん属性を足したくなります。私も最初のメモではもう少し多く書いていました。ただ、span attribute は検索しやすいぶん、保存範囲が広がりやすいです。だから今回の範囲からは外しました。

入れたくなるもの 外した理由
user_id 生のユーザー識別子を trace backend に広げたくない
prompt_tokens total だけで初期調査は足りると判断した
completion_tokens 同上
error_message 入力由来の文字列が混ざる可能性がある
provider router の裏側を app trace に寄せすぎないため
latency_ms span duration でまず見るため

もちろん、全部だめという話ではありません。社内の trace backend、保存期間、アクセス権、マスキング方針が決まっているなら、必要な属性を増やす選択もあると思います。ただ、記事としては「まず 4 つだけで始める」ほうが誤解が少ないと考えました。

特に user_id は便利ですが、私は最初から入れないことにしました。どうしても必要なら、raw value ではなく、短い保持期間の internal ID や hash を検討します。それでも、この記事の fixture では扱いません。LLM call の追跡とユーザー分析を同じ span attribute に乗せ始めると、目的が混ざりやすいからです。

テストをどこに置くか

この手の guard は unit test に近い場所に置くのがよさそうです。exporter の設定や backend の設定を確認する integration test とは別に、wrapper が作る attributes の形だけを固定します。

私なら次の 2 つを CI に置きます。

テスト 見ること
allowlist test finished span の key set が期待値と一致する
leak test forbidden key と既知の秘密文字列が attribute にない

大事なのは、fake response の中にはあえて危ない文字列を入れることです。今回も internal policyprivate user question という文字列を fake messages に入れています。テストデータに危ない値がないと、漏れても気づけません。

この fixture は実 provider に投げないので、CI で安定して動きます。LLM API の疎通確認とは別物です。疎通確認は flake しやすいので、allowlist の安全性チェックまで live API に寄せないほうがよいと思います。

運用で見る順番

実際に障害が起きたら、私はまず app trace で失敗した handler と llm.chat span を見ます。次に llm.request_id を拾い、gateway や provider 側のログで同じ request を探します。最後に usage と routing の画面を見ます。

この順番にしておくと、本文を trace backend に保存しなくても、かなりの初動調査はできます。もちろん、回答品質の分析や prompt 改善は別の仕組みが必要です。ただ、それは observability span の仕事ではないと割り切りました。

調査用の情報を減らすのは少し不安ですが、後から「保存しすぎていた」と気づくほうが戻しにくいです。まず少なく始めて、足す理由をレビューするほうが現実的だと思います。

Flatkey AI ではどこを見るか

Flatkey AI のような OpenAI-compatible gateway を使っている場合、app 側の span と dashboard 側の見え方を分けて考えるのがよさそうです。

app 側の OpenTelemetry span は、アプリケーションの trace の中で「この handler からこの LLM request が出た」と見るためのものです。request_id を残しておくと、provider や gateway 側の調査とつなげやすくなります。

一方で Flatkey AI の dashboard は、one API key、one base URL のルーティング、usage、billing、keys をまとめて見る場所です。つまり、アプリの trace で request の文脈を見て、gateway の dashboard で usage や routing を確認する、という分担です。

ここを混ぜると、span に請求確認用の情報を全部詰めたくなります。私はそこまではやらず、span は correlation に寄せ、dashboard は usage/routing 確認に寄せるほうが運用しやすいと感じました。

まとめ

LLM 呼び出しの observability は、何を見えるようにするかより、何を保存しないかを先に決めたほうが実装しやすかったです。

今回の最小構成では、span attribute は次の 4 つだけにしました。

llm.request_id
llm.model
llm.status
llm.usage.total_tokens

これだけでも、障害調査と usage 確認の入口としてはかなり役に立ちます。逆に prompt や body を入れたい理由が出てきたら、その時点で保存期間、マスキング、アクセス制御、サンプリングを別途設計したほうがいいと思います。

地味な実装ですが、LLM wrapper に span を足すときは最初の 1 回で属性の癖が決まるので、allowlist から始めるのがよさそうです。

参考

  • OpenTelemetry Python instrumentation docs
  • OpenTelemetry Python cookbook
  • OpenAI API reference, Debugging requests
  • OpenAI Chat Completions API reference
  • Flatkey AI product overview

間違いあったらコメントください。よろしくお願いします。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?