Skip to content

Commit affb6bb

Browse files
vertex-sdk-botcopybara-github
authored andcommitted
feat: export ADK agent engine template logs to the Telemetry API
CloudLoggingExporter is deprecated, so the ADK agent engine templates now send logs over OTLP to telemetry.googleapis.com, alongside the traces they already export there. Applied to all four copies of the template (vertexai/agent_engines, agentplatform/agent_engines, vertexai/preview/reasoning_engines, agentplatform/private/frameworks) so their telemetry does not diverge. This collapses the semconv split. Experimental semconv used a batching processor writing through the Cloud Logging API, while stable semconv wrote structured JSON to stdout, because Agent Engine mis-parsed `gen_ai.client.inference.operation.details` records off stdout. Neither branch survives the move to OTLP -- there is one path now, and it behaves the same under both settings. Because the OTLP mapping is done server side, two behaviours had to be reproduced explicitly: - `_create_otel_resource(project_id, "logs")` pins `gcp.resource_type` and the location/reasoning_engine_id labels, so entries keep landing on `aiplatform.googleapis.com/ReasoningEngine` instead of being detected as `generic_task`. It returns a logs-only resource: the metrics pipeline reads `gcp.resource_type` too, and would move Agent Engine metrics off `prometheus_target` if it saw it. - `_named_batch_log_record_processor` keeps GCP_DEFAULT_LOG_NAME (and the `adk-on-agent-engine` default) working, and re-publishes the record's event name as an `event.name` attribute so it still lands as a log entry label. The User-Agent string is now built by `_get_user_agent` rather than inline in the tracing branch, so both signals report the same one. Nothing imports `opentelemetry-exporter-gcp-logging` or `google-cloud-logging` any more -- the ADK templates were their only users, and the other templates never set up logging -- so both are dropped from the `agent_engines` and `reasoning_engine` extras, and the missing-dependency warning now names the packages logging actually needs. PiperOrigin-RevId: 966602473
1 parent 5248240 commit affb6bb

5 files changed

Lines changed: 574 additions & 250 deletions

File tree

agentplatform/agent_engines/templates/adk.py

Lines changed: 148 additions & 82 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,6 @@
1818
import enum
1919
import os
2020
import queue
21-
import sys
2221
import threading
2322
from typing import (
2423
Any,
@@ -133,6 +132,14 @@
133132
# rather than inherit AuthorizedSession's 120s default.
134133
_TELEMETRY_API_CHECK_TIMEOUT_SECONDS = 5.0
135134

135+
_DEFAULT_TELEMETRY_LOGS_ENDPOINT = "https://telemetry.googleapis.com/v1/logs"
136+
137+
_GCP_LOG_NAME = "gcp.log_name"
138+
_EVENT_NAME = "event.name"
139+
_GCP_RESOURCE_TYPE = "gcp.resource_type"
140+
_LOCATION = "location"
141+
_REASONING_ENGINE_ID = "reasoning_engine_id"
142+
136143

137144
class _MtlsEndpoint(enum.Enum):
138145
"""Enum for the mTLS endpoint setting."""
@@ -357,8 +364,8 @@ def _warn_missing_dependency(
357364
)
358365
MISSING_LOGGING_IMPORT_ERROR_MESSAGE = (
359366
"proceeding with logging disabled because not all packages (i.e."
360-
" `google-cloud-logging`, `opentelemetry-sdk`,"
361-
" `opentelemetry-exporter-gcp-logging`) for tracing have been installed"
367+
" `opentelemetry-sdk`, `opentelemetry-exporter-otlp-proto-http`)"
368+
" for logging have been installed"
362369
)
363370

364371
if needed_for_tracing and enable_tracing:
@@ -367,15 +374,6 @@ def _warn_missing_dependency(
367374
_warn(MISSING_LOGGING_IMPORT_ERROR_MESSAGE)
368375
return None
369376

370-
def _detect_cloud_resource_id(project_id: str) -> Optional[str]:
371-
location = os.getenv("GOOGLE_CLOUD_AGENT_ENGINE_LOCATION", "") or os.getenv(
372-
"GOOGLE_CLOUD_LOCATION", ""
373-
)
374-
agent_engine_id = os.getenv("GOOGLE_CLOUD_AGENT_ENGINE_ID")
375-
if all(v is not None for v in (location, agent_engine_id)):
376-
return f"//aiplatform.googleapis.com/projects/{project_id}/locations/{location}/reasoningEngines/{agent_engine_id}"
377-
return None
378-
379377
try:
380378
import opentelemetry
381379
import opentelemetry.trace
@@ -396,30 +394,7 @@ def _detect_cloud_resource_id(project_id: str) -> Optional[str]:
396394
"opentelemetry-sdk", needed_for_tracing=True, needed_for_logging=True
397395
)
398396

399-
import uuid
400-
401-
# Provide a set of resource attributes but allow to override them with env
402-
# variables like OTEL_RESOURCE_ATTRIBUTES and OTEL_SERVICE_NAME.
403-
cloud_resource_id = _detect_cloud_resource_id(project_id)
404-
resource = opentelemetry.sdk.resources.Resource.create(
405-
attributes={
406-
"gcp.project_id": project_id,
407-
"cloud.account.id": project_id,
408-
"cloud.provider": "gcp",
409-
"cloud.platform": "gcp.agent_engine",
410-
"service.name": os.getenv("GOOGLE_CLOUD_AGENT_ENGINE_ID", ""),
411-
"service.instance.id": f"{uuid.uuid4().hex}-{os.getpid()}",
412-
"cloud.region": (
413-
os.getenv("GOOGLE_CLOUD_AGENT_ENGINE_LOCATION", "")
414-
or os.getenv("GOOGLE_CLOUD_LOCATION", "")
415-
),
416-
}
417-
| (
418-
{"cloud.resource_id": cloud_resource_id}
419-
if cloud_resource_id is not None
420-
else {}
421-
)
422-
).merge(opentelemetry.sdk.resources.OTELResourceDetector().detect())
397+
resource = _create_otel_resource(project_id)
423398

424399
if enable_tracing:
425400
try:
@@ -435,12 +410,7 @@ def _detect_cloud_resource_id(project_id: str) -> Optional[str]:
435410
import google.auth
436411

437412
credentials, _ = google.auth.default()
438-
vertex_sdk_version = aip_version.__version__
439-
otlp_http_version = opentelemetry.exporter.otlp.proto.http.version.__version__
440-
user_agent = (
441-
f"Vertex-Agent-Engine/{vertex_sdk_version}"
442-
f" OTel-OTLP-Exporter-Python/{otlp_http_version}"
443-
)
413+
user_agent = _get_user_agent()
444414

445415
session = requests_auth.AuthorizedSession(credentials=credentials)
446416

@@ -500,54 +470,47 @@ def _detect_cloud_resource_id(project_id: str) -> Optional[str]:
500470

501471
if enable_logging:
502472
try:
503-
import opentelemetry.exporter.cloud_logging
473+
import opentelemetry.exporter.otlp.proto.http._log_exporter
474+
import google.auth.transport.requests
504475
except (ImportError, AttributeError):
505476
return _warn_missing_dependency(
506-
"opentelemetry-exporter-gcp-logging", needed_for_logging=True
477+
"opentelemetry-exporter-otlp-proto-http", needed_for_logging=True
507478
)
508479

509-
class _SimpleLogRecordProcessor(
510-
opentelemetry.sdk._logs.export.SimpleLogRecordProcessor
511-
):
480+
import google.auth
512481

513-
def force_flush(
514-
self, timeout_millis: int = 30000
515-
) -> bool: # pylint: disable=no-self-use
516-
sys.stdout.flush()
517-
sys.stderr.flush()
518-
return True
519-
520-
logger_provider = opentelemetry.sdk._logs.LoggerProvider(resource=resource)
521-
# Use the legacy log processor when experimental semconv is enabled.
522-
# Exporting JSON logs to stdout is bugged; Agent Engine fails to
523-
# correctly parse the `gen_ai.client.inference.operation.details`
524-
# messages.
525-
# TODO: b/480102541 - Unify both branches once the regression is fixed.
526-
if "gen_ai_latest_experimental" in os.getenv(
527-
"OTEL_SEMCONV_STABILITY_OPT_IN", ""
528-
).split(","):
529-
logger_provider.add_log_record_processor(
530-
opentelemetry.sdk._logs.export.BatchLogRecordProcessor(
531-
opentelemetry.exporter.cloud_logging.CloudLoggingExporter(
532-
project_id=project_id,
533-
default_log_name=os.getenv(
534-
"GCP_DEFAULT_LOG_NAME", "adk-on-agent-engine"
535-
),
536-
),
537-
)
482+
credentials, _ = google.auth.default()
483+
session = requests_auth.AuthorizedSession(credentials=credentials)
484+
485+
if _use_client_cert_effective():
486+
client_cert_source = (
487+
mtls.default_client_cert_source()
488+
if mtls.has_default_client_cert_source()
489+
else None
538490
)
491+
session.configure_mtls_channel()
492+
endpoint = _get_logs_api_endpoint(client_cert_source)
539493
else:
540-
logger_provider.add_log_record_processor(
541-
_SimpleLogRecordProcessor(
542-
opentelemetry.exporter.cloud_logging.CloudLoggingExporter(
543-
project_id=project_id,
544-
default_log_name=os.getenv(
545-
"GCP_DEFAULT_LOG_NAME", "adk-on-agent-engine"
546-
),
547-
structured_json_file=sys.stdout,
548-
),
549-
)
494+
endpoint = _DEFAULT_TELEMETRY_LOGS_ENDPOINT
495+
496+
# One processor serves stable and experimental semconv. The stdout
497+
# branch experimental records used to need is gone along with the
498+
# Cloud Logging exporter (b/480102541).
499+
logger_provider = opentelemetry.sdk._logs.LoggerProvider(
500+
resource=_create_otel_resource(project_id, "logs")
501+
)
502+
logger_provider.add_log_record_processor(
503+
_named_batch_log_record_processor(
504+
opentelemetry.exporter.otlp.proto.http._log_exporter.OTLPLogExporter(
505+
session=session,
506+
endpoint=endpoint,
507+
headers={"User-Agent": _get_user_agent()},
508+
),
509+
default_log_name=os.getenv(
510+
"GCP_DEFAULT_LOG_NAME", "adk-on-agent-engine"
511+
),
550512
)
513+
)
551514

552515
opentelemetry._logs.set_logger_provider(logger_provider=logger_provider)
553516

@@ -637,6 +600,109 @@ def _warn_if_telemetry_api_disabled():
637600
_warn(_TELEMETRY_API_DISABLED_WARNING % (project, project))
638601

639602

603+
def _get_user_agent() -> str:
604+
"""Returns the User-Agent to send on OTLP exports."""
605+
from google.cloud.aiplatform import version as aip_version
606+
607+
user_agent = f"Vertex-Agent-Engine/{aip_version.__version__}"
608+
try:
609+
import opentelemetry.exporter.otlp.proto.http.version
610+
611+
user_agent += (
612+
" OTel-OTLP-Exporter-Python/"
613+
f"{opentelemetry.exporter.otlp.proto.http.version.__version__}"
614+
)
615+
except (ImportError, AttributeError):
616+
pass
617+
return user_agent
618+
619+
620+
def _get_logs_api_endpoint(client_cert_source: bytes | None = None) -> str:
621+
"""Returns the logs endpoint matching _get_api_endpoint's mTLS decision.
622+
623+
Args:
624+
client_cert_source (bytes | None): The client certificate source.
625+
626+
Returns:
627+
str: The logs API endpoint to be used.
628+
"""
629+
return _get_api_endpoint(client_cert_source).replace("/v1/traces", "/v1/logs")
630+
631+
632+
def _create_otel_resource(project_id: str, for_signal: str = "unspecified"):
633+
"""Returns the OTel resource describing the Agent Engine deployment.
634+
635+
Args:
636+
project_id: Project to which to send telemetry.
637+
for_signal: The signal the resource is for. `logs` adds the
638+
MonitoredResource hints Cloud Logging needs, which must not be set
639+
on the resource traces and metrics share.
640+
641+
Returns:
642+
The resource to set on the provider for `for_signal`.
643+
"""
644+
import os
645+
import uuid
646+
647+
import opentelemetry.sdk.resources
648+
649+
location = os.getenv("GOOGLE_CLOUD_AGENT_ENGINE_LOCATION", "") or os.getenv(
650+
"GOOGLE_CLOUD_LOCATION", ""
651+
)
652+
agent_engine_id = os.getenv("GOOGLE_CLOUD_AGENT_ENGINE_ID", "")
653+
attributes = {
654+
"gcp.project_id": project_id,
655+
"cloud.account.id": project_id,
656+
"cloud.provider": "gcp",
657+
"cloud.platform": "gcp.agent_engine",
658+
"service.name": agent_engine_id,
659+
"service.instance.id": f"{uuid.uuid4().hex}-{os.getpid()}",
660+
"cloud.region": location,
661+
}
662+
if location and agent_engine_id:
663+
attributes["cloud.resource_id"] = (
664+
f"//aiplatform.googleapis.com/projects/{project_id}"
665+
f"/locations/{location}/reasoningEngines/{agent_engine_id}"
666+
)
667+
if for_signal == "logs":
668+
# Cloud Logging otherwise detects resource as `generic_task`
669+
attributes[_GCP_RESOURCE_TYPE] = "aiplatform.googleapis.com/ReasoningEngine"
670+
attributes[_LOCATION] = location
671+
attributes[_REASONING_ENGINE_ID] = agent_engine_id
672+
673+
# Provide a set of resource attributes but allow to override them with env
674+
# variables like OTEL_RESOURCE_ATTRIBUTES and OTEL_SERVICE_NAME.
675+
return opentelemetry.sdk.resources.Resource.create(attributes=attributes).merge(
676+
opentelemetry.sdk.resources.OTELResourceDetector().detect()
677+
)
678+
679+
680+
def _named_batch_log_record_processor(exporter, *, default_log_name: str):
681+
"""Returns a batch processor that keeps log names and labels stable.
682+
683+
Args:
684+
exporter: The OTLP log exporter to wrap.
685+
default_log_name (str): Log name for records that carry none.
686+
687+
Returns:
688+
The configured log record processor.
689+
"""
690+
import opentelemetry.sdk._logs.export
691+
692+
class _Processor(opentelemetry.sdk._logs.export.BatchLogRecordProcessor):
693+
def on_emit(self, log_record) -> None:
694+
record = log_record.log_record
695+
attributes = dict(record.attributes or {})
696+
if record.event_name:
697+
attributes.setdefault(_EVENT_NAME, record.event_name)
698+
elif _GCP_LOG_NAME not in attributes:
699+
attributes[_GCP_LOG_NAME] = default_log_name
700+
record.attributes = attributes
701+
super().on_emit(log_record)
702+
703+
return _Processor(exporter)
704+
705+
640706
def _get_api_endpoint(client_cert_source: bytes | None = None) -> str:
641707
"""Returns API endpoint based on mTLS configuration and cert availability.
642708

setup.py

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -151,7 +151,6 @@
151151
reasoning_engine_extra_require = [
152152
"cloudpickle >= 3.0, < 4.0",
153153
"opentelemetry-sdk < 2",
154-
"opentelemetry-exporter-gcp-logging >= 1.11.0a0, < 2.0.0",
155154
"opentelemetry-exporter-otlp-proto-http < 2",
156155
"opentelemetry-instrumentation-google-genai>=0.3b0, <1.0.0",
157156
# TODO(b/538550724): update to stable version of
@@ -165,9 +164,7 @@
165164
agent_engines_extra_require = [
166165
"packaging >= 24.0",
167166
"cloudpickle >= 3.0, < 4.0",
168-
"google-cloud-logging < 4",
169167
"opentelemetry-sdk < 2",
170-
"opentelemetry-exporter-gcp-logging >= 1.11.0a0, < 2.0.0",
171168
"opentelemetry-exporter-otlp-proto-http < 2",
172169
"pydantic >= 2.11.1, < 3",
173170
"typing_extensions",

0 commit comments

Comments
 (0)