mirror of
https://github.com/encounter/adk-python.git
synced 2026-07-09 18:19:28 -07:00
feat: add structured JSON logging with Cloud Trace correlation Close #1683
- add a shared --structured_logs flag to adk web and adk api_server so users can opt into JSON-formatted output - introduce CloudTraceJSONFormatter that emits structured entries and attaches current Cloud Trace/Span IDs when an OpenTelemetry context is active - update CLI logging setup to clear duplicate stdout handlers when Cloud Logging is enabled and to reconfigure existing handlers (like from Uvicorn) so they also pick up the structured format and requested log level With the flag disabled the CLIs keep their existing text logs; when enabled, the services now produce Cloud Logging–friendly JSON that can be correlated with distributed traces. PiperOrigin-RevId: 818823818
This commit is contained in:
committed by
Copybara-Service
parent
df05ed6b3b
commit
d8548aabd2
@@ -31,10 +31,13 @@ dependencies = [
|
||||
"authlib>=1.5.1, <2.0.0", # For RestAPI Tool
|
||||
"click>=8.1.8, <9.0.0", # For CLI tools
|
||||
"fastapi>=0.115.0, <1.0.0", # FastAPI framework
|
||||
"google-api-core>=2.20.0, <3.0.0", # Core Google API helpers
|
||||
"google-api-python-client>=2.157.0, <3.0.0", # Google API client discovery
|
||||
"google-auth>=2.24.0, <3.0.0", # Google auth helpers
|
||||
"google-cloud-aiplatform[agent_engines]>=1.112.0, <2.0.0",# For VertexAI integrations, e.g. example store.
|
||||
"google-cloud-bigtable>=2.32.0", # For Bigtable database
|
||||
"google-cloud-discoveryengine>=0.13.12, <0.14.0", # For Discovery Engine Search Tool
|
||||
"google-cloud-logging>=3.8.0, <4.0.0", # For structured logging with trace correlation
|
||||
"google-cloud-secret-manager>=2.22.0, <3.0.0", # Fetching secrets in RestAPI Tool
|
||||
"google-cloud-spanner>=3.56.0, <4.0.0", # For Spanner database
|
||||
"google-cloud-speech>=2.30.0, <3.0.0", # For Audio Transcription
|
||||
|
||||
@@ -59,7 +59,7 @@ COPY --chown=myuser:myuser "agents/{app_name}/" "/app/agents/{app_name}/"
|
||||
|
||||
EXPOSE {port}
|
||||
|
||||
CMD adk {command} --port={port} {host_option} {service_option} {trace_to_cloud_option} {allow_origins_option} {a2a_option} "/app/agents"
|
||||
CMD adk {command} --port={port} {host_option} {service_option} {trace_to_cloud_option} {log_to_cloud_option} {allow_origins_option} {a2a_option} "/app/agents"
|
||||
"""
|
||||
|
||||
_AGENT_ENGINE_APP_TEMPLATE: Final[str] = """
|
||||
@@ -267,6 +267,7 @@ def to_cloud_run(
|
||||
memory_service_uri,
|
||||
),
|
||||
trace_to_cloud_option='--trace_to_cloud' if trace_to_cloud else '',
|
||||
log_to_cloud_option='--log_to_cloud',
|
||||
allow_origins_option=allow_origins_option,
|
||||
adk_version=adk_version,
|
||||
host_option=host_option,
|
||||
@@ -702,6 +703,7 @@ def to_gke(
|
||||
memory_service_uri,
|
||||
),
|
||||
trace_to_cloud_option='--trace_to_cloud' if trace_to_cloud else '',
|
||||
log_to_cloud_option='--log_to_cloud',
|
||||
allow_origins_option=allow_origins_option,
|
||||
adk_version=adk_version,
|
||||
host_option=host_option,
|
||||
|
||||
@@ -937,6 +937,7 @@ def fast_api_common_options():
|
||||
"""Decorator to add common fast api options to click commands."""
|
||||
|
||||
def decorator(func):
|
||||
|
||||
@click.option(
|
||||
"--host",
|
||||
type=str,
|
||||
@@ -986,6 +987,17 @@ def fast_api_common_options():
|
||||
" Observability services - Cloud Trace and Cloud Logging."
|
||||
),
|
||||
)
|
||||
@click.option(
|
||||
"--log_to_cloud",
|
||||
is_flag=True,
|
||||
show_default=True,
|
||||
default=False,
|
||||
help=(
|
||||
"Optional. Emit structured JSON logs via Google Cloud Logging,"
|
||||
" keeping multiline messages in a single entry. Only set this to"
|
||||
" True when deployed to Google Cloud environment."
|
||||
),
|
||||
)
|
||||
@click.option(
|
||||
"--reload/--no-reload",
|
||||
default=True,
|
||||
@@ -1065,6 +1077,7 @@ def cli_web(
|
||||
port: int = 8000,
|
||||
trace_to_cloud: bool = False,
|
||||
otel_to_cloud: bool = False,
|
||||
log_to_cloud: bool = False,
|
||||
reload: bool = True,
|
||||
session_service_uri: Optional[str] = None,
|
||||
artifact_service_uri: Optional[str] = None,
|
||||
@@ -1086,7 +1099,9 @@ def cli_web(
|
||||
|
||||
adk web --session_service_uri=[uri] --port=[port] path/to/agents_dir
|
||||
"""
|
||||
logs.setup_adk_logger(getattr(logging, log_level.upper()))
|
||||
logs.setup_adk_logger(
|
||||
getattr(logging, log_level.upper()), log_to_cloud=log_to_cloud
|
||||
)
|
||||
|
||||
@asynccontextmanager
|
||||
async def _lifespan(app: FastAPI):
|
||||
@@ -1164,6 +1179,7 @@ def cli_api_server(
|
||||
port: int = 8000,
|
||||
trace_to_cloud: bool = False,
|
||||
otel_to_cloud: bool = False,
|
||||
log_to_cloud: bool = False,
|
||||
reload: bool = True,
|
||||
session_service_uri: Optional[str] = None,
|
||||
artifact_service_uri: Optional[str] = None,
|
||||
@@ -1183,7 +1199,9 @@ def cli_api_server(
|
||||
|
||||
adk api_server --session_service_uri=[uri] --port=[port] path/to/agents_dir
|
||||
"""
|
||||
logs.setup_adk_logger(getattr(logging, log_level.upper()))
|
||||
logs.setup_adk_logger(
|
||||
getattr(logging, log_level.upper()), log_to_cloud=log_to_cloud
|
||||
)
|
||||
|
||||
session_service_uri = session_service_uri or session_db_url
|
||||
artifact_service_uri = artifact_service_uri or artifact_storage_uri
|
||||
|
||||
@@ -19,17 +19,37 @@ import os
|
||||
import tempfile
|
||||
import time
|
||||
|
||||
from google.api_core import exceptions as api_core_exceptions
|
||||
from google.auth import exceptions as auth_exceptions
|
||||
from google.cloud import logging as cloud_logging
|
||||
|
||||
LOGGING_FORMAT = (
|
||||
'%(asctime)s - %(levelname)s - %(filename)s:%(lineno)d - %(message)s'
|
||||
)
|
||||
|
||||
|
||||
def setup_adk_logger(level=logging.INFO):
|
||||
# Configure the root logger format and level.
|
||||
logging.basicConfig(level=level, format=LOGGING_FORMAT)
|
||||
def setup_adk_logger(level=logging.INFO, *, log_to_cloud: bool = False):
|
||||
"""Set up ADK logger with optional Google Cloud Logging integration."""
|
||||
root_logger = logging.getLogger()
|
||||
|
||||
adk_logger = logging.getLogger('google_adk')
|
||||
adk_logger.setLevel(level)
|
||||
if log_to_cloud:
|
||||
# Remove the default StreamHandler to avoid duplicate stdout logs.
|
||||
root_logger.handlers = []
|
||||
client = cloud_logging.Client()
|
||||
client.setup_logging(log_level=level)
|
||||
root_logger.setLevel(level)
|
||||
else:
|
||||
if root_logger.handlers:
|
||||
# Uvicorn installs handlers ahead of application code, so basicConfig is a no-op.
|
||||
formatter = logging.Formatter(LOGGING_FORMAT)
|
||||
for handler in root_logger.handlers:
|
||||
handler.setLevel(level)
|
||||
handler.setFormatter(formatter)
|
||||
else:
|
||||
logging.basicConfig(level=level, format=LOGGING_FORMAT)
|
||||
root_logger.setLevel(level)
|
||||
|
||||
logging.getLogger('google_adk').setLevel(level)
|
||||
|
||||
|
||||
def log_to_tmp_folder(
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
# Copyright 2025 Google LLC
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from importlib import reload
|
||||
import io
|
||||
import json
|
||||
import logging
|
||||
from unittest import mock
|
||||
from unittest import TestCase
|
||||
|
||||
from google.adk.cli.utils import logs as logs_module
|
||||
|
||||
logs = reload(logs_module)
|
||||
|
||||
|
||||
class TestSetupAdkLogger(TestCase):
|
||||
"""Tests for setup_adk_logger helper."""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self._reset_logging()
|
||||
self.addCleanup(self._reset_logging)
|
||||
|
||||
def _reset_logging(self):
|
||||
root_logger = logging.getLogger()
|
||||
for handler in list(root_logger.handlers):
|
||||
root_logger.removeHandler(handler)
|
||||
try:
|
||||
handler.close()
|
||||
except Exception: # pylint: disable=broad-except
|
||||
pass
|
||||
root_logger.setLevel(logging.WARNING)
|
||||
root_logger.propagate = True
|
||||
|
||||
def test_log_to_cloud_uses_google_cloud_logging_client(self):
|
||||
"""Log setup delegates to google.cloud.logging client when available."""
|
||||
buffer = io.StringIO()
|
||||
|
||||
class _JsonFormatter(logging.Formatter):
|
||||
|
||||
def format(self, record):
|
||||
return json.dumps({'message': record.getMessage()}, ensure_ascii=False)
|
||||
|
||||
class FakeClient:
|
||||
|
||||
def __init__(self):
|
||||
self.called_with = None
|
||||
|
||||
def setup_logging(self, log_level=logging.INFO):
|
||||
self.called_with = log_level
|
||||
handler = logging.StreamHandler(buffer)
|
||||
handler.setLevel(log_level)
|
||||
handler.setFormatter(_JsonFormatter())
|
||||
root = logging.getLogger()
|
||||
root.handlers = [handler]
|
||||
root.setLevel(log_level)
|
||||
root.propagate = False
|
||||
|
||||
fake_client = FakeClient()
|
||||
client_factory = mock.Mock(return_value=fake_client)
|
||||
with mock.patch.object(logs, 'cloud_logging', autospec=True) as mock_module:
|
||||
mock_module.Client = client_factory
|
||||
logs.setup_adk_logger(level=logging.INFO, log_to_cloud=True)
|
||||
logging.getLogger('google_adk.test').info('hello\nworld')
|
||||
|
||||
client_factory.assert_called_once()
|
||||
self.assertEqual(logging.getLogger('google_adk').level, logging.INFO)
|
||||
self.assertEqual(fake_client.called_with, logging.INFO)
|
||||
output_lines = [
|
||||
line for line in buffer.getvalue().splitlines() if line.strip()
|
||||
]
|
||||
self.assertEqual(len(output_lines), 1)
|
||||
entry = json.loads(output_lines[0])
|
||||
self.assertEqual(entry['message'], 'hello\nworld')
|
||||
|
||||
def test_log_to_cloud_client_failure_surfaces_error(self):
|
||||
"""Cloud logging setup failures surface as actionable errors."""
|
||||
|
||||
class FailingClient:
|
||||
|
||||
def setup_logging(self, log_level=logging.INFO):
|
||||
del log_level
|
||||
raise OSError('boom')
|
||||
|
||||
client_factory = mock.Mock(return_value=FailingClient())
|
||||
with mock.patch.object(logs, 'cloud_logging', autospec=True) as mock_module:
|
||||
mock_module.Client = client_factory
|
||||
with self.assertRaises(OSError):
|
||||
logs.setup_adk_logger(level=logging.INFO, log_to_cloud=True)
|
||||
|
||||
client_factory.assert_called_once()
|
||||
|
||||
def test_text_logging_configures_basic_logging(self):
|
||||
"""Fallback text logging configures default formatter and handlers."""
|
||||
logs.setup_adk_logger(level=logging.ERROR, log_to_cloud=False)
|
||||
|
||||
root_logger = logging.getLogger()
|
||||
self.assertEqual(root_logger.level, logging.ERROR)
|
||||
self.assertTrue(root_logger.handlers)
|
||||
handler = root_logger.handlers[0]
|
||||
formatter = handler.formatter
|
||||
self.assertIsInstance(formatter, logging.Formatter)
|
||||
self.assertEqual(
|
||||
formatter._style._fmt, # pylint: disable=protected-access
|
||||
logs.LOGGING_FORMAT,
|
||||
)
|
||||
|
||||
def test_text_logging_sets_adk_logger_level(self):
|
||||
"""ADK logger level is aligned when text logging is used."""
|
||||
logs.setup_adk_logger(level=logging.WARNING, log_to_cloud=False)
|
||||
self.assertEqual(logging.getLogger('google_adk').level, logging.WARNING)
|
||||
Reference in New Issue
Block a user