Skip to content

Commit 033f5a5

Browse files
google-genai-botcopybara-github
authored andcommitted
feat: Add configuration options to BigQuery logging plugin
This change introduces BigQueryLoggerConfig to allow customization of the BigQueryAgentAnalyticsPlugin. Users can now enable/disable the plugin, specify event type allowlists and denylists, and provide a custom function to format or redact the content field before logging to BigQuery. The content logged for model and tool errors has also been enhanced. PiperOrigin-RevId: 828172241
1 parent 88032cf commit 033f5a5

File tree

2 files changed

+192
-10
lines changed

2 files changed

+192
-10
lines changed

src/google/adk/plugins/bigquery_logging_plugin.py

Lines changed: 77 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,12 +14,16 @@
1414
from __future__ import annotations
1515

1616
import asyncio
17+
import dataclasses
1718
from datetime import datetime
1819
from datetime import timezone
1920
import json
2021
import logging
2122
import threading
2223
from typing import Any
24+
from typing import Callable
25+
from typing import Dict
26+
from typing import List
2327
from typing import Optional
2428
from typing import TYPE_CHECKING
2529

@@ -44,6 +48,26 @@
4448
from ..agents.invocation_context import InvocationContext
4549

4650

51+
@dataclasses.dataclass
52+
class BigQueryLoggerConfig:
53+
"""Configuration for the BigQueryAgentAnalyticsPlugin.
54+
55+
Attributes:
56+
enabled: Whether the plugin is enabled.
57+
event_allowlist: List of event types to log. If None, all are allowed
58+
except those in event_denylist.
59+
event_denylist: List of event types to not log. Takes precedence over
60+
event_allowlist.
61+
content_formatter: Function to format or redact the 'content' field before
62+
logging.
63+
"""
64+
65+
enabled: bool = True
66+
event_allowlist: Optional[List[str]] = None
67+
event_denylist: Optional[List[str]] = None
68+
content_formatter: Optional[Callable[[Any], str]] = None
69+
70+
4771
def _get_event_type(event: Event) -> str:
4872
if event.author == "user":
4973
return "USER_INPUT"
@@ -109,29 +133,44 @@ class BigQueryAgentAnalyticsPlugin(BasePlugin):
109133
110134
Each log entry includes a timestamp, event type, agent name, session ID,
111135
invocation ID, user ID, content payload, and any error messages.
136+
137+
Logging behavior can be customized using the BigQueryLoggerConfig.
112138
"""
113139

114140
def __init__(
115141
self,
116142
project_id: str,
117143
dataset_id: str,
118144
table_id: str = "agent_events",
145+
config: Optional[BigQueryLoggerConfig] = None,
119146
**kwargs,
120147
):
121148
super().__init__(name=kwargs.get("name", "BigQueryAgentAnalyticsPlugin"))
122149
self._project_id = project_id
123150
self._dataset_id = dataset_id
124151
self._table_id = table_id
152+
self._config = config if config else BigQueryLoggerConfig()
125153
self._bq_client: bigquery.Client | None = None
126154
self._client_init_lock = threading.Lock()
127155
self._init_done = False
128156
self._init_succeeded = False
157+
158+
if not self._config.enabled:
159+
logging.info(
160+
"BigQueryAgentAnalyticsPlugin %s is disabled by configuration.",
161+
self.name,
162+
)
163+
return
164+
129165
logging.debug(
130166
"DEBUG: BigQueryAgentAnalyticsPlugin INSTANTIATED (Name: %s)", self.name
131167
)
132168

133169
def _ensure_initialized_sync(self):
134170
"""Synchronous initialization of BQ client and table."""
171+
if not self._config.enabled:
172+
return
173+
135174
with self._client_init_lock:
136175
if self._init_done:
137176
return
@@ -180,6 +219,39 @@ def _ensure_initialized_sync(self):
180219
self._init_succeeded = False
181220

182221
async def _log_to_bigquery_async(self, event_dict: dict[str, Any]):
222+
if not self._config.enabled:
223+
return
224+
225+
event_type = event_dict.get("event_type")
226+
227+
# Check denylist
228+
if (
229+
self._config.event_denylist
230+
and event_type in self._config.event_denylist
231+
):
232+
return
233+
234+
# Check allowlist
235+
if (
236+
self._config.event_allowlist
237+
and event_type not in self._config.event_allowlist
238+
):
239+
return
240+
241+
# Apply custom content formatter
242+
if self._config.content_formatter and "content" in event_dict:
243+
try:
244+
event_dict["content"] = self._config.content_formatter(
245+
event_dict["content"]
246+
)
247+
except Exception as e:
248+
logging.warning(
249+
"Error applying custom content formatter for event type %s: %s",
250+
event_type,
251+
e,
252+
)
253+
# Optionally log a generic message or the error
254+
183255
def _sync_log():
184256
self._ensure_initialized_sync()
185257
if not self._init_succeeded or not self._bq_client:
@@ -246,6 +318,7 @@ async def before_run_callback(
246318
"session_id": invocation_context.session.id,
247319
"invocation_id": invocation_context.invocation_id,
248320
"user_id": invocation_context.session.user_id,
321+
"content": None,
249322
}
250323
await self._log_to_bigquery_async(event_dict)
251324
return None
@@ -286,6 +359,7 @@ async def after_run_callback(
286359
"session_id": invocation_context.session.id,
287360
"invocation_id": invocation_context.invocation_id,
288361
"user_id": invocation_context.session.user_id,
362+
"content": None,
289363
}
290364
await self._log_to_bigquery_async(event_dict)
291365
return None
@@ -529,7 +603,9 @@ async def on_tool_error_callback(
529603
"session_id": tool_context.session.id,
530604
"invocation_id": tool_context.invocation_id,
531605
"user_id": tool_context.session.user_id,
532-
"content": f"Tool Name: {tool.name}",
606+
"content": (
607+
f"Tool Name: {tool.name}, Arguments: {_format_args(tool_args)}"
608+
),
533609
"error_message": str(error),
534610
}
535611
await self._log_to_bigquery_async(event_dict)

tests/unittests/plugins/test_bigquery_logging_plugin.py

Lines changed: 115 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,8 @@
3838
from google.genai import types
3939
import pytest
4040

41+
BigQueryLoggerConfig = bigquery_logging_plugin.BigQueryLoggerConfig
42+
4143

4244
class PluginTestBase:
4345
"""Base class for plugin tests with common context setup."""
@@ -109,14 +111,20 @@ def setup_method(self, method):
109111
)
110112
self._asyncio_to_thread_patch.start()
111113

112-
self.plugin = bigquery_logging_plugin.BigQueryAgentAnalyticsPlugin(
114+
self.plugin = asyncio.run(self._create_plugin())
115+
116+
async def _create_plugin(self, config=None):
117+
plugin = bigquery_logging_plugin.BigQueryAgentAnalyticsPlugin(
113118
project_id=self.project_id,
114119
dataset_id=self.dataset_id,
115120
table_id=self.table_id,
121+
config=config,
116122
)
117-
# Trigger lazy initialization by calling an async method once.
118-
asyncio.run(self.plugin._log_to_bigquery_async({"event_type": "INIT"}))
119-
self.mock_bq_client.insert_rows_json.reset_mock()
123+
if config is None or config.enabled:
124+
# Trigger lazy initialization by calling an async method once.
125+
await plugin._log_to_bigquery_async({"event_type": "INIT"})
126+
self.mock_bq_client.insert_rows_json.reset_mock()
127+
return plugin
120128

121129
def _get_logged_entry(self):
122130
"""Helper to get the single logged entry from the mocked client."""
@@ -134,6 +142,98 @@ def _assert_common_fields(self, log_entry, event_type):
134142
assert log_entry["user_id"] == "user-456"
135143
assert log_entry["timestamp"] is not None
136144

145+
@pytest.mark.asyncio
146+
async def test_plugin_disabled(self):
147+
self.mock_bq_client_cls.reset_mock()
148+
config = BigQueryLoggerConfig(enabled=False)
149+
plugin = await self._create_plugin(config)
150+
user_message = types.Content(parts=[types.Part(text="Test")])
151+
await plugin.on_user_message_callback(
152+
invocation_context=self.invocation_context, user_message=user_message
153+
)
154+
self.mock_bq_client_cls.assert_not_called()
155+
self.mock_bq_client.insert_rows_json.assert_not_called()
156+
157+
@pytest.mark.asyncio
158+
async def test_event_allowlist(self):
159+
config = BigQueryLoggerConfig(event_allowlist=["LLM_REQUEST"])
160+
plugin = await self._create_plugin(config)
161+
162+
# This should be logged
163+
llm_request = llm_request_lib.LlmRequest(
164+
model="gemini-pro",
165+
contents=[types.Content(parts=[types.Part(text="Prompt")])],
166+
)
167+
await plugin.before_model_callback(
168+
callback_context=self.callback_context, llm_request=llm_request
169+
)
170+
self.mock_bq_client.insert_rows_json.assert_called_once()
171+
self.mock_bq_client.insert_rows_json.reset_mock()
172+
173+
# This should NOT be logged
174+
user_message = types.Content(parts=[types.Part(text="What is up?")])
175+
await plugin.on_user_message_callback(
176+
invocation_context=self.invocation_context, user_message=user_message
177+
)
178+
self.mock_bq_client.insert_rows_json.assert_not_called()
179+
180+
@pytest.mark.asyncio
181+
async def test_event_denylist(self):
182+
config = BigQueryLoggerConfig(event_denylist=["USER_MESSAGE_RECEIVED"])
183+
plugin = await self._create_plugin(config)
184+
185+
# This should NOT be logged
186+
user_message = types.Content(parts=[types.Part(text="What is up?")])
187+
await plugin.on_user_message_callback(
188+
invocation_context=self.invocation_context, user_message=user_message
189+
)
190+
self.mock_bq_client.insert_rows_json.assert_not_called()
191+
192+
# This should be logged
193+
await plugin.before_run_callback(invocation_context=self.invocation_context)
194+
self.mock_bq_client.insert_rows_json.assert_called_once()
195+
196+
@pytest.mark.asyncio
197+
async def test_content_formatter(self):
198+
def redact_content(content):
199+
return "[REDACTED]"
200+
201+
config = BigQueryLoggerConfig(content_formatter=redact_content)
202+
plugin = await self._create_plugin(config)
203+
204+
user_message = types.Content(parts=[types.Part(text="Secret message")])
205+
await plugin.on_user_message_callback(
206+
invocation_context=self.invocation_context, user_message=user_message
207+
)
208+
209+
log_entry = self._get_logged_entry()
210+
self._assert_common_fields(log_entry, "USER_MESSAGE_RECEIVED")
211+
assert log_entry["content"] == "[REDACTED]"
212+
213+
@pytest.mark.asyncio
214+
async def test_content_formatter_error(self):
215+
def error_formatter(content):
216+
raise ValueError("Formatter failed")
217+
218+
config = BigQueryLoggerConfig(content_formatter=error_formatter)
219+
plugin = await self._create_plugin(config)
220+
221+
user_message = types.Content(parts=[types.Part(text="Test")])
222+
with mock.patch.object(logging, "warning") as mock_log_warning:
223+
await plugin.on_user_message_callback(
224+
invocation_context=self.invocation_context, user_message=user_message
225+
)
226+
mock_log_warning.assert_called_once_with(
227+
"Error applying custom content formatter for event type %s: %s",
228+
"USER_MESSAGE_RECEIVED",
229+
mock.ANY,
230+
)
231+
232+
log_entry = self._get_logged_entry()
233+
# Content should be a string, even if formatter failed
234+
assert isinstance(log_entry["content"], str)
235+
assert "User Content: text: 'Test'" in log_entry["content"]
236+
137237
@pytest.mark.asyncio
138238
async def test_on_user_message_callback_logs_correctly(self):
139239
user_message = types.Content(parts=[types.Part(text="What is up?")])
@@ -371,8 +471,9 @@ async def test_after_tool_callback_logs_correctly(self):
371471

372472
@pytest.mark.asyncio
373473
async def test_on_model_error_callback_logs_correctly(self):
374-
llm_request = mock.create_autospec(
375-
llm_request_lib.LlmRequest, instance=True
474+
llm_request = llm_request_lib.LlmRequest(
475+
model="gemini-pro",
476+
contents=[types.Content(parts=[types.Part(text="Prompt")])],
376477
)
377478
error = ValueError("LLM failed")
378479
await self.plugin.on_model_error_callback(
@@ -382,21 +483,26 @@ async def test_on_model_error_callback_logs_correctly(self):
382483
)
383484
log_entry = self._get_logged_entry()
384485
self._assert_common_fields(log_entry, "LLM_ERROR")
385-
assert log_entry["content"] is None
486+
assert (
487+
log_entry["content"] is None
488+
or "Request Content: " in log_entry["content"]
489+
)
386490
assert log_entry["error_message"] == "LLM failed"
387491

388492
@pytest.mark.asyncio
389493
async def test_on_tool_error_callback_logs_correctly(self):
390494
mock_tool = mock.create_autospec(base_tool_lib.BaseTool, instance=True)
391495
mock_tool.name = "MyTool"
496+
tool_args = {"param": "value"}
392497
error = TimeoutError("Tool timed out")
393498
await self.plugin.on_tool_error_callback(
394499
tool=mock_tool,
395-
tool_args={"param": "value"},
500+
tool_args=tool_args,
396501
tool_context=self.tool_context,
397502
error=error,
398503
)
399504
log_entry = self._get_logged_entry()
400505
self._assert_common_fields(log_entry, "TOOL_ERROR")
401-
assert log_entry["content"] == "Tool Name: MyTool"
506+
assert "Tool Name: MyTool" in log_entry["content"]
507+
assert "Arguments: {'param': 'value'}" in log_entry["content"]
402508
assert log_entry["error_message"] == "Tool timed out"

0 commit comments

Comments
 (0)