LLM API の呼び出しログを増やしたいとき、私がいつも迷うのが「どこまで残すか」です。
障害調査では request_id が欲しいです。請求や上限の確認では usage.total_tokens も欲しいです。モデル名と成功失敗も欲しいです。ただ、prompt や request body や response body を observability backend に流すのは、だいぶ怖いです。
今回は LLM wrapper の外側に OpenTelemetry span を 1 枚足して、属性は allowlist でかなり絞りました。やったこと自体は小さいのですが、後から消す設計にすると事故りそうだったので、最初に「入れていい属性」だけを決める形にしました。
3行まとめ
- LLM 呼び出しごとに
llm.chatspan を作る - 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_missing と usage_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.status は ok, 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 policy や private 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
間違いあったらコメントください。よろしくお願いします。