Gemini API を本番アプリケーションへ投入してから3ヶ月。最初のうちはアクセスログを眺めるだけで運用できていたものの、トラフィックが増えるにつれて「夜中だけ妙に遅い」「特定ユーザーだけコストが膨らむ」「Function Calling のループが想定外の回数で回っている」といった、ログ1行では追えない問題 が出てきたのではないでしょうか。私自身、個人で運用しているサービスで同じ壁にぶつかり、Cloud Logging のフィルタを書く時間より「どこで遅くなっているか」を考える時間のほうが長くなったとき、ようやく分散トレーシングの導入を決めました。
ここではGemini API を中心としたバックエンドに OpenTelemetry を組み込み、1リクエストの裏側を span 単位で全部見せる本番運用構成を、実装コードと運用ノウハウの両面から解説します。OpenTelemetry の GenAI セマンティック規約に沿うことで、Grafana Tempo・Jaeger・Datadog のどれを選んでも同じダッシュボードが組める、ベンダーロックインしない設計を目指します。
なぜ Gemini API には分散トレーシングが必要なのか
LLM を組み込んだバックエンドは、従来の Web API とは性質がかなり違います。1リクエストの中で、リトリーバ・ベクター検索・モデル呼び出し・Function Calling・後処理が直列または並列で走り、それぞれが独自の失敗モードと遅延特性 を持ちます。エラー率や P95 レイテンシだけを見ていると、「全体としては問題ない」のにユーザー体験が壊れている状態を取りこぼします。
私が現場で遭遇した代表例は次のようなものです。
平均レイテンシは 1.8 秒で安定しているのに、gemini-2.5-pro の長文プロンプトだけ 12 秒に張り付いて UI のスピナーが回り続ける
Function Calling が想定 1 ホップで終わるはずが、特定の入力で 5 ホップまでループし、トークン消費が他のリクエストの 8 倍になる
ベクター検索が 200ms で帰ってくる日と 4 秒かかる日があり、原因がモデルではなくリトリーバ側にあると気付くのが遅れる
これらは 「リクエスト全体の構造を可視化する道具」 がないと、推測と勘で潰すしかありません。OpenTelemetry の分散トレーシングは、1リクエストを root span とし、その下にモデル呼び出し・検索・後処理を子 span として並べることで、どこで何ミリ秒消費したかをタイムラインとして再生 できる仕組みを提供してくれます。
OpenTelemetry の基本構造を Gemini 向けに整理する
まず最低限の用語を、Gemini API を使うバックエンドに即して整理します。
Trace : ユーザーの 1 リクエスト全体に対応する実行記録。単一の trace_id で識別される
Span : trace を構成する最小単位。「Gemini API 呼び出し」「Pinecone 検索」「JSON 整形」など、計測したい処理ごとに 1 つ作る
Context propagation : span を親子関係で繋ぐ仕組み。HTTP ヘッダ traceparent を介してマイクロサービス間にも伝播できる
Span attributes : span に紐づける Key-Value。トークン数・モデル名・ユーザー ID などを乗せる
Span events : span 内で起きた離散イベント(ストリーミング chunk 受信、ガードレール発火など)
GenAI 特有の属性キーは、OpenTelemetry の GenAI Semantic Conventions で標準化が進んでいます。代表的なものを覚えておきましょう。
gen_ai.system: ベンダー名(google.gemini を使用)
gen_ai.request.model: 要求したモデル名(gemini-2.5-pro 等)
gen_ai.response.model: 応答が返ってきたモデル名
gen_ai.usage.input_tokens / gen_ai.usage.output_tokens: トークン使用量
gen_ai.request.temperature / gen_ai.request.top_p 等: 生成パラメータ
gen_ai.response.finish_reasons: 終了理由の配列
これらを span に統一して載せておけば、後でどの可視化バックエンドに切り替えても同じクエリ・同じダッシュボードが使えます。実装ごとにキーを散らかさないことが、長く運用するうえで効いてきます。
Python 実装: Gemini SDK を計装する最小コード
公式の google-genai SDK は内部で httpx を使うため、自動計装で HTTP span は取れますが、GenAI セマンティック規約に沿った属性は自分で乗せる必要がある のが現状です。私は以下のような薄いラッパーを 1 ファイルだけ用意し、アプリ側からはこれを呼ぶ運用にしています。
# gemini_traced.py
# Gemini API 呼び出しを OpenTelemetry の span でラップし、
# GenAI Semantic Conventions に沿った属性を自動で付与する。
# 目的: モデル・トークン・終了理由を全 span に統一して載せ、
# 後段の集計(コスト・遅延・失敗の相関分析)を可能にする。
from google import genai
from google.genai import types
from opentelemetry import trace
from opentelemetry.trace import Status, StatusCode
tracer = trace.get_tracer( "gemini.client" , "1.0.0" )
client = genai.Client() # GOOGLE_API_KEY または GEMINI_API_KEY を環境変数で
def generate_traced (
* ,
model: str ,
contents: str | list ,
config: types.GenerateContentConfig | None = None ,
user_id: str | None = None ,
):
"""OTel span でラップした Gemini 呼び出し。"""
with tracer.start_as_current_span(
name = f "gemini.generate_content { model } " ,
attributes = {
"gen_ai.system" : "google.gemini" ,
"gen_ai.request.model" : model,
"gen_ai.operation.name" : "generate_content" ,
** ({ "enduser.id" : user_id} if user_id else {}),
},
) as span:
try :
cfg = config or types.GenerateContentConfig()
if cfg.temperature is not None :
span.set_attribute( "gen_ai.request.temperature" , cfg.temperature)
if cfg.max_output_tokens is not None :
span.set_attribute( "gen_ai.request.max_tokens" , cfg.max_output_tokens)
res = client.models.generate_content(
model = model, contents = contents, config = cfg
)
# 使用トークンを規約属性で記録(後段で集計しやすい)
if res.usage_metadata:
u = res.usage_metadata
span.set_attribute( "gen_ai.usage.input_tokens" , u.prompt_token_count or 0 )
span.set_attribute( "gen_ai.usage.output_tokens" , u.candidates_token_count or 0 )
if getattr (u, "cached_content_token_count" , None ):
span.set_attribute( "gen_ai.usage.cached_input_tokens" , u.cached_content_token_count)
# 終了理由(配列で持つのが規約)
if res.candidates:
fr = [c.finish_reason.name for c in res.candidates if c.finish_reason]
if fr:
span.set_attribute( "gen_ai.response.finish_reasons" , fr)
span.set_attribute( "gen_ai.response.model" , res.model_version or model)
span.set_status(Status(StatusCode. OK ))
return res
except Exception as e:
span.record_exception(e)
span.set_status(Status(StatusCode. ERROR , str (e)[: 200 ]))
raise
期待する動作は、Jaeger や Tempo の UI を開くと gemini.generate_content gemini-2.5-pro という span が並び、それぞれにトークン数・終了理由・モデルバージョンが乗っているというものです。enduser.id を入れておくと「特定ユーザーのコスト暴騰」を後から SQL/PromQL で割り出せる ので、本番運用ではほぼ必須の属性として推奨します。
アプリ起動時の OpenTelemetry セットアップ
ラッパーだけでは送信先が決まらないので、アプリ起動時に Provider を組み立てます。OTLP/HTTP で Collector に送る構成が、ベンダーロックインを避ける意味で最も汎用的です。
# tracing_setup.py
# 起動時に 1 度だけ呼ぶ。Resource にサービス情報を持たせ、
# 「どのサービスのどのバージョンが何 ms かかったか」を後から絞り込めるようにする。
import os
from opentelemetry import trace
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import BatchSpanProcessor
from opentelemetry.sdk.resources import Resource
from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter
def setup_tracing ():
resource = Resource.create({
"service.name" : os.getenv( "OTEL_SERVICE_NAME" , "gemini-app" ),
"service.version" : os.getenv( "APP_VERSION" , "0.1.0" ),
"deployment.environment" : os.getenv( "APP_ENV" , "production" ),
})
provider = TracerProvider( resource = resource)
exporter = OTLPSpanExporter(
endpoint = os.getenv( "OTEL_EXPORTER_OTLP_ENDPOINT" , "http://localhost:4318/v1/traces" ),
)
provider.add_span_processor(BatchSpanProcessor(exporter))
trace.set_tracer_provider(provider)
OTEL_EXPORTER_OTLP_ENDPOINT を Grafana Cloud の Tempo・Datadog OTLP Ingest・自前の otelcol どれに向けても、アプリ側のコードは無変更で済みます。「Collector を 1 個挟む」だけで、可視化先の差し替えが設定変更になる のが分散トレーシングを早めに入れる最大のメリットだと感じています。
Node.js 実装: Express + GenAI SDK の例
Node.js でも基本構造は同じです。@opentelemetry/sdk-node で自動計装を有効化したうえで、Gemini 呼び出しだけ手動 span を挟みます。
// telemetry.ts
// Node アプリの最初に require/import で読み込むこと。
// HTTP・Express の自動計装と Gemini 用の Tracer を共存させる構成。
import { NodeSDK } from "@opentelemetry/sdk-node" ;
import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-http" ;
import { getNodeAutoInstrumentations } from "@opentelemetry/auto-instrumentations-node" ;
import { Resource } from "@opentelemetry/resources" ;
import { SemanticResourceAttributes } from "@opentelemetry/semantic-conventions" ;
const sdk = new NodeSDK ({
resource: new Resource ({
[SemanticResourceAttributes. SERVICE_NAME ]: process.env. OTEL_SERVICE_NAME ?? "gemini-app" ,
[SemanticResourceAttributes. SERVICE_VERSION ]: process.env. APP_VERSION ?? "0.1.0" ,
}),
traceExporter: new OTLPTraceExporter ({
url: process.env. OTEL_EXPORTER_OTLP_ENDPOINT ?? "http://localhost:4318/v1/traces" ,
}),
instrumentations: [ getNodeAutoInstrumentations ()],
});
sdk. start ();
process. on ( "SIGTERM" , () => sdk. shutdown ());
// geminiTraced.ts
// generateContent 呼び出しを span でラップする。
// SDK 例外時は span に exception event を残し、ステータスを ERROR にする。
import { GoogleGenAI } from "@google/genai" ;
import { trace, SpanStatusCode } from "@opentelemetry/api" ;
const tracer = trace. getTracer ( "gemini.client" , "1.0.0" );
const ai = new GoogleGenAI ({ apiKey: process.env. GEMINI_API_KEY ! });
export async function generateTraced ( opts : {
model : string ;
contents : string ;
userId ?: string ;
temperature ?: number ;
}) {
return tracer. startActiveSpan ( `gemini.generate_content ${ opts . model }` , async ( span ) => {
span. setAttributes ({
"gen_ai.system" : "google.gemini" ,
"gen_ai.request.model" : opts.model,
"gen_ai.operation.name" : "generate_content" ,
... (opts.temperature != null ? { "gen_ai.request.temperature" : opts.temperature } : {}),
... (opts.userId ? { "enduser.id" : opts.userId } : {}),
});
try {
const res = await ai.models. generateContent ({
model: opts.model,
contents: opts.contents,
config: { temperature: opts.temperature },
});
const u = res.usageMetadata;
if (u) {
span. setAttribute ( "gen_ai.usage.input_tokens" , u.promptTokenCount ?? 0 );
span. setAttribute ( "gen_ai.usage.output_tokens" , u.candidatesTokenCount ?? 0 );
}
const fr = res.candidates?. map (( c ) => c.finishReason). filter (Boolean) as string [] | undefined ;
if (fr?. length ) span. setAttribute ( "gen_ai.response.finish_reasons" , fr);
span. setStatus ({ code: SpanStatusCode. OK });
return res;
} catch ( e : any ) {
span. recordException (e);
span. setStatus ({ code: SpanStatusCode. ERROR , message: String (e?.message ?? e). slice ( 0 , 200 ) });
throw e;
} finally {
span. end ();
}
});
}
このラッパーを Express の各ハンドラから呼べば、HTTP GET /api/chat → gemini.generate_content gemini-2.5-pro が親子で繋がった trace が自然に生成されます。
Streaming と Function Calling のトレース戦略
Gemini を本番で活用すると、ほぼ必ず Streaming と Function Calling に行き当たります。この 2 つは span の張り方に少しコツが要ります。
Streaming レスポンスの場合、私は 「全体を 1 span で覆い、各 chunk を span event として記録する」 やり方を採っています。time-to-first-token をユーザー体験指標として可視化したいので、最初の chunk が来た時刻だけは属性に残すのがコツです。
# generate_content_stream を span event つきでラップする部分(要点のみ抜粋)。
# 目的: TTFT・チャンク数・総バイトを 1 span に集約し、UI 体感とサーバ実測を一致させる。
with tracer.start_as_current_span( "gemini.generate_content_stream" ) as span:
span.set_attribute( "gen_ai.request.model" , model)
first = True
chunks = 0
for chunk in client.models.generate_content_stream( model = model, contents = contents):
chunks += 1
if first:
span.add_event( "first_token" )
first = False
# 1 chunk ごとに event を打つと cardinality が爆発するので
# 100 chunk 単位で間引くのが運用上の現実解。
if chunks % 100 == 0 :
span.add_event( "chunk_progress" , attributes = { "chunks" : chunks})
yield chunk.text or ""
span.set_attribute( "gen_ai.response.chunk_count" , chunks)
Function Calling のループは、ループ全体を親 span で包み、各ホップを子 span として並べる 構成にします。gen_ai.tool.name 属性にツール名を入れておくと、「どのツールが何ホップ目で呼ばれて、どれだけトークンを食ったか」をあとからクエリできます。本番で Function Calling が暴走したケースの 9 割は、この構造で 5 分以内に原因が判明しました。
コスト属性の付け方とサンプリング戦略
トレースは入れただけでは値段の話が見えません。私が運用しているサービスでは、gen_ai.usage.input_tokens と gen_ai.usage.output_tokens に加えて、span 終了時に概算コストを gen_ai.cost.usd として乗せる 後処理を入れています。モデル単価表を dict に持たせ、span プロセッサで属性を追加するだけのシンプルな仕組みですが、Grafana 上で「ユーザー × モデル × 日次コスト」のヒートマップが描けるようになり、課金トラブルの予兆検知に効きました。
サンプリングについては、全件保存は本番ではほぼ非現実的 です。私が落ち着いた構成は次の組み合わせです。
ParentBased(TraceIdRatioBased(0.1)) でデフォルト 10% サンプリング
ただし gen_ai.response.finish_reasons に SAFETY または MAX_TOKENS が含まれる場合は Tail-based Sampler で 100% 保存
例外発生時も 100% 保存
enduser.id が社内検証アカウントの場合は 100% 保存
Tail-based Sampling は OpenTelemetry Collector の tail_sampling プロセッサで実現できます。「異常だけ全部残す」発想にするだけで、ストレージ費を 1/10 に抑えつつ、調査に必要な trace は確実に残せます。
可視化バックエンドごとの注意点
OTLP に揃えておけば乗り換えは容易ですが、それぞれ少し癖があります。
Grafana Tempo + Grafana : コストが安く、OTLP/HTTP をそのまま受け付けます。trace を span 検索する場合は TraceQL を覚える必要があり、{ name = "gemini.generate_content" && span.gen_ai.usage.output_tokens > 1000 } のようなクエリでスロー span を絞り込めます
Datadog APM : OTLP Ingest 経由で送ると GenAI 属性が自動でファセット化され、ダッシュボードを組まなくても LLM Observability ビューに乗ります。ただし高トラフィックではサンプリングを Collector 側でしっかり下げないと請求額が跳ねます
Jaeger : セルフホストで気軽に始めたい場合に向きます。属性検索機能が他より弱いので、ヘビーな分析には Tempo か Datadog を検討した方が良いです
私の運用では、開発環境で Jaeger、本番で Grafana Tempo + Loki という組み合わせに落ち着きました。Datadog は規模が出てから検討する順序が、個人開発・小規模 SaaS では現実的だと感じています。
よくある間違いと落とし穴
実際に導入してみると、いくつか必ず踏む落とし穴があります。先回りして書いておきます。
ひとつ目は Context propagation の漏れ です。FastAPI や Express の手前にあるリバースプロキシで traceparent ヘッダが落とされると、フロント由来の trace と Gemini 呼び出しが繋がらず、別々の trace に分裂します。Cloud Run・Cloudflare Workers・Vercel のいずれでも、対象ヘッダを通過させる設定を最初に確認してください。
ふたつ目は PII を span 属性に乗せてしまう事故 です。プロンプト全文を gen_ai.prompt に入れる実装をよく見かけますが、本番では原則禁止です。乗せるならハッシュ値・トークン数・先頭 32 文字までに留め、原文は別系統の暗号化ストレージに記録する設計にしましょう。OpenTelemetry の規約でも gen_ai.prompt は opt-in とされており、デフォルトオフが推奨です。
みっつ目は Span attribute のカーディナリティ爆発 です。enduser.id を入れる場合、Datadog は属性ごとにメトリクス化するためコストが跳ね上がる場合があります。ユーザー ID を直接乗せるのではなく、ハッシュ化したうえで enduser.id 属性、課金プランは enduser.plan のように分けると、メトリクス側のカーディナリティを下げられます。
よっつ目は Streaming で span を with を抜ける前に終了してしまう バグです。Python の Generator と OTel の start_as_current_span を素直に組み合わせると、ジェネレータが消費されきる前に span が終了し、TTFT が常に 0ms と記録されるという地味な誤計測が起きます。私は tracer.start_span() で明示的に開始し、ジェネレータの finally で span.end() を呼ぶ実装に切り替えてから、この問題が消えました。
最後に Sampler のラッパー位置間違い です。Span をフィルタする処理を Span Processor 段に書いてしまうと、Sampler を通った span を後から落とすことになり、コスト圧縮効果がほとんど出ません。サンプリング判断は Sampler 段で行い、Span Processor は属性追加・PII マスキングに専念させる、という分業を徹底するのが正解です。
実運用ダッシュボードの最低構成
ここまで仕込めば、本番で監視すべき項目は驚くほど少なくなります。私が常用しているのは次の 4 枚のパネルです。
モデル別 P95 / P99 レイテンシ (gen_ai.request.model で分割)
モデル別の 1 時間あたりトークン消費 (gen_ai.usage.output_tokens の sum)
Finish reasons の比率 (SAFETY MAX_TOKENS STOP の割合)
TTFT 分布 (Streaming span の first_token event 時刻 - span 開始時刻)
この 4 枚に「異常時は trace へドリルダウン」のリンクを貼っておけば、コスト・体感・安全性の三面を 1 つのダッシュボードで把握できます。メトリクスとトレースが同じ Resource 属性で結びついている のが OpenTelemetry の真価で、ここまで揃って初めて「LLM を本番運用している」と言える、というのが私の実感です。
全体を振り返って — 次にやるべきこと
明日まずやるべきは、gemini_traced.py または geminiTraced.ts を 1 ファイル作り、既存の Gemini 呼び出しを 1 経路だけ差し替えること です。最初から全置換は要りません。1 経路通せば、どのトレースバックエンドを選ぶかも、どの属性が役立つかも、自分のサービスの実データで判断できるようになります。
そのあと、Collector を Docker で 1 個立てて Jaeger に送るところまで行けば、ベンダー選定はじっくりやれます。最初の小さな 1 step を踏めるか踏めないかで、半年後の運用コストと障害対応の質が大きく変わります。私自身、後悔しているのは「もっと早く入れておけばよかった」という一点だけでした。
なお、メトリクス寄りの可視化ですでに Gemini API の本番モニタリング設計 を実装している場合は、本記事の内容と Resource 属性を揃えるだけで、メトリクスとトレースが自然に紐づきます。Langfuse を使ったプロンプト中心のロギングを整えたい場合は、Gemini API + Langfuse 本番運用ガイド と組み合わせることで、Trace(OTel)・Metrics(Prometheus)・Prompt-level(Langfuse)の三層が補完関係になります。コスト面の打ち手をまだ整理していなければ、Gemini API コスト最適化 完全ガイド に span 属性 gen_ai.cost.usd を流し込む構成にすると、コスト管理とトレースが 1 つの基盤に乗ります。