1717from collections .abc import Awaitable
1818import enum
1919import os
20- import sys
2120import threading
2221from typing import (
2322 Any ,
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
136148class _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+
673813def _get_api_endpoint (client_cert_source : bytes | None = None ) -> str :
674814 """Returns API endpoint based on mTLS configuration and cert availability.
675815
0 commit comments