Google ADK の telemetry schema v2 に移行したら分析基盤が全滅した話
Google ADK 2.7 → 2.13 へのアップグレードついでに、telemetry schema を v2(OpenTelemetry GenAI semantic conventions 準拠)へ移行した。
結論から言うと、ADK のバージョンを上げるだけの作業だと思っていたら、Cloud Trace を舐めている BigQuery の分析クエリが丸ごと空振りになった。 span の名前も属性のキーも全部変わるので、WHERE name = 'call_llm' みたいな条件を書いていると何もヒットしなくなる。しかもエラーにはならず、ただ 0 行が返る。
同じ移行をやる人向けに、踏んだ地雷を順番に書いておく。
前提: span を BigQuery で読んでいる
まず構成の前提から。エージェントが吐く span は Cloud Trace に入れて、それを BigQuery から SQL で読んでいる。「どのエージェントが」「どんな instruction で」「ユーザーが何を言って」「モデルが何を返したか」を後から集計したいので、トレース UI を人間が眺めるだけでは足りない。
ここで誤解しやすいのが、これは Logging のログシンクではないという点。ログルーターで BigQuery にエクスポートしているわけではなく、Cloud Trace 側の _Trace バケットに対して linked dataset を張っている。Terraform ではこうなる。
resource "google_observability_link" "cloud_trace_spans" {
location = "us"
bucket = "_Trace"
dataset = "Spans"
link_id = "cloud_trace_spans"
}
# リンク作成には observability のサービスエージェントに BigQuery 権限が要る
resource "google_project_iam_member" "observability_bigquery_admin" {
project = var.project_id
role = "roles/bigquery.admin"
member = "serviceAccount:service-${data.google_project.project.number}@gcp-sa-observability.iam.gserviceaccount.com"
}
この構成の性質を押さえておくと後の話が読みやすい。
-
_AllSpansは TABLE ではなく VIEW。 実体のコピーは作られないので、保持期間は Cloud Trace 側(既定 30 日)に従う。「去年のデータを掘る」はできない -
dataset のロケーションが
us。bq queryに--location=usを付けないと「データセットが見つからない」と言われる -
span の属性は
attributesという JSON カラム 1 本に入る。だから分析クエリはこうなる
SELECT JSON_VALUE(attributes, '$."gen_ai.input.messages"') AS input
FROM `<project>.cloud_trace_spans._AllSpans`
WHERE start_time >= TIMESTAMP_SUB(CURRENT_TIMESTAMP(), INTERVAL 7 DAY)
この最後の性質が、今回の事故の直接の原因になる。JSON パスには型もスキーマ検証もない。 存在しないキーを指定しても JSON_VALUE は何も言わずに NULL を返す。テーブルのカラム名を間違えれば BigQuery は「そんなカラムはない」と即座に怒ってくれるが、JSON パスは一切怒らない。
結果として、schema v2 で属性名が全部変わっても、クエリは正常終了して 0 行を返す。これが「全滅したのに誰も気づかない」の正体だった。
schema v1 と v2 で何が変わるのか
ADK の telemetry には v1(ADK 独自)と v2(OTel GenAI semconv 準拠)があり、ADK_TELEMETRY_SCHEMA_VERSION_OPT_IN=2 で切り替わる。対応はこうなる。
| v1 | v2 | |
|---|---|---|
| LLM 呼び出しの span 名 | call_llm |
generate_content <model> |
| エージェント実行の span 名 |
invocation / agent_run [name]
|
invoke_agent <name> / invoke_workflow <name>
|
| ツール実行の span 名 | execute_tool <name> |
execute_tool <name>(同じ) |
| LLM リクエスト本文 | gcp.vertex.agent.llm_request |
gen_ai.input.messages |
| LLM レスポンス本文 | gcp.vertex.agent.llm_response |
gen_ai.output.messages |
| system instruction | (llm_request の中) | gen_ai.system_instructions |
| ツール定義 | (llm_request の中) | gen_ai.tool.definitions |
| ツール引数 | gcp.vertex.agent.tool_call_args |
gen_ai.tool.call.arguments |
| ツール結果 | gcp.vertex.agent.tool_response |
gen_ai.tool.call.result |
| エージェント名 | (span 名に埋め込み) | gen_ai.agent.name |
v1 は「JSON をまるごと 1 属性に詰める」設計で、v2 は「意味ごとに属性を分ける」設計。方向としてはまっとうな変更だけど、移行する側からすると全部の参照箇所が壊れる。
地味にいやらしいのが、gen_ai.tool.definitions の区切り。筆者は最初 gen_ai.tool_definitions だと思い込んで spec に書いてしまった。正しくは tool と definitions の間もドットで、gen_ai.tool.definitions。OTel の semconv 定数を直接引いて確認するのが確実。
from opentelemetry.semconv._incubating.attributes import gen_ai_attributes as g
print(g.GEN_AI_TOOL_DEFINITIONS) # => gen_ai.tool.definitions
罠 1: SPAN_ONLY を指定しないと本文が一切記録されない
ここが一番ハマった。schema v2 に切り替えた瞬間、span は出るのに本文の属性が全部空になる。
v2 では本文のキャプチャが OTel 側の環境変数 OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT に支配される。値は 4 つ。
| 値 | 挙動 |
|---|---|
NO_CONTENT(既定) |
本文を一切記録しない |
SPAN_ONLY |
span 属性に記録する |
EVENT_ONLY |
log record(OTel の event)に記録する |
SPAN_AND_EVENT |
両方 |
ADK 独自の ADK_CAPTURE_MESSAGE_CONTENT_IN_SPANS=true は v1 用のノブで、v2 では効かない。v1 から移行してきた環境だとこっちだけ true になっていて、「設定してあるのに本文が出ない」という状態になる。
で、「どうせなら両方取るか」と SPAN_AND_EVENT を選びたくなるが、これが罠。EVENT 側は LoggerProvider を要求する。 ADK の telemetry setup はこうなっている。
# google/adk/telemetry/setup.py 相当
if log_record_processors:
# ここで初めて LoggerProvider が立つ
...
Cloud Trace だけ有効にして get_gcp_exporters(enable_cloud_tracing=True) を呼んでいる構成だと log record processor が 0 個なので、LoggerProvider が存在しない。送り先のない event を作ろうとして無駄が出るだけになる。
というわけで、Cloud Trace だけに吐く構成なら SPAN_ONLY 一択。
env {
name = "OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT"
value = "SPAN_ONLY"
}
env {
name = "ADK_TELEMETRY_SCHEMA_VERSION_OPT_IN"
value = "2"
}
罠 2: v1 と v2 の本文は「別カラム」ではなく「別の行」にある
移行期間中に v1 と v2 のデータが混ざるので、クエリ側でフォールバックしたくなる。筆者は最初こう書いた。
-- これは動かない
COALESCE(
JSON_VALUE(attributes, '$."gcp.vertex.agent.llm_request"'),
JSON_VALUE(attributes, '$."gen_ai.input.messages"')
) AS llm_input
COALESCE は同じ行の中で最初の非 NULL を返す関数だ。ところが v1 の本文は call_llm という span の行に、v2 の本文は generate_content ... という別の span の行に乗っている。同じ行に v1 と v2 の属性が同居することはないので、この COALESCE は常に「その行に存在する方」を返すだけで、フォールバックとして一切機能しない。
正しくは span 名で分岐して、別々の行として拾う。
WHERE name = 'call_llm' OR STARTS_WITH(name, 'generate_content ')
そのうえで、本文の取り出し方も span 名で切り替える。
CASE
WHEN name = 'call_llm' THEN
-- v1: llm_request.contents[last].parts[].text
(SELECT STRING_AGG(JSON_VALUE(pt, '$.text'), ' / ')
FROM UNNEST(JSON_QUERY_ARRAY(last_content, '$.parts')) AS pt)
ELSE
-- v2: input_messages[last].parts[].content
(SELECT STRING_AGG(JSON_VALUE(pt, '$.content'), ' / ')
FROM UNNEST(JSON_QUERY_ARRAY(last_message, '$.parts')) AS pt)
END AS user_text
キー名も微妙に違う。v1 の part は text、v2 の part は content。role も v1 が model なのに対し v2 は assistant になる(OTel semconv 準拠なので)。
一方、ツールの引数と結果は本当に同じ行に v1/v2 の属性が並ぶので、こっちは素直に COALESCE でいい。execute_tool <name> という span 名が v1/v2 で共通だから、同じ行に両方の属性が乗りうる。
COALESCE(
JSON_VALUE(attributes, '$."gcp.vertex.agent.tool_call_args"'),
JSON_VALUE(attributes, '$."gen_ai.tool.call.arguments"')
) AS tool_args
同じ移行なのに、LLM 側とツール側でフォールバックの書き方が変わる。ここは実際に span を採取して確かめないと気づけない。
罠 3: エージェント名の取り方が変わる
v1 では span 名に agent_run [SomeLeafAgent] のようにエージェント名が埋め込まれていた。v2 では gen_ai.agent.name という独立した属性になる。
ただしこの属性、invoke_agent span には付くが、v1 の call_llm には付かない。移行期間中に両方拾いたいなら、親 span をたどって補完する必要がある。
SELECT b.*,
COALESCE(
JSON_VALUE(b.attributes, '$."gen_ai.agent.name"'),
JSON_VALUE(p.attributes, '$."gen_ai.agent.name"'),
JSON_VALUE(g.attributes, '$."gen_ai.agent.name"')
) AS agent_name
FROM base b
LEFT JOIN base p ON b.parent_span_id = p.span_id -- 親
LEFT JOIN base g ON p.parent_span_id = g.span_id -- 祖父
ここで trace_id で JOIN したくなるが、やってはいけない。1 トレースの中にルーターや他の AgentTool の span が同居しているので、関係ないエージェントの名前を拾ってしまう。必ず parent_span_id → span_id でたどる。
なお実測したところ、v2 の generate_content span には gen_ai.agent.name が直接付いていた。親たどりは v1 が混ざる移行期間のための保険ということになる。
罠 4: 属性に 64 KiB の上限がある(v2 で踏みやすくなる)
GCP の Telemetry API は 1 属性あたり 64 KiB を超えるとリクエストを受け付けない。しかもその属性だけが落ちるのではなく、バッチ全体が HTTP 400 で蹴られる。1 つの巨大な system instruction のせいで、同じバッチの全 span が消える。
v1 は本文を 1 属性に詰め込む設計だったので元々踏みやすかったが、v2 でも system instruction や tool definitions が単独で肥大するので状況は変わらない。実際、筆者の環境では gen_ai.system_instructions が 63,427 バイトに達して上限に当たった。
OTel SDK の OTEL_ATTRIBUTE_VALUE_LENGTH_LIMIT で切るという手は使えない。これは文字数ベースの切り詰めで UTF-8 バイト数を保証しないうえ、JSON を途中で切るので壊れた JSON ができあがる。パースできない本文が残っても分析には使えない。
採った方針は、ADK の span processor をラップして、エクスポート直前に JSON 構造を理解したうえで削ることだった。
wrapped_hooks = OTelHooks(
span_processors=[
SpanAttributeLimitSpanProcessor(p) for p in hooks.span_processors
],
metric_readers=list(hooks.metric_readers),
log_record_processors=list(hooks.log_record_processors),
)
maybe_set_otel_providers([wrapped_hooks], resource)
エクスポータ自体を差し替えると ADK ネイティブの Cloud Trace バッチ設定を捨てることになるので、processor をラップするだけにしている。削る順番は「分析価値の低いものから」で、grounding_metadata や logprobs_result のような付随情報 → tool definitions → system instruction → 古い会話履歴、という優先度をつけた。削ったことが後からわかるように __adk_span_truncated__ マーカーを必ず入れる。
実運用のログではこう出る。
WARNING Truncated span attribute: span=generate_content gemini-3.8-flash
attribute=gen_ai.system_instructions original_bytes=63427
63 KB が 27,826 文字まで削られて、末尾(=直近の文脈)が保持された。400 は 1 件も出ていない。
ちなみにこの切り詰め、「1 個の巨大な文字列」には強いが「中サイズが大量にある」ケースには弱いという弱点を残している。どれか 1 つを消しても予算に収まらない状況だと、深さ優先の削除が諦めて全体がマーカーだけに潰れる。30 要素 × 2.1 KB = 63,520 バイト(予算超過はわずか 3.5 KB)が 47 バイトになる、という再現を確認した。v2 は本文が属性ごとに分かれて配列化されやすいので、この経路を踏む確率はむしろ上がっている。
罠 5: uv.lock の exclude-newer-span で新しいバージョンが解決できない
ここからは schema v2 と直接関係ないが、同じ作業で踏んだので書いておく。
Dependabot が投げてきた ADK 2.13 の PR が CI で落ちていて、手元で再現しようとしたら今度は 2.13 がインストールできない。
Because only google-adk<=2.12.0 is available ...
原因は uv.lock の先頭にあるこれ。
exclude-newer = "0001-01-01T00:00:00Z"
exclude-newer-span = "P1W"
exclude-newer-span は「ロックを解決した時点から 1 週間以内に公開されたパッケージは使わない」という再現性のための設定。供給チェーン攻撃対策として理にかなっているが、今回は 2.13.0 の公開がカットオフのわずか 5 時間半後だった。数時間の差で弾かれていたわけで、エラーメッセージからは理由がまったく読めない。
さらに厄介なことに、筆者の ~/.config/uv/uv.toml にも exclude-newer = "1 week" が入っていて、PR のロックファイルを上書きしていた。「PR のブランチに切り替えて uv sync したのに 2.7.0 が入る」という現象の正体がこれ。
# VIRTUAL_ENV が別 repo を向いていると uv pip list も嘘をつく
unset VIRTUAL_ENV
UV_EXCLUDE_NEWER=2026-12-31T00:00:00Z uv sync
教訓: ロックファイルの再現性設定は、再現性と同じだけ「再現できなさ」も作る。 落ちたときにエラーから原因へ到達できないので、設定した本人以外は確実にハマる。
罠 6: pytest の sys.modules スタブがテストファイルを跨いで漏れる
Dependabot PR の CI 失敗は、実はプロダクションコードとは一切関係なかった。壊れたテストファイル 1 つを除外したら残り 1,243 件は全部通る。
原因は 2 つあって、どちらもテスト足場の問題だった。
1 つめ。あるテストファイルがモジュールインポート時にスタブを sys.modules へ突っ込んでいて、後片付けしていなかった。
# tests/test_leaf_agent.py
google_genai_types = types.ModuleType("google.genai.types")
google_genai_types.GenerateContentConfig = MagicMock(name="GenerateContentConfig")
sys.modules["google.genai"] = google_genai
sys.modules["google.genai.types"] = google_genai_types
_MCP_CLIENT_STUB = _build_stub_modules() # ← モジュール読み込み時に実行、teardown なし
sys.modules はプロセス全体で共有される。収集順によっては、このスタブが刺さった状態で別のテストファイルが google.genai.types を本物だと思ってインポートし、ImportError で collection ごと落ちる。ADK 2.7 では偶然通っていたのが、2.13 で参照のされ方が変わって露見した。
2 つめ。contents.types という属性が ADK 2.13 で消えていた。
original_contents_types = contents.types # AttributeError on ADK 2.13
デバッグで厄介だったのは、素の python -c でインポートすると成功すること。pytest 経由でだけ落ちる。スタブが刺さるのは pytest が他のテストモジュールを収集したときだけなので当然なのだが、最初は「インポートは通るのに CI が落ちる」で数十分溶かした。
python -c で再現しないインポートエラーを見たら、まず sys.modules の汚染を疑うとよい。
おまけ: 計測属性のタイポは「値がゼロ」と見分けがつかない
移行後の確認ついでに ADK 2.13 の新しい警告に気づいた。
App "router" can transfer between agents but has no context_cache_config.
Every transfer swaps the system instruction and the tool set, so the request
prefix changes and the whole prompt is re-sent uncached after each transfer.
で、「じゃあ実際どれくらいキャッシュが効いているのか」を調べようとして、こう書いた。
JSON_VALUE(attributes, '$."gen_ai.usage.cached_input_tokens"') AS cached_tok
全行 NULL。「キャッシュが一度も成立していない」と結論して issue を立てた。
これが間違いだった。 正しい属性名は gen_ai.usage.cache_read.input_tokens で、筆者が書いたキーはそもそも存在しない。
# google/adk/telemetry/_token_usage.py
GEN_AI_USAGE_CACHE_READ_INPUT_TOKENS = 'gen_ai.usage.cache_read.input_tokens'
GEN_AI_USAGE_CACHE_CREATION_INPUT_TOKENS = 'gen_ai.usage.cache_creation.input_tokens'
正しいキーで引き直したら、修正前の時点ですでにキャッシュは効いていた。
| 時刻 | agent | input_tokens | cache_read.input_tokens |
|---|---|---|---|
| 10-31 07:41 | leaf_agent | 19,290 | (なし) |
| 10-31 07:41 | leaf_agent | 23,599 | (なし) |
| 10-31 07:41 | leaf_agent | 24,472 | 19,410 |
| 10-31 07:41 | leaf_agent | 26,465 | 23,359 |
Vertex AI の暗黙キャッシュが 3 回目以降で成立していた。「キャッシュがゼロ」という前提は成り立っていなかったことになる。
ここで効いてくるのが、ADK の属性セットが条件付きだという性質。
if self.cache_read_input_tokens is not None:
attrs[GEN_AI_USAGE_CACHE_READ_INPUT_TOKENS] = self.cache_read_input_tokens
値が None なら属性そのものが付かない。つまり JSON パスで引いたときの NULL は「キャッシュが 0 だった」とも「キーを間違えた」とも「この span には付かない属性だ」とも読める。3 つが完全に同じ見た目になる。
対策は単純で、結論を出す前に「その属性が存在する行が 1 行でもあるか」を確かめること。
SELECT COUNTIF(JSON_VALUE(attributes, '$."gen_ai.usage.cache_read.input_tokens"') IS NOT NULL) AS has_attr,
COUNT(*) AS n
FROM spans WHERE name LIKE 'generate_content%'
has_attr = 0 なら、まずキー名を疑う。全行 NULL は「その事象が起きていない」証拠としては弱すぎる。
なお context_cache_config 自体は未設定だったので、明示キャッシュを有効にする対応には意味があった。設定後はこうなった。
| 時刻 | agent | input_tokens | cache_read.input_tokens |
|---|---|---|---|
| 11-01 08:05 | router | 2,875 | (なし) |
| 11-01 08:06 | router | 3,010 | (なし) |
| 11-01 08:06 | leaf_agent | 19,410 | (なし) |
| 11-01 08:06 | leaf_agent | 23,655 | 19,391 |
| 11-01 08:06 | leaf_agent | 24,554 | 19,391 |
| 11-01 08:06 | leaf_agent | 24,757 | 19,391 |
| 11-01 08:07 | leaf_agent | 24,958 | 19,391 |
ヒット率は上がった(2/4 → 4/5)が、「ゼロから成立するようになった」のではなく「元々あった暗黙キャッシュがより安定した」が正確な表現になる。issue に書いた「一度も成立していない」は訂正が必要だった。
router にキャッシュが付かないのは仕様どおり。ContextCacheConfig の docstring いわく、キャッシュ可能なプレフィクスは Gemini 2.5 で 2,048 tokens、Gemini 3 で 4,096 tokens 必要で、ルーターの 2,875〜3,010 tokens は届かない。ルーターのように instruction が小さいエージェントは、設定しても明示キャッシュの対象外になる。
まとめ
- schema v2 は span 名も属性キーも全部変わる。エラーにならず 0 行になるので、移行前に必ず実 span を採取して突き合わせる
-
OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENTを設定しないと本文は記録されない。Cloud Trace だけならSPAN_ONLY - v1/v2 の LLM 本文は別の行にある。
COALESCEでのフォールバックは効かない。ツールの引数・結果は同じ行なので効く - 属性の 64 KiB 上限はバッチ全体を落とす。SDK の文字数ベース切り詰めでは JSON が壊れるので、構造を理解した切り詰めが要る
- ロックファイルの
exclude-newer-spanは、原因の読めないエラーを生む -
sys.modulesにスタブを刺すテストは後片付けまで書く。python -cで再現しないインポートエラーの定番 - 全行 NULL を「その事象がゼロ」と読んではいけない。 属性名の誤り・条件付き属性と区別がつかない
ADK のバージョン差分は PyPI の google-adk と adk-python のリリース を見るのが早い。属性名の確認は、ブログやまとめ記事ではなく OTel semconv の定数を直接引くこと。筆者は 2 回それで間違えた。