ADK 에이전트의 트레이스를 Cloud Trace로 전송하는 방법: 조용히 실패하는 5가지 함정
요약
Google ADK 에이전트의 트레이싱을 Cloud Trace로 전송하는 과정에서 발생할 수 있는 5가지 흔한 함정들을 다룹니다. 단순히 `cloudtrace.googleapis.com`만 활성화해서는 안 되며, 실제로는 Telemetry API를 사용해야 합니다. 또한, 오류 로그가 일반적인 서비스 로그가 아닌 Reasoning Engine의 리소스 로그에 기록되는 점을 강조합니다.
핵심 포인트
- ADK 에이전트 트레이싱은 Cloud Trace가 아닌 Telemetry API를 통해 이루어집니다.
- 단순히 `cloudtrace.googleapis.com`만 활성화하는 것으로는 충분하지 않습니다.
- API 미활성화 시 경고 메시지가 비치명적(non-fatal)이라 문제가 인지하기 어렵습니다.
- 실제 오류 로그는 일반 서비스 로그가 아닌 Reasoning Engine 리소스 로그에서 확인해야 합니다.
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가 메트릭(telemetry)을 best-effort로 처리하기 때문에, 설정 실수는 가용성(availability)이 아니라 관측성(observability)만 앗아갑니다. 프로덕션 설계 관점에서는 맞지만, 디버깅하는 입장에서는 최악입니다.
검증 환경은 google-adk
2.7.0 / google-cloud-aiplatform
1.165.1입니다.
먼저 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(Service Account)로 여기에 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로 감싸져 있어 명시적으로 비치명적(non-fatal)입니다. 컨테이너는 정상적으로 시작하고 요청도 처리합니다. 경고는 시작 로그 속을 지나가지만, 트레이스만 조용히 사라집니다.
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은 읽기 측면과 콘솔에서 사용하므로 제거할 필요는 없습니다. 하지만 이것만 활성화해서도 쓰기 경로에는 전혀 영향을 주지 않습니다. 위의 명령어로 두 이름이 모두 반환되는 상태로 만듭니다.
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"'
...
BatchSpanProcessor가 실패를 기록하고 배치(batch)를 버리고 있을 뿐이므로, 에이전트 본체는 무사합니다. 답변도 정상이고, 레이턴시도 정상이며, 트레이스만 0입니다.
필요한 permission은 telemetry.traces.write입니다.
이것을 가진 역할은 두 가지가 있고, 이름이 헷갈립니다.
gcloud iam roles describe roles/cloudtrace.agent \
--format='value(includedPermissions)'
cloudtrace.traces.patch;telemetry.traces.write
...
roles/cloudtrace.agent는 상위 집합입니다. 이름만 보면 구형 API용으로 보이지만, 이미 Telemetry API 측의 권한을 포함하고 있으므로 둘 다 지정할 의미가 없습니다.
gcloud projects add-iam-policy-binding my-project \
--member="serviceAccount:[email protected]" \
--role="roles/cloudtrace.agent"
설정된 것을 확인합니다.
gcloud projects get-iam-policy my-project \
--flatten="bindings[].members" \
--filter="bindings.role=roles/cloudtrace.agent" \
...
이 실패 모드는 유용한 특성을 가지고 있습니다. IAM을 수정하면 재배포하지 않고도 특정 시점을 기점으로 403 에러가 딱 멈춥니다. 시간 T까지 403이 나오고 T 이후에 트레이스가 나타났다면, 바인딩(binding)이 전파되었다고 판단해도 됩니다. 이것이 확인 신호가 됩니다.
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()
미래에 업스트림의 기본값이 바뀌어 조용히 본문이 사라지는 것은 곤란하므로, 배포 설정에서 명시적으로 고정해 두자고 생각합니다. 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"
...
env_vars에 무엇을 넣어도 시작 시점에 덮어쓰여집니다. 여기서의 본문 캡처는 enable_tracing의 순수한 종속 변수입니다.
(사실 adk deploy CLI는 확실히 false를 주입합니다. 하지만 그것은 해당 CLI를 통해 배포했을 경우에만 해당되며, SDK나 IaC 도구를 사용하면 통하지 않습니다.)
실무상의 귀결은 보안 이야기로 이어집니다. Agent Engine에서 enable_tracing=True는 프롬프트와 도구의 내용 전체가 트레이스에 실린다는 것을 의미하며, 이 변수로는 막을 수 없습니다. 민감한 정보를 다루는 에이전트라면,
if self._tmpl_attrs.get("enable_tracing") is False:
_warn("Your 'enable_tracing=False' setting is being deprecated ...")
enable_tracing=True
은 아무런 경고도 표시하지 않는다. 오히려 비활성화하고 싶다면 인수를 전달하지 않고 환경 변수를 사용하는 것이 좋으며, False를 명시하면 콘솔의 토글 표시와 실제 상태가 달라진다.
invoke_agent을 찾아봐도 root에는 없다. ADK에는 telemetry 스키마 버전이 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
...
invoke_agent {name}은 자식(child)이다. 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
...
주의할 점은, 기본 목록이 시간순이 아니라 trace ID 순이라는 것이다. 필터 없이 요청하면 임의의 단편(fragment)이 반환되므로, 기록되어 있음에도 '나오지 않는다'고 오판할 수 있다.
여기는 설정 문제가 아니라 설계 문제로 넘어간다.
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의 컨텍스트 내에서 작동하므로, 여기가 올바른 훅(hook)이 된다.
import logging
from google.adk.agents.invocation_context import InvocationContext
from google.adk.plugins.base_plugin import BasePlugin
...
이렇게 하면 app.record_id = "12345"로 3개를 한 번에 가져올 수 있다.
이 코드에서 작동하는 포인트가 네 가지 있다.
async def가 아니면 망한다. 기본 시그니처 자체가 코루틴(coroutine)이기 때문에, 동기 메서드로 만들면 PluginManager가 await 하는 부분에서 실패한다. None을 반환해야 한다. None 이외의 반환값은 early exit으로 간주되어 실행이 중단된다.
⚠️ [IMG:N] 형식 토큰은 이미지 placeholder 입니다. 번역하지 말고 원래 위치에 그대로 유지하세요.
google/adk/plugins/plugin_manager.py
result = await callback_method(**kwargs)
if result is not None:
...
** try / except 는 임시방편이 아니다.** 플러그인의 예외는 봉쇄할 수 없다.
PluginManager._run_callbacks가 RuntimeError에 감싸지고, Runner._exec_with_plugin이 다시 발생(re-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:
...
여기서 알 수 있는 것이 있다. create_session() 시점에서 자신이 결정할 수 있는 이상, user_id는 Agent Engine의 경계를 넘어 상관관계를 가져올 수 있는 유일한 범용 채널이다. 인코딩은 신중하게 해야 한다. 그리고 인코딩과 디코딩은 한 모듈에 두어야 한다. 둘 다 별 프로세스에서 작동하기 때문에, 어느 한쪽만 형식을 바꾸면 이 글이 계속 써온 것과 똑같은 종류의 무언의 실패를 겪게 된다.
속성 값의 상한선은 64 KiB이며, 초과분은 비결정적으로 버려진다. 큰 문서를 입력으로 받는 에이전트라면 gcp.vertex.agent.llm_request가 보통 초과하여 단순히 존재하지 않게 된다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Qiita AI의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기