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?

Google ADK の telemetry schema v2 に移行したら分析基盤が全滅した話

0
Last updated at Posted at 2026-11-01

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 回それで間違えた。

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?