Skip to content

Commit de63b25

Browse files
vertex-sdk-botcopybara-github
authored andcommitted
feat: export ADK agent engine template logs to the Telemetry API
PiperOrigin-RevId: 966602473
1 parent 7c94277 commit de63b25

5 files changed

Lines changed: 914 additions & 253 deletions

File tree

agentplatform/frameworks/adk.py

Lines changed: 223 additions & 83 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,6 @@
1717
from collections.abc import Awaitable
1818
import enum
1919
import os
20-
import sys
2120
import threading
2221
from typing import (
2322
Any,
@@ -132,6 +131,19 @@
132131
# rather than inherit AuthorizedSession's 120s default.
133132
_TELEMETRY_API_CHECK_TIMEOUT_SECONDS = 5.0
134133

134+
_DEFAULT_TELEMETRY_LOGS_ENDPOINT = "https://telemetry.googleapis.com/v1/logs"
135+
_DEFAULT_MTLS_TELEMETRY_LOGS_ENDPOINT = (
136+
"https://telemetry.mtls.googleapis.com/v1/logs"
137+
)
138+
139+
_GCP_LOG_NAME = "gcp.log_name"
140+
_EVENT_NAME = "event.name"
141+
_GCP_RESOURCE_TYPE = "gcp.resource_type"
142+
_LOCATION = "location"
143+
_REASONING_ENGINE_ID = "reasoning_engine_id"
144+
_RESOURCE_CONTAINER = "resource_container"
145+
_SERVICE_VERSION = "service.version"
146+
135147

136148
class _MtlsEndpoint(enum.Enum):
137149
"""Enum for the mTLS endpoint setting."""
@@ -356,8 +368,8 @@ def _warn_missing_dependency(
356368
)
357369
MISSING_LOGGING_IMPORT_ERROR_MESSAGE = (
358370
"proceeding with logging disabled because not all packages (i.e."
359-
" `google-cloud-logging`, `opentelemetry-sdk`,"
360-
" `opentelemetry-exporter-gcp-logging`) for tracing have been installed"
371+
" `opentelemetry-sdk`, `opentelemetry-exporter-otlp-proto-http`)"
372+
" for logging have been installed"
361373
)
362374

363375
if needed_for_tracing and enable_tracing:
@@ -366,15 +378,6 @@ def _warn_missing_dependency(
366378
_warn(MISSING_LOGGING_IMPORT_ERROR_MESSAGE)
367379
return None
368380

369-
def _detect_cloud_resource_id(project_id: str) -> Optional[str]:
370-
location = os.getenv("GOOGLE_CLOUD_AGENT_ENGINE_LOCATION", "") or os.getenv(
371-
"GOOGLE_CLOUD_LOCATION", ""
372-
)
373-
runtime_id = os.getenv("GOOGLE_CLOUD_AGENT_ENGINE_ID")
374-
if all(v is not None for v in (location, runtime_id)):
375-
return f"//aiplatform.googleapis.com/projects/{project_id}/locations/{location}/reasoningEngines/{runtime_id}"
376-
return None
377-
378381
try:
379382
import opentelemetry
380383
import opentelemetry.trace
@@ -395,37 +398,13 @@ def _detect_cloud_resource_id(project_id: str) -> Optional[str]:
395398
"opentelemetry-sdk", needed_for_tracing=True, needed_for_logging=True
396399
)
397400

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

423403
if enable_tracing:
424404
try:
425405
import opentelemetry.exporter.otlp.proto.http.version
426406
import opentelemetry.exporter.otlp.proto.http.trace_exporter
427407
import google.auth.transport.requests
428-
from google.cloud.aiplatform import version as aip_version
429408
except (ImportError, AttributeError):
430409
return _warn_missing_dependency(
431410
"opentelemetry-exporter-otlp-proto-http", needed_for_tracing=True
@@ -434,12 +413,7 @@ def _detect_cloud_resource_id(project_id: str) -> Optional[str]:
434413
import google.auth
435414

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

444418
session = requests_auth.AuthorizedSession(credentials=credentials)
445419

@@ -499,54 +473,45 @@ def _detect_cloud_resource_id(project_id: str) -> Optional[str]:
499473

500474
if enable_logging:
501475
try:
502-
import opentelemetry.exporter.cloud_logging
476+
import opentelemetry.exporter.otlp.proto.http._log_exporter
477+
import google.auth.transport.requests
503478
except (ImportError, AttributeError):
504479
return _warn_missing_dependency(
505-
"opentelemetry-exporter-gcp-logging", needed_for_logging=True
480+
"opentelemetry-exporter-otlp-proto-http", needed_for_logging=True
506481
)
507482

508-
class _SimpleLogRecordProcessor(
509-
opentelemetry.sdk._logs.export.SimpleLogRecordProcessor
510-
):
483+
import google.auth
511484

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

551516
opentelemetry._logs.set_logger_provider(logger_provider=logger_provider)
552517

@@ -670,6 +635,181 @@ def _warn_if_telemetry_api_disabled():
670635
_warn(_TELEMETRY_API_DISABLED_WARNING % (project, project))
671636

672637

638+
def _get_user_agent() -> str:
639+
"""Returns the User-Agent to send on OTLP exports."""
640+
from google.cloud.aiplatform import version as aip_version
641+
642+
user_agent = f"Vertex-Agent-Engine/{aip_version.__version__}"
643+
try:
644+
import opentelemetry.exporter.otlp.proto.http.version
645+
646+
user_agent += (
647+
" OTel-OTLP-Exporter-Python/"
648+
f"{opentelemetry.exporter.otlp.proto.http.version.__version__}"
649+
)
650+
except (ImportError, AttributeError):
651+
pass
652+
return user_agent
653+
654+
655+
def _get_logs_api_endpoint(client_cert_source: bytes | None = None) -> str:
656+
"""Returns the logs endpoint matching _get_api_endpoint's mTLS decision.
657+
658+
Args:
659+
client_cert_source (bytes | None): The client certificate source.
660+
661+
Returns:
662+
str: The logs API endpoint to be used.
663+
"""
664+
if _get_api_endpoint(client_cert_source) == _DEFAULT_MTLS_TELEMETRY_ENDPOINT:
665+
return _DEFAULT_MTLS_TELEMETRY_LOGS_ENDPOINT
666+
return _DEFAULT_TELEMETRY_LOGS_ENDPOINT
667+
668+
669+
def _create_otel_resource(project_id: str):
670+
"""Returns the OTel resource describing the Agent Engine deployment.
671+
672+
Args:
673+
project_id: Project to which to send telemetry.
674+
675+
Returns:
676+
The resource to set on the providers.
677+
"""
678+
import os
679+
import uuid
680+
681+
import opentelemetry.sdk.resources
682+
683+
location = os.getenv("GOOGLE_CLOUD_AGENT_ENGINE_LOCATION", "") or os.getenv(
684+
"GOOGLE_CLOUD_LOCATION", ""
685+
)
686+
agent_engine_id = os.getenv("GOOGLE_CLOUD_AGENT_ENGINE_ID", "")
687+
attributes = {
688+
"gcp.project_id": project_id,
689+
"cloud.account.id": project_id,
690+
"cloud.provider": "gcp",
691+
"cloud.platform": "gcp.agent_engine",
692+
"service.name": agent_engine_id,
693+
"service.instance.id": f"{uuid.uuid4().hex}-{os.getpid()}",
694+
"cloud.region": location,
695+
}
696+
if location and agent_engine_id:
697+
attributes["cloud.resource_id"] = (
698+
f"//aiplatform.googleapis.com/projects/{project_id}"
699+
f"/locations/{location}/reasoningEngines/{agent_engine_id}"
700+
)
701+
702+
# Provide a set of resource attributes but allow to override them with env
703+
# variables like OTEL_RESOURCE_ATTRIBUTES and OTEL_SERVICE_NAME.
704+
return opentelemetry.sdk.resources.Resource.create(attributes=attributes).merge(
705+
opentelemetry.sdk.resources.OTELResourceDetector().detect()
706+
)
707+
708+
709+
def _gcp_batch_log_record_processor(exporter, project_id: str):
710+
"""Returns a batch processor that keeps log names and labels stable.
711+
712+
Args:
713+
exporter: The OTLP log exporter to wrap.
714+
project_id (str): Project to which to send telemetry.
715+
716+
Returns:
717+
The configured log record processor.
718+
"""
719+
import copy
720+
import os
721+
722+
import opentelemetry.sdk._logs.export
723+
import opentelemetry.sdk.resources
724+
725+
class GCPBatchLogRecordProcessor(
726+
opentelemetry.sdk._logs.export.BatchLogRecordProcessor
727+
):
728+
"""Keeps Cloud Logging log names and labels stable."""
729+
730+
def __init__(
731+
self,
732+
exporter: opentelemetry.sdk._logs.export.LogRecordExporter,
733+
project_id: str,
734+
):
735+
super().__init__(exporter)
736+
self._log_resource = self._agent_engine_log_resource(project_id)
737+
self._default_log_name = os.getenv(
738+
# OTel logs used to be written to stdout in Agent Engine.
739+
# Let's keep the same log name for backward compatibility.
740+
"GCP_DEFAULT_LOG_NAME",
741+
"aiplatform.googleapis.com/reasoning_engine_stdout",
742+
)
743+
# Resource attributes are dropped once ingested as a
744+
# MonitoredResource, so the version has to travel as a label to
745+
# stay queryable.
746+
self._service_version = os.getenv(
747+
"GOOGLE_CLOUD_AGENT_ENGINE_RUNTIME_REVISION_ID", ""
748+
)
749+
750+
@staticmethod
751+
def _agent_engine_log_resource(project_id: str):
752+
"""Returns the MonitoredResource hints Cloud Logging needs.
753+
754+
These are logs-only: `gcp.resource_type` also steers metric
755+
ingestion, so putting them on the resource traces and metrics
756+
share would move Agent Engine metrics off their monitored
757+
resource.
758+
759+
Args:
760+
project_id (str): Project to which to send telemetry.
761+
762+
Returns:
763+
The resource to merge onto every exported log record.
764+
"""
765+
location = os.getenv(
766+
"GOOGLE_CLOUD_AGENT_ENGINE_LOCATION", ""
767+
) or os.getenv("GOOGLE_CLOUD_LOCATION", "")
768+
return opentelemetry.sdk.resources.Resource(
769+
attributes={
770+
# Cloud Logging otherwise detects the resource as
771+
# `generic_task`.
772+
_GCP_RESOURCE_TYPE: "aiplatform.googleapis.com/ReasoningEngine",
773+
_LOCATION: location,
774+
_REASONING_ENGINE_ID: os.getenv(
775+
"GOOGLE_CLOUD_AGENT_ENGINE_ID", ""
776+
),
777+
# Without `projects/`, telemetry.googleapis.com returns a 4xx.
778+
_RESOURCE_CONTAINER: f"projects/{project_id}",
779+
}
780+
)
781+
782+
def on_emit(
783+
self, log_record: opentelemetry.sdk._logs.ReadWriteLogRecord
784+
) -> None:
785+
# The provider hands the same record to every registered
786+
# processor, so the rewrites below go on a copy of our own.
787+
# Shallow is enough: nothing here mutates the body, the
788+
# attributes or the resource in place.
789+
emitted = copy.copy(log_record)
790+
record = emitted.log_record = copy.copy(log_record.log_record)
791+
792+
attributes = dict(record.attributes or {})
793+
if record.event_name:
794+
attributes.setdefault(_EVENT_NAME, record.event_name)
795+
# Cloud Logging derives the log name from `event_name` in
796+
# preference to `gcp.log_name`, which would scatter records
797+
# over one log per event type. The name survives as the
798+
# `event.name` label set above.
799+
record.event_name = None
800+
attributes.setdefault(_GCP_LOG_NAME, self._default_log_name)
801+
emitted.resource = (
802+
log_record.resource
803+
or opentelemetry.sdk.resources.Resource.get_empty()
804+
).merge(self._log_resource)
805+
if self._service_version:
806+
attributes.setdefault(_SERVICE_VERSION, self._service_version)
807+
record.attributes = attributes
808+
super().on_emit(emitted)
809+
810+
return GCPBatchLogRecordProcessor(exporter, project_id)
811+
812+
673813
def _get_api_endpoint(client_cert_source: bytes | None = None) -> str:
674814
"""Returns API endpoint based on mTLS configuration and cert availability.
675815

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)