Skip to content

Commit d8548aa

Browse files
GWealecopybara-github
authored andcommitted
feat: add structured JSON logging with Cloud Trace correlation Close google#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
1 parent df05ed6 commit d8548aa

5 files changed

Lines changed: 175 additions & 8 deletions

File tree

‎pyproject.toml‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,10 +31,13 @@ dependencies = [
3131
"authlib>=1.5.1, <2.0.0", # For RestAPI Tool
3232
"click>=8.1.8, <9.0.0", # For CLI tools
3333
"fastapi>=0.115.0, <1.0.0", # FastAPI framework
34+
"google-api-core>=2.20.0, <3.0.0", # Core Google API helpers
3435
"google-api-python-client>=2.157.0, <3.0.0", # Google API client discovery
36+
"google-auth>=2.24.0, <3.0.0", # Google auth helpers
3537
"google-cloud-aiplatform[agent_engines]>=1.112.0, <2.0.0",# For VertexAI integrations, e.g. example store.
3638
"google-cloud-bigtable>=2.32.0", # For Bigtable database
3739
"google-cloud-discoveryengine>=0.13.12, <0.14.0", # For Discovery Engine Search Tool
40+
"google-cloud-logging>=3.8.0, <4.0.0", # For structured logging with trace correlation
3841
"google-cloud-secret-manager>=2.22.0, <3.0.0", # Fetching secrets in RestAPI Tool
3942
"google-cloud-spanner>=3.56.0, <4.0.0", # For Spanner database
4043
"google-cloud-speech>=2.30.0, <3.0.0", # For Audio Transcription

‎src/google/adk/cli/cli_deploy.py‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@
5959
6060
EXPOSE {port}
6161
62-
CMD adk {command} --port={port} {host_option} {service_option} {trace_to_cloud_option} {allow_origins_option} {a2a_option} "/app/agents"
62+
CMD adk {command} --port={port} {host_option} {service_option} {trace_to_cloud_option} {log_to_cloud_option} {allow_origins_option} {a2a_option} "/app/agents"
6363
"""
6464

6565
_AGENT_ENGINE_APP_TEMPLATE: Final[str] = """
@@ -267,6 +267,7 @@ def to_cloud_run(
267267
memory_service_uri,
268268
),
269269
trace_to_cloud_option='--trace_to_cloud' if trace_to_cloud else '',
270+
log_to_cloud_option='--log_to_cloud',
270271
allow_origins_option=allow_origins_option,
271272
adk_version=adk_version,
272273
host_option=host_option,
@@ -702,6 +703,7 @@ def to_gke(
702703
memory_service_uri,
703704
),
704705
trace_to_cloud_option='--trace_to_cloud' if trace_to_cloud else '',
706+
log_to_cloud_option='--log_to_cloud',
705707
allow_origins_option=allow_origins_option,
706708
adk_version=adk_version,
707709
host_option=host_option,

‎src/google/adk/cli/cli_tools_click.py‎

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -937,6 +937,7 @@ def fast_api_common_options():
937937
"""Decorator to add common fast api options to click commands."""
938938

939939
def decorator(func):
940+
940941
@click.option(
941942
"--host",
942943
type=str,
@@ -986,6 +987,17 @@ def decorator(func):
986987
" Observability services - Cloud Trace and Cloud Logging."
987988
),
988989
)
990+
@click.option(
991+
"--log_to_cloud",
992+
is_flag=True,
993+
show_default=True,
994+
default=False,
995+
help=(
996+
"Optional. Emit structured JSON logs via Google Cloud Logging,"
997+
" keeping multiline messages in a single entry. Only set this to"
998+
" True when deployed to Google Cloud environment."
999+
),
1000+
)
9891001
@click.option(
9901002
"--reload/--no-reload",
9911003
default=True,
@@ -1065,6 +1077,7 @@ def cli_web(
10651077
port: int = 8000,
10661078
trace_to_cloud: bool = False,
10671079
otel_to_cloud: bool = False,
1080+
log_to_cloud: bool = False,
10681081
reload: bool = True,
10691082
session_service_uri: Optional[str] = None,
10701083
artifact_service_uri: Optional[str] = None,
@@ -1086,7 +1099,9 @@ def cli_web(
10861099
10871100
adk web --session_service_uri=[uri] --port=[port] path/to/agents_dir
10881101
"""
1089-
logs.setup_adk_logger(getattr(logging, log_level.upper()))
1102+
logs.setup_adk_logger(
1103+
getattr(logging, log_level.upper()), log_to_cloud=log_to_cloud
1104+
)
10901105

10911106
@asynccontextmanager
10921107
async def _lifespan(app: FastAPI):
@@ -1164,6 +1179,7 @@ def cli_api_server(
11641179
port: int = 8000,
11651180
trace_to_cloud: bool = False,
11661181
otel_to_cloud: bool = False,
1182+
log_to_cloud: bool = False,
11671183
reload: bool = True,
11681184
session_service_uri: Optional[str] = None,
11691185
artifact_service_uri: Optional[str] = None,
@@ -1183,7 +1199,9 @@ def cli_api_server(
11831199
11841200
adk api_server --session_service_uri=[uri] --port=[port] path/to/agents_dir
11851201
"""
1186-
logs.setup_adk_logger(getattr(logging, log_level.upper()))
1202+
logs.setup_adk_logger(
1203+
getattr(logging, log_level.upper()), log_to_cloud=log_to_cloud
1204+
)
11871205

11881206
session_service_uri = session_service_uri or session_db_url
11891207
artifact_service_uri = artifact_service_uri or artifact_storage_uri

‎src/google/adk/cli/utils/logs.py‎

Lines changed: 25 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -19,17 +19,37 @@
1919
import tempfile
2020
import time
2121

22+
from google.api_core import exceptions as api_core_exceptions
23+
from google.auth import exceptions as auth_exceptions
24+
from google.cloud import logging as cloud_logging
25+
2226
LOGGING_FORMAT = (
2327
'%(asctime)s - %(levelname)s - %(filename)s:%(lineno)d - %(message)s'
2428
)
2529

2630

27-
def setup_adk_logger(level=logging.INFO):
28-
# Configure the root logger format and level.
29-
logging.basicConfig(level=level, format=LOGGING_FORMAT)
31+
def setup_adk_logger(level=logging.INFO, *, log_to_cloud: bool = False):
32+
"""Set up ADK logger with optional Google Cloud Logging integration."""
33+
root_logger = logging.getLogger()
3034

31-
adk_logger = logging.getLogger('google_adk')
32-
adk_logger.setLevel(level)
35+
if log_to_cloud:
36+
# Remove the default StreamHandler to avoid duplicate stdout logs.
37+
root_logger.handlers = []
38+
client = cloud_logging.Client()
39+
client.setup_logging(log_level=level)
40+
root_logger.setLevel(level)
41+
else:
42+
if root_logger.handlers:
43+
# Uvicorn installs handlers ahead of application code, so basicConfig is a no-op.
44+
formatter = logging.Formatter(LOGGING_FORMAT)
45+
for handler in root_logger.handlers:
46+
handler.setLevel(level)
47+
handler.setFormatter(formatter)
48+
else:
49+
logging.basicConfig(level=level, format=LOGGING_FORMAT)
50+
root_logger.setLevel(level)
51+
52+
logging.getLogger('google_adk').setLevel(level)
3353

3454

3555
def log_to_tmp_folder(
Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
# Copyright 2025 Google LLC
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
from __future__ import annotations
16+
17+
from importlib import reload
18+
import io
19+
import json
20+
import logging
21+
from unittest import mock
22+
from unittest import TestCase
23+
24+
from google.adk.cli.utils import logs as logs_module
25+
26+
logs = reload(logs_module)
27+
28+
29+
class TestSetupAdkLogger(TestCase):
30+
"""Tests for setup_adk_logger helper."""
31+
32+
def setUp(self):
33+
super().setUp()
34+
self._reset_logging()
35+
self.addCleanup(self._reset_logging)
36+
37+
def _reset_logging(self):
38+
root_logger = logging.getLogger()
39+
for handler in list(root_logger.handlers):
40+
root_logger.removeHandler(handler)
41+
try:
42+
handler.close()
43+
except Exception: # pylint: disable=broad-except
44+
pass
45+
root_logger.setLevel(logging.WARNING)
46+
root_logger.propagate = True
47+
48+
def test_log_to_cloud_uses_google_cloud_logging_client(self):
49+
"""Log setup delegates to google.cloud.logging client when available."""
50+
buffer = io.StringIO()
51+
52+
class _JsonFormatter(logging.Formatter):
53+
54+
def format(self, record):
55+
return json.dumps({'message': record.getMessage()}, ensure_ascii=False)
56+
57+
class FakeClient:
58+
59+
def __init__(self):
60+
self.called_with = None
61+
62+
def setup_logging(self, log_level=logging.INFO):
63+
self.called_with = log_level
64+
handler = logging.StreamHandler(buffer)
65+
handler.setLevel(log_level)
66+
handler.setFormatter(_JsonFormatter())
67+
root = logging.getLogger()
68+
root.handlers = [handler]
69+
root.setLevel(log_level)
70+
root.propagate = False
71+
72+
fake_client = FakeClient()
73+
client_factory = mock.Mock(return_value=fake_client)
74+
with mock.patch.object(logs, 'cloud_logging', autospec=True) as mock_module:
75+
mock_module.Client = client_factory
76+
logs.setup_adk_logger(level=logging.INFO, log_to_cloud=True)
77+
logging.getLogger('google_adk.test').info('hello\nworld')
78+
79+
client_factory.assert_called_once()
80+
self.assertEqual(logging.getLogger('google_adk').level, logging.INFO)
81+
self.assertEqual(fake_client.called_with, logging.INFO)
82+
output_lines = [
83+
line for line in buffer.getvalue().splitlines() if line.strip()
84+
]
85+
self.assertEqual(len(output_lines), 1)
86+
entry = json.loads(output_lines[0])
87+
self.assertEqual(entry['message'], 'hello\nworld')
88+
89+
def test_log_to_cloud_client_failure_surfaces_error(self):
90+
"""Cloud logging setup failures surface as actionable errors."""
91+
92+
class FailingClient:
93+
94+
def setup_logging(self, log_level=logging.INFO):
95+
del log_level
96+
raise OSError('boom')
97+
98+
client_factory = mock.Mock(return_value=FailingClient())
99+
with mock.patch.object(logs, 'cloud_logging', autospec=True) as mock_module:
100+
mock_module.Client = client_factory
101+
with self.assertRaises(OSError):
102+
logs.setup_adk_logger(level=logging.INFO, log_to_cloud=True)
103+
104+
client_factory.assert_called_once()
105+
106+
def test_text_logging_configures_basic_logging(self):
107+
"""Fallback text logging configures default formatter and handlers."""
108+
logs.setup_adk_logger(level=logging.ERROR, log_to_cloud=False)
109+
110+
root_logger = logging.getLogger()
111+
self.assertEqual(root_logger.level, logging.ERROR)
112+
self.assertTrue(root_logger.handlers)
113+
handler = root_logger.handlers[0]
114+
formatter = handler.formatter
115+
self.assertIsInstance(formatter, logging.Formatter)
116+
self.assertEqual(
117+
formatter._style._fmt, # pylint: disable=protected-access
118+
logs.LOGGING_FORMAT,
119+
)
120+
121+
def test_text_logging_sets_adk_logger_level(self):
122+
"""ADK logger level is aligned when text logging is used."""
123+
logs.setup_adk_logger(level=logging.WARNING, log_to_cloud=False)
124+
self.assertEqual(logging.getLogger('google_adk').level, logging.WARNING)

0 commit comments

Comments
 (0)