Google ADK のエージェントを Vertex AI Agent Engine にデプロイして、やり取りを Cloud Trace で見られるようにした。設定自体はキーワード引数ひとつで終わる。
from vertexai.agent_engines import AdkApp
agent_engine = AdkApp(agent=root_agent, enable_tracing=True)
これをデプロイしてエージェントを叩き、Cloud Trace を開く。何も出ない。アプリケーションログにもエラーは無い。
この記事で挙げるハマりどころは全部が無言で失敗する。SDK がテレメトリを best-effort 扱いしているからで、設定ミスは可用性ではなく観測性だけを奪う。本番の設計としては正しいが、デバッグする側としては最悪だ。
検証環境は google-adk 2.7.0 / google-cloud-aiplatform 1.165.1。
1. 送信先は Cloud Trace API ではない
まず cloudtrace.googleapis.com を有効化して終わり、と考える。そこじゃない。
エクスポータは Telemetry API 向けの OTLP/HTTP エクスポータだ。
# vertexai/agent_engines/templates/adk.py
_DEFAULT_TELEMETRY_ENDPOINT = "https://telemetry.googleapis.com/v1/traces"
google.auth.default() の資格情報、つまり Agent Engine の実行 SA でここに POST する。SDK は起動時にこの API の有効状態を自分で確認して警告まで出してくれる。
Tracing integration for Agent Engine has migrated to a new API.
The 'telemetry.googleapis.com' has not been enabled in project <project>.
**Impact:** Until this API is enabled, telemetry data will not be stored.
ただしこのチェックは丸ごと except で囲まれていて明示的に非致命的だ。コンテナは普通に起動してリクエストも捌く。警告は起動ログの中を流れていき、トレースだけが静かに消える。
gcloud services enable telemetry.googleapis.com --project=my-project
実際にどうなっているかは確認しておく。この記事に辿り着く原因として一番確率が高いのがここだ。
gcloud services list --enabled --project=my-project \
--filter="config.name:(telemetry.googleapis.com OR cloudtrace.googleapis.com)" \
--format="value(config.name)"
cloudtrace.googleapis.com は読み取り側とコンソールが使うので外さなくていい。ただ、これだけ有効にしても書き込み経路には一切効かない。上のコマンドで両方の名前が返ってくる状態にする。
2. IAM の失敗は、たぶん見ていないログの中の 403
API を有効化して再デプロイ。まだ出ない。エラーは存在するが、探している場所に無い。Cloud Run のサービスログでもアプリの出力でもなく、Reasoning Engine のリソースログに出る。
ERROR: Failed to export span batch code: 403, reason: Forbidden
探すならこれ。
gcloud logging read \
'resource.type="aiplatform.googleapis.com/ReasoningEngine"
AND textPayload=~"Failed to export span batch"' \
--project=my-project --freshness=1h --limit=20
BatchSpanProcessor が失敗を記録してバッチを捨てているだけなので、エージェント本体は無傷だ。回答も正常、レイテンシも正常、トレースだけゼロ。
必要な permission は telemetry.traces.write。これを持つロールは2つあって、名前が紛らわしい。
$ gcloud iam roles describe roles/cloudtrace.agent \
--format='value(includedPermissions)'
cloudtrace.traces.patch;telemetry.traces.write
$ gcloud iam roles describe roles/telemetry.tracesWriter \
--format='value(includedPermissions)'
telemetry.traces.write
roles/cloudtrace.agent が上位集合になっている。名前からは旧世代の API 用に見えるが、Telemetry API 側の permission を既に含んでいるので、両方付ける意味は無い。
gcloud projects add-iam-policy-binding my-project \
--member="serviceAccount:my-agent@my-project.iam.gserviceaccount.com" \
--role="roles/cloudtrace.agent"
付いたことの確認。
gcloud projects get-iam-policy my-project \
--flatten="bindings[].members" \
--filter="bindings.role=roles/cloudtrace.agent" \
--format="value(bindings.members)"
この失敗モードには便利な性質がある。IAM を直すと再デプロイせずにある時刻を境に 403 がピタッと止まる。時刻 T まで 403 が出ていて T 以降にトレースが現れたら、binding が伝播したと判断していい。これが確認シグナルになる。
3. ADK_CAPTURE_MESSAGE_CONTENT_IN_SPANS は自分で設定しても無駄
ADK は既定でプロンプト・モデル応答・ツール引数を span に記録する。制御する環境変数は ADK_CAPTURE_MESSAGE_CONTENT_IN_SPANS で、ADK 自身の既定値は on だ。
# google/adk/telemetry/context.py
env_value = os.getenv(ADK_CAPTURE_MESSAGE_CONTENT_IN_SPANS, 'true').strip().lower()
将来 upstream の既定が変わって黙って本文が消えるのは困るから、デプロイ設定で明示的に固定しておこう——と考える。Agent Engine では意味が無い。AdkApp.set_up() が無条件に上書きするからだ。
# vertexai/agent_engines/templates/adk.py — set_up() の中
if self._tmpl_attrs.get("enable_tracing"):
os.environ["ADK_CAPTURE_MESSAGE_CONTENT_IN_SPANS"] = "true"
else:
os.environ["ADK_CAPTURE_MESSAGE_CONTENT_IN_SPANS"] = "false"
env_vars に何を入れても起動時に潰される。ここでの本文キャプチャは enable_tracing の純粋な従属変数だ。
(adk deploy CLI は確かに false を注入する。ただしそれはその CLI 経由でデプロイした場合だけで、SDK や IaC ツールから作ると通らない。)
実務上の帰結はセキュリティの話になる。Agent Engine では enable_tracing=True はプロンプトとツールの中身が丸ごとトレースに載ることを意味し、この変数では降りられない。機微な情報を扱うエージェントなら、「誰がトレースを読めるか」を後回しにできない設計判断として扱う必要がある。
4. スイッチは enable_tracing だけじゃない
トレースの有効化は2つの制御の AND で、真理値表がソースに書いてある。
# vertexai/agent_engines/templates/adk.py
def _tracing_enabled(self) -> bool:
"""
| enable_tracing | enable_telemetry(env) | tracing_actually_enabled |
|----------------|-----------------------|--------------------------|
| true | false | false |
| true | true | true |
| true | None | true |
| None(default) | true | adk_version >= 1.17 |
| None(default) | None | false |
"""
enable_telemetry は環境変数 GOOGLE_CLOUD_AGENT_ENGINE_ENABLE_TELEMETRY 由来で、Cloud Console のトグルが書き込むのもこれ。false になっていると enable_tracing=True は無効化される。未設定(None)なら問題ない。
ここは正確に押さえておきたい。SDK が deprecated にしているのは enable_tracing=False であって、引数そのものではない。
if self._tmpl_attrs.get("enable_tracing") is False:
_warn("Your 'enable_tracing=False' setting is being deprecated ...")
enable_tracing=True は何も警告されない。逆に無効化したいなら引数を渡さず環境変数を使うべきで、False を明示するとコンソールのトグル表示と実態がズレる。
5. root span は invoke_agent じゃない
invoke_agent を探しても root には無い。ADK には telemetry schema のバージョンが2つあって、Agent Engine は v2 が既定だ。
# google/adk/telemetry/_schema_version.py
# Default resolution: 2 on Agent Engine, detected via the presence of
# the GOOGLE_CLOUD_AGENT_ENGINE_ID env var.
v2 では木構造がこうなる。
invoke_workflow my_agent <- root
└─ invoke_agent my_agent
├─ call_llm
│ └─ generate_content gemini-x.y
├─ execute_tool transfer_to_agent
└─ invoke_agent my_sub_agent
└─ execute_tool my_sub_tool
invoke_agent {name} は子。execute_tool にはツール名が付く。本文は call_llm の gcp.vertex.agent.llm_request / llm_response、execute_tool の tool_call_args / tool_response に入る。
root span 名での絞り込みは v1 の読み取り API で効く。
GET https://cloudtrace.googleapis.com/v1/projects/<PROJECT>/traces
?startTime=<RFC3339>&endTime=<RFC3339>
&filter=root:invoke_workflow
&view=COMPLETE
注意点として、既定の一覧は時刻順ではなく trace ID 順だ。filter 無しで叩くと任意の断片が返ってくるので、記録されているのに「出ていない」と誤判定しかねない。
構造的な問題: 1リクエスト = 1トレース ではない
ここは設定の話ではなく設計の話になる。
create_session() と stream_query() のペアはそれぞれ独立した invocation で、それぞれ別のトレースになる。同一の業務レコードに対して3つのチェックを並列で投げるようなハンドラだと、
await asyncio.gather(*(
query_agent_engine(message=m, user_id=f"check-{i}-{record_id}")
for i, m in enumerate(messages)
))
無関係な3本のトレースができる。trace context が Agent Engine の境界を越えないので、親子関係も付かない。
ADK 標準の span 属性は invocation_id / session_id / event_id で、全部内部識別子だ。自分のドメインのオブジェクトに紐づく情報は何も無いし、user_id はそもそもエクスポートされない。
なので自分で足す。BasePlugin は root span のコンテキスト内で動くので、ここが正しいフックになる。
import logging
from google.adk.agents.invocation_context import InvocationContext
from google.adk.plugins.base_plugin import BasePlugin
from opentelemetry import trace
logger = logging.getLogger(__name__)
class RecordTracingPlugin(BasePlugin):
def __init__(self) -> None:
super().__init__(name="record_tracing")
async def before_run_callback(
self, *, invocation_context: InvocationContext
) -> None:
try:
identity = parse_user_id(invocation_context.user_id)
if identity is None:
return
span = trace.get_current_span()
if not span.is_recording():
return
span.set_attribute("app.record_id", identity.record_id)
span.set_attribute("app.flow", identity.flow)
except Exception:
logger.warning("failed to tag span with record identity", exc_info=True)
return None
agent_engine = AdkApp(
agent=root_agent,
enable_tracing=True,
plugins=[RecordTracingPlugin()],
)
これで app.record_id = "12345" で3本まとめて引ける。
このコードで効いているポイントが4つある。
async def でないと壊れる。 ベースのシグネチャがコルーチンなので、同期メソッドにすると PluginManager が await するところで落ちる。
None を返さないといけない。 None 以外の戻り値は early exit 扱いで実行が打ち切られる。
# google/adk/plugins/plugin_manager.py
result = await callback_method(**kwargs)
if result is not None:
# Early exit: A plugin has returned a value. We stop
# processing further plugins and return this value immediately.
try / except は気休めではない。 プラグインの例外は封じ込められない。PluginManager._run_callbacks が RuntimeError に包み、Runner._exec_with_plugin が再 raise する。
except Exception as e:
await _notify_run_error(plugin_manager, invocation_context, e)
raise
属性を付けるだけのコードのバグでエージェントの実行そのものが落ちる。観測コードがそれをやっていい理由は無いので、Exception を捕まえてログに流す。BaseException は捕まえない(CancelledError と KeyboardInterrupt はそのまま通す)。
invocation_context.user_id は property。 モデルのフィールドではないので、model_fields を覗いても出てこない。
# google/adk/agents/invocation_context.py
@property
def user_id(self) -> str:
return self.session.user_id
ここから分かることがある。create_session() の時点で自分が決められる以上、user_id は Agent Engine の境界を越えて相関情報を持ち込める唯一の汎用チャネルだ。エンコードは慎重にやる。そしてエンコードとデコードは1モジュールに寄せること。両者は別プロセスで動くので、片方だけ形式を変えると、この記事がずっと書いてきたのとまったく同じ種類の無言の失敗をする。
設計前に知っておきたい制限2つ
属性値の上限は 64 KiB で、超過分は非決定的に捨てられる。大きなドキュメントを入力に取るエージェントだと gcp.vertex.agent.llm_request が普通に超えて、単に存在しなくなる。「プロンプトはトレースから必ず復元できる」前提のワークフローを組んではいけない。
保持期間は 30 日固定(_Trace バケット)。ここでの Cloud Trace は障害調査のツールであって分析ストアではない。オフライン評価のために数ヶ月分のプロンプト履歴が欲しいなら、Logging と BigQuery へ別途エクスポートする。
まとめ
| 症状 | 原因 |
|---|---|
| トレースが出ない。エラーも無い |
telemetry.googleapis.com が未有効化 |
| トレースが出ない。Reasoning Engine ログに 403 | SA に roles/cloudtrace.agent が無い |
| 本文が入らない / 意図せず入る |
set_up() が enable_tracing から環境変数を上書き |
enable_tracing=True が効かない |
GOOGLE_CLOUD_AGENT_ENGINE_ENABLE_TELEMETRY=false |
root に invoke_agent が無い |
Agent Engine は schema v2。root は invoke_workflow {name}
|
| 1リクエストで無関係なN本のトレース |
stream_query ごとに別 invocation。プラグインの属性で相関させる |
設定自体は本当に1行だ。時間が溶けるのは API 有効化・IAM・span モデルの方で、しかもそのどれもが無言で失敗する。結局、403 がどのログに出るかを知っているかどうかで作業時間が決まる。