mirror of
https://github.com/encounter/adk-python.git
synced 2026-07-09 18:19:28 -07:00
Removes the dataset_id field from the BigQuery table schema and from each log entry created by the BigQueryAgentAnalyticsPlugin. This field is redundant, as all rows logged to a specific table belong to the same dataset. To ensure the plugin can still target the correct dataset, dataset_id is now a required argument in the BigQueryAgentAnalyticsPlugin constructor, and its default value has been removed. The BigQuery client user_agent is also updated with plugin version info to help identify traffic originating from this plugin. Unit tests have been updated to reflect the removal of dataset_id from log entries. PiperOrigin-RevId: 826596499
403 lines
15 KiB
Python
403 lines
15 KiB
Python
# 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
|
|
|
|
import asyncio
|
|
import datetime
|
|
import json
|
|
import logging
|
|
from unittest import mock
|
|
|
|
from google.adk.agents import base_agent
|
|
from google.adk.agents import callback_context as callback_context_lib
|
|
from google.adk.agents import invocation_context as invocation_context_lib
|
|
from google.adk.events import event as event_lib
|
|
from google.adk.models import llm_request as llm_request_lib
|
|
from google.adk.models import llm_response as llm_response_lib
|
|
from google.adk.plugins import bigquery_logging_plugin
|
|
from google.adk.plugins import plugin_manager as plugin_manager_lib
|
|
from google.adk.sessions import base_session_service as base_session_service_lib
|
|
from google.adk.sessions import session as session_lib
|
|
from google.adk.tools import base_tool as base_tool_lib
|
|
from google.adk.tools import tool_context as tool_context_lib
|
|
import google.auth
|
|
from google.auth import exceptions as auth_exceptions
|
|
from google.cloud import bigquery
|
|
from google.genai import types
|
|
import pytest
|
|
|
|
|
|
class PluginTestBase:
|
|
"""Base class for plugin tests with common context setup."""
|
|
|
|
def setup_method(self, method):
|
|
self.mock_session = mock.create_autospec(session_lib.Session, instance=True)
|
|
self.mock_session.id = "session-123"
|
|
self.mock_session.user_id = "user-456"
|
|
self.mock_session.app_name = "test_app"
|
|
self.mock_session.state = {}
|
|
self.mock_agent = mock.create_autospec(base_agent.BaseAgent, instance=True)
|
|
self.mock_agent.name = "MyTestAgent"
|
|
mock_session_service = mock.create_autospec(
|
|
base_session_service_lib.BaseSessionService, instance=True
|
|
)
|
|
mock_plugin_manager = mock.create_autospec(
|
|
plugin_manager_lib.PluginManager, instance=True
|
|
)
|
|
self.invocation_context = invocation_context_lib.InvocationContext(
|
|
agent=self.mock_agent,
|
|
session=self.mock_session,
|
|
invocation_id="inv-789",
|
|
session_service=mock_session_service,
|
|
plugin_manager=mock_plugin_manager,
|
|
)
|
|
self.callback_context = callback_context_lib.CallbackContext(
|
|
invocation_context=self.invocation_context
|
|
)
|
|
self.tool_context = tool_context_lib.ToolContext(
|
|
invocation_context=self.invocation_context
|
|
)
|
|
|
|
def teardown_method(self, method):
|
|
mock.patch.stopall()
|
|
|
|
|
|
class TestBigQueryAgentAnalyticsPlugin(PluginTestBase):
|
|
"""Tests for the BigQueryAgentAnalyticsPlugin."""
|
|
|
|
def setup_method(self, method):
|
|
super().setup_method(method)
|
|
self.project_id = "test-gcp-project"
|
|
self.dataset_id = "adk_logs"
|
|
self.table_id = "agent_events"
|
|
|
|
# Mock Google Auth default credentials
|
|
self._auth_patch = mock.patch.object(google.auth, "default", autospec=True)
|
|
self.mock_auth_default = self._auth_patch.start()
|
|
self.mock_auth_default.return_value = (mock.Mock(), self.project_id)
|
|
|
|
# Mock BigQuery Client class
|
|
self._bq_client_patch = mock.patch.object(bigquery, "Client", autospec=True)
|
|
self.mock_bq_client_cls = self._bq_client_patch.start()
|
|
self.mock_bq_client = self.mock_bq_client_cls.return_value
|
|
self.mock_bq_client.create_dataset.return_value = None
|
|
self.mock_bq_client.create_table.return_value = None
|
|
self.mock_bq_client.insert_rows_json.return_value = [] # No errors
|
|
self.mock_table_ref = mock.Mock()
|
|
self.mock_table_ref.dataset_id = self.dataset_id
|
|
self.mock_table_ref.table_id = self.table_id
|
|
self.mock_dataset_ref = mock.Mock()
|
|
self.mock_dataset_ref.table.return_value = self.mock_table_ref
|
|
self.mock_bq_client.dataset.return_value = self.mock_dataset_ref
|
|
|
|
# Patch asyncio.to_thread to run the function synchronously
|
|
self._asyncio_to_thread_patch = mock.patch(
|
|
"asyncio.to_thread",
|
|
side_effect=lambda func, *args, **kwargs: func(*args, **kwargs),
|
|
)
|
|
self._asyncio_to_thread_patch.start()
|
|
|
|
self.plugin = bigquery_logging_plugin.BigQueryAgentAnalyticsPlugin(
|
|
project_id=self.project_id,
|
|
dataset_id=self.dataset_id,
|
|
table_id=self.table_id,
|
|
)
|
|
# Trigger lazy initialization by calling an async method once.
|
|
asyncio.run(self.plugin._log_to_bigquery_async({"event_type": "INIT"}))
|
|
self.mock_bq_client.insert_rows_json.reset_mock()
|
|
|
|
def _get_logged_entry(self):
|
|
"""Helper to get the single logged entry from the mocked client."""
|
|
self.mock_bq_client.insert_rows_json.assert_called_once()
|
|
args, _ = self.mock_bq_client.insert_rows_json.call_args
|
|
rows = args[1]
|
|
assert len(rows) == 1
|
|
return rows[0]
|
|
|
|
def _assert_common_fields(self, log_entry, event_type):
|
|
assert log_entry["event_type"] == event_type
|
|
assert log_entry["agent"] == "MyTestAgent"
|
|
assert log_entry["session_id"] == "session-123"
|
|
assert log_entry["invocation_id"] == "inv-789"
|
|
assert log_entry["user_id"] == "user-456"
|
|
assert log_entry["timestamp"] is not None
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_on_user_message_callback_logs_correctly(self):
|
|
user_message = types.Content(parts=[types.Part(text="What is up?")])
|
|
await self.plugin.on_user_message_callback(
|
|
invocation_context=self.invocation_context, user_message=user_message
|
|
)
|
|
|
|
log_entry = self._get_logged_entry()
|
|
self._assert_common_fields(log_entry, "USER_MESSAGE_RECEIVED")
|
|
assert log_entry["content"] == "User Content: text: 'What is up?'"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_on_event_callback_tool_call(self):
|
|
tool_fc = types.FunctionCall(name="get_weather", args={"location": "Paris"})
|
|
event = event_lib.Event(
|
|
author="MyTestAgent",
|
|
content=types.Content(parts=[types.Part(function_call=tool_fc)]),
|
|
timestamp=datetime.datetime(
|
|
2025, 10, 22, 10, 0, 0, tzinfo=datetime.timezone.utc
|
|
).timestamp(),
|
|
)
|
|
await self.plugin.on_event_callback(
|
|
invocation_context=self.invocation_context, event=event
|
|
)
|
|
|
|
log_entry = self._get_logged_entry()
|
|
self._assert_common_fields(log_entry, "TOOL_CALL")
|
|
logged_content = json.loads(log_entry["content"])
|
|
assert logged_content[0]["function_call"]["args"] == {"location": "Paris"}
|
|
assert logged_content[0]["function_call"]["name"] == "get_weather"
|
|
assert log_entry["timestamp"] == "2025-10-22T10:00:00+00:00"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_on_event_callback_model_response(self):
|
|
event = event_lib.Event(
|
|
author="MyTestAgent",
|
|
content=types.Content(parts=[types.Part(text="Hello there!")]),
|
|
timestamp=datetime.datetime(
|
|
2025, 10, 22, 11, 0, 0, tzinfo=datetime.timezone.utc
|
|
).timestamp(),
|
|
)
|
|
await self.plugin.on_event_callback(
|
|
invocation_context=self.invocation_context, event=event
|
|
)
|
|
|
|
log_entry = self._get_logged_entry()
|
|
self._assert_common_fields(log_entry, "MODEL_RESPONSE")
|
|
logged_content = json.loads(log_entry["content"])
|
|
assert logged_content[0]["text"] == "Hello there!"
|
|
assert log_entry["timestamp"] == "2025-10-22T11:00:00+00:00"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_bigquery_client_initialization_failure(self):
|
|
# Simulate auth failure
|
|
self.mock_auth_default.side_effect = auth_exceptions.GoogleAuthError(
|
|
"Auth failed"
|
|
)
|
|
self.mock_bq_client.insert_rows_json.reset_mock()
|
|
|
|
# Re-instantiate the plugin so init is re-attempted
|
|
plugin_with_fail = bigquery_logging_plugin.BigQueryAgentAnalyticsPlugin(
|
|
project_id=self.project_id,
|
|
dataset_id=self.dataset_id,
|
|
table_id=self.table_id,
|
|
)
|
|
|
|
# Trigger a callback; initialization happens lazily
|
|
with mock.patch.object(logging, "exception") as mock_log_exception:
|
|
await plugin_with_fail.before_run_callback(
|
|
invocation_context=self.invocation_context
|
|
)
|
|
mock_log_exception.assert_called_once()
|
|
|
|
# Ensure insert_rows_json was never called because init failed
|
|
self.mock_bq_client.insert_rows_json.assert_not_called()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_bigquery_insert_error_does_not_raise(self):
|
|
# Simulate an insert error in the future result
|
|
self.mock_bq_client.insert_rows_json.return_value = [{"errors": ["error"]}]
|
|
|
|
with mock.patch.object(logging, "error") as mock_log_error:
|
|
await self.plugin.on_user_message_callback(
|
|
invocation_context=self.invocation_context,
|
|
user_message=types.Content(parts=[types.Part(text="Test")]),
|
|
)
|
|
# The plugin should handle the error internally without raising
|
|
mock_log_error.assert_called_with(
|
|
"Errors occurred while inserting to BigQuery table %s.%s: %s",
|
|
self.dataset_id,
|
|
self.table_id,
|
|
[{"errors": ["error"]}],
|
|
)
|
|
|
|
self.mock_bq_client.insert_rows_json.assert_called_once()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_before_run_callback_logs_correctly(self):
|
|
await self.plugin.before_run_callback(
|
|
invocation_context=self.invocation_context
|
|
)
|
|
log_entry = self._get_logged_entry()
|
|
self._assert_common_fields(log_entry, "INVOCATION_STARTING")
|
|
assert log_entry["content"] is None
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_after_run_callback_logs_correctly(self):
|
|
await self.plugin.after_run_callback(
|
|
invocation_context=self.invocation_context
|
|
)
|
|
log_entry = self._get_logged_entry()
|
|
self._assert_common_fields(log_entry, "INVOCATION_COMPLETED")
|
|
assert log_entry["content"] is None
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_before_agent_callback_logs_correctly(self):
|
|
await self.plugin.before_agent_callback(
|
|
agent=self.mock_agent, callback_context=self.callback_context
|
|
)
|
|
log_entry = self._get_logged_entry()
|
|
self._assert_common_fields(log_entry, "AGENT_STARTING")
|
|
assert log_entry["content"] == "Agent Name: MyTestAgent"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_after_agent_callback_logs_correctly(self):
|
|
await self.plugin.after_agent_callback(
|
|
agent=self.mock_agent, callback_context=self.callback_context
|
|
)
|
|
log_entry = self._get_logged_entry()
|
|
self._assert_common_fields(log_entry, "AGENT_COMPLETED")
|
|
assert log_entry["content"] == "Agent Name: MyTestAgent"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_before_model_callback_logs_correctly(self):
|
|
llm_request = llm_request_lib.LlmRequest(
|
|
model="gemini-pro",
|
|
contents=[types.Content(parts=[types.Part(text="Prompt")])],
|
|
config=types.GenerateContentConfig(
|
|
temperature=0.5,
|
|
top_p=0.9,
|
|
max_output_tokens=100,
|
|
system_instruction="Be helpful",
|
|
),
|
|
tools_dict={
|
|
"my_tool": mock.create_autospec(
|
|
base_tool_lib.BaseTool, instance=True
|
|
)
|
|
}, # Fixed mock
|
|
)
|
|
|
|
await self.plugin.before_model_callback(
|
|
callback_context=self.callback_context, llm_request=llm_request
|
|
)
|
|
log_entry = self._get_logged_entry()
|
|
self._assert_common_fields(log_entry, "LLM_REQUEST")
|
|
assert "Model: gemini-pro" in log_entry["content"]
|
|
assert "System Prompt: Be helpful" in log_entry["content"]
|
|
assert (
|
|
"Params: {temperature=0.5, top_p=0.9, max_output_tokens=100}"
|
|
in log_entry["content"]
|
|
)
|
|
assert "Available Tools: ['my_tool']" in log_entry["content"]
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_after_model_callback_text_response(self):
|
|
llm_response = llm_response_lib.LlmResponse(
|
|
content=types.Content(parts=[types.Part(text="Model response")]),
|
|
usage_metadata=types.UsageMetadata(
|
|
prompt_token_count=10,
|
|
total_token_count=15,
|
|
),
|
|
)
|
|
await self.plugin.after_model_callback(
|
|
callback_context=self.callback_context, llm_response=llm_response
|
|
)
|
|
log_entry = self._get_logged_entry()
|
|
self._assert_common_fields(log_entry, "LLM_RESPONSE")
|
|
assert (
|
|
"Tool Name: text_response, text: 'Model response'"
|
|
in log_entry["content"]
|
|
)
|
|
# Adjusted assertion to expect None for candidates
|
|
assert "Token Usage: {prompt: 10" in log_entry["content"]
|
|
assert log_entry["error_message"] is None
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_after_model_callback_tool_call(self):
|
|
llm_response = llm_response_lib.LlmResponse(
|
|
content=types.Content(
|
|
parts=[
|
|
types.Part(
|
|
function_call=types.FunctionCall(name="tool1", args={})
|
|
)
|
|
]
|
|
),
|
|
)
|
|
await self.plugin.after_model_callback(
|
|
callback_context=self.callback_context, llm_response=llm_response
|
|
)
|
|
log_entry = self._get_logged_entry()
|
|
self._assert_common_fields(log_entry, "LLM_RESPONSE")
|
|
assert "Tool Name: tool1" in log_entry["content"]
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_before_tool_callback_logs_correctly(self):
|
|
mock_tool = mock.create_autospec(base_tool_lib.BaseTool, instance=True)
|
|
mock_tool.name = "MyTool"
|
|
mock_tool.description = "Does something"
|
|
tool_args = {"param": "value"}
|
|
await self.plugin.before_tool_callback(
|
|
tool=mock_tool, tool_args=tool_args, tool_context=self.tool_context
|
|
)
|
|
log_entry = self._get_logged_entry()
|
|
self._assert_common_fields(log_entry, "TOOL_STARTING")
|
|
assert "Tool Name: MyTool" in log_entry["content"]
|
|
assert "Description: Does something" in log_entry["content"]
|
|
assert "Arguments: {'param': 'value'}" in log_entry["content"]
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_after_tool_callback_logs_correctly(self):
|
|
mock_tool = mock.create_autospec(base_tool_lib.BaseTool, instance=True)
|
|
mock_tool.name = "MyTool"
|
|
tool_args = {"param": "value"}
|
|
result = {"status": "success"}
|
|
await self.plugin.after_tool_callback(
|
|
tool=mock_tool,
|
|
tool_args=tool_args,
|
|
tool_context=self.tool_context,
|
|
result=result,
|
|
)
|
|
log_entry = self._get_logged_entry()
|
|
self._assert_common_fields(log_entry, "TOOL_COMPLETED")
|
|
assert "Tool Name: MyTool" in log_entry["content"]
|
|
assert "Result: {'status': 'success'}" in log_entry["content"]
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_on_model_error_callback_logs_correctly(self):
|
|
llm_request = mock.create_autospec(
|
|
llm_request_lib.LlmRequest, instance=True
|
|
)
|
|
error = ValueError("LLM failed")
|
|
await self.plugin.on_model_error_callback(
|
|
callback_context=self.callback_context,
|
|
llm_request=llm_request,
|
|
error=error,
|
|
)
|
|
log_entry = self._get_logged_entry()
|
|
self._assert_common_fields(log_entry, "LLM_ERROR")
|
|
assert log_entry["content"] is None
|
|
assert log_entry["error_message"] == "LLM failed"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_on_tool_error_callback_logs_correctly(self):
|
|
mock_tool = mock.create_autospec(base_tool_lib.BaseTool, instance=True)
|
|
mock_tool.name = "MyTool"
|
|
error = TimeoutError("Tool timed out")
|
|
await self.plugin.on_tool_error_callback(
|
|
tool=mock_tool,
|
|
tool_args={"param": "value"},
|
|
tool_context=self.tool_context,
|
|
error=error,
|
|
)
|
|
log_entry = self._get_logged_entry()
|
|
self._assert_common_fields(log_entry, "TOOL_ERROR")
|
|
assert log_entry["content"] == "Tool Name: MyTool"
|
|
assert log_entry["error_message"] == "Tool timed out"
|