96573404a5
Co-authored-by: George Weale <gweale@google.com> PiperOrigin-RevId: 970126718
15834 lines
541 KiB
Python
15834 lines
541 KiB
Python
# Copyright 2026 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 contextlib
|
|
import dataclasses
|
|
import json
|
|
import logging
|
|
import os
|
|
import sys
|
|
import threading
|
|
import time
|
|
from unittest import mock
|
|
|
|
from google.adk.agents import base_agent
|
|
from google.adk.agents.callback_context import CallbackContext
|
|
from google.adk.agents.invocation_context import InvocationContext
|
|
from google.adk.events import event as event_lib
|
|
from google.adk.events import event_actions as event_actions_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.platform import thread as platform_thread
|
|
from google.adk.plugins import bigquery_agent_analytics_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
|
|
from google.adk.utils import streaming_utils
|
|
from google.adk.utils._telemetry_context import _is_visual_builder
|
|
from google.adk.version import __version__
|
|
from google.api_core import exceptions as api_exceptions
|
|
import google.auth
|
|
from google.auth import exceptions as auth_exceptions
|
|
import google.auth.credentials
|
|
from google.cloud import bigquery
|
|
from google.cloud import exceptions as cloud_exceptions
|
|
from google.genai import types
|
|
from opentelemetry import trace
|
|
import pyarrow as pa
|
|
from pydantic import BaseModel
|
|
import pytest
|
|
|
|
PROJECT_ID = "test-gcp-project"
|
|
DATASET_ID = "adk_logs"
|
|
TABLE_ID = "agent_events"
|
|
DEFAULT_STREAM_NAME = (
|
|
f"projects/{PROJECT_ID}/datasets/{DATASET_ID}/tables/{TABLE_ID}/_default"
|
|
)
|
|
|
|
|
|
# --- Pytest Fixtures ---
|
|
@pytest.fixture
|
|
def mock_session():
|
|
mock_s = mock.create_autospec(
|
|
session_lib.Session, instance=True, spec_set=True
|
|
)
|
|
type(mock_s).id = mock.PropertyMock(return_value="session-123")
|
|
type(mock_s).user_id = mock.PropertyMock(return_value="user-456")
|
|
type(mock_s).app_name = mock.PropertyMock(return_value="test_app")
|
|
type(mock_s).state = mock.PropertyMock(return_value={})
|
|
return mock_s
|
|
|
|
|
|
@pytest.fixture
|
|
def mock_agent():
|
|
mock_a = mock.create_autospec(
|
|
base_agent.BaseAgent, instance=True, spec_set=True
|
|
)
|
|
# Mock the 'name' property
|
|
type(mock_a).name = mock.PropertyMock(return_value="MyTestAgent")
|
|
type(mock_a).instruction = mock.PropertyMock(return_value="Test Instruction")
|
|
return mock_a
|
|
|
|
|
|
@pytest.fixture
|
|
def invocation_context(mock_agent, mock_session):
|
|
mock_session_service = mock.create_autospec(
|
|
base_session_service_lib.BaseSessionService, instance=True, spec_set=True
|
|
)
|
|
mock_plugin_manager = mock.create_autospec(
|
|
plugin_manager_lib.PluginManager, instance=True, spec_set=True
|
|
)
|
|
return InvocationContext(
|
|
agent=mock_agent,
|
|
session=mock_session,
|
|
invocation_id="inv-789",
|
|
session_service=mock_session_service,
|
|
plugin_manager=mock_plugin_manager,
|
|
)
|
|
|
|
|
|
@pytest.fixture
|
|
def callback_context(invocation_context):
|
|
return CallbackContext(invocation_context=invocation_context)
|
|
|
|
|
|
@pytest.fixture
|
|
def tool_context(invocation_context):
|
|
return tool_context_lib.ToolContext(invocation_context=invocation_context)
|
|
|
|
|
|
class FakeCredentials(google.auth.credentials.Credentials):
|
|
|
|
def __init__(self):
|
|
pass
|
|
|
|
def refresh(self, request):
|
|
pass
|
|
|
|
|
|
@pytest.fixture
|
|
def mock_auth_default():
|
|
mock_creds = FakeCredentials()
|
|
with mock.patch.object(
|
|
google.auth,
|
|
"default",
|
|
autospec=True,
|
|
return_value=(mock_creds, PROJECT_ID),
|
|
) as mock_auth:
|
|
yield mock_auth
|
|
|
|
|
|
@pytest.fixture
|
|
def mock_bq_client():
|
|
with mock.patch.object(bigquery, "Client", autospec=True) as mock_cls:
|
|
yield mock_cls.return_value
|
|
|
|
|
|
@pytest.fixture
|
|
def mock_write_client():
|
|
with mock.patch.object(
|
|
bigquery_agent_analytics_plugin, "BigQueryWriteAsyncClient", autospec=True
|
|
) as mock_cls:
|
|
mock_client = mock_cls.return_value
|
|
mock_client.transport = mock.AsyncMock()
|
|
|
|
async def fake_append_rows(requests, **kwargs):
|
|
# This function is now async, so `await client.append_rows` works.
|
|
mock_append_rows_response = mock.MagicMock()
|
|
mock_append_rows_response.row_errors = []
|
|
mock_append_rows_response.error = mock.MagicMock()
|
|
mock_append_rows_response.error.code = 0 # OK status
|
|
# This a gen is what's returned *after* the await.
|
|
return _async_gen(mock_append_rows_response)
|
|
|
|
mock_client.append_rows.side_effect = fake_append_rows
|
|
yield mock_client
|
|
|
|
|
|
@pytest.fixture
|
|
def dummy_arrow_schema():
|
|
return pa.schema([
|
|
pa.field("timestamp", pa.timestamp("us", tz="UTC"), nullable=False),
|
|
pa.field("event_id", pa.string(), nullable=True),
|
|
pa.field("root_agent_name", pa.string(), nullable=True),
|
|
pa.field("event_type", pa.string(), nullable=True),
|
|
pa.field("agent", pa.string(), nullable=True),
|
|
pa.field("session_id", pa.string(), nullable=True),
|
|
pa.field("invocation_id", pa.string(), nullable=True),
|
|
pa.field("user_id", pa.string(), nullable=True),
|
|
pa.field("trace_id", pa.string(), nullable=True),
|
|
pa.field("span_id", pa.string(), nullable=True),
|
|
pa.field("parent_span_id", pa.string(), nullable=True),
|
|
pa.field(
|
|
"content", pa.string(), nullable=True
|
|
), # JSON stored as string in Arrow
|
|
pa.field(
|
|
"content_parts",
|
|
pa.list_(
|
|
pa.struct([
|
|
pa.field("mime_type", pa.string(), nullable=True),
|
|
pa.field("uri", pa.string(), nullable=True),
|
|
pa.field(
|
|
"object_ref",
|
|
pa.struct([
|
|
pa.field("uri", pa.string(), nullable=True),
|
|
pa.field("authorizer", pa.string(), nullable=True),
|
|
pa.field("version", pa.string(), nullable=True),
|
|
pa.field(
|
|
"details",
|
|
pa.string(),
|
|
nullable=True,
|
|
metadata={
|
|
b"ARROW:extension:name": (
|
|
b"google:sqlType:json"
|
|
)
|
|
},
|
|
),
|
|
]),
|
|
nullable=True,
|
|
),
|
|
pa.field("text", pa.string(), nullable=True),
|
|
pa.field("part_index", pa.int64(), nullable=True),
|
|
pa.field("part_attributes", pa.string(), nullable=True),
|
|
pa.field("storage_mode", pa.string(), nullable=True),
|
|
])
|
|
),
|
|
nullable=True,
|
|
),
|
|
pa.field("attributes", pa.string(), nullable=True),
|
|
pa.field("latency_ms", pa.string(), nullable=True),
|
|
pa.field("status", pa.string(), nullable=True),
|
|
pa.field("error_message", pa.string(), nullable=True),
|
|
pa.field("is_truncated", pa.bool_(), nullable=True),
|
|
])
|
|
|
|
|
|
@pytest.fixture
|
|
def mock_to_arrow_schema(dummy_arrow_schema):
|
|
with mock.patch.object(
|
|
bigquery_agent_analytics_plugin,
|
|
"to_arrow_schema",
|
|
autospec=True,
|
|
return_value=dummy_arrow_schema,
|
|
) as mock_func:
|
|
yield mock_func
|
|
|
|
|
|
@pytest.fixture
|
|
def mock_asyncio_to_thread():
|
|
async def fake_to_thread(func, *args, **kwargs):
|
|
return func(*args, **kwargs)
|
|
|
|
with mock.patch(
|
|
"asyncio.to_thread", side_effect=fake_to_thread
|
|
) as mock_async:
|
|
yield mock_async
|
|
|
|
|
|
@pytest.fixture
|
|
def mock_storage_client():
|
|
with mock.patch("google.cloud.storage.Client") as mock_client:
|
|
yield mock_client
|
|
|
|
|
|
@pytest.fixture
|
|
async def bq_plugin_inst(
|
|
mock_auth_default,
|
|
mock_bq_client,
|
|
mock_write_client,
|
|
mock_to_arrow_schema,
|
|
mock_asyncio_to_thread,
|
|
):
|
|
plugin = bigquery_agent_analytics_plugin.BigQueryAgentAnalyticsPlugin(
|
|
project_id=PROJECT_ID,
|
|
dataset_id=DATASET_ID,
|
|
table_id=TABLE_ID,
|
|
)
|
|
await plugin._ensure_started() # Ensure clients are initialized
|
|
mock_write_client.append_rows.reset_mock()
|
|
yield plugin
|
|
await plugin.shutdown()
|
|
|
|
|
|
@contextlib.asynccontextmanager
|
|
async def managed_plugin(*args, **kwargs):
|
|
"""Async context manager to ensure plugin shutdown."""
|
|
plugin = bigquery_agent_analytics_plugin.BigQueryAgentAnalyticsPlugin(
|
|
*args, **kwargs
|
|
)
|
|
try:
|
|
yield plugin
|
|
finally:
|
|
await plugin.shutdown()
|
|
|
|
|
|
# --- Helper Functions ---
|
|
async def _async_gen(val):
|
|
yield val
|
|
|
|
|
|
async def _get_captured_event_dict_async(mock_write_client, expected_schema):
|
|
"""Helper to get the event_dict passed to append_rows."""
|
|
mock_write_client.append_rows.assert_called_once()
|
|
call_args = mock_write_client.append_rows.call_args
|
|
requests_iter = call_args.args[0]
|
|
requests = []
|
|
if hasattr(requests_iter, "__aiter__"):
|
|
async for req in requests_iter:
|
|
requests.append(req)
|
|
else:
|
|
requests = list(requests_iter)
|
|
assert len(requests) == 1
|
|
request = requests[0]
|
|
assert request.write_stream == DEFAULT_STREAM_NAME
|
|
assert request.trace_id.startswith("google-adk-bq-logger")
|
|
assert request.trace_id.endswith(f"/{__version__}")
|
|
# Parse the Arrow batch back to a dict for verification
|
|
try:
|
|
reader = pa.ipc.open_stream(request.arrow_rows.rows.serialized_record_batch)
|
|
table = reader.read_all()
|
|
except Exception:
|
|
# Fallback: try reading as a single batch
|
|
buf = pa.py_buffer(request.arrow_rows.rows.serialized_record_batch)
|
|
batch = pa.ipc.read_record_batch(buf, expected_schema)
|
|
table = pa.Table.from_batches([batch])
|
|
assert table.schema.equals(
|
|
expected_schema
|
|
), f"Schema mismatch: Expected {expected_schema}, got {table.schema}"
|
|
pydict = table.to_pydict()
|
|
return {k: v[0] for k, v in pydict.items()}
|
|
|
|
|
|
async def _get_captured_rows_async(mock_write_client, expected_schema):
|
|
"""Helper to get all rows passed to append_rows."""
|
|
all_rows = []
|
|
for call in mock_write_client.append_rows.call_args_list:
|
|
requests_iter = call.args[0]
|
|
requests = []
|
|
if hasattr(requests_iter, "__aiter__"):
|
|
async for req in requests_iter:
|
|
requests.append(req)
|
|
else:
|
|
requests = list(requests_iter)
|
|
for request in requests:
|
|
# Parse the Arrow batch back to a dict for verification
|
|
try:
|
|
reader = pa.ipc.open_stream(
|
|
request.arrow_rows.rows.serialized_record_batch
|
|
)
|
|
table = reader.read_all()
|
|
except Exception:
|
|
# Fallback: try reading as a single batch
|
|
buf = pa.py_buffer(request.arrow_rows.rows.serialized_record_batch)
|
|
batch = pa.ipc.read_record_batch(buf, expected_schema)
|
|
table = pa.Table.from_batches([batch])
|
|
pydict = table.to_pylist()
|
|
all_rows.extend(pydict)
|
|
return all_rows
|
|
|
|
|
|
def _assert_common_fields(log_entry, event_type, agent="MyTestAgent"):
|
|
assert log_entry["event_type"] == event_type
|
|
assert log_entry["agent"] == agent
|
|
assert log_entry["session_id"] == "session-123"
|
|
assert log_entry["invocation_id"] == "inv-789"
|
|
|
|
|
|
def test_recursive_smart_truncate():
|
|
"""Test recursive smart truncate."""
|
|
obj = {
|
|
"a": "long string" * 10,
|
|
"b": ["short", "long string" * 10],
|
|
"c": {"d": "long string" * 10},
|
|
}
|
|
max_len = 10
|
|
truncated, is_truncated = (
|
|
bigquery_agent_analytics_plugin._recursive_smart_truncate(obj, max_len)
|
|
)
|
|
assert is_truncated
|
|
|
|
assert truncated["a"] == "long strin...[TRUNCATED]"
|
|
assert truncated["b"][0] == "short"
|
|
assert truncated["b"][1] == "long strin...[TRUNCATED]"
|
|
assert truncated["c"]["d"] == "long strin...[TRUNCATED]"
|
|
|
|
|
|
def test_recursive_smart_truncate_with_dataclasses():
|
|
"""Test recursive smart truncate with dataclasses."""
|
|
|
|
@dataclasses.dataclass
|
|
class LocalMissedKPI:
|
|
kpi: str
|
|
value: float
|
|
|
|
@dataclasses.dataclass
|
|
class LocalIncident:
|
|
id: str
|
|
kpi_missed: list[LocalMissedKPI]
|
|
status: str
|
|
|
|
incident = LocalIncident(
|
|
id="inc-123",
|
|
kpi_missed=[LocalMissedKPI(kpi="latency", value=99.9)],
|
|
status="active",
|
|
)
|
|
content = {"result": incident}
|
|
max_len = 1000
|
|
|
|
truncated, is_truncated = (
|
|
bigquery_agent_analytics_plugin._recursive_smart_truncate(
|
|
content, max_len
|
|
)
|
|
)
|
|
assert not is_truncated
|
|
assert isinstance(truncated["result"], dict)
|
|
assert truncated["result"]["id"] == "inc-123"
|
|
assert isinstance(truncated["result"]["kpi_missed"][0], dict)
|
|
assert truncated["result"]["kpi_missed"][0]["kpi"] == "latency"
|
|
|
|
|
|
def test_recursive_smart_truncate_redaction():
|
|
"""Test that sensitive keys and temp: state keys are redacted."""
|
|
obj = {
|
|
"client_secret": "super-secret-123",
|
|
"access_token": "ya29.blah",
|
|
"refresh_token": "1//0g",
|
|
"id_token": "eyJhb",
|
|
"api_key": "AIza",
|
|
"password": "my-password",
|
|
"private_key": "private-key-material",
|
|
"token": "generic-token",
|
|
"secret": "generic-secret",
|
|
"authorization": "Bearer credential",
|
|
"safe_key": "safe-value",
|
|
"temp:auth_state": "some-auth-state",
|
|
"nested": {
|
|
"CLIENT_SECRET": "nested-secret",
|
|
"normal": "value",
|
|
},
|
|
}
|
|
max_len = 1000
|
|
truncated, is_truncated = (
|
|
bigquery_agent_analytics_plugin._recursive_smart_truncate(obj, max_len)
|
|
)
|
|
assert not is_truncated
|
|
assert truncated["client_secret"] == "[REDACTED]"
|
|
assert truncated["access_token"] == "[REDACTED]"
|
|
assert truncated["refresh_token"] == "[REDACTED]"
|
|
assert truncated["id_token"] == "[REDACTED]"
|
|
assert truncated["api_key"] == "[REDACTED]"
|
|
assert truncated["password"] == "[REDACTED]"
|
|
assert truncated["private_key"] == "[REDACTED]"
|
|
assert truncated["token"] == "[REDACTED]"
|
|
assert truncated["secret"] == "[REDACTED]"
|
|
assert truncated["authorization"] == "[REDACTED]"
|
|
assert truncated["safe_key"] == "safe-value"
|
|
assert truncated["temp:auth_state"] == "[REDACTED]"
|
|
assert truncated["nested"]["CLIENT_SECRET"] == "[REDACTED]"
|
|
assert truncated["nested"]["normal"] == "value"
|
|
|
|
|
|
class TestBigQueryAgentAnalyticsPlugin:
|
|
"""Tests for the BigQueryAgentAnalyticsPlugin."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_plugin_disabled(
|
|
self,
|
|
mock_auth_default,
|
|
mock_bq_client,
|
|
mock_write_client,
|
|
invocation_context,
|
|
):
|
|
config = bigquery_agent_analytics_plugin.BigQueryLoggerConfig(enabled=False)
|
|
async with managed_plugin(
|
|
project_id=PROJECT_ID,
|
|
dataset_id=DATASET_ID,
|
|
table_id=TABLE_ID,
|
|
config=config,
|
|
) as plugin:
|
|
# user_message = types.Content(parts=[types.Part(text="Test")])
|
|
await plugin.on_user_message_callback(
|
|
invocation_context=invocation_context,
|
|
user_message=types.Content(parts=[types.Part(text="Test")]),
|
|
)
|
|
mock_auth_default.assert_not_called()
|
|
mock_bq_client.assert_not_called()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_enriched_metadata_logging(
|
|
self,
|
|
mock_auth_default,
|
|
mock_bq_client,
|
|
mock_write_client,
|
|
mock_to_arrow_schema,
|
|
dummy_arrow_schema,
|
|
callback_context,
|
|
):
|
|
# Setup
|
|
config = bigquery_agent_analytics_plugin.BigQueryLoggerConfig()
|
|
async with managed_plugin(PROJECT_ID, DATASET_ID, config=config) as plugin:
|
|
# Mock root agent
|
|
mock_root = mock.create_autospec(
|
|
base_agent.BaseAgent, instance=True, spec_set=True
|
|
)
|
|
type(mock_root).name = mock.PropertyMock(return_value="RootAgent")
|
|
callback_context._invocation_context.agent.root_agent = mock_root
|
|
# 1. Test root_agent_name and model extraction from request
|
|
llm_request = llm_request_lib.LlmRequest(
|
|
model="gemini-pro",
|
|
contents=[types.Content(parts=[types.Part(text="Hi")])],
|
|
)
|
|
await plugin.before_model_callback(
|
|
callback_context=callback_context, llm_request=llm_request
|
|
)
|
|
# 2. Test model_version and usage_metadata extraction from response
|
|
usage = types.GenerateContentResponseUsageMetadata(
|
|
prompt_token_count=10, candidates_token_count=20, total_token_count=30
|
|
)
|
|
llm_response = llm_response_lib.LlmResponse(
|
|
content=types.Content(parts=[types.Part(text="Hello")]),
|
|
usage_metadata=usage,
|
|
model_version="v1.2.3",
|
|
)
|
|
await plugin.after_model_callback(
|
|
callback_context=callback_context, llm_response=llm_response
|
|
)
|
|
# Verify captured rows from mock client
|
|
rows = await _get_captured_rows_async(mock_write_client, dummy_arrow_schema)
|
|
assert len(rows) == 2
|
|
# Check LLM_REQUEST row
|
|
# Sort by event_type to ensure consistent indexing
|
|
rows.sort(key=lambda x: x["event_type"])
|
|
request_row = rows[0] # LLM_REQUEST
|
|
response_row = rows[1] # LLM_RESPONSE
|
|
assert request_row["event_type"] == "LLM_REQUEST"
|
|
attr_req = json.loads(request_row["attributes"])
|
|
assert attr_req["root_agent_name"] == "RootAgent"
|
|
assert attr_req["model"] == "gemini-pro"
|
|
# Check LLM_RESPONSE row
|
|
assert response_row["event_type"] == "LLM_RESPONSE"
|
|
attr_res = json.loads(response_row["attributes"])
|
|
assert attr_res["root_agent_name"] == "RootAgent"
|
|
assert attr_res["model_version"] == "v1.2.3"
|
|
usage_meta = attr_res["usage_metadata"]
|
|
assert "prompt_token_count" in usage_meta
|
|
assert usage_meta["prompt_token_count"] == 10
|
|
mock_write_client.append_rows.assert_called()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_concurrent_span_management(
|
|
self,
|
|
mock_auth_default,
|
|
mock_bq_client,
|
|
mock_write_client,
|
|
mock_to_arrow_schema,
|
|
callback_context,
|
|
):
|
|
# Setup
|
|
config = bigquery_agent_analytics_plugin.BigQueryLoggerConfig()
|
|
plugin = bigquery_agent_analytics_plugin.BigQueryAgentAnalyticsPlugin(
|
|
PROJECT_ID, DATASET_ID, config=config
|
|
)
|
|
# Initialize trace in main context
|
|
bigquery_agent_analytics_plugin.TraceManager.init_trace(callback_context)
|
|
|
|
async def branch_1():
|
|
s_id = bigquery_agent_analytics_plugin.TraceManager.push_span(
|
|
callback_context, span_name="span-1"
|
|
)
|
|
await asyncio.sleep(0.02)
|
|
current_s_id = (
|
|
bigquery_agent_analytics_plugin.TraceManager.get_current_span_id()
|
|
)
|
|
assert s_id == current_s_id
|
|
bigquery_agent_analytics_plugin.TraceManager.pop_span()
|
|
return s_id
|
|
|
|
async def branch_2():
|
|
s_id = bigquery_agent_analytics_plugin.TraceManager.push_span(
|
|
callback_context, span_name="span-2"
|
|
)
|
|
await asyncio.sleep(0.02)
|
|
current_s_id = (
|
|
bigquery_agent_analytics_plugin.TraceManager.get_current_span_id()
|
|
)
|
|
assert s_id == current_s_id
|
|
bigquery_agent_analytics_plugin.TraceManager.pop_span()
|
|
return s_id
|
|
|
|
# Run concurrently
|
|
results = await asyncio.gather(branch_1(), branch_2())
|
|
# If they shared the same list/dict, they would interfere.
|
|
assert results[0] is not None
|
|
assert results[1] is not None
|
|
assert results[0] != results[1]
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_event_allowlist(
|
|
self,
|
|
mock_write_client,
|
|
callback_context,
|
|
invocation_context,
|
|
mock_auth_default,
|
|
mock_bq_client,
|
|
mock_to_arrow_schema,
|
|
dummy_arrow_schema,
|
|
mock_asyncio_to_thread,
|
|
):
|
|
_ = mock_auth_default
|
|
_ = mock_bq_client
|
|
config = bigquery_agent_analytics_plugin.BigQueryLoggerConfig(
|
|
event_allowlist=["LLM_REQUEST"]
|
|
)
|
|
async with managed_plugin(
|
|
PROJECT_ID, DATASET_ID, table_id=TABLE_ID, config=config
|
|
) as plugin:
|
|
await plugin._ensure_started()
|
|
mock_write_client.append_rows.reset_mock()
|
|
llm_request = llm_request_lib.LlmRequest(
|
|
model="gemini-pro",
|
|
contents=[types.Content(parts=[types.Part(text="Prompt")])],
|
|
)
|
|
bigquery_agent_analytics_plugin.TraceManager.push_span(callback_context)
|
|
await plugin.before_model_callback(
|
|
callback_context=callback_context, llm_request=llm_request
|
|
)
|
|
await plugin.flush()
|
|
mock_write_client.append_rows.assert_called_once()
|
|
mock_write_client.append_rows.reset_mock()
|
|
user_message = types.Content(parts=[types.Part(text="What is up?")])
|
|
bigquery_agent_analytics_plugin.TraceManager.push_span(invocation_context)
|
|
await plugin.on_user_message_callback(
|
|
invocation_context=invocation_context, user_message=user_message
|
|
)
|
|
await plugin.flush()
|
|
mock_write_client.append_rows.assert_not_called()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_event_denylist(
|
|
self,
|
|
mock_write_client,
|
|
invocation_context,
|
|
mock_auth_default,
|
|
mock_bq_client,
|
|
mock_to_arrow_schema,
|
|
dummy_arrow_schema,
|
|
mock_asyncio_to_thread,
|
|
):
|
|
_ = mock_auth_default
|
|
_ = mock_bq_client
|
|
config = bigquery_agent_analytics_plugin.BigQueryLoggerConfig(
|
|
event_denylist=["USER_MESSAGE_RECEIVED"]
|
|
)
|
|
async with managed_plugin(
|
|
PROJECT_ID, DATASET_ID, table_id=TABLE_ID, config=config
|
|
) as plugin:
|
|
await plugin._ensure_started()
|
|
mock_write_client.append_rows.reset_mock()
|
|
user_message = types.Content(parts=[types.Part(text="What is up?")])
|
|
bigquery_agent_analytics_plugin.TraceManager.push_span(invocation_context)
|
|
await plugin.on_user_message_callback(
|
|
invocation_context=invocation_context, user_message=user_message
|
|
)
|
|
await plugin.flush()
|
|
mock_write_client.append_rows.assert_not_called()
|
|
bigquery_agent_analytics_plugin.TraceManager.push_span(invocation_context)
|
|
await plugin.before_run_callback(invocation_context=invocation_context)
|
|
await plugin.flush()
|
|
mock_write_client.append_rows.assert_called_once()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_append_rows_sets_regional_routing_header(
|
|
self,
|
|
mock_write_client,
|
|
callback_context,
|
|
mock_auth_default,
|
|
mock_bq_client,
|
|
mock_to_arrow_schema,
|
|
dummy_arrow_schema,
|
|
mock_asyncio_to_thread,
|
|
):
|
|
"""Regression test for cross-region writes.
|
|
|
|
The Storage Write API streaming AppendRows RPC does not
|
|
auto-populate the request-routing header, so writes to a dataset
|
|
outside the US multiregion (e.g. northamerica-northeast1) fail with
|
|
a "session not found" / stream-not-found error unless the header is
|
|
set explicitly. Assert the header is passed to append_rows so the
|
|
request reaches the region that owns the write stream.
|
|
"""
|
|
_ = mock_auth_default
|
|
_ = mock_bq_client
|
|
config = bigquery_agent_analytics_plugin.BigQueryLoggerConfig()
|
|
async with managed_plugin(
|
|
PROJECT_ID,
|
|
DATASET_ID,
|
|
table_id=TABLE_ID,
|
|
config=config,
|
|
location="northamerica-northeast1",
|
|
) as plugin:
|
|
await plugin._ensure_started()
|
|
mock_write_client.append_rows.reset_mock()
|
|
llm_request = llm_request_lib.LlmRequest(
|
|
model="gemini-pro",
|
|
contents=[types.Content(parts=[types.Part(text="Prompt")])],
|
|
)
|
|
bigquery_agent_analytics_plugin.TraceManager.push_span(callback_context)
|
|
await plugin.before_model_callback(
|
|
callback_context=callback_context, llm_request=llm_request
|
|
)
|
|
await plugin.flush()
|
|
mock_write_client.append_rows.assert_called_once()
|
|
metadata = mock_write_client.append_rows.call_args.kwargs.get("metadata")
|
|
assert metadata is not None, "append_rows must receive routing metadata"
|
|
assert (
|
|
"x-goog-request-params",
|
|
f"write_stream={DEFAULT_STREAM_NAME}",
|
|
) in tuple(metadata)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_content_formatter(
|
|
self,
|
|
mock_write_client,
|
|
invocation_context,
|
|
mock_auth_default,
|
|
mock_bq_client,
|
|
mock_to_arrow_schema,
|
|
dummy_arrow_schema,
|
|
mock_asyncio_to_thread,
|
|
):
|
|
"""Test content formatter."""
|
|
_ = mock_auth_default
|
|
_ = mock_bq_client
|
|
|
|
def redact_content(content, event_type):
|
|
return "[REDACTED]"
|
|
|
|
config = bigquery_agent_analytics_plugin.BigQueryLoggerConfig(
|
|
content_formatter=redact_content
|
|
)
|
|
async with managed_plugin(
|
|
PROJECT_ID, DATASET_ID, table_id=TABLE_ID, config=config
|
|
) as plugin:
|
|
await plugin._ensure_started()
|
|
mock_write_client.append_rows.reset_mock()
|
|
user_message = types.Content(parts=[types.Part(text="Secret message")])
|
|
bigquery_agent_analytics_plugin.TraceManager.push_span(invocation_context)
|
|
await plugin.on_user_message_callback(
|
|
invocation_context=invocation_context, user_message=user_message
|
|
)
|
|
await plugin.flush()
|
|
mock_write_client.append_rows.assert_called_once()
|
|
log_entry = await _get_captured_event_dict_async(
|
|
mock_write_client, dummy_arrow_schema
|
|
)
|
|
# If the formatter returns a string, it's stored directly.
|
|
assert log_entry["content"] == "[REDACTED]"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_content_formatter_error(
|
|
self,
|
|
mock_write_client,
|
|
invocation_context,
|
|
mock_auth_default,
|
|
mock_bq_client,
|
|
mock_to_arrow_schema,
|
|
dummy_arrow_schema,
|
|
mock_asyncio_to_thread,
|
|
):
|
|
"""Test content formatter error handling."""
|
|
_ = mock_auth_default
|
|
_ = mock_bq_client
|
|
|
|
def error_formatter(content, event_type):
|
|
raise ValueError("Formatter failed")
|
|
|
|
config = bigquery_agent_analytics_plugin.BigQueryLoggerConfig(
|
|
content_formatter=error_formatter
|
|
)
|
|
async with managed_plugin(
|
|
PROJECT_ID, DATASET_ID, table_id=TABLE_ID, config=config
|
|
) as plugin:
|
|
await plugin._ensure_started()
|
|
mock_write_client.append_rows.reset_mock()
|
|
user_message = types.Content(parts=[types.Part(text="Secret message")])
|
|
bigquery_agent_analytics_plugin.TraceManager.push_span(invocation_context)
|
|
await plugin.on_user_message_callback(
|
|
invocation_context=invocation_context, user_message=user_message
|
|
)
|
|
await plugin.flush()
|
|
mock_write_client.append_rows.assert_called_once()
|
|
log_entry = await _get_captured_event_dict_async(
|
|
mock_write_client, dummy_arrow_schema
|
|
)
|
|
# Fail CLOSED: a raising formatter must never fall back
|
|
# to the unformatted payload. The row keeps its metadata but content
|
|
# is replaced with the sentinel, and the loss is observable.
|
|
assert "Secret message" not in str(log_entry["content"])
|
|
assert bigquery_agent_analytics_plugin._FORMATTER_FAILED_SENTINEL in str(
|
|
log_entry["content"]
|
|
)
|
|
assert log_entry["event_type"] == "USER_MESSAGE_RECEIVED"
|
|
assert plugin.get_drop_stats().get("formatter_failed") == 1
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_max_content_length(
|
|
self,
|
|
mock_write_client,
|
|
invocation_context,
|
|
callback_context,
|
|
mock_auth_default,
|
|
mock_bq_client,
|
|
mock_to_arrow_schema,
|
|
dummy_arrow_schema,
|
|
mock_asyncio_to_thread,
|
|
):
|
|
_ = mock_auth_default
|
|
_ = mock_bq_client
|
|
config = bigquery_agent_analytics_plugin.BigQueryLoggerConfig(
|
|
max_content_length=40
|
|
)
|
|
async with managed_plugin(
|
|
PROJECT_ID, DATASET_ID, table_id=TABLE_ID, config=config
|
|
) as plugin:
|
|
await plugin._ensure_started()
|
|
mock_write_client.append_rows.reset_mock()
|
|
# Test User Message Truncation
|
|
user_message = types.Content(
|
|
parts=[types.Part(text="12345678901234567890123456789012345678901")]
|
|
) # 41 chars
|
|
bigquery_agent_analytics_plugin.TraceManager.push_span(invocation_context)
|
|
await plugin.on_user_message_callback(
|
|
invocation_context=invocation_context, user_message=user_message
|
|
)
|
|
await plugin.flush()
|
|
mock_write_client.append_rows.assert_called_once()
|
|
log_entry = await _get_captured_event_dict_async(
|
|
mock_write_client, dummy_arrow_schema
|
|
)
|
|
assert (
|
|
log_entry["content"]
|
|
== '{"text_summary":'
|
|
' "1234567890123456789012345678901234567890...[TRUNCATED]"}'
|
|
)
|
|
assert log_entry["is_truncated"]
|
|
mock_write_client.append_rows.reset_mock()
|
|
# Test before_model_callback full content truncation
|
|
llm_request = llm_request_lib.LlmRequest(
|
|
model="gemini-pro",
|
|
config=types.GenerateContentConfig(
|
|
system_instruction=types.Content(
|
|
parts=[types.Part(text="System Instruction")]
|
|
)
|
|
),
|
|
contents=[
|
|
types.Content(role="user", parts=[types.Part(text="Prompt")])
|
|
],
|
|
)
|
|
bigquery_agent_analytics_plugin.TraceManager.push_span(callback_context)
|
|
await plugin.before_model_callback(
|
|
callback_context=callback_context, llm_request=llm_request
|
|
)
|
|
await plugin.flush()
|
|
mock_write_client.append_rows.assert_called_once()
|
|
log_entry = await _get_captured_event_dict_async(
|
|
mock_write_client, dummy_arrow_schema
|
|
)
|
|
# Full content: {"prompt": "text: 'Prompt'",
|
|
# "system_prompt": "text: 'System Instruction'"}
|
|
# In our new logic, we don't truncate the whole JSON string if it's valid JSON.
|
|
# Instead, we should have truncated the values within the dict, but currently we don't.
|
|
# For now, update test to reflect current behavior (valid JSON, no truncation of the whole string).
|
|
assert log_entry["content"].startswith(
|
|
'{"prompt": [{"role": "user", "content": "Prompt"}]'
|
|
)
|
|
assert log_entry["is_truncated"] is False
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_max_content_length_tool_args(
|
|
self,
|
|
mock_write_client,
|
|
tool_context,
|
|
mock_auth_default,
|
|
mock_bq_client,
|
|
mock_to_arrow_schema,
|
|
dummy_arrow_schema,
|
|
mock_asyncio_to_thread,
|
|
):
|
|
_ = mock_auth_default
|
|
_ = mock_bq_client
|
|
_ = mock_to_arrow_schema
|
|
_ = mock_asyncio_to_thread
|
|
config = bigquery_agent_analytics_plugin.BigQueryLoggerConfig(
|
|
max_content_length=80
|
|
)
|
|
async with managed_plugin(
|
|
PROJECT_ID, DATASET_ID, table_id=TABLE_ID, config=config
|
|
) as plugin:
|
|
await plugin._ensure_started()
|
|
mock_write_client.append_rows.reset_mock()
|
|
mock_tool = mock.create_autospec(
|
|
base_tool_lib.BaseTool, instance=True, spec_set=True
|
|
)
|
|
type(mock_tool).name = mock.PropertyMock(return_value="MyTool")
|
|
type(mock_tool).description = mock.PropertyMock(
|
|
return_value="Description"
|
|
)
|
|
# Args length > 80
|
|
# {"param": "A" * 100} is > 100 chars.
|
|
bigquery_agent_analytics_plugin.TraceManager.push_span(tool_context)
|
|
await plugin.before_tool_callback(
|
|
tool=mock_tool,
|
|
tool_args={"param": "A" * 100},
|
|
tool_context=tool_context,
|
|
)
|
|
await plugin.flush()
|
|
mock_write_client.append_rows.assert_called_once()
|
|
log_entry = await _get_captured_event_dict_async(
|
|
mock_write_client, dummy_arrow_schema
|
|
)
|
|
_assert_common_fields(log_entry, "TOOL_STARTING")
|
|
# Now we do truncate nested values, and is_truncated flag is True
|
|
assert log_entry["is_truncated"]
|
|
content_dict = json.loads(log_entry["content"])
|
|
assert content_dict["tool"] == "MyTool"
|
|
assert content_dict["args"]["param"].endswith("...[TRUNCATED]")
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_max_content_length_tool_args_no_truncation(
|
|
self,
|
|
mock_write_client,
|
|
tool_context,
|
|
mock_auth_default,
|
|
mock_bq_client,
|
|
mock_to_arrow_schema,
|
|
dummy_arrow_schema,
|
|
mock_asyncio_to_thread,
|
|
):
|
|
config = bigquery_agent_analytics_plugin.BigQueryLoggerConfig(
|
|
max_content_length=-1
|
|
)
|
|
async with managed_plugin(
|
|
PROJECT_ID, DATASET_ID, table_id=TABLE_ID, config=config
|
|
) as plugin:
|
|
await plugin._ensure_started()
|
|
mock_write_client.append_rows.reset_mock()
|
|
mock_tool = mock.create_autospec(
|
|
base_tool_lib.BaseTool, instance=True, spec_set=True
|
|
)
|
|
type(mock_tool).name = mock.PropertyMock(return_value="MyTool")
|
|
type(mock_tool).description = mock.PropertyMock(
|
|
return_value="Description"
|
|
)
|
|
# Args length > 80
|
|
# {"param": "A" * 100} is > 100 chars.
|
|
bigquery_agent_analytics_plugin.TraceManager.push_span(tool_context)
|
|
await plugin.before_tool_callback(
|
|
tool=mock_tool,
|
|
tool_args={"param": "A" * 100},
|
|
tool_context=tool_context,
|
|
)
|
|
await plugin.flush()
|
|
mock_write_client.append_rows.assert_called_once()
|
|
log_entry = await _get_captured_event_dict_async(
|
|
mock_write_client, dummy_arrow_schema
|
|
)
|
|
_assert_common_fields(log_entry, "TOOL_STARTING")
|
|
# No truncation
|
|
assert not log_entry["is_truncated"]
|
|
content_dict = json.loads(log_entry["content"])
|
|
assert content_dict["tool"] == "MyTool"
|
|
assert content_dict["args"]["param"] == "A" * 100
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_max_content_length_tool_result(
|
|
self,
|
|
mock_write_client,
|
|
tool_context,
|
|
mock_auth_default,
|
|
mock_bq_client,
|
|
mock_asyncio_to_thread,
|
|
mock_to_arrow_schema,
|
|
dummy_arrow_schema,
|
|
):
|
|
"""Test max content length for tool result."""
|
|
_ = mock_auth_default
|
|
_ = mock_bq_client
|
|
_ = mock_to_arrow_schema
|
|
_ = mock_asyncio_to_thread
|
|
config = bigquery_agent_analytics_plugin.BigQueryLoggerConfig(
|
|
max_content_length=80
|
|
)
|
|
async with managed_plugin(
|
|
PROJECT_ID, DATASET_ID, table_id=TABLE_ID, config=config
|
|
) as plugin:
|
|
await plugin._ensure_started()
|
|
mock_write_client.append_rows.reset_mock()
|
|
mock_tool = mock.create_autospec(
|
|
base_tool_lib.BaseTool, instance=True, spec_set=True
|
|
)
|
|
type(mock_tool).name = mock.PropertyMock(return_value="MyTool")
|
|
# Result length > 80
|
|
# {"res": "A" * 100} is > 100 chars.
|
|
bigquery_agent_analytics_plugin.TraceManager.push_span(tool_context)
|
|
await plugin.after_tool_callback(
|
|
tool=mock_tool,
|
|
tool_args={},
|
|
tool_context=tool_context,
|
|
result={"res": "A" * 100},
|
|
)
|
|
await plugin.flush()
|
|
mock_write_client.append_rows.assert_called_once()
|
|
log_entry = await _get_captured_event_dict_async(
|
|
mock_write_client, dummy_arrow_schema
|
|
)
|
|
_assert_common_fields(log_entry, "TOOL_COMPLETED")
|
|
# Now we do truncate nested values, and is_truncated flag is True
|
|
assert log_entry["is_truncated"]
|
|
content_dict = json.loads(log_entry["content"])
|
|
assert content_dict["tool"] == "MyTool"
|
|
assert content_dict["result"]["res"].endswith("...[TRUNCATED]")
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_max_content_length_tool_result_no_truncation(
|
|
self,
|
|
mock_write_client,
|
|
tool_context,
|
|
mock_auth_default,
|
|
mock_bq_client,
|
|
mock_to_arrow_schema,
|
|
dummy_arrow_schema,
|
|
mock_asyncio_to_thread,
|
|
):
|
|
"""Test max content length for tool result with no truncation."""
|
|
_ = mock_auth_default
|
|
_ = mock_bq_client
|
|
_ = mock_to_arrow_schema
|
|
_ = mock_asyncio_to_thread
|
|
config = bigquery_agent_analytics_plugin.BigQueryLoggerConfig(
|
|
max_content_length=-1
|
|
)
|
|
async with managed_plugin(
|
|
PROJECT_ID, DATASET_ID, table_id=TABLE_ID, config=config
|
|
) as plugin:
|
|
await plugin._ensure_started()
|
|
mock_write_client.append_rows.reset_mock()
|
|
mock_tool = mock.create_autospec(
|
|
base_tool_lib.BaseTool, instance=True, spec_set=True
|
|
)
|
|
type(mock_tool).name = mock.PropertyMock(return_value="MyTool")
|
|
# Result length > 80
|
|
# {"res": "A" * 100} is > 100 chars.
|
|
bigquery_agent_analytics_plugin.TraceManager.push_span(tool_context)
|
|
await plugin.after_tool_callback(
|
|
tool=mock_tool,
|
|
tool_args={},
|
|
tool_context=tool_context,
|
|
result={"res": "A" * 100},
|
|
)
|
|
await plugin.flush()
|
|
mock_write_client.append_rows.assert_called_once()
|
|
log_entry = await _get_captured_event_dict_async(
|
|
mock_write_client, dummy_arrow_schema
|
|
)
|
|
_assert_common_fields(log_entry, "TOOL_COMPLETED")
|
|
# No truncation
|
|
assert not log_entry["is_truncated"]
|
|
content_dict = json.loads(log_entry["content"])
|
|
assert content_dict["tool"] == "MyTool"
|
|
assert content_dict["result"]["res"] == "A" * 100
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_after_tool_callback_logs_agent_response_for_final_tool(
|
|
self,
|
|
mock_write_client,
|
|
tool_context,
|
|
mock_auth_default,
|
|
mock_bq_client,
|
|
mock_to_arrow_schema,
|
|
dummy_arrow_schema,
|
|
mock_asyncio_to_thread,
|
|
):
|
|
"""A configured final-response tool also logs AGENT_RESPONSE from its args."""
|
|
_ = mock_auth_default
|
|
_ = mock_bq_client
|
|
_ = mock_to_arrow_schema
|
|
_ = mock_asyncio_to_thread
|
|
config = bigquery_agent_analytics_plugin.BigQueryLoggerConfig(
|
|
final_response_tool_names=frozenset({"submit_final_response"})
|
|
)
|
|
async with managed_plugin(
|
|
PROJECT_ID, DATASET_ID, table_id=TABLE_ID, config=config
|
|
) as plugin:
|
|
await plugin._ensure_started()
|
|
mock_write_client.append_rows.reset_mock()
|
|
mock_tool = mock.create_autospec(
|
|
base_tool_lib.BaseTool, instance=True, spec_set=True
|
|
)
|
|
type(mock_tool).name = mock.PropertyMock(
|
|
return_value="submit_final_response"
|
|
)
|
|
bigquery_agent_analytics_plugin.TraceManager.push_span(tool_context)
|
|
await plugin.after_tool_callback(
|
|
tool=mock_tool,
|
|
tool_args={"answer": "The table has 241 rows."},
|
|
tool_context=tool_context,
|
|
result={"status": "SUCCESS"},
|
|
)
|
|
await plugin.flush()
|
|
rows = await _get_captured_rows_async(
|
|
mock_write_client, dummy_arrow_schema
|
|
)
|
|
event_types = [r["event_type"] for r in rows]
|
|
assert "TOOL_COMPLETED" in event_types
|
|
assert event_types.count("AGENT_RESPONSE") == 1
|
|
agent_resp = next(r for r in rows if r["event_type"] == "AGENT_RESPONSE")
|
|
content_dict = json.loads(agent_resp["content"])
|
|
assert content_dict["response"] == {"answer": "The table has 241 rows."}
|
|
attributes = json.loads(agent_resp["attributes"])
|
|
assert attributes["source_tool"] == "submit_final_response"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_after_tool_callback_no_agent_response_by_default(
|
|
self,
|
|
mock_write_client,
|
|
tool_context,
|
|
mock_auth_default,
|
|
mock_bq_client,
|
|
mock_to_arrow_schema,
|
|
dummy_arrow_schema,
|
|
mock_asyncio_to_thread,
|
|
):
|
|
"""With the default empty set, a tool never emits AGENT_RESPONSE."""
|
|
_ = mock_auth_default
|
|
_ = mock_bq_client
|
|
_ = mock_to_arrow_schema
|
|
_ = mock_asyncio_to_thread
|
|
config = bigquery_agent_analytics_plugin.BigQueryLoggerConfig()
|
|
async with managed_plugin(
|
|
PROJECT_ID, DATASET_ID, table_id=TABLE_ID, config=config
|
|
) as plugin:
|
|
await plugin._ensure_started()
|
|
mock_write_client.append_rows.reset_mock()
|
|
mock_tool = mock.create_autospec(
|
|
base_tool_lib.BaseTool, instance=True, spec_set=True
|
|
)
|
|
type(mock_tool).name = mock.PropertyMock(
|
|
return_value="submit_final_response"
|
|
)
|
|
bigquery_agent_analytics_plugin.TraceManager.push_span(tool_context)
|
|
await plugin.after_tool_callback(
|
|
tool=mock_tool,
|
|
tool_args={"answer": "hi"},
|
|
tool_context=tool_context,
|
|
result={"status": "SUCCESS"},
|
|
)
|
|
await plugin.flush()
|
|
rows = await _get_captured_rows_async(
|
|
mock_write_client, dummy_arrow_schema
|
|
)
|
|
event_types = [r["event_type"] for r in rows]
|
|
assert "AGENT_RESPONSE" not in event_types
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_max_content_length_tool_error(
|
|
self,
|
|
mock_write_client,
|
|
tool_context,
|
|
mock_auth_default,
|
|
mock_bq_client,
|
|
mock_to_arrow_schema,
|
|
dummy_arrow_schema,
|
|
mock_asyncio_to_thread,
|
|
):
|
|
config = bigquery_agent_analytics_plugin.BigQueryLoggerConfig(
|
|
max_content_length=80
|
|
)
|
|
async with managed_plugin(
|
|
PROJECT_ID, DATASET_ID, table_id=TABLE_ID, config=config
|
|
) as plugin:
|
|
await plugin._ensure_started()
|
|
mock_write_client.append_rows.reset_mock()
|
|
mock_tool = mock.create_autospec(
|
|
base_tool_lib.BaseTool, instance=True, spec_set=True
|
|
)
|
|
type(mock_tool).name = mock.PropertyMock(return_value="MyTool")
|
|
# Args length > 80
|
|
# {"arg": "A" * 100} is > 100 chars.
|
|
bigquery_agent_analytics_plugin.TraceManager.push_span(tool_context)
|
|
await plugin.on_tool_error_callback(
|
|
tool=mock_tool,
|
|
tool_args={"arg": "A" * 100},
|
|
tool_context=tool_context,
|
|
error=ValueError("Oops"),
|
|
)
|
|
await plugin.flush()
|
|
mock_write_client.append_rows.assert_called_once()
|
|
log_entry = await _get_captured_event_dict_async(
|
|
mock_write_client, dummy_arrow_schema
|
|
)
|
|
assert log_entry["content"].startswith(
|
|
'{"tool": "MyTool", "args": {"arg": "AAAAA'
|
|
)
|
|
# Check for truncation in the nested value
|
|
content_dict = json.loads(log_entry["content"])
|
|
assert content_dict["args"]["arg"].endswith("...[TRUNCATED]")
|
|
assert log_entry["is_truncated"]
|
|
assert log_entry["error_message"] == "Oops"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_on_user_message_callback_logs_correctly(
|
|
self,
|
|
bq_plugin_inst,
|
|
mock_write_client,
|
|
invocation_context,
|
|
dummy_arrow_schema,
|
|
):
|
|
user_message = types.Content(parts=[types.Part(text="What is up?")])
|
|
bigquery_agent_analytics_plugin.TraceManager.push_span(invocation_context)
|
|
await bq_plugin_inst.on_user_message_callback(
|
|
invocation_context=invocation_context, user_message=user_message
|
|
)
|
|
await bq_plugin_inst.flush()
|
|
log_entry = await _get_captured_event_dict_async(
|
|
mock_write_client, dummy_arrow_schema
|
|
)
|
|
_assert_common_fields(log_entry, "USER_MESSAGE_RECEIVED")
|
|
assert log_entry["content"] == '{"text_summary": "What is up?"}'
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_offloading_with_connection_id(
|
|
self,
|
|
mock_write_client,
|
|
invocation_context,
|
|
mock_auth_default,
|
|
mock_bq_client,
|
|
mock_to_arrow_schema,
|
|
dummy_arrow_schema,
|
|
mock_asyncio_to_thread,
|
|
mock_storage_client,
|
|
):
|
|
_ = mock_auth_default
|
|
_ = mock_bq_client
|
|
_ = mock_to_arrow_schema
|
|
_ = mock_asyncio_to_thread
|
|
# Mock GCS bucket
|
|
mock_bucket = mock.Mock()
|
|
mock_blob = mock.Mock()
|
|
mock_bucket.blob.return_value = mock_blob
|
|
mock_bucket.name = "my-bucket"
|
|
mock_storage_client.return_value.bucket.return_value = mock_bucket
|
|
config = bigquery_agent_analytics_plugin.BigQueryLoggerConfig(
|
|
gcs_bucket_name="my-bucket",
|
|
connection_id="us.my-connection",
|
|
max_content_length=20, # Small limit to force offloading
|
|
)
|
|
async with managed_plugin(
|
|
PROJECT_ID, DATASET_ID, table_id=TABLE_ID, config=config
|
|
) as plugin:
|
|
await plugin._ensure_started(
|
|
storage_client=mock_storage_client.return_value
|
|
)
|
|
mock_write_client.append_rows.reset_mock()
|
|
# Create mixed content: one small inline, one large offloaded
|
|
small_text = "Small inline text"
|
|
large_text = "A" * 100
|
|
user_message = types.Content(
|
|
parts=[types.Part(text=small_text), types.Part(text=large_text)]
|
|
)
|
|
bigquery_agent_analytics_plugin.TraceManager.push_span(invocation_context)
|
|
await plugin.on_user_message_callback(
|
|
invocation_context=invocation_context, user_message=user_message
|
|
)
|
|
await plugin.flush()
|
|
mock_write_client.append_rows.assert_called_once()
|
|
log_entry = await _get_captured_event_dict_async(
|
|
mock_write_client, dummy_arrow_schema
|
|
)
|
|
# Verify content parts
|
|
assert len(log_entry["content_parts"]) == 2
|
|
# Part 0: Inline
|
|
part0 = log_entry["content_parts"][0]
|
|
assert part0["storage_mode"] == "INLINE"
|
|
assert part0["text"] == small_text
|
|
assert part0["object_ref"] is None
|
|
# Part 1: Offloaded
|
|
part1 = log_entry["content_parts"][1]
|
|
assert part1["storage_mode"] == "GCS_REFERENCE"
|
|
assert part1["uri"].startswith("gs://my-bucket/")
|
|
assert part1["object_ref"]["uri"] == part1["uri"]
|
|
assert part1["object_ref"]["authorizer"] == "us.my-connection"
|
|
assert json.loads(part1["object_ref"]["details"]) == {
|
|
"gcs_metadata": {"content_type": "text/plain"}
|
|
}
|
|
|
|
# Removed on_event_callback tests as they are no longer applicable in V2
|
|
@pytest.mark.asyncio
|
|
async def test_bigquery_client_initialization_failure(
|
|
self,
|
|
mock_auth_default,
|
|
mock_write_client,
|
|
invocation_context,
|
|
mock_asyncio_to_thread,
|
|
):
|
|
_ = mock_asyncio_to_thread
|
|
mock_auth_default.side_effect = auth_exceptions.GoogleAuthError(
|
|
"Auth failed"
|
|
)
|
|
async with managed_plugin(
|
|
project_id=PROJECT_ID,
|
|
dataset_id=DATASET_ID,
|
|
table_id=TABLE_ID,
|
|
) as plugin_with_fail:
|
|
with mock.patch(
|
|
"google.adk.plugins.bigquery_agent_analytics_plugin.logger"
|
|
) as mock_logger:
|
|
bigquery_agent_analytics_plugin.TraceManager.push_span(
|
|
invocation_context
|
|
)
|
|
await plugin_with_fail.on_user_message_callback(
|
|
invocation_context=invocation_context,
|
|
user_message=types.Content(parts=[types.Part(text="Test")]),
|
|
)
|
|
await plugin_with_fail.flush()
|
|
mock_logger.error.assert_called_with(
|
|
"Failed to initialize BigQuery Plugin (attempt %d, next"
|
|
" retry in %.0fs): %s",
|
|
mock.ANY,
|
|
mock.ANY,
|
|
mock.ANY,
|
|
)
|
|
mock_write_client.append_rows.assert_not_called()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_bigquery_insert_error_does_not_raise(
|
|
self, bq_plugin_inst, mock_write_client, invocation_context
|
|
):
|
|
|
|
_ = bq_plugin_inst
|
|
|
|
async def fake_append_rows_with_error(requests, **kwargs):
|
|
mock_append_rows_response = mock.MagicMock()
|
|
mock_append_rows_response.row_errors = [] # No row errors
|
|
mock_append_rows_response.error = mock.MagicMock()
|
|
mock_append_rows_response.error.code = 3 # INVALID_ARGUMENT
|
|
mock_append_rows_response.error.message = "Test BQ Error"
|
|
return _async_gen(mock_append_rows_response)
|
|
|
|
mock_write_client.append_rows.side_effect = fake_append_rows_with_error
|
|
with mock.patch(
|
|
"google.adk.plugins.bigquery_agent_analytics_plugin.logger"
|
|
) as mock_logger:
|
|
bigquery_agent_analytics_plugin.TraceManager.push_span(invocation_context)
|
|
await bq_plugin_inst.on_user_message_callback(
|
|
invocation_context=invocation_context,
|
|
user_message=types.Content(parts=[types.Part(text="Test")]),
|
|
)
|
|
await bq_plugin_inst.flush()
|
|
# The logger is called multiple times, check that one of them is the error message
|
|
# Or just check that it was called with the expected message at some point
|
|
mock_logger.error.assert_any_call(
|
|
"Non-retryable BigQuery error: %s", "Test BQ Error"
|
|
)
|
|
mock_write_client.append_rows.assert_called_once()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_bigquery_insert_retryable_error(
|
|
self, bq_plugin_inst, mock_write_client, invocation_context
|
|
):
|
|
"""Test that retryable BigQuery errors are logged and retried."""
|
|
|
|
async def fake_append_rows_with_retryable_error(requests, **kwargs):
|
|
mock_append_rows_response = mock.MagicMock()
|
|
mock_append_rows_response.row_errors = [] # No row errors
|
|
mock_append_rows_response.error = mock.MagicMock()
|
|
mock_append_rows_response.error.code = 10 # ABORTED (retryable)
|
|
mock_append_rows_response.error.message = "Test BQ Retryable Error"
|
|
return _async_gen(mock_append_rows_response)
|
|
|
|
mock_write_client.append_rows.side_effect = (
|
|
fake_append_rows_with_retryable_error
|
|
)
|
|
with mock.patch(
|
|
"google.adk.plugins.bigquery_agent_analytics_plugin.logger"
|
|
) as mock_logger:
|
|
bigquery_agent_analytics_plugin.TraceManager.push_span(invocation_context)
|
|
await bq_plugin_inst.on_user_message_callback(
|
|
invocation_context=invocation_context,
|
|
user_message=types.Content(parts=[types.Part(text="Test")]),
|
|
)
|
|
await bq_plugin_inst.flush()
|
|
mock_logger.warning.assert_any_call(
|
|
"BigQuery Write API returned error code %s: %s",
|
|
10,
|
|
"Test BQ Retryable Error",
|
|
)
|
|
# Should be called at least once. Retries are hard to test due to async backoff.
|
|
assert mock_write_client.append_rows.call_count >= 1
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_schema_mismatch_error_handling(
|
|
self, bq_plugin_inst, mock_write_client, invocation_context
|
|
):
|
|
async def fake_append_rows_with_schema_error(requests, **kwargs):
|
|
mock_resp = mock.MagicMock()
|
|
mock_resp.row_errors = []
|
|
mock_resp.error = mock.MagicMock()
|
|
mock_resp.error.code = 3
|
|
mock_resp.error.message = (
|
|
"Schema mismatch: Field 'new_field' not found in table."
|
|
)
|
|
return _async_gen(mock_resp)
|
|
|
|
mock_write_client.append_rows.side_effect = (
|
|
fake_append_rows_with_schema_error
|
|
)
|
|
with mock.patch(
|
|
"google.adk.plugins.bigquery_agent_analytics_plugin.logger"
|
|
) as mock_logger:
|
|
bigquery_agent_analytics_plugin.TraceManager.push_span(invocation_context)
|
|
await bq_plugin_inst.on_user_message_callback(
|
|
invocation_context=invocation_context,
|
|
user_message=types.Content(parts=[types.Part(text="Test")]),
|
|
)
|
|
await bq_plugin_inst.flush()
|
|
mock_logger.error.assert_called_with(
|
|
"BigQuery Schema Mismatch: %s. This usually means the"
|
|
" table schema does not match the expected schema.",
|
|
"Schema mismatch: Field 'new_field' not found in table.",
|
|
)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_close(self, bq_plugin_inst, mock_bq_client, mock_write_client):
|
|
"""Test plugin shutdown."""
|
|
|
|
await bq_plugin_inst.shutdown()
|
|
# shutdown calls transport.close() on all clients
|
|
assert mock_write_client.transport.close.call_count >= 1
|
|
# Verify loop states are cleared
|
|
assert not bq_plugin_inst._loop_state_by_loop
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_before_run_callback_logs_correctly(
|
|
self,
|
|
bq_plugin_inst,
|
|
mock_write_client,
|
|
invocation_context,
|
|
dummy_arrow_schema,
|
|
):
|
|
"""Test before_run_callback logs correctly."""
|
|
|
|
bigquery_agent_analytics_plugin.TraceManager.push_span(invocation_context)
|
|
await bq_plugin_inst.before_run_callback(
|
|
invocation_context=invocation_context
|
|
)
|
|
await bq_plugin_inst.flush()
|
|
log_entry = await _get_captured_event_dict_async(
|
|
mock_write_client, dummy_arrow_schema
|
|
)
|
|
_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,
|
|
bq_plugin_inst,
|
|
mock_write_client,
|
|
invocation_context,
|
|
dummy_arrow_schema,
|
|
):
|
|
bigquery_agent_analytics_plugin.TraceManager.push_span(invocation_context)
|
|
await bq_plugin_inst.after_run_callback(
|
|
invocation_context=invocation_context
|
|
)
|
|
await bq_plugin_inst.flush()
|
|
log_entry = await _get_captured_event_dict_async(
|
|
mock_write_client, dummy_arrow_schema
|
|
)
|
|
_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,
|
|
bq_plugin_inst,
|
|
mock_write_client,
|
|
mock_agent,
|
|
callback_context,
|
|
dummy_arrow_schema,
|
|
):
|
|
bigquery_agent_analytics_plugin.TraceManager.push_span(callback_context)
|
|
await bq_plugin_inst.before_agent_callback(
|
|
agent=mock_agent, callback_context=callback_context
|
|
)
|
|
await bq_plugin_inst.flush()
|
|
log_entry = await _get_captured_event_dict_async(
|
|
mock_write_client, dummy_arrow_schema
|
|
)
|
|
_assert_common_fields(log_entry, "AGENT_STARTING")
|
|
assert log_entry["content"] == "Test Instruction"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_after_agent_callback_logs_correctly(
|
|
self,
|
|
bq_plugin_inst,
|
|
mock_write_client,
|
|
mock_agent,
|
|
callback_context,
|
|
dummy_arrow_schema,
|
|
):
|
|
bigquery_agent_analytics_plugin.TraceManager.push_span(callback_context)
|
|
await bq_plugin_inst.after_agent_callback(
|
|
agent=mock_agent, callback_context=callback_context
|
|
)
|
|
await bq_plugin_inst.flush()
|
|
log_entry = await _get_captured_event_dict_async(
|
|
mock_write_client, dummy_arrow_schema
|
|
)
|
|
_assert_common_fields(log_entry, "AGENT_COMPLETED")
|
|
assert log_entry["content"] is None
|
|
# Latency should be an int >= 0 now that we instrument it
|
|
assert log_entry["latency_ms"] is not None
|
|
latency_dict = json.loads(log_entry["latency_ms"])
|
|
assert latency_dict["total_ms"] >= 0
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_before_model_callback_logs_correctly(
|
|
self,
|
|
bq_plugin_inst,
|
|
mock_write_client,
|
|
callback_context,
|
|
dummy_arrow_schema,
|
|
):
|
|
llm_request = llm_request_lib.LlmRequest(
|
|
model="gemini-pro",
|
|
contents=[
|
|
types.Content(role="user", parts=[types.Part(text="Prompt")])
|
|
],
|
|
)
|
|
bigquery_agent_analytics_plugin.TraceManager.push_span(callback_context)
|
|
await bq_plugin_inst.before_model_callback(
|
|
callback_context=callback_context, llm_request=llm_request
|
|
)
|
|
await bq_plugin_inst.flush()
|
|
log_entry = await _get_captured_event_dict_async(
|
|
mock_write_client, dummy_arrow_schema
|
|
)
|
|
_assert_common_fields(log_entry, "LLM_REQUEST")
|
|
assert "Prompt" in log_entry["content"]
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_before_model_callback_with_params_and_tools(
|
|
self,
|
|
bq_plugin_inst,
|
|
mock_write_client,
|
|
callback_context,
|
|
dummy_arrow_schema,
|
|
):
|
|
llm_request = llm_request_lib.LlmRequest(
|
|
model="gemini-pro",
|
|
config=types.GenerateContentConfig(
|
|
temperature=0.5,
|
|
top_p=0.9,
|
|
system_instruction=types.Content(parts=[types.Part(text="Sys")]),
|
|
),
|
|
contents=[types.Content(role="user", parts=[types.Part(text="User")])],
|
|
)
|
|
# Manually set tools_dict as it is excluded from init
|
|
llm_request.tools_dict = {"tool1": "func1", "tool2": "func2"}
|
|
bigquery_agent_analytics_plugin.TraceManager.push_span(callback_context)
|
|
await bq_plugin_inst.before_model_callback(
|
|
callback_context=callback_context, llm_request=llm_request
|
|
)
|
|
await bq_plugin_inst.flush()
|
|
log_entry = await _get_captured_event_dict_async(
|
|
mock_write_client, dummy_arrow_schema
|
|
)
|
|
_assert_common_fields(log_entry, "LLM_REQUEST")
|
|
# Verify content is JSON and has correct fields
|
|
assert "content" in log_entry
|
|
content_dict = json.loads(log_entry["content"])
|
|
assert content_dict["prompt"] == [{"role": "user", "content": "User"}]
|
|
assert content_dict["system_prompt"] == "Sys"
|
|
# Verify attributes
|
|
assert "attributes" in log_entry
|
|
attributes = json.loads(log_entry["attributes"])
|
|
assert attributes["llm_config"]["temperature"] == 0.5
|
|
assert attributes["llm_config"]["top_p"] == 0.9
|
|
assert attributes["llm_config"]["top_p"] == 0.9
|
|
# Tools without a name/description/declaration fall back to just the key.
|
|
assert attributes["tools"] == [{"name": "tool1"}, {"name": "tool2"}]
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_before_model_callback_logs_tool_declarations(
|
|
self,
|
|
bq_plugin_inst,
|
|
mock_write_client,
|
|
callback_context,
|
|
dummy_arrow_schema,
|
|
):
|
|
"""LLM_REQUEST tools carry name, description, and parameter schema."""
|
|
|
|
class _FakeTool(base_tool_lib.BaseTool):
|
|
|
|
def __init__(self, name, description, declaration):
|
|
super().__init__(name=name, description=description)
|
|
self._declaration = declaration
|
|
|
|
def _get_declaration(self):
|
|
return self._declaration
|
|
|
|
execute_sql = _FakeTool(
|
|
name="execute_sql",
|
|
description="Run a SQL query against BigQuery.",
|
|
declaration=types.FunctionDeclaration(
|
|
name="execute_sql",
|
|
description="Run a SQL query against BigQuery.",
|
|
parameters=types.Schema(
|
|
type=types.Type.OBJECT,
|
|
properties={
|
|
"query": types.Schema(
|
|
type=types.Type.STRING,
|
|
description="The SQL query to run.",
|
|
)
|
|
},
|
|
required=["query"],
|
|
),
|
|
),
|
|
)
|
|
# A tool without a declaration still contributes name + description.
|
|
list_datasets = _FakeTool(
|
|
name="list_dataset_ids",
|
|
description="List available datasets.",
|
|
declaration=None,
|
|
)
|
|
|
|
llm_request = llm_request_lib.LlmRequest(
|
|
model="gemini-pro",
|
|
contents=[types.Content(role="user", parts=[types.Part(text="hi")])],
|
|
)
|
|
llm_request.tools_dict = {
|
|
"execute_sql": execute_sql,
|
|
"list_dataset_ids": list_datasets,
|
|
}
|
|
bigquery_agent_analytics_plugin.TraceManager.push_span(callback_context)
|
|
await bq_plugin_inst.before_model_callback(
|
|
callback_context=callback_context, llm_request=llm_request
|
|
)
|
|
await bq_plugin_inst.flush()
|
|
log_entry = await _get_captured_event_dict_async(
|
|
mock_write_client, dummy_arrow_schema
|
|
)
|
|
_assert_common_fields(log_entry, "LLM_REQUEST")
|
|
attributes = json.loads(log_entry["attributes"])
|
|
tools_by_name = {t["name"]: t for t in attributes["tools"]}
|
|
|
|
assert tools_by_name["execute_sql"]["description"] == (
|
|
"Run a SQL query against BigQuery."
|
|
)
|
|
params = tools_by_name["execute_sql"]["parameters"]
|
|
assert params["type"] == "OBJECT"
|
|
assert params["properties"]["query"]["type"] == "STRING"
|
|
assert params["required"] == ["query"]
|
|
|
|
assert tools_by_name["list_dataset_ids"]["description"] == (
|
|
"List available datasets."
|
|
)
|
|
assert "parameters" not in tools_by_name["list_dataset_ids"]
|
|
|
|
def test_extract_tool_declarations_declaration_error_is_isolated(self):
|
|
"""A tool whose _get_declaration raises still yields name + description."""
|
|
|
|
class _RaisingTool(base_tool_lib.BaseTool):
|
|
|
|
def _get_declaration(self):
|
|
raise ValueError("boom")
|
|
|
|
class _OkTool(base_tool_lib.BaseTool):
|
|
|
|
def _get_declaration(self):
|
|
return None
|
|
|
|
result = bigquery_agent_analytics_plugin._extract_tool_declarations({
|
|
"raiser": _RaisingTool(name="raiser", description="Raises."),
|
|
"ok": _OkTool(name="ok", description="Fine."),
|
|
})
|
|
by_name = {t["name"]: t for t in result}
|
|
|
|
# The raising tool is not dropped; other tools are unaffected.
|
|
assert by_name["raiser"] == {"name": "raiser", "description": "Raises."}
|
|
assert by_name["ok"] == {"name": "ok", "description": "Fine."}
|
|
|
|
def test_extract_tool_declarations_parameters_serialization_error(self):
|
|
"""A parameters object that fails to serialize is dropped, not fatal."""
|
|
|
|
class _BadParams:
|
|
|
|
def model_dump(self, *args, **kwargs):
|
|
raise ValueError("cannot serialize")
|
|
|
|
class _BadDecl:
|
|
description = None
|
|
parameters = _BadParams()
|
|
|
|
class _BadParamTool(base_tool_lib.BaseTool):
|
|
|
|
def _get_declaration(self):
|
|
return _BadDecl()
|
|
|
|
result = bigquery_agent_analytics_plugin._extract_tool_declarations(
|
|
{"bad_params": _BadParamTool(name="bad_params", description="Bad.")}
|
|
)
|
|
|
|
# Name + description survive; the unserializable parameters key is omitted.
|
|
assert result == [{"name": "bad_params", "description": "Bad."}]
|
|
|
|
def test_extract_tool_declarations_uses_parameters_json_schema(self):
|
|
"""Declarations exposing parameters_json_schema log that raw schema."""
|
|
|
|
json_schema = {
|
|
"type": "object",
|
|
"properties": {"path": {"type": "string"}},
|
|
"required": ["path"],
|
|
}
|
|
|
|
class _JsonSchemaTool(base_tool_lib.BaseTool):
|
|
|
|
def _get_declaration(self):
|
|
return types.FunctionDeclaration(
|
|
name="read_file",
|
|
description="Read a file.",
|
|
parameters_json_schema=json_schema,
|
|
)
|
|
|
|
result = bigquery_agent_analytics_plugin._extract_tool_declarations(
|
|
{"read_file": _JsonSchemaTool(name="read_file", description="Read.")}
|
|
)
|
|
|
|
# parameters_json_schema is logged verbatim (preferred over `parameters`).
|
|
assert result == [{
|
|
"name": "read_file",
|
|
"description": "Read.",
|
|
"parameters": json_schema,
|
|
}]
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_before_model_callback_with_full_config(
|
|
self,
|
|
bq_plugin_inst,
|
|
mock_write_client,
|
|
callback_context,
|
|
dummy_arrow_schema,
|
|
):
|
|
"""Test that all config fields, including falsy values and labels, are logged."""
|
|
llm_request = llm_request_lib.LlmRequest(
|
|
model="gemini-pro",
|
|
config=types.GenerateContentConfig(
|
|
temperature=0.0,
|
|
top_p=0.1,
|
|
top_k=5.0,
|
|
candidate_count=5,
|
|
max_output_tokens=65000,
|
|
stop_sequences=["STOP"],
|
|
presence_penalty=0.1,
|
|
frequency_penalty=0.5,
|
|
seed=42,
|
|
response_logprobs=True,
|
|
logprobs=3,
|
|
labels={"llm.agent.name": "test_agent"},
|
|
),
|
|
contents=[types.Content(role="user", parts=[types.Part(text="User")])],
|
|
)
|
|
bigquery_agent_analytics_plugin.TraceManager.push_span(callback_context)
|
|
await bq_plugin_inst.before_model_callback(
|
|
callback_context=callback_context, llm_request=llm_request
|
|
)
|
|
await bq_plugin_inst.flush()
|
|
log_entry = await _get_captured_event_dict_async(
|
|
mock_write_client, dummy_arrow_schema
|
|
)
|
|
_assert_common_fields(log_entry, "LLM_REQUEST")
|
|
|
|
# Verify attributes
|
|
assert "attributes" in log_entry
|
|
attributes = json.loads(log_entry["attributes"])
|
|
|
|
llm_config = attributes.get("llm_config", {})
|
|
expected_llm_config = {
|
|
"temperature": 0.0,
|
|
"top_p": 0.1,
|
|
"top_k": 5.0,
|
|
"candidate_count": 5,
|
|
"max_output_tokens": 65000,
|
|
"stop_sequences": ["STOP"],
|
|
"presence_penalty": 0.1,
|
|
"frequency_penalty": 0.5,
|
|
"seed": 42,
|
|
"response_logprobs": True,
|
|
"logprobs": 3,
|
|
}
|
|
assert llm_config == expected_llm_config
|
|
|
|
assert attributes.get("labels") == {"llm.agent.name": "test_agent"}
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_before_model_callback_multipart_separator(
|
|
self,
|
|
bq_plugin_inst,
|
|
mock_write_client,
|
|
callback_context,
|
|
dummy_arrow_schema,
|
|
):
|
|
llm_request = llm_request_lib.LlmRequest(
|
|
model="gemini-pro",
|
|
contents=[
|
|
types.Content(
|
|
role="user",
|
|
parts=[types.Part(text="Part1"), types.Part(text="Part2")],
|
|
)
|
|
],
|
|
)
|
|
bigquery_agent_analytics_plugin.TraceManager.push_span(callback_context)
|
|
await bq_plugin_inst.before_model_callback(
|
|
callback_context=callback_context, llm_request=llm_request
|
|
)
|
|
await bq_plugin_inst.flush()
|
|
log_entry = await _get_captured_event_dict_async(
|
|
mock_write_client, dummy_arrow_schema
|
|
)
|
|
content_dict = json.loads(log_entry["content"])
|
|
# Verify the separator is " | "
|
|
assert content_dict["prompt"][0]["content"] == "Part1 | Part2"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_after_model_callback_text_response(
|
|
self,
|
|
bq_plugin_inst,
|
|
mock_write_client,
|
|
callback_context,
|
|
dummy_arrow_schema,
|
|
):
|
|
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
|
|
),
|
|
)
|
|
bigquery_agent_analytics_plugin.TraceManager.push_span(
|
|
callback_context, "llm_request"
|
|
)
|
|
await bq_plugin_inst.after_model_callback(
|
|
callback_context=callback_context,
|
|
llm_response=llm_response,
|
|
# latency_ms is now calculated internally via TraceManager
|
|
)
|
|
await bq_plugin_inst.flush()
|
|
log_entry = await _get_captured_event_dict_async(
|
|
mock_write_client, dummy_arrow_schema
|
|
)
|
|
_assert_common_fields(log_entry, "LLM_RESPONSE")
|
|
content_dict = json.loads(log_entry["content"])
|
|
assert content_dict["response"] == "text: 'Model response'"
|
|
assert content_dict["usage"]["prompt"] == 10
|
|
assert content_dict["usage"]["total"] == 15
|
|
assert log_entry["error_message"] is None
|
|
latency_dict = json.loads(log_entry["latency_ms"])
|
|
# Latency comes from time.time(), so we can't assert exact 100ms
|
|
# But it should be present
|
|
assert latency_dict["total_ms"] >= 0
|
|
# tfft is passed via kwargs if present, or we can mock it.
|
|
# In this test we didn't pass it in kwargs in the updated call above, so it might be missing unless we add it back to kwargs.
|
|
# The original test passed it as kwarg.
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize(
|
|
"finish_reason",
|
|
[
|
|
types.FinishReason.STOP,
|
|
types.FinishReason.MAX_TOKENS,
|
|
types.FinishReason.SAFETY,
|
|
types.FinishReason.MALFORMED_FUNCTION_CALL,
|
|
],
|
|
)
|
|
async def test_after_model_callback_projects_finish_reason(
|
|
self,
|
|
finish_reason,
|
|
bq_plugin_inst,
|
|
mock_write_client,
|
|
callback_context,
|
|
dummy_arrow_schema,
|
|
):
|
|
"""LLM termination reasons are queryable in response attributes."""
|
|
response = llm_response_lib.LlmResponse(
|
|
content=types.Content(parts=[types.Part(text="response")]),
|
|
finish_reason=finish_reason,
|
|
)
|
|
bigquery_agent_analytics_plugin.TraceManager.push_span(
|
|
callback_context, "llm_request"
|
|
)
|
|
|
|
await bq_plugin_inst.after_model_callback(
|
|
callback_context=callback_context, llm_response=response
|
|
)
|
|
await bq_plugin_inst.flush()
|
|
|
|
row = await _get_captured_event_dict_async(
|
|
mock_write_client, dummy_arrow_schema
|
|
)
|
|
assert json.loads(row["attributes"])["finish_reason"] == finish_reason.name
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_streaming_partial_omits_missing_finish_reason(
|
|
self,
|
|
bq_plugin_inst,
|
|
mock_write_client,
|
|
callback_context,
|
|
dummy_arrow_schema,
|
|
):
|
|
"""Streaming chunks without a termination reason omit the JSON key."""
|
|
response = llm_response_lib.LlmResponse(
|
|
content=types.Content(parts=[types.Part(text="chunk")]), partial=True
|
|
)
|
|
bigquery_agent_analytics_plugin.TraceManager.push_span(
|
|
callback_context, "llm_request"
|
|
)
|
|
|
|
await bq_plugin_inst.after_model_callback(
|
|
callback_context=callback_context, llm_response=response
|
|
)
|
|
await bq_plugin_inst.flush()
|
|
|
|
row = await _get_captured_event_dict_async(
|
|
mock_write_client, dummy_arrow_schema
|
|
)
|
|
assert "finish_reason" not in json.loads(row["attributes"])
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_streaming_terminal_metadata_is_logged_only_on_final_response(
|
|
self,
|
|
bq_plugin_inst,
|
|
mock_write_client,
|
|
callback_context,
|
|
dummy_arrow_schema,
|
|
):
|
|
"""A streamed turn contributes one finish reason and diagnostic row."""
|
|
aggregator = streaming_utils.StreamingResponseAggregator()
|
|
terminal_chunk = types.GenerateContentResponse(
|
|
candidates=[
|
|
types.Candidate(
|
|
finish_reason=types.FinishReason.MAX_TOKENS,
|
|
finish_message="token limit reached",
|
|
)
|
|
]
|
|
)
|
|
responses = [
|
|
response
|
|
async for response in aggregator.process_response(terminal_chunk)
|
|
]
|
|
responses.append(aggregator.close())
|
|
bigquery_agent_analytics_plugin.TraceManager.push_span(
|
|
callback_context, "llm_request"
|
|
)
|
|
|
|
for response in responses:
|
|
await bq_plugin_inst.after_model_callback(
|
|
callback_context=callback_context, llm_response=response
|
|
)
|
|
await bq_plugin_inst.flush()
|
|
|
|
rows = await _get_captured_rows_async(mock_write_client, dummy_arrow_schema)
|
|
assert len(rows) == 2
|
|
assert "finish_reason" not in json.loads(rows[0]["attributes"])
|
|
assert rows[0]["error_message"] is None
|
|
assert json.loads(rows[1]["attributes"])["finish_reason"] == "MAX_TOKENS"
|
|
assert rows[1]["error_message"] == "token limit reached"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_after_model_callback_accepts_string_finish_reason(
|
|
self,
|
|
bq_plugin_inst,
|
|
mock_write_client,
|
|
callback_context,
|
|
dummy_arrow_schema,
|
|
):
|
|
"""Response-like objects with string finish reasons still produce a row."""
|
|
response = llm_response_lib.LlmResponse.model_construct(
|
|
finish_reason="CUSTOM_REASON"
|
|
)
|
|
bigquery_agent_analytics_plugin.TraceManager.push_span(
|
|
callback_context, "llm_request"
|
|
)
|
|
|
|
await bq_plugin_inst.after_model_callback(
|
|
callback_context=callback_context, llm_response=response
|
|
)
|
|
await bq_plugin_inst.flush()
|
|
|
|
row = await _get_captured_event_dict_async(
|
|
mock_write_client, dummy_arrow_schema
|
|
)
|
|
assert json.loads(row["attributes"])["finish_reason"] == "CUSTOM_REASON"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_after_model_callback_sanitizes_error_message_without_error_status(
|
|
self,
|
|
bq_plugin_inst,
|
|
mock_write_client,
|
|
callback_context,
|
|
dummy_arrow_schema,
|
|
):
|
|
"""Response diagnostics use the safe error column without changing status."""
|
|
response = llm_response_lib.LlmResponse(
|
|
error_message="Authorization: Bearer MODEL-SECRET",
|
|
finish_reason=types.FinishReason.SAFETY,
|
|
)
|
|
bigquery_agent_analytics_plugin.TraceManager.push_span(
|
|
callback_context, "llm_request"
|
|
)
|
|
|
|
await bq_plugin_inst.after_model_callback(
|
|
callback_context=callback_context, llm_response=response
|
|
)
|
|
await bq_plugin_inst.flush()
|
|
|
|
row = await _get_captured_event_dict_async(
|
|
mock_write_client, dummy_arrow_schema
|
|
)
|
|
assert row["error_message"] == "Authorization: [REDACTED]"
|
|
assert row["status"] == "OK"
|
|
assert row["is_truncated"] is True
|
|
assert "MODEL-SECRET" not in json.dumps(row, default=str)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_after_model_callback_tool_call(
|
|
self,
|
|
bq_plugin_inst,
|
|
mock_write_client,
|
|
callback_context,
|
|
dummy_arrow_schema,
|
|
):
|
|
tool_fc = types.FunctionCall(name="get_weather", args={"location": "Paris"})
|
|
llm_response = llm_response_lib.LlmResponse(
|
|
content=types.Content(parts=[types.Part(function_call=tool_fc)]),
|
|
usage_metadata=types.UsageMetadata(
|
|
prompt_token_count=10, total_token_count=15
|
|
),
|
|
)
|
|
bigquery_agent_analytics_plugin.TraceManager.push_span(callback_context)
|
|
await bq_plugin_inst.after_model_callback(
|
|
callback_context=callback_context,
|
|
llm_response=llm_response,
|
|
)
|
|
await bq_plugin_inst.flush()
|
|
log_entry = await _get_captured_event_dict_async(
|
|
mock_write_client, dummy_arrow_schema
|
|
)
|
|
_assert_common_fields(log_entry, "LLM_RESPONSE")
|
|
content_dict = json.loads(log_entry["content"])
|
|
assert content_dict["response"] == "call: get_weather"
|
|
assert content_dict["usage"]["prompt"] == 10
|
|
assert content_dict["usage"]["total"] == 15
|
|
assert log_entry["error_message"] is None
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_before_tool_callback_logs_correctly(
|
|
self, bq_plugin_inst, mock_write_client, tool_context, dummy_arrow_schema
|
|
):
|
|
mock_tool = mock.create_autospec(
|
|
base_tool_lib.BaseTool, instance=True, spec_set=True
|
|
)
|
|
type(mock_tool).name = mock.PropertyMock(return_value="MyTool")
|
|
type(mock_tool).description = mock.PropertyMock(return_value="Description")
|
|
bigquery_agent_analytics_plugin.TraceManager.push_span(tool_context)
|
|
await bq_plugin_inst.before_tool_callback(
|
|
tool=mock_tool, tool_args={"param": "value"}, tool_context=tool_context
|
|
)
|
|
await bq_plugin_inst.flush()
|
|
log_entry = await _get_captured_event_dict_async(
|
|
mock_write_client, dummy_arrow_schema
|
|
)
|
|
_assert_common_fields(log_entry, "TOOL_STARTING")
|
|
content_dict = json.loads(log_entry["content"])
|
|
assert content_dict["tool"] == "MyTool"
|
|
assert content_dict["args"] == {"param": "value"}
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_after_tool_callback_logs_correctly(
|
|
self, bq_plugin_inst, mock_write_client, tool_context, dummy_arrow_schema
|
|
):
|
|
mock_tool = mock.create_autospec(
|
|
base_tool_lib.BaseTool, instance=True, spec_set=True
|
|
)
|
|
type(mock_tool).name = mock.PropertyMock(return_value="MyTool")
|
|
type(mock_tool).description = mock.PropertyMock(return_value="Description")
|
|
bigquery_agent_analytics_plugin.TraceManager.push_span(tool_context)
|
|
await bq_plugin_inst.after_tool_callback(
|
|
tool=mock_tool,
|
|
tool_args={"arg1": "val1"},
|
|
tool_context=tool_context,
|
|
result={"res": "success"},
|
|
)
|
|
await bq_plugin_inst.flush()
|
|
log_entry = await _get_captured_event_dict_async(
|
|
mock_write_client, dummy_arrow_schema
|
|
)
|
|
_assert_common_fields(log_entry, "TOOL_COMPLETED")
|
|
content_dict = json.loads(log_entry["content"])
|
|
assert content_dict["tool"] == "MyTool"
|
|
assert content_dict["result"] == {"res": "success"}
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_after_tool_callback_no_state_delta_logging(
|
|
self, bq_plugin_inst, mock_write_client, tool_context, dummy_arrow_schema
|
|
):
|
|
"""State deltas are now logged via on_event_callback, not after_tool."""
|
|
mock_tool = mock.create_autospec(
|
|
base_tool_lib.BaseTool, instance=True, spec_set=True
|
|
)
|
|
type(mock_tool).name = mock.PropertyMock(return_value="StateTool")
|
|
type(mock_tool).description = mock.PropertyMock(return_value="Sets state")
|
|
|
|
# Simulate a tool modifying the state
|
|
tool_context.actions.state_delta["new_key"] = "new_value"
|
|
|
|
bigquery_agent_analytics_plugin.TraceManager.push_span(tool_context)
|
|
await bq_plugin_inst.after_tool_callback(
|
|
tool=mock_tool,
|
|
tool_args={"arg1": "val1"},
|
|
tool_context=tool_context,
|
|
result={"res": "success"},
|
|
)
|
|
await bq_plugin_inst.flush()
|
|
|
|
# Only TOOL_COMPLETED should be logged; STATE_DELTA is handled
|
|
# by on_event_callback now.
|
|
rows = await _get_captured_rows_async(mock_write_client, dummy_arrow_schema)
|
|
assert len(rows) == 1
|
|
assert rows[0]["event_type"] == "TOOL_COMPLETED"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_on_event_callback_logs_state_delta(
|
|
self,
|
|
bq_plugin_inst,
|
|
mock_write_client,
|
|
invocation_context,
|
|
dummy_arrow_schema,
|
|
):
|
|
"""on_event_callback logs STATE_DELTA for events with state changes."""
|
|
state_delta = {"key": "value", "new_key": 123}
|
|
event = event_lib.Event(
|
|
author="test_agent",
|
|
actions=event_actions_lib.EventActions(state_delta=state_delta),
|
|
)
|
|
|
|
bigquery_agent_analytics_plugin.TraceManager.push_span(invocation_context)
|
|
result = await bq_plugin_inst.on_event_callback(
|
|
invocation_context=invocation_context, event=event
|
|
)
|
|
# Must return None to not modify the event
|
|
assert result is None
|
|
|
|
await bq_plugin_inst.flush()
|
|
log_entry = await _get_captured_event_dict_async(
|
|
mock_write_client, dummy_arrow_schema
|
|
)
|
|
_assert_common_fields(log_entry, "STATE_DELTA")
|
|
assert log_entry["content"] is None
|
|
|
|
attributes = json.loads(log_entry["attributes"])
|
|
assert attributes["state_delta"] == state_delta
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_on_event_callback_ignores_empty_state_delta(
|
|
self,
|
|
bq_plugin_inst,
|
|
mock_write_client,
|
|
invocation_context,
|
|
dummy_arrow_schema,
|
|
):
|
|
"""on_event_callback should not log when state_delta is empty."""
|
|
event = event_lib.Event(
|
|
author="test_agent",
|
|
actions=event_actions_lib.EventActions(state_delta={}),
|
|
)
|
|
|
|
result = await bq_plugin_inst.on_event_callback(
|
|
invocation_context=invocation_context, event=event
|
|
)
|
|
assert result is None
|
|
|
|
# No events should have been logged
|
|
mock_write_client.append_rows.assert_not_called()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_log_event_with_session_metadata(
|
|
self,
|
|
bq_plugin_inst,
|
|
mock_write_client,
|
|
callback_context,
|
|
dummy_arrow_schema,
|
|
):
|
|
"""Test that session metadata is logged when enabled."""
|
|
# Setup session state with user metadata
|
|
session = callback_context._invocation_context.session
|
|
type(session).state = mock.PropertyMock(
|
|
return_value={"thread_id": "gchat-123", "customer_id": "cust-42"}
|
|
)
|
|
|
|
# Ensure config enabled (default is True)
|
|
bq_plugin_inst.config.log_session_metadata = True
|
|
|
|
await bq_plugin_inst._log_event(
|
|
"TEST_EVENT",
|
|
callback_context,
|
|
raw_content="test content",
|
|
)
|
|
await bq_plugin_inst.flush()
|
|
log_entry = await _get_captured_event_dict_async(
|
|
mock_write_client, dummy_arrow_schema
|
|
)
|
|
|
|
attributes = json.loads(log_entry["attributes"])
|
|
meta = attributes["session_metadata"]
|
|
assert meta["session_id"] == session.id
|
|
assert meta["app_name"] == session.app_name
|
|
assert meta["user_id"] == session.user_id
|
|
assert meta["state"] == {
|
|
"thread_id": "gchat-123",
|
|
"customer_id": "cust-42",
|
|
}
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_log_event_with_custom_tags(
|
|
self,
|
|
bq_plugin_inst,
|
|
mock_write_client,
|
|
callback_context,
|
|
dummy_arrow_schema,
|
|
):
|
|
"""Test that custom tags are logged."""
|
|
custom_tags = {"agent_role": "sales", "env": "prod"}
|
|
bq_plugin_inst.config.custom_tags = custom_tags
|
|
|
|
await bq_plugin_inst._log_event(
|
|
"TEST_EVENT",
|
|
callback_context,
|
|
raw_content="test content",
|
|
)
|
|
await bq_plugin_inst.flush()
|
|
log_entry = await _get_captured_event_dict_async(
|
|
mock_write_client, dummy_arrow_schema
|
|
)
|
|
|
|
attributes = json.loads(log_entry["attributes"])
|
|
assert attributes["custom_tags"] == custom_tags
|
|
|
|
def test_resolve_agent_label_prefers_running_agent(self, callback_context):
|
|
"""agent present → agent.name, regardless of any source event."""
|
|
event = event_lib.Event(author="WorkflowNodeA")
|
|
label = bigquery_agent_analytics_plugin.BigQueryAgentAnalyticsPlugin._resolve_agent_label(
|
|
callback_context, event
|
|
)
|
|
assert label == "MyTestAgent"
|
|
|
|
def test_resolve_agent_label_falls_back_to_event_author(
|
|
self, callback_context
|
|
):
|
|
"""No agent + source Event → Event.author (the emitting node)."""
|
|
callback_context._invocation_context.agent = None
|
|
event = event_lib.Event(author="WorkflowNodeA")
|
|
label = bigquery_agent_analytics_plugin.BigQueryAgentAnalyticsPlugin._resolve_agent_label(
|
|
callback_context, event
|
|
)
|
|
assert label == "WorkflowNodeA"
|
|
|
|
def test_resolve_agent_label_null_for_callback_only_row(
|
|
self, callback_context
|
|
):
|
|
"""No agent and no source Event → None (SQL NULL)."""
|
|
callback_context._invocation_context.agent = None
|
|
label = bigquery_agent_analytics_plugin.BigQueryAgentAnalyticsPlugin._resolve_agent_label(
|
|
callback_context, None
|
|
)
|
|
assert label is None
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_log_event_survives_none_agent_with_event_author(
|
|
self,
|
|
bq_plugin_inst,
|
|
mock_write_client,
|
|
callback_context,
|
|
dummy_arrow_schema,
|
|
):
|
|
"""Regression: None agent falls back to source event author."""
|
|
# Workflow-driven invocations leave ``InvocationContext.agent`` as None.
|
|
# Reading ``callback_context.agent_name`` then raised ``AttributeError``,
|
|
# which ``@_safe_callback`` swallowed, silently dropping the BigQuery row.
|
|
# The row must now be written with the source Event's author as the label.
|
|
callback_context._invocation_context.agent = None
|
|
event = event_lib.Event(author="WorkflowNodeA")
|
|
|
|
await bq_plugin_inst._log_event(
|
|
"TEST_EVENT",
|
|
callback_context,
|
|
raw_content="test content",
|
|
event_data=bigquery_agent_analytics_plugin.EventData(
|
|
source_event=event
|
|
),
|
|
)
|
|
await bq_plugin_inst.flush()
|
|
log_entry = await _get_captured_event_dict_async(
|
|
mock_write_client, dummy_arrow_schema
|
|
)
|
|
|
|
assert log_entry["event_type"] == "TEST_EVENT"
|
|
assert log_entry["agent"] == "WorkflowNodeA"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_log_event_survives_none_agent_without_source_event(
|
|
self,
|
|
bq_plugin_inst,
|
|
mock_write_client,
|
|
callback_context,
|
|
dummy_arrow_schema,
|
|
):
|
|
"""Regression: callback-only row with no agent writes null."""
|
|
callback_context._invocation_context.agent = None
|
|
|
|
await bq_plugin_inst._log_event(
|
|
"TEST_EVENT",
|
|
callback_context,
|
|
raw_content="test content",
|
|
)
|
|
await bq_plugin_inst.flush()
|
|
log_entry = await _get_captured_event_dict_async(
|
|
mock_write_client, dummy_arrow_schema
|
|
)
|
|
|
|
assert log_entry["event_type"] == "TEST_EVENT"
|
|
assert log_entry["agent"] is None
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_on_model_error_callback_logs_correctly(
|
|
self,
|
|
bq_plugin_inst,
|
|
mock_write_client,
|
|
callback_context,
|
|
dummy_arrow_schema,
|
|
):
|
|
llm_request = llm_request_lib.LlmRequest(
|
|
model="gemini-pro",
|
|
contents=[types.Content(parts=[types.Part(text="Prompt")])],
|
|
)
|
|
error = ValueError("LLM failed")
|
|
bigquery_agent_analytics_plugin.TraceManager.push_span(callback_context)
|
|
await bq_plugin_inst.on_model_error_callback(
|
|
callback_context=callback_context, llm_request=llm_request, error=error
|
|
)
|
|
await bq_plugin_inst.flush()
|
|
log_entry = await _get_captured_event_dict_async(
|
|
mock_write_client, dummy_arrow_schema
|
|
)
|
|
_assert_common_fields(log_entry, "LLM_ERROR")
|
|
assert log_entry["content"] is None
|
|
assert log_entry["error_message"] == "LLM failed"
|
|
assert log_entry["status"] == "ERROR"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_on_tool_error_callback_logs_correctly(
|
|
self, bq_plugin_inst, mock_write_client, tool_context, dummy_arrow_schema
|
|
):
|
|
mock_tool = mock.create_autospec(
|
|
base_tool_lib.BaseTool, instance=True, spec_set=True
|
|
)
|
|
type(mock_tool).name = mock.PropertyMock(return_value="MyTool")
|
|
type(mock_tool).description = mock.PropertyMock(return_value="Description")
|
|
error = TimeoutError("Tool timed out")
|
|
bigquery_agent_analytics_plugin.TraceManager.push_span(tool_context)
|
|
await bq_plugin_inst.on_tool_error_callback(
|
|
tool=mock_tool,
|
|
tool_args={"param": "value"},
|
|
tool_context=tool_context,
|
|
error=error,
|
|
)
|
|
await bq_plugin_inst.flush()
|
|
log_entry = await _get_captured_event_dict_async(
|
|
mock_write_client, dummy_arrow_schema
|
|
)
|
|
_assert_common_fields(log_entry, "TOOL_ERROR")
|
|
content_dict = json.loads(log_entry["content"])
|
|
assert content_dict["tool"] == "MyTool"
|
|
assert content_dict["args"] == {"param": "value"}
|
|
assert log_entry["error_message"] == "Tool timed out"
|
|
assert log_entry["status"] == "ERROR"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_on_agent_error_callback_logs_correctly(
|
|
self,
|
|
bq_plugin_inst,
|
|
mock_write_client,
|
|
callback_context,
|
|
mock_agent,
|
|
dummy_arrow_schema,
|
|
):
|
|
"""on_agent_error_callback emits AGENT_ERROR with traceback."""
|
|
error = RuntimeError("Agent crashed")
|
|
try:
|
|
raise error
|
|
except RuntimeError:
|
|
pass # populate __traceback__
|
|
pushed_span_id = bigquery_agent_analytics_plugin.TraceManager.push_span(
|
|
callback_context, "agent"
|
|
)
|
|
await bq_plugin_inst.on_agent_error_callback(
|
|
agent=mock_agent,
|
|
callback_context=callback_context,
|
|
error=error,
|
|
)
|
|
await bq_plugin_inst.flush()
|
|
rows = await _get_captured_rows_async(mock_write_client, dummy_arrow_schema)
|
|
log_entry = next(r for r in rows if r["event_type"] == "AGENT_ERROR")
|
|
assert log_entry["error_message"] == "Agent crashed"
|
|
assert log_entry["status"] == "ERROR"
|
|
# The agent span BQAA pushed is popped and attributed to the error row.
|
|
assert log_entry["span_id"] == pushed_span_id
|
|
content = json.loads(log_entry["content"])
|
|
assert "error_traceback" in content
|
|
assert "RuntimeError: Agent crashed" in content["error_traceback"]
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_on_agent_error_does_not_pop_foreign_invocation_span(
|
|
self,
|
|
bq_plugin_inst,
|
|
mock_write_client,
|
|
callback_context,
|
|
mock_agent,
|
|
dummy_arrow_schema,
|
|
):
|
|
"""on_agent_error must not pop a span BQAA did not push for this agent.
|
|
|
|
Simulates another plugin's before_agent_callback raising before BQAA's
|
|
own before_agent_callback ran: the stack holds only the invocation root.
|
|
The guarded pop must leave the invocation span in place so the
|
|
subsequent INVOCATION_ERROR keeps correct span/latency data.
|
|
"""
|
|
trace_manager = bigquery_agent_analytics_plugin.TraceManager
|
|
inv_span_id = trace_manager.push_span(callback_context, "invocation")
|
|
|
|
error = RuntimeError("other plugin's before_agent failed")
|
|
try:
|
|
raise error
|
|
except RuntimeError:
|
|
pass
|
|
|
|
await bq_plugin_inst.on_agent_error_callback(
|
|
agent=mock_agent,
|
|
callback_context=callback_context,
|
|
error=error,
|
|
)
|
|
await bq_plugin_inst.flush()
|
|
|
|
# The invocation root was NOT consumed by the agent-error pop.
|
|
assert trace_manager.get_current_span_id() == inv_span_id
|
|
# The AGENT_ERROR row is still emitted.
|
|
rows = await _get_captured_rows_async(mock_write_client, dummy_arrow_schema)
|
|
log_entry = next(r for r in rows if r["event_type"] == "AGENT_ERROR")
|
|
assert log_entry["error_message"] == "other plugin's before_agent failed"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_on_run_error_callback_logs_correctly(
|
|
self,
|
|
bq_plugin_inst,
|
|
mock_write_client,
|
|
invocation_context,
|
|
dummy_arrow_schema,
|
|
):
|
|
"""on_run_error_callback emits INVOCATION_ERROR with traceback."""
|
|
error = ValueError("Invocation failed")
|
|
try:
|
|
raise error
|
|
except ValueError:
|
|
pass
|
|
bigquery_agent_analytics_plugin.TraceManager.push_span(
|
|
invocation_context, "invocation"
|
|
)
|
|
await bq_plugin_inst.on_run_error_callback(
|
|
invocation_context=invocation_context,
|
|
error=error,
|
|
)
|
|
await bq_plugin_inst.flush()
|
|
rows = await _get_captured_rows_async(mock_write_client, dummy_arrow_schema)
|
|
log_entry = next(r for r in rows if r["event_type"] == "INVOCATION_ERROR")
|
|
assert log_entry["error_message"] == "Invocation failed"
|
|
assert log_entry["status"] == "ERROR"
|
|
content = json.loads(log_entry["content"])
|
|
assert "error_traceback" in content
|
|
assert "ValueError: Invocation failed" in content["error_traceback"]
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_on_run_error_callback_cleanup_runs_on_log_failure(
|
|
self,
|
|
bq_plugin_inst,
|
|
mock_write_client,
|
|
invocation_context,
|
|
):
|
|
"""on_run_error_callback cleans up even when _log_event raises."""
|
|
# Push spans and set context vars to simulate active invocation
|
|
bigquery_agent_analytics_plugin.TraceManager.push_span(invocation_context)
|
|
bigquery_agent_analytics_plugin._active_invocation_id_ctx.set("test-inv")
|
|
bigquery_agent_analytics_plugin._root_agent_name_ctx.set("test-agent")
|
|
|
|
# Make _log_event raise
|
|
with mock.patch.object(
|
|
bq_plugin_inst, "_log_event", side_effect=RuntimeError("boom")
|
|
):
|
|
# @_safe_callback swallows the exception
|
|
await bq_plugin_inst.on_run_error_callback(
|
|
invocation_context=invocation_context,
|
|
error=ValueError("app error"),
|
|
)
|
|
|
|
# finally block must have cleaned up
|
|
assert (
|
|
bigquery_agent_analytics_plugin._active_invocation_id_ctx.get(None)
|
|
is None
|
|
)
|
|
assert (
|
|
bigquery_agent_analytics_plugin._root_agent_name_ctx.get(None) is None
|
|
)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_traceback_not_truncated_with_negative_max_len(
|
|
self,
|
|
mock_auth_default,
|
|
mock_bq_client,
|
|
mock_write_client,
|
|
mock_to_arrow_schema,
|
|
mock_asyncio_to_thread,
|
|
invocation_context,
|
|
mock_agent,
|
|
dummy_arrow_schema,
|
|
):
|
|
"""Traceback is not truncated when max_content_length is -1."""
|
|
config = bigquery_agent_analytics_plugin.BigQueryLoggerConfig(
|
|
max_content_length=-1,
|
|
create_views=False,
|
|
)
|
|
async with managed_plugin(
|
|
project_id=PROJECT_ID,
|
|
dataset_id=DATASET_ID,
|
|
table_id=TABLE_ID,
|
|
config=config,
|
|
) as plugin:
|
|
await plugin._ensure_started()
|
|
|
|
error = RuntimeError("x" * 2000)
|
|
try:
|
|
raise error
|
|
except RuntimeError:
|
|
pass
|
|
bigquery_agent_analytics_plugin.TraceManager.push_span(invocation_context)
|
|
await plugin.on_agent_error_callback(
|
|
agent=mock_agent,
|
|
callback_context=bigquery_agent_analytics_plugin.CallbackContext(
|
|
invocation_context
|
|
),
|
|
error=error,
|
|
)
|
|
await plugin.flush()
|
|
rows = await _get_captured_rows_async(
|
|
mock_write_client, dummy_arrow_schema
|
|
)
|
|
log_entry = next(r for r in rows if r["event_type"] == "AGENT_ERROR")
|
|
content = json.loads(log_entry["content"])
|
|
# Should NOT be truncated
|
|
assert "[truncated]" not in content["error_traceback"]
|
|
assert "x" * 2000 in content["error_traceback"]
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_table_creation_options(
|
|
self,
|
|
mock_auth_default,
|
|
mock_bq_client,
|
|
mock_write_client,
|
|
mock_to_arrow_schema,
|
|
mock_asyncio_to_thread,
|
|
):
|
|
async with managed_plugin(
|
|
PROJECT_ID, DATASET_ID, table_id=TABLE_ID
|
|
) as plugin:
|
|
mock_bq_client.get_table.side_effect = cloud_exceptions.NotFound(
|
|
"Not found"
|
|
)
|
|
await plugin._ensure_started()
|
|
# Verify create_table was called with correct table options
|
|
mock_bq_client.create_table.assert_called_once()
|
|
call_args = mock_bq_client.create_table.call_args
|
|
table_arg = call_args[0][0]
|
|
assert isinstance(table_arg, bigquery.Table)
|
|
assert table_arg.time_partitioning.type_ == "DAY"
|
|
assert table_arg.time_partitioning.field == "timestamp"
|
|
assert table_arg.clustering_fields == ["event_type", "agent", "user_id"]
|
|
# Verify schema descriptions are present (spot check)
|
|
timestamp_field = next(
|
|
f for f in table_arg.schema if f.name == "timestamp"
|
|
)
|
|
assert (
|
|
timestamp_field.description
|
|
== "The UTC timestamp when the event occurred. Used for ordering"
|
|
" events"
|
|
" within a session."
|
|
)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_init_in_thread_pool(
|
|
self,
|
|
mock_auth_default,
|
|
mock_bq_client,
|
|
mock_write_client,
|
|
mock_to_arrow_schema,
|
|
mock_asyncio_to_thread,
|
|
invocation_context,
|
|
):
|
|
"""Verifies that the plugin can be initialized from a thread pool."""
|
|
async with managed_plugin(
|
|
PROJECT_ID, DATASET_ID, table_id=TABLE_ID
|
|
) as plugin:
|
|
|
|
def _run_in_thread(p):
|
|
# In a real thread pool, there might not be an event loop.
|
|
# However, since we are calling an async method (_ensure_started),
|
|
# we must run it in an event loop. The issue was that _lazy_setup
|
|
# called get_event_loop() which fails in threads without a loop.
|
|
# Here we simulate the condition by running in a thread and creating a new loop if needed,
|
|
# but the key is that the plugin's internal calls should use the correct loop.
|
|
loop = asyncio.new_event_loop()
|
|
asyncio.set_event_loop(loop)
|
|
try:
|
|
# _ensure_started is called by managed_plugin, but we need to ensure
|
|
# that if it were called in a thread, it would work.
|
|
# For this test, we just ensure the plugin is accessible and started.
|
|
loop.run_until_complete(p._ensure_started())
|
|
return p._started, bool(p._loop_state_by_loop)
|
|
finally:
|
|
try:
|
|
loop.run_until_complete(p.shutdown())
|
|
finally:
|
|
loop.close()
|
|
|
|
# Run in a separate thread to simulate ThreadPoolExecutor-0_0
|
|
from concurrent.futures import ThreadPoolExecutor
|
|
|
|
with ThreadPoolExecutor(max_workers=1) as executor:
|
|
future = executor.submit(_run_in_thread, plugin)
|
|
started, had_loop_state = future.result()
|
|
assert started
|
|
assert had_loop_state
|
|
assert not plugin._loop_state_by_loop
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_multimodal_offloading(
|
|
self,
|
|
mock_write_client,
|
|
callback_context,
|
|
mock_auth_default,
|
|
mock_bq_client,
|
|
mock_to_arrow_schema,
|
|
dummy_arrow_schema,
|
|
mock_storage_client,
|
|
):
|
|
# Setup
|
|
bucket_name = "test-bucket"
|
|
config = bigquery_agent_analytics_plugin.BigQueryLoggerConfig(
|
|
gcs_bucket_name=bucket_name
|
|
)
|
|
async with managed_plugin(
|
|
PROJECT_ID, DATASET_ID, table_id=TABLE_ID, config=config
|
|
) as plugin:
|
|
await plugin._ensure_started(
|
|
storage_client=mock_storage_client.return_value
|
|
)
|
|
# Mock GCS bucket and blob
|
|
mock_bucket = mock_storage_client.return_value.bucket.return_value
|
|
mock_bucket.name = bucket_name
|
|
mock_blob = mock_bucket.blob.return_value
|
|
# Create content with large text that should be offloaded
|
|
large_text = "A" * (32 * 1024 + 1)
|
|
llm_request = llm_request_lib.LlmRequest(
|
|
model="gemini-pro",
|
|
contents=[types.Content(parts=[types.Part(text=large_text)])],
|
|
)
|
|
# Execute
|
|
await plugin.before_model_callback(
|
|
callback_context=callback_context, llm_request=llm_request
|
|
)
|
|
# Use flush instead of sleep for robustness
|
|
await plugin.flush()
|
|
# Verify GCS upload
|
|
mock_blob.upload_from_string.assert_called_once()
|
|
args, kwargs = mock_blob.upload_from_string.call_args
|
|
assert args[0] == large_text
|
|
assert kwargs["content_type"] == "text/plain"
|
|
# Verify BQ write
|
|
mock_write_client.append_rows.assert_called_once()
|
|
event_dict = await _get_captured_event_dict_async(
|
|
mock_write_client, dummy_arrow_schema
|
|
)
|
|
content_parts = event_dict["content_parts"]
|
|
assert len(content_parts) == 1
|
|
assert content_parts[0]["storage_mode"] == "GCS_REFERENCE"
|
|
assert content_parts[0]["uri"].startswith(f"gs://{bucket_name}/")
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_quota_project_id_used_in_client(
|
|
self,
|
|
mock_bq_client,
|
|
mock_to_arrow_schema,
|
|
mock_asyncio_to_thread,
|
|
):
|
|
mock_creds = mock.create_autospec(
|
|
google.auth.credentials.Credentials, instance=True, spec_set=True
|
|
)
|
|
mock_creds.quota_project_id = "quota-project"
|
|
with mock.patch.object(
|
|
google.auth,
|
|
"default",
|
|
autospec=True,
|
|
return_value=(mock_creds, PROJECT_ID),
|
|
) as mock_auth_default:
|
|
with mock.patch.object(
|
|
bigquery_agent_analytics_plugin,
|
|
"BigQueryWriteAsyncClient",
|
|
autospec=True,
|
|
) as mock_bq_write_cls:
|
|
async with managed_plugin(
|
|
project_id=PROJECT_ID,
|
|
dataset_id=DATASET_ID,
|
|
table_id=TABLE_ID,
|
|
) as plugin:
|
|
await plugin._ensure_started()
|
|
mock_auth_default.assert_called_once()
|
|
mock_bq_write_cls.assert_called_once()
|
|
_, kwargs = mock_bq_write_cls.call_args
|
|
assert kwargs["client_options"].quota_project_id == "quota-project"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_no_quota_project_when_creds_lack_it(
|
|
self,
|
|
mock_bq_client,
|
|
mock_to_arrow_schema,
|
|
mock_asyncio_to_thread,
|
|
):
|
|
"""Verify no quota_project_id is set when credentials don't provide one.
|
|
|
|
This is critical for Workload Identity Federation flows where setting
|
|
quota_project_id on the client breaks auth token refresh.
|
|
"""
|
|
mock_creds = mock.create_autospec(
|
|
google.auth.credentials.Credentials, instance=True, spec_set=True
|
|
)
|
|
mock_creds.quota_project_id = None
|
|
with mock.patch.object(
|
|
google.auth,
|
|
"default",
|
|
autospec=True,
|
|
return_value=(mock_creds, PROJECT_ID),
|
|
):
|
|
with mock.patch.object(
|
|
bigquery_agent_analytics_plugin,
|
|
"BigQueryWriteAsyncClient",
|
|
autospec=True,
|
|
) as mock_bq_write_cls:
|
|
async with managed_plugin(
|
|
project_id=PROJECT_ID,
|
|
dataset_id=DATASET_ID,
|
|
table_id=TABLE_ID,
|
|
) as plugin:
|
|
await plugin._ensure_started()
|
|
mock_bq_write_cls.assert_called_once()
|
|
_, kwargs = mock_bq_write_cls.call_args
|
|
assert kwargs["client_options"] is None
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_custom_credentials_used(
|
|
self,
|
|
mock_to_arrow_schema,
|
|
mock_asyncio_to_thread,
|
|
):
|
|
"""Verify custom credentials are used and default auth is not called."""
|
|
mock_custom_creds = mock.create_autospec(
|
|
google.auth.credentials.Credentials, instance=True, spec_set=True
|
|
)
|
|
mock_custom_creds.quota_project_id = "custom-quota-project"
|
|
|
|
config = bigquery_agent_analytics_plugin.BigQueryLoggerConfig(
|
|
gcs_bucket_name="test-bucket",
|
|
create_views=False,
|
|
)
|
|
|
|
with mock.patch.object(
|
|
google.auth,
|
|
"default",
|
|
autospec=True,
|
|
) as mock_auth_default:
|
|
with mock.patch.object(
|
|
bigquery_agent_analytics_plugin,
|
|
"BigQueryWriteAsyncClient",
|
|
autospec=True,
|
|
) as mock_bq_write_cls:
|
|
with mock.patch(
|
|
"google.cloud.bigquery.Client", autospec=True
|
|
) as mock_bq_cls:
|
|
with mock.patch(
|
|
"google.cloud.storage.Client", autospec=True
|
|
) as mock_storage_cls:
|
|
async with managed_plugin(
|
|
project_id=PROJECT_ID,
|
|
dataset_id=DATASET_ID,
|
|
table_id=TABLE_ID,
|
|
credentials=mock_custom_creds,
|
|
config=config,
|
|
) as plugin:
|
|
await plugin._ensure_started()
|
|
|
|
mock_auth_default.assert_not_called()
|
|
|
|
mock_bq_write_cls.assert_called_once()
|
|
_, kwargs = mock_bq_write_cls.call_args
|
|
assert kwargs["credentials"] == mock_custom_creds
|
|
|
|
mock_bq_cls.assert_called_once()
|
|
_, kwargs = mock_bq_cls.call_args
|
|
assert kwargs["credentials"] == mock_custom_creds
|
|
|
|
mock_storage_cls.assert_called_once()
|
|
_, kwargs = mock_storage_cls.call_args
|
|
assert kwargs["credentials"] == mock_custom_creds
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_pickle_safety(self, mock_auth_default, mock_bq_client):
|
|
"""Test that the plugin can be pickled safely."""
|
|
import pickle
|
|
|
|
config = bigquery_agent_analytics_plugin.BigQueryLoggerConfig(enabled=True)
|
|
plugin = bigquery_agent_analytics_plugin.BigQueryAgentAnalyticsPlugin(
|
|
PROJECT_ID, DATASET_ID, table_id=TABLE_ID, config=config
|
|
)
|
|
# Test pickling before start
|
|
pickled = pickle.dumps(plugin)
|
|
unpickled = pickle.loads(pickled)
|
|
assert unpickled.project_id == PROJECT_ID
|
|
assert unpickled._setup_future is None
|
|
assert unpickled._executor is None
|
|
# Start the plugin
|
|
await plugin._ensure_started()
|
|
assert plugin._executor is not None
|
|
try:
|
|
# Test pickling after start
|
|
pickled_started = pickle.dumps(plugin)
|
|
unpickled_started = pickle.loads(pickled_started)
|
|
assert unpickled_started.project_id == PROJECT_ID
|
|
# Runtime objects should be None after unpickling
|
|
assert unpickled_started._setup_future is None
|
|
assert unpickled_started._executor is None
|
|
assert not unpickled_started._loop_state_by_loop
|
|
finally:
|
|
await plugin.shutdown()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_span_hierarchy_llm_call(
|
|
self,
|
|
bq_plugin_inst,
|
|
mock_write_client,
|
|
callback_context,
|
|
dummy_arrow_schema,
|
|
):
|
|
"""Verifies that LLM events have correct Span ID hierarchy."""
|
|
# 1. Start Agent Span
|
|
bigquery_agent_analytics_plugin.TraceManager.push_span(callback_context)
|
|
_, _ = (
|
|
bigquery_agent_analytics_plugin.TraceManager.get_current_span_and_parent()
|
|
)
|
|
agent_span_id = (
|
|
bigquery_agent_analytics_plugin.TraceManager.get_current_span_id()
|
|
)
|
|
# 2. Start LLM Span (Implicitly handled if we push it?
|
|
# Actually before_model_callback assumes a span is pushed for the LLM call if we want one?
|
|
# No, usually the Runner/Agent pushes a span BEFORE calling before_model_callback?
|
|
# Let's verify usage in agent.py or plugin.
|
|
# Plugin does NOT push spans automatically for LLM. It relies on TraceManager being managed externally
|
|
# OR it uses current span.
|
|
# Wait, the Runner pushes spans.
|
|
# 3. LLM Request
|
|
llm_request = llm_request_lib.LlmRequest(
|
|
model="gemini-pro",
|
|
contents=[types.Content(parts=[types.Part(text="Prompt")])],
|
|
)
|
|
await bq_plugin_inst.before_model_callback(
|
|
callback_context=callback_context, llm_request=llm_request
|
|
)
|
|
await bq_plugin_inst.flush()
|
|
# Capture the actual LLM Span ID (pushed by before_model_callback)
|
|
llm_span_id = (
|
|
bigquery_agent_analytics_plugin.TraceManager.get_current_span_id()
|
|
)
|
|
# Now that we push a new span for LLM calls, it should differ from agent_span_id
|
|
assert llm_span_id != agent_span_id
|
|
log_entry_req = await _get_captured_event_dict_async(
|
|
mock_write_client, dummy_arrow_schema
|
|
)
|
|
assert log_entry_req["event_type"] == "LLM_REQUEST"
|
|
assert log_entry_req["span_id"] == llm_span_id
|
|
# The parent of the LLM span should be the Agent span
|
|
assert log_entry_req["parent_span_id"] == agent_span_id
|
|
mock_write_client.append_rows.reset_mock()
|
|
# 4. LLM Response
|
|
# In the actual flow, after_model_callback pops the span.
|
|
# But explicitly via TraceManager.pop_span()?
|
|
# No, after_model_callback calls TraceManager.pop_span().
|
|
# So we should validly call it.
|
|
llm_response = llm_response_lib.LlmResponse(
|
|
content=types.Content(parts=[types.Part(text="Response")]),
|
|
)
|
|
await bq_plugin_inst.after_model_callback(
|
|
callback_context=callback_context, llm_response=llm_response
|
|
)
|
|
await bq_plugin_inst.flush()
|
|
log_entry_resp = await _get_captured_event_dict_async(
|
|
mock_write_client, dummy_arrow_schema
|
|
)
|
|
assert log_entry_resp["event_type"] == "LLM_RESPONSE"
|
|
assert log_entry_resp["span_id"] == llm_span_id
|
|
# The parent of the LLM span should be the Agent span
|
|
assert log_entry_resp["parent_span_id"] == agent_span_id
|
|
# Verify LLM Span was popped and we are back to Agent Span
|
|
assert (
|
|
bigquery_agent_analytics_plugin.TraceManager.get_current_span_id()
|
|
== agent_span_id
|
|
)
|
|
# Clean up Agent Span
|
|
bigquery_agent_analytics_plugin.TraceManager.pop_span()
|
|
assert (
|
|
not bigquery_agent_analytics_plugin.TraceManager.get_current_span_id()
|
|
)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_custom_object_serialization(
|
|
self,
|
|
mock_write_client,
|
|
tool_context,
|
|
mock_auth_default,
|
|
mock_bq_client,
|
|
mock_to_arrow_schema,
|
|
dummy_arrow_schema,
|
|
mock_asyncio_to_thread,
|
|
):
|
|
"""Verifies that custom objects (Dataclasses) are serialized to dicts."""
|
|
_ = mock_auth_default
|
|
_ = mock_bq_client
|
|
|
|
@dataclasses.dataclass
|
|
class LocalMissedKPI:
|
|
kpi: str
|
|
value: float
|
|
|
|
@dataclasses.dataclass
|
|
class LocalIncident:
|
|
id: str
|
|
kpi_missed: list[LocalMissedKPI]
|
|
status: str
|
|
|
|
incident = LocalIncident(
|
|
id="inc-123",
|
|
kpi_missed=[LocalMissedKPI(kpi="latency", value=99.9)],
|
|
status="active",
|
|
)
|
|
config = bigquery_agent_analytics_plugin.BigQueryLoggerConfig()
|
|
async with managed_plugin(
|
|
PROJECT_ID, DATASET_ID, table_id=TABLE_ID, config=config
|
|
) as plugin:
|
|
await plugin._ensure_started()
|
|
mock_write_client.append_rows.reset_mock()
|
|
content = {"result": incident}
|
|
# Verify full flow
|
|
await plugin._log_event(
|
|
"TOOL_PARTIAL",
|
|
tool_context,
|
|
raw_content=content,
|
|
)
|
|
await plugin.flush()
|
|
mock_write_client.append_rows.assert_called_once()
|
|
log_entry = await _get_captured_event_dict_async(
|
|
mock_write_client, dummy_arrow_schema
|
|
)
|
|
# Content should be valid JSON string
|
|
content_json = json.loads(log_entry["content"])
|
|
assert content_json["result"]["id"] == "inc-123"
|
|
assert content_json["result"]["kpi_missed"][0]["kpi"] == "latency"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_push_pop_does_not_export_spans_through_real_provider(
|
|
self, callback_context
|
|
):
|
|
"""End-to-end guard against duplicate Cloud Trace spans with a real OTel
|
|
|
|
provider + in-memory exporter.
|
|
|
|
Wires an ``InMemorySpanExporter`` to a real ``TracerProvider``,
|
|
drives a push/pop cycle through ``TraceManager``, and asserts
|
|
that **zero** spans were exported. Pre-fix behavior was to
|
|
export one span per push/pop pair — visible to Cloud Trace as
|
|
duplicate spans alongside the framework's real ones.
|
|
"""
|
|
# pylint: disable=g-import-not-at-top
|
|
from opentelemetry.sdk import trace as trace_sdk
|
|
from opentelemetry.sdk.trace import export as trace_export
|
|
from opentelemetry.sdk.trace.export import in_memory_span_exporter
|
|
|
|
# pylint: enable=g-import-not-at-top
|
|
provider = trace_sdk.TracerProvider()
|
|
exporter = in_memory_span_exporter.InMemorySpanExporter()
|
|
provider.add_span_processor(trace_export.SimpleSpanProcessor(exporter))
|
|
real_tracer = provider.get_tracer("test_tracer")
|
|
|
|
span_id = bigquery_agent_analytics_plugin.TraceManager.push_span(
|
|
callback_context, "test_span"
|
|
)
|
|
assert exporter.get_finished_spans() == ()
|
|
|
|
trace_id = bigquery_agent_analytics_plugin.TraceManager.get_trace_id(
|
|
callback_context
|
|
)
|
|
assert trace_id is not None and len(trace_id) == 32
|
|
|
|
popped_span_id, _ = bigquery_agent_analytics_plugin.TraceManager.pop_span()
|
|
assert popped_span_id == span_id
|
|
|
|
assert exporter.get_finished_spans() == (), (
|
|
"Plugin must not export OTel spans; any owned span would"
|
|
" surface as a duplicate in Cloud Trace alongside the"
|
|
" framework's real spans."
|
|
)
|
|
|
|
provider.shutdown()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_push_span_inherits_ambient_trace_id(self, callback_context):
|
|
"""When the host has an ambient OTel span (e.g.
|
|
|
|
Agent Engine's Runner span), the plugin's ``trace_id`` MUST inherit from it
|
|
so BigQuery rows correlate with the host's Cloud Trace entries via a shared
|
|
``trace_id``.
|
|
"""
|
|
# pylint: disable=g-import-not-at-top
|
|
from opentelemetry import trace as otel_trace
|
|
from opentelemetry.sdk import trace as trace_sdk
|
|
|
|
# pylint: enable=g-import-not-at-top
|
|
provider = trace_sdk.TracerProvider()
|
|
host_tracer = provider.get_tracer("host_tracer")
|
|
|
|
# Clear any state on the plugin's contextvar stack.
|
|
bigquery_agent_analytics_plugin._span_records_ctx.set(None)
|
|
|
|
with host_tracer.start_as_current_span("ambient-host-span") as host_span:
|
|
expected_trace_id = format(host_span.get_span_context().trace_id, "032x")
|
|
|
|
# Plugin pushes its first internal span inside the ambient span.
|
|
bigquery_agent_analytics_plugin.TraceManager.push_span(
|
|
callback_context, "bqaa-span"
|
|
)
|
|
|
|
plugin_trace_id = (
|
|
bigquery_agent_analytics_plugin.TraceManager.get_trace_id(
|
|
callback_context
|
|
)
|
|
)
|
|
assert plugin_trace_id == expected_trace_id, (
|
|
"Plugin must inherit ambient trace_id so BigQuery rows join"
|
|
" to Cloud Trace via the same trace_id"
|
|
)
|
|
|
|
# Nested plugin push also stays under the ambient trace_id.
|
|
bigquery_agent_analytics_plugin.TraceManager.push_span(
|
|
callback_context, "bqaa-nested"
|
|
)
|
|
assert (
|
|
bigquery_agent_analytics_plugin.TraceManager.get_trace_id(
|
|
callback_context
|
|
)
|
|
== expected_trace_id
|
|
)
|
|
|
|
bigquery_agent_analytics_plugin.TraceManager.clear_stack()
|
|
provider.shutdown()
|
|
del otel_trace # unused; imported for symmetry with provider setup
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_llm_request_response_share_span_id_contract(
|
|
self, callback_context
|
|
):
|
|
"""Lifecycle contract: ``LLM_REQUEST`` and ``LLM_RESPONSE`` for the
|
|
|
|
same model call share one ``span_id`` and one ``trace_id``.
|
|
|
|
Models the structural pattern the real callbacks use:
|
|
* ``before_model_callback`` calls ``push_span(...)`` and writes
|
|
``LLM_REQUEST`` with the returned ``span_id``.
|
|
* ``after_model_callback`` calls ``get_current_span_id()`` /
|
|
``pop_span()`` and writes ``LLM_RESPONSE`` with the same
|
|
``span_id``.
|
|
|
|
A future change must not split this pair onto two different
|
|
``span_id``s — that would break the documented BigQuery query
|
|
shape and the BQAA join contract.
|
|
"""
|
|
bigquery_agent_analytics_plugin._span_records_ctx.set(None)
|
|
TM = bigquery_agent_analytics_plugin.TraceManager
|
|
|
|
# before_model_callback path.
|
|
pushed_span_id = TM.push_span(callback_context, "llm_request")
|
|
request_trace_id = TM.get_trace_id(callback_context)
|
|
|
|
# after_model_callback (final chunk) path.
|
|
response_top_of_stack = TM.get_current_span_id()
|
|
popped_span_id, _duration_ms = TM.pop_span()
|
|
response_trace_id = TM.get_trace_id(callback_context)
|
|
|
|
assert response_top_of_stack == pushed_span_id
|
|
assert popped_span_id == pushed_span_id
|
|
# trace_id resolved on the response side may have to fall back
|
|
# past the now-empty stack — but if it does resolve, it must
|
|
# match what the request observed. An empty-stack fallback to
|
|
# invocation_id is acceptable here; what we are guarding against
|
|
# is the *pair* drifting onto two structurally different ids.
|
|
if response_trace_id is not None and len(response_trace_id) == 32:
|
|
assert response_trace_id == request_trace_id
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_tool_starting_completed_share_span_id_contract(
|
|
self, callback_context
|
|
):
|
|
"""Lifecycle contract: ``TOOL_STARTING`` and ``TOOL_COMPLETED`` for
|
|
|
|
the same tool call share one ``span_id``.
|
|
|
|
Same shape as the LLM pair above — push on before, pop on after,
|
|
same id on both sides.
|
|
"""
|
|
bigquery_agent_analytics_plugin._span_records_ctx.set(None)
|
|
TM = bigquery_agent_analytics_plugin.TraceManager
|
|
|
|
# before_tool_callback path.
|
|
pushed_span_id = TM.push_span(callback_context, "tool")
|
|
starting_trace_id = TM.get_trace_id(callback_context)
|
|
|
|
# after_tool_callback path.
|
|
popped_span_id, _duration_ms = TM.pop_span()
|
|
|
|
assert popped_span_id == pushed_span_id
|
|
assert isinstance(starting_trace_id, str) and len(starting_trace_id) == 32
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_streaming_llm_response_shares_span_id_until_final_contract(
|
|
self, callback_context
|
|
):
|
|
"""Streaming-response contract.
|
|
|
|
On a streaming LLM call, ``after_model_callback`` is fired once
|
|
per partial chunk *plus* once for the final chunk. Partial fires
|
|
do NOT pop the span (see ``after_model_callback:3354-3363``) —
|
|
they only read ``get_current_span_id()`` and record first-token
|
|
timing. Only the final fire calls ``pop_span()``.
|
|
|
|
All resulting ``LLM_RESPONSE`` rows therefore share one
|
|
``span_id`` (the same as the paired ``LLM_REQUEST``). A future
|
|
change must not "dedupe" the partial rows by switching to a fresh
|
|
span id per chunk — those rows are real and intentional.
|
|
"""
|
|
bigquery_agent_analytics_plugin._span_records_ctx.set(None)
|
|
TM = bigquery_agent_analytics_plugin.TraceManager
|
|
|
|
pushed_span_id = TM.push_span(callback_context, "llm_request")
|
|
|
|
# Simulate three partial chunks: each callback observes the same
|
|
# span_id at top of stack and does NOT pop.
|
|
for _ in range(3):
|
|
assert TM.get_current_span_id() == pushed_span_id
|
|
|
|
# Final chunk: pop_span returns the same id and a populated
|
|
# latency.
|
|
popped_span_id, duration_ms = TM.pop_span()
|
|
assert popped_span_id == pushed_span_id
|
|
assert duration_ms is not None and duration_ms >= 0
|
|
|
|
# Stack must be empty after the final chunk.
|
|
assert TM.get_current_span_id() is None
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_keyword_identifiers_emission_default(
|
|
self,
|
|
mock_auth_default,
|
|
mock_bq_client,
|
|
callback_context,
|
|
):
|
|
"""Verify the default keyword flow for User-Agent and Trace-ID."""
|
|
keyword = "google-adk-bq-logger"
|
|
mock_write_client = mock.AsyncMock()
|
|
|
|
# 1. Verify User-Agent contains default keyword.
|
|
with mock.patch(
|
|
"google.adk.plugins.bigquery_agent_analytics_plugin.BigQueryWriteAsyncClient",
|
|
autospec=True,
|
|
) as mock_write_cls:
|
|
mock_write_cls.return_value = mock_write_client
|
|
async with managed_plugin(PROJECT_ID, DATASET_ID) as plugin:
|
|
await plugin._ensure_started()
|
|
|
|
_, kwargs = mock_write_cls.call_args
|
|
client_info = kwargs.get("client_info")
|
|
assert f"{keyword}/{__version__}" in client_info.user_agent
|
|
|
|
# 2. Verify Trace ID contains default keyword.
|
|
with mock.patch(
|
|
"google.adk.plugins.bigquery_agent_analytics_plugin.BigQueryWriteAsyncClient",
|
|
autospec=True,
|
|
) as mock_write_cls:
|
|
mock_write_cls.return_value = mock_write_client
|
|
async with managed_plugin(PROJECT_ID, DATASET_ID) as plugin:
|
|
await plugin._ensure_started()
|
|
mock_write_client.append_rows.reset_mock()
|
|
|
|
llm_request = llm_request_lib.LlmRequest(
|
|
model="gemini-pro",
|
|
contents=[types.Content(parts=[types.Part(text="Hi")])],
|
|
)
|
|
await plugin.before_model_callback(
|
|
callback_context=callback_context, llm_request=llm_request
|
|
)
|
|
await plugin.flush()
|
|
|
|
call_args = mock_write_client.append_rows.call_args
|
|
requests_iter = call_args.args[0]
|
|
requests = []
|
|
async for req in requests_iter:
|
|
requests.append(req)
|
|
|
|
assert requests[0].trace_id.startswith(keyword)
|
|
assert requests[0].trace_id.endswith(f"/{__version__}")
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_visual_builder_identifiers_flow(
|
|
self,
|
|
mock_auth_default,
|
|
mock_bq_client,
|
|
callback_context,
|
|
dummy_arrow_schema,
|
|
):
|
|
"""Verify visual-builder keyword flow via contextvars."""
|
|
keyword = "google-adk-visual-builder"
|
|
mock_write_client = mock.AsyncMock()
|
|
|
|
# Simulate setting the internal flag via contextvars
|
|
token = _is_visual_builder.set(True)
|
|
try:
|
|
# 1. Verify Client User-Agent
|
|
with mock.patch(
|
|
"google.adk.plugins.bigquery_agent_analytics_plugin.BigQueryWriteAsyncClient",
|
|
autospec=True,
|
|
) as mock_write_cls:
|
|
mock_write_cls.return_value = mock_write_client
|
|
async with managed_plugin(PROJECT_ID, DATASET_ID) as plugin:
|
|
await plugin._ensure_started()
|
|
|
|
_, kwargs = mock_write_cls.call_args
|
|
client_info = kwargs.get("client_info")
|
|
assert keyword in client_info.user_agent
|
|
|
|
# 2. Verify Request Trace ID
|
|
with mock.patch(
|
|
"google.adk.plugins.bigquery_agent_analytics_plugin.BigQueryWriteAsyncClient",
|
|
autospec=True,
|
|
) as mock_write_cls:
|
|
mock_write_cls.return_value = mock_write_client
|
|
async with managed_plugin(PROJECT_ID, DATASET_ID) as plugin:
|
|
await plugin._ensure_started()
|
|
mock_write_client.append_rows.reset_mock()
|
|
|
|
llm_request = llm_request_lib.LlmRequest(
|
|
model="gemini-pro",
|
|
contents=[types.Content(parts=[types.Part(text="Hi")])],
|
|
)
|
|
await plugin.before_model_callback(
|
|
callback_context=callback_context, llm_request=llm_request
|
|
)
|
|
await plugin.flush()
|
|
|
|
call_args = mock_write_client.append_rows.call_args
|
|
requests_iter = call_args.args[0]
|
|
requests = []
|
|
async for req in requests_iter:
|
|
requests.append(req)
|
|
|
|
assert requests[0].trace_id.startswith(
|
|
"google-adk-bq-logger-visual-builder"
|
|
)
|
|
assert requests[0].trace_id.endswith(f"/{__version__}")
|
|
finally:
|
|
_is_visual_builder.reset(token)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_flush_mechanism(
|
|
self,
|
|
bq_plugin_inst,
|
|
mock_write_client,
|
|
dummy_arrow_schema,
|
|
invocation_context,
|
|
):
|
|
"""Verifies that flush() forces pending events to be written."""
|
|
# Log an event
|
|
bigquery_agent_analytics_plugin.TraceManager.push_span(invocation_context)
|
|
await bq_plugin_inst.before_run_callback(
|
|
invocation_context=invocation_context
|
|
)
|
|
# Call flush - this should block until the event is written
|
|
await bq_plugin_inst.flush()
|
|
# Verify write called
|
|
mock_write_client.append_rows.assert_called_once()
|
|
log_entry = await _get_captured_event_dict_async(
|
|
mock_write_client, dummy_arrow_schema
|
|
)
|
|
assert log_entry["event_type"] == "INVOCATION_STARTING"
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize(
|
|
"gen_config_kwargs, expected_llm_config",
|
|
[
|
|
(
|
|
{
|
|
"temperature": 0.0,
|
|
"top_k": 5.0,
|
|
"top_p": 0.1,
|
|
"candidate_count": 5,
|
|
"max_output_tokens": 65000,
|
|
"presence_penalty": 0.1,
|
|
"frequency_penalty": 0.5,
|
|
"response_logprobs": True,
|
|
"logprobs": 3,
|
|
"seed": 42,
|
|
"labels": {"llm.agent.name": "test_agent"},
|
|
},
|
|
{
|
|
"temperature": 0.0,
|
|
"top_k": 5.0,
|
|
"top_p": 0.1,
|
|
"candidate_count": 5,
|
|
"max_output_tokens": 65000,
|
|
"presence_penalty": 0.1,
|
|
"frequency_penalty": 0.5,
|
|
"response_logprobs": True,
|
|
"logprobs": 3,
|
|
"seed": 42,
|
|
},
|
|
),
|
|
],
|
|
)
|
|
async def test_generation_config_logging(
|
|
self,
|
|
bq_plugin_inst,
|
|
mock_write_client,
|
|
dummy_arrow_schema,
|
|
callback_context,
|
|
gen_config_kwargs,
|
|
expected_llm_config,
|
|
):
|
|
"""Verifies that all fields in GenerateContentConfig are logged correctly."""
|
|
gen_config = types.GenerateContentConfig(**gen_config_kwargs)
|
|
|
|
llm_request = llm_request_lib.LlmRequest(
|
|
model="gemini-pro",
|
|
contents=[types.Content(parts=[types.Part(text="Prompt")])],
|
|
config=gen_config,
|
|
)
|
|
|
|
bigquery_agent_analytics_plugin.TraceManager.push_span(callback_context)
|
|
await bq_plugin_inst.before_model_callback(
|
|
callback_context=callback_context, llm_request=llm_request
|
|
)
|
|
# Flush
|
|
await bq_plugin_inst.flush()
|
|
|
|
# Verify
|
|
log_entry = await _get_captured_event_dict_async(
|
|
mock_write_client, dummy_arrow_schema
|
|
)
|
|
assert log_entry["event_type"] == "LLM_REQUEST"
|
|
|
|
attributes = json.loads(log_entry["attributes"])
|
|
llm_config = attributes.get("llm_config", {})
|
|
|
|
assert llm_config == expected_llm_config
|
|
|
|
if "labels" in gen_config_kwargs:
|
|
assert attributes.get("labels") == gen_config_kwargs["labels"]
|
|
|
|
|
|
class TestSafeCallbackDecorator:
|
|
"""Tests that _safe_callback prevents plugin errors from propagating."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_callback_exception_does_not_propagate(
|
|
self,
|
|
bq_plugin_inst,
|
|
mock_write_client,
|
|
invocation_context,
|
|
):
|
|
"""A callback that throws should return None, not crash."""
|
|
# Force _log_event to raise
|
|
with mock.patch.object(
|
|
bq_plugin_inst,
|
|
"_log_event",
|
|
side_effect=RuntimeError("BQ network timeout"),
|
|
):
|
|
# Should NOT raise
|
|
result = await bq_plugin_inst.on_user_message_callback(
|
|
invocation_context=invocation_context,
|
|
user_message=types.Content(parts=[types.Part(text="Test")]),
|
|
)
|
|
assert result is None
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_callback_exception_is_logged(
|
|
self,
|
|
bq_plugin_inst,
|
|
mock_write_client,
|
|
invocation_context,
|
|
):
|
|
"""The swallowed exception should be logged with exc_info."""
|
|
with mock.patch.object(
|
|
bq_plugin_inst,
|
|
"_log_event",
|
|
side_effect=RuntimeError("BQ write failed"),
|
|
):
|
|
with mock.patch(
|
|
"google.adk.plugins.bigquery_agent_analytics_plugin.logger"
|
|
) as mock_logger:
|
|
await bq_plugin_inst.before_run_callback(
|
|
invocation_context=invocation_context,
|
|
)
|
|
mock_logger.exception.assert_called_once_with(
|
|
"BigQuery analytics plugin error in %s; skipping.",
|
|
"before_run_callback",
|
|
)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_subsequent_callbacks_work_after_failure(
|
|
self,
|
|
bq_plugin_inst,
|
|
mock_write_client,
|
|
invocation_context,
|
|
dummy_arrow_schema,
|
|
):
|
|
"""After one callback fails, the next one should still work."""
|
|
call_count = 0
|
|
original_log_event = bq_plugin_inst._log_event
|
|
|
|
async def fail_once(*args, **kwargs):
|
|
nonlocal call_count
|
|
call_count += 1
|
|
if call_count == 1:
|
|
raise RuntimeError("Transient error")
|
|
return await original_log_event(*args, **kwargs)
|
|
|
|
with mock.patch.object(bq_plugin_inst, "_log_event", side_effect=fail_once):
|
|
# First call fails silently
|
|
await bq_plugin_inst.on_user_message_callback(
|
|
invocation_context=invocation_context,
|
|
user_message=types.Content(parts=[types.Part(text="Fail")]),
|
|
)
|
|
# Second call succeeds
|
|
bigquery_agent_analytics_plugin.TraceManager.push_span(invocation_context)
|
|
await bq_plugin_inst.before_run_callback(
|
|
invocation_context=invocation_context,
|
|
)
|
|
await bq_plugin_inst.flush()
|
|
mock_write_client.append_rows.assert_called_once()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_on_event_callback_exception_returns_none(
|
|
self,
|
|
bq_plugin_inst,
|
|
mock_write_client,
|
|
invocation_context,
|
|
):
|
|
"""on_event_callback should return None on error, not crash."""
|
|
event = event_lib.Event(
|
|
author="test_agent",
|
|
actions=event_actions_lib.EventActions(state_delta={"key": "value"}),
|
|
)
|
|
with mock.patch.object(
|
|
bq_plugin_inst,
|
|
"_log_event",
|
|
side_effect=Exception("serialize error"),
|
|
):
|
|
result = await bq_plugin_inst.on_event_callback(
|
|
invocation_context=invocation_context, event=event
|
|
)
|
|
assert result is None
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_tool_callback_exception_does_not_propagate(
|
|
self,
|
|
bq_plugin_inst,
|
|
mock_write_client,
|
|
tool_context,
|
|
):
|
|
"""Tool callbacks should not crash even if plugin errors."""
|
|
mock_tool = mock.create_autospec(
|
|
base_tool_lib.BaseTool, instance=True, spec_set=True
|
|
)
|
|
type(mock_tool).name = mock.PropertyMock(return_value="MyTool")
|
|
with mock.patch.object(
|
|
bq_plugin_inst,
|
|
"_log_event",
|
|
side_effect=RuntimeError("BQ down"),
|
|
):
|
|
# before_tool_callback
|
|
result = await bq_plugin_inst.before_tool_callback(
|
|
tool=mock_tool,
|
|
tool_args={"p": "v"},
|
|
tool_context=tool_context,
|
|
)
|
|
assert result is None
|
|
|
|
# after_tool_callback
|
|
result = await bq_plugin_inst.after_tool_callback(
|
|
tool=mock_tool,
|
|
tool_args={"p": "v"},
|
|
tool_context=tool_context,
|
|
result={"r": "ok"},
|
|
)
|
|
assert result is None
|
|
|
|
# on_tool_error_callback
|
|
result = await bq_plugin_inst.on_tool_error_callback(
|
|
tool=mock_tool,
|
|
tool_args={"p": "v"},
|
|
tool_context=tool_context,
|
|
error=ValueError("tool broke"),
|
|
)
|
|
assert result is None
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_model_callback_exception_does_not_propagate(
|
|
self,
|
|
bq_plugin_inst,
|
|
mock_write_client,
|
|
callback_context,
|
|
):
|
|
"""Model callbacks should not crash even if plugin errors."""
|
|
with mock.patch.object(
|
|
bq_plugin_inst,
|
|
"_log_event",
|
|
side_effect=RuntimeError("BQ down"),
|
|
):
|
|
llm_request = llm_request_lib.LlmRequest(
|
|
model="gemini-pro",
|
|
contents=[types.Content(parts=[types.Part(text="Hi")])],
|
|
)
|
|
result = await bq_plugin_inst.before_model_callback(
|
|
callback_context=callback_context, llm_request=llm_request
|
|
)
|
|
assert result is None
|
|
|
|
llm_response = llm_response_lib.LlmResponse(
|
|
content=types.Content(parts=[types.Part(text="Hi")]),
|
|
)
|
|
result = await bq_plugin_inst.after_model_callback(
|
|
callback_context=callback_context, llm_response=llm_response
|
|
)
|
|
assert result is None
|
|
|
|
result = await bq_plugin_inst.on_model_error_callback(
|
|
callback_context=callback_context,
|
|
llm_request=llm_request_lib.LlmRequest(model="gemini-pro"),
|
|
error=ValueError("llm error"),
|
|
)
|
|
assert result is None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_lazy_setup_creates_executor_for_preconfigured_client(
|
|
monkeypatch,
|
|
):
|
|
"""GCS setup must not depend on whether the BQ client was preconfigured."""
|
|
config = bigquery_agent_analytics_plugin.BigQueryLoggerConfig(
|
|
gcs_bucket_name="test-bucket"
|
|
)
|
|
plugin = bigquery_agent_analytics_plugin.BigQueryAgentAnalyticsPlugin(
|
|
PROJECT_ID, DATASET_ID, table_id=TABLE_ID, config=config
|
|
)
|
|
plugin.client = mock.MagicMock()
|
|
monkeypatch.setattr(plugin, "_ensure_schema_exists", mock.Mock())
|
|
monkeypatch.setattr(plugin, "_get_loop_state", mock.AsyncMock())
|
|
monkeypatch.setattr(
|
|
bigquery_agent_analytics_plugin,
|
|
"to_arrow_schema",
|
|
lambda _: pa.schema([pa.field("event_type", pa.string())]),
|
|
)
|
|
monkeypatch.setattr(
|
|
bigquery_agent_analytics_plugin.cloud_storage,
|
|
"Client",
|
|
mock.MagicMock(),
|
|
)
|
|
|
|
try:
|
|
await plugin._lazy_setup()
|
|
|
|
assert plugin._executor is not None
|
|
assert plugin.offloader is not None
|
|
assert plugin.offloader.executor is plugin._executor
|
|
finally:
|
|
await plugin.shutdown()
|
|
|
|
|
|
class TestParserReuse:
|
|
"""Tests that HybridContentParser is reused, not recreated per event."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_parser_instance_is_reused(
|
|
self,
|
|
bq_plugin_inst,
|
|
mock_write_client,
|
|
invocation_context,
|
|
):
|
|
"""The same parser instance should be reused across _log_event calls."""
|
|
parser_after_init = bq_plugin_inst.parser
|
|
assert parser_after_init is not None
|
|
|
|
bigquery_agent_analytics_plugin.TraceManager.push_span(invocation_context)
|
|
await bq_plugin_inst.on_user_message_callback(
|
|
invocation_context=invocation_context,
|
|
user_message=types.Content(parts=[types.Part(text="Hello")]),
|
|
)
|
|
await bq_plugin_inst.flush()
|
|
|
|
# Parser should be the same instance, not a new one
|
|
assert bq_plugin_inst.parser is parser_after_init
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_parser_identity_not_mutated_per_call(
|
|
self,
|
|
bq_plugin_inst,
|
|
mock_write_client,
|
|
invocation_context,
|
|
dummy_arrow_schema,
|
|
):
|
|
"""_log_event must NOT store request identity on the shared parser.
|
|
|
|
trace_id/span_id are passed per parse() call: mutating the
|
|
shared instance let a concurrent event's await resume with another
|
|
event's identity and overwrite its GCS objects.
|
|
"""
|
|
parser = bq_plugin_inst.parser
|
|
original_trace_id = parser.trace_id
|
|
original_span_id = parser.span_id
|
|
|
|
bigquery_agent_analytics_plugin.TraceManager.push_span(invocation_context)
|
|
await bq_plugin_inst.on_user_message_callback(
|
|
invocation_context=invocation_context,
|
|
user_message=types.Content(parts=[types.Part(text="Test")]),
|
|
)
|
|
await bq_plugin_inst.flush()
|
|
|
|
# The shared parser's constructor-time fields are untouched; identity
|
|
# travelled through the parse() call arguments instead.
|
|
assert parser.trace_id == original_trace_id
|
|
assert parser.span_id == original_span_id
|
|
mock_write_client.append_rows.assert_called_once()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_parser_not_recreated_with_constructor(
|
|
self,
|
|
bq_plugin_inst,
|
|
mock_write_client,
|
|
invocation_context,
|
|
):
|
|
"""HybridContentParser constructor should not be called in
|
|
_log_event."""
|
|
with mock.patch.object(
|
|
bigquery_agent_analytics_plugin,
|
|
"HybridContentParser",
|
|
wraps=bigquery_agent_analytics_plugin.HybridContentParser,
|
|
) as mock_parser_cls:
|
|
bigquery_agent_analytics_plugin.TraceManager.push_span(invocation_context)
|
|
await bq_plugin_inst.on_user_message_callback(
|
|
invocation_context=invocation_context,
|
|
user_message=types.Content(parts=[types.Part(text="Test")]),
|
|
)
|
|
await bq_plugin_inst.flush()
|
|
# Constructor should NOT have been called during _log_event
|
|
mock_parser_cls.assert_not_called()
|
|
|
|
|
|
class TestPropertyAccessors:
|
|
"""Tests that properties work correctly after __getattribute__ removal."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def testbatch_processorerty_returns_processor(self, bq_plugin_inst):
|
|
"""batch_processor property should return the processor for the
|
|
current loop."""
|
|
bp = bq_plugin_inst.batch_processor
|
|
assert bp is not None
|
|
assert isinstance(bp, bigquery_agent_analytics_plugin.BatchProcessor)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_write_client_property_returns_client(self, bq_plugin_inst):
|
|
"""write_client property should return the client for the current
|
|
loop."""
|
|
wc = bq_plugin_inst.write_client
|
|
assert wc is not None
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_write_stream_property_returns_stream(self, bq_plugin_inst):
|
|
"""write_stream property should return the stream name."""
|
|
ws = bq_plugin_inst.write_stream
|
|
assert ws is not None
|
|
assert ws == DEFAULT_STREAM_NAME
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_properties_return_none_when_no_loop_state(self):
|
|
"""Properties should return None when no state exists for the
|
|
current loop."""
|
|
plugin = bigquery_agent_analytics_plugin.BigQueryAgentAnalyticsPlugin(
|
|
project_id=PROJECT_ID,
|
|
dataset_id=DATASET_ID,
|
|
table_id=TABLE_ID,
|
|
)
|
|
assert plugin.batch_processor is None
|
|
assert plugin.write_client is None
|
|
assert plugin.write_stream is None
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_regular_attributes_still_accessible(self, bq_plugin_inst):
|
|
"""Regular instance attributes should still be accessible."""
|
|
assert bq_plugin_inst.project_id == PROJECT_ID
|
|
assert bq_plugin_inst.dataset_id == DATASET_ID
|
|
assert bq_plugin_inst.table_id == TABLE_ID
|
|
assert bq_plugin_inst.config is not None
|
|
assert bq_plugin_inst._started is True
|
|
|
|
def test_properties_without_running_loop(self):
|
|
"""Properties should return None when no event loop is running."""
|
|
plugin = bigquery_agent_analytics_plugin.BigQueryAgentAnalyticsPlugin(
|
|
project_id=PROJECT_ID,
|
|
dataset_id=DATASET_ID,
|
|
table_id=TABLE_ID,
|
|
)
|
|
# No running loop → should return None, not crash
|
|
assert plugin.batch_processor is None
|
|
assert plugin.write_client is None
|
|
assert plugin.write_stream is None
|
|
|
|
|
|
class TestUnifiedSpanRecords:
|
|
"""Tests for the unified _SpanRecord-based TraceManager."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_push_pop_keeps_stacks_in_sync(self, callback_context):
|
|
"""Push and pop should always leave the records stack consistent."""
|
|
TM = bigquery_agent_analytics_plugin.TraceManager
|
|
TM.init_trace(callback_context)
|
|
|
|
span_id_1 = TM.push_span(callback_context, "span-1")
|
|
span_id_2 = TM.push_span(callback_context, "span-2")
|
|
|
|
# Both should be on the stack
|
|
assert TM.get_current_span_id() == span_id_2
|
|
current, parent = TM.get_current_span_and_parent()
|
|
assert current == span_id_2
|
|
assert parent == span_id_1
|
|
|
|
# Pop span-2
|
|
popped_id, duration = TM.pop_span()
|
|
assert popped_id == span_id_2
|
|
assert duration is not None
|
|
assert TM.get_current_span_id() == span_id_1
|
|
|
|
# Pop span-1
|
|
popped_id, _ = TM.pop_span()
|
|
assert popped_id == span_id_1
|
|
assert TM.get_current_span_id() is None
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_pop_empty_stack_returns_none(self, callback_context):
|
|
"""Popping an empty stack should return (None, None)."""
|
|
TM = bigquery_agent_analytics_plugin.TraceManager
|
|
TM.init_trace(callback_context)
|
|
|
|
span_id, duration = TM.pop_span()
|
|
assert span_id is None
|
|
assert duration is None
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_first_token_time_stored_in_record(self, callback_context):
|
|
"""first_token_time should be stored on the span record."""
|
|
TM = bigquery_agent_analytics_plugin.TraceManager
|
|
TM.init_trace(callback_context)
|
|
|
|
span_id = TM.push_span(callback_context, "llm-span")
|
|
|
|
# No first token yet
|
|
assert TM.get_first_token_time(span_id) is None
|
|
|
|
# Record first token
|
|
assert TM.record_first_token(span_id) is True
|
|
ftt = TM.get_first_token_time(span_id)
|
|
assert ftt is not None
|
|
|
|
# Second call should return False (already recorded)
|
|
assert TM.record_first_token(span_id) is False
|
|
|
|
# Clean up
|
|
TM.pop_span()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_start_time_accessible_by_span_id(self, callback_context):
|
|
"""get_start_time should find the span by ID in the records."""
|
|
TM = bigquery_agent_analytics_plugin.TraceManager
|
|
TM.init_trace(callback_context)
|
|
|
|
span_id = TM.push_span(callback_context, "timed-span")
|
|
start = TM.get_start_time(span_id)
|
|
assert start is not None
|
|
assert start > 0
|
|
|
|
TM.pop_span()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_attach_current_span_does_not_own(self, callback_context):
|
|
"""attach_current_span should not end the span on pop."""
|
|
TM = bigquery_agent_analytics_plugin.TraceManager
|
|
TM.init_trace(callback_context)
|
|
|
|
mock_span = mock.Mock()
|
|
mock_ctx = mock.Mock()
|
|
mock_ctx.is_valid = False
|
|
mock_span.get_span_context.return_value = mock_ctx
|
|
|
|
with mock.patch(
|
|
"opentelemetry.trace.get_current_span", return_value=mock_span
|
|
):
|
|
span_id = TM.attach_current_span(callback_context)
|
|
assert span_id is not None
|
|
|
|
TM.pop_span()
|
|
# Should NOT have called span.end() since we don't own it
|
|
mock_span.end.assert_not_called()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_concurrent_tasks_have_isolated_stacks(self, callback_context):
|
|
"""Concurrent async tasks should have isolated span stacks."""
|
|
TM = bigquery_agent_analytics_plugin.TraceManager
|
|
TM.init_trace(callback_context)
|
|
|
|
async def task_a():
|
|
s = TM.push_span(callback_context, "task-a")
|
|
await asyncio.sleep(0.02)
|
|
assert TM.get_current_span_id() == s
|
|
TM.pop_span()
|
|
return s
|
|
|
|
async def task_b():
|
|
s = TM.push_span(callback_context, "task-b")
|
|
await asyncio.sleep(0.02)
|
|
assert TM.get_current_span_id() == s
|
|
TM.pop_span()
|
|
return s
|
|
|
|
results = await asyncio.gather(task_a(), task_b())
|
|
assert results[0] != results[1]
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_pop_cleans_up_record_completely(self, callback_context):
|
|
"""After pop, the record should be fully removed from the stack."""
|
|
TM = bigquery_agent_analytics_plugin.TraceManager
|
|
TM.init_trace(callback_context)
|
|
|
|
span_id = TM.push_span(callback_context, "temp-span")
|
|
|
|
# Record is on the stack
|
|
assert TM.get_current_span_id() == span_id
|
|
assert TM.get_start_time(span_id) is not None
|
|
|
|
TM.pop_span()
|
|
|
|
# Record is gone
|
|
assert TM.get_current_span_id() is None
|
|
assert TM.get_start_time(span_id) is None
|
|
assert TM.get_first_token_time(span_id) is None
|
|
|
|
|
|
class TestLoopStateValidation:
|
|
"""Tests for loop state validation and stale loop cleanup."""
|
|
|
|
def _make_plugin(self):
|
|
"""Creates a plugin instance without starting it."""
|
|
return bigquery_agent_analytics_plugin.BigQueryAgentAnalyticsPlugin(
|
|
project_id=PROJECT_ID,
|
|
dataset_id=DATASET_ID,
|
|
table_id=TABLE_ID,
|
|
)
|
|
|
|
def _make_loop_state(self):
|
|
"""Creates a mock _LoopState with batch_processor and write_client."""
|
|
state = mock.MagicMock()
|
|
state.batch_processor = mock.create_autospec(
|
|
bigquery_agent_analytics_plugin.BatchProcessor,
|
|
instance=True,
|
|
spec_set=True,
|
|
)
|
|
state.batch_processor.get_drop_stats.return_value = {}
|
|
state.write_client = mock.MagicMock()
|
|
return state
|
|
|
|
def test_cleanup_stale_loop_states_removes_closed_loops(self):
|
|
"""Closed loops should be removed from _loop_state_by_loop."""
|
|
plugin = self._make_plugin()
|
|
|
|
closed_loop = mock.MagicMock(spec=asyncio.AbstractEventLoop)
|
|
closed_loop.is_closed.return_value = True
|
|
|
|
plugin._loop_state_by_loop[closed_loop] = self._make_loop_state()
|
|
|
|
plugin._cleanup_stale_loop_states()
|
|
|
|
assert closed_loop not in plugin._loop_state_by_loop
|
|
|
|
def test_cleanup_stale_loop_states_keeps_open_loops(self):
|
|
"""Open loops should not be removed from _loop_state_by_loop."""
|
|
plugin = self._make_plugin()
|
|
|
|
open_loop = mock.MagicMock(spec=asyncio.AbstractEventLoop)
|
|
open_loop.is_closed.return_value = False
|
|
|
|
plugin._loop_state_by_loop[open_loop] = self._make_loop_state()
|
|
|
|
plugin._cleanup_stale_loop_states()
|
|
|
|
assert open_loop in plugin._loop_state_by_loop
|
|
|
|
def test_cleanup_removes_only_closed_loops(self):
|
|
"""Only closed loops should be removed; open ones stay."""
|
|
plugin = self._make_plugin()
|
|
|
|
open_loop = mock.MagicMock(spec=asyncio.AbstractEventLoop)
|
|
open_loop.is_closed.return_value = False
|
|
closed_loop = mock.MagicMock(spec=asyncio.AbstractEventLoop)
|
|
closed_loop.is_closed.return_value = True
|
|
|
|
plugin._loop_state_by_loop[open_loop] = self._make_loop_state()
|
|
plugin._loop_state_by_loop[closed_loop] = self._make_loop_state()
|
|
|
|
plugin._cleanup_stale_loop_states()
|
|
|
|
assert open_loop in plugin._loop_state_by_loop
|
|
assert closed_loop not in plugin._loop_state_by_loop
|
|
|
|
@pytest.mark.asyncio
|
|
async def testbatch_processor_returns_processor_for_open_loop(
|
|
self,
|
|
):
|
|
"""batch_processor returns processor for the current loop."""
|
|
plugin = self._make_plugin()
|
|
|
|
loop = asyncio.get_running_loop()
|
|
state = self._make_loop_state()
|
|
plugin._loop_state_by_loop[loop] = state
|
|
|
|
assert plugin.batch_processor is state.batch_processor
|
|
|
|
# Clean up
|
|
del plugin._loop_state_by_loop[loop]
|
|
|
|
@pytest.mark.asyncio
|
|
async def testbatch_processor_cleans_closed_loop_entry(self):
|
|
"""Accessing batch_processor cleans up closed loop entries."""
|
|
plugin = self._make_plugin()
|
|
|
|
closed_loop = mock.MagicMock(spec=asyncio.AbstractEventLoop)
|
|
closed_loop.is_closed.return_value = True
|
|
plugin._loop_state_by_loop[closed_loop] = self._make_loop_state()
|
|
|
|
# Accessing the prop should clean up the closed loop entry
|
|
_ = plugin.batch_processor
|
|
assert closed_loop not in plugin._loop_state_by_loop
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_flush_cleans_stale_states(self):
|
|
"""flush() should clean up stale loop states before flushing."""
|
|
plugin = self._make_plugin()
|
|
|
|
closed_loop = mock.MagicMock(spec=asyncio.AbstractEventLoop)
|
|
closed_loop.is_closed.return_value = True
|
|
plugin._loop_state_by_loop[closed_loop] = self._make_loop_state()
|
|
|
|
await plugin.flush()
|
|
|
|
assert closed_loop not in plugin._loop_state_by_loop
|
|
|
|
|
|
class TestAtexitCleanup:
|
|
"""Tests for the simplified _atexit_cleanup static method."""
|
|
|
|
def _make_batch_processor(self, queue_items=0):
|
|
bp = mock.MagicMock()
|
|
bp._shutdown = False
|
|
q = asyncio.Queue()
|
|
for i in range(queue_items):
|
|
q.put_nowait({"event": i})
|
|
bp._queue = q
|
|
return bp
|
|
|
|
def test_skips_none_processor(self):
|
|
"""Should return immediately when batch_processor is None."""
|
|
bigquery_agent_analytics_plugin.BigQueryAgentAnalyticsPlugin._atexit_cleanup(
|
|
None
|
|
)
|
|
|
|
def test_skips_already_shutdown(self):
|
|
"""Should return immediately when batch_processor._shutdown is True."""
|
|
bp = self._make_batch_processor()
|
|
bp._shutdown = True
|
|
bigquery_agent_analytics_plugin.BigQueryAgentAnalyticsPlugin._atexit_cleanup(
|
|
bp
|
|
)
|
|
|
|
def test_skips_reference_error(self):
|
|
"""Should handle ReferenceError from weakref'd processor."""
|
|
bp = mock.MagicMock()
|
|
type(bp)._shutdown = mock.PropertyMock(side_effect=ReferenceError)
|
|
bigquery_agent_analytics_plugin.BigQueryAgentAnalyticsPlugin._atexit_cleanup(
|
|
bp
|
|
)
|
|
|
|
def test_empty_queue_no_warning(self):
|
|
"""Should not warn when queue is empty."""
|
|
bp = self._make_batch_processor(queue_items=0)
|
|
with mock.patch.object(
|
|
bigquery_agent_analytics_plugin.logger, "warning"
|
|
) as mock_warn:
|
|
bigquery_agent_analytics_plugin.BigQueryAgentAnalyticsPlugin._atexit_cleanup(
|
|
bp
|
|
)
|
|
mock_warn.assert_not_called()
|
|
|
|
def test_remaining_items_logs_warning(self):
|
|
"""Should drain queue and log warning with count of lost items."""
|
|
bp = self._make_batch_processor(queue_items=3)
|
|
with mock.patch.object(
|
|
bigquery_agent_analytics_plugin.logger, "warning"
|
|
) as mock_warn:
|
|
bigquery_agent_analytics_plugin.BigQueryAgentAnalyticsPlugin._atexit_cleanup(
|
|
bp
|
|
)
|
|
mock_warn.assert_called_once()
|
|
# Verify the warning mentions the count
|
|
call_args = mock_warn.call_args
|
|
assert "3" in str(call_args)
|
|
|
|
def test_queue_is_drained(self):
|
|
"""Should drain all items from the queue."""
|
|
bp = self._make_batch_processor(queue_items=5)
|
|
bigquery_agent_analytics_plugin.BigQueryAgentAnalyticsPlugin._atexit_cleanup(
|
|
bp
|
|
)
|
|
assert bp._queue.empty()
|
|
|
|
|
|
class TestDuplicateLabels:
|
|
"""Tests that labels in before_model_callback are set exactly once."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_labels_set_when_present(
|
|
self,
|
|
bq_plugin_inst,
|
|
mock_write_client,
|
|
callback_context,
|
|
dummy_arrow_schema,
|
|
):
|
|
"""Labels should appear in attributes when config has them."""
|
|
llm_request = llm_request_lib.LlmRequest(
|
|
model="gemini-pro",
|
|
config=types.GenerateContentConfig(
|
|
labels={"env": "test"},
|
|
),
|
|
contents=[types.Content(role="user", parts=[types.Part(text="hi")])],
|
|
)
|
|
bigquery_agent_analytics_plugin.TraceManager.push_span(callback_context)
|
|
await bq_plugin_inst.before_model_callback(
|
|
callback_context=callback_context, llm_request=llm_request
|
|
)
|
|
await bq_plugin_inst.flush()
|
|
log_entry = await _get_captured_event_dict_async(
|
|
mock_write_client, dummy_arrow_schema
|
|
)
|
|
attributes = json.loads(log_entry["attributes"])
|
|
assert attributes["labels"] == {"env": "test"}
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_labels_absent_when_none(
|
|
self,
|
|
bq_plugin_inst,
|
|
mock_write_client,
|
|
callback_context,
|
|
dummy_arrow_schema,
|
|
):
|
|
"""Labels should not appear in attributes when config.labels is None."""
|
|
llm_request = llm_request_lib.LlmRequest(
|
|
model="gemini-pro",
|
|
config=types.GenerateContentConfig(
|
|
temperature=0.5,
|
|
),
|
|
contents=[types.Content(role="user", parts=[types.Part(text="hi")])],
|
|
)
|
|
bigquery_agent_analytics_plugin.TraceManager.push_span(callback_context)
|
|
await bq_plugin_inst.before_model_callback(
|
|
callback_context=callback_context, llm_request=llm_request
|
|
)
|
|
await bq_plugin_inst.flush()
|
|
log_entry = await _get_captured_event_dict_async(
|
|
mock_write_client, dummy_arrow_schema
|
|
)
|
|
attributes = json.loads(log_entry["attributes"])
|
|
assert "labels" not in attributes
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_no_config_no_labels(
|
|
self,
|
|
bq_plugin_inst,
|
|
mock_write_client,
|
|
callback_context,
|
|
dummy_arrow_schema,
|
|
):
|
|
"""Labels should not appear when llm_request has no config."""
|
|
llm_request = llm_request_lib.LlmRequest(
|
|
model="gemini-pro",
|
|
contents=[types.Content(role="user", parts=[types.Part(text="hi")])],
|
|
)
|
|
bigquery_agent_analytics_plugin.TraceManager.push_span(callback_context)
|
|
await bq_plugin_inst.before_model_callback(
|
|
callback_context=callback_context, llm_request=llm_request
|
|
)
|
|
await bq_plugin_inst.flush()
|
|
log_entry = await _get_captured_event_dict_async(
|
|
mock_write_client, dummy_arrow_schema
|
|
)
|
|
attributes = json.loads(log_entry["attributes"])
|
|
assert "labels" not in attributes
|
|
|
|
|
|
class TestResolveIds:
|
|
"""Tests for the _resolve_ids static helper."""
|
|
|
|
def _resolve(self, ed, callback_context):
|
|
return bigquery_agent_analytics_plugin.BigQueryAgentAnalyticsPlugin._resolve_ids(
|
|
ed, callback_context
|
|
)
|
|
|
|
def test_uses_trace_manager_defaults(self, callback_context):
|
|
"""Should use TraceManager values when no overrides and no ambient."""
|
|
ed = bigquery_agent_analytics_plugin.EventData(
|
|
extra_attributes={"some_key": "value"}
|
|
)
|
|
with (
|
|
mock.patch.object(
|
|
bigquery_agent_analytics_plugin.TraceManager,
|
|
"get_current_span_and_parent",
|
|
return_value=("span-1", "parent-1"),
|
|
),
|
|
mock.patch.object(
|
|
bigquery_agent_analytics_plugin.TraceManager,
|
|
"get_trace_id",
|
|
return_value="trace-1",
|
|
),
|
|
):
|
|
trace_id, span_id, parent_id = self._resolve(ed, callback_context)
|
|
assert trace_id == "trace-1"
|
|
assert span_id == "span-1"
|
|
assert parent_id == "parent-1"
|
|
|
|
def test_span_id_override(self, callback_context):
|
|
"""Should use span_id_override from EventData."""
|
|
ed = bigquery_agent_analytics_plugin.EventData(
|
|
span_id_override="custom-span"
|
|
)
|
|
with (
|
|
mock.patch.object(
|
|
bigquery_agent_analytics_plugin.TraceManager,
|
|
"get_current_span_and_parent",
|
|
return_value=("span-1", "parent-1"),
|
|
),
|
|
mock.patch.object(
|
|
bigquery_agent_analytics_plugin.TraceManager,
|
|
"get_trace_id",
|
|
return_value="trace-1",
|
|
),
|
|
):
|
|
trace_id, span_id, parent_id = self._resolve(ed, callback_context)
|
|
assert span_id == "custom-span"
|
|
assert parent_id == "parent-1"
|
|
|
|
def test_parent_span_id_override(self, callback_context):
|
|
"""Should use parent_span_id_override from EventData."""
|
|
ed = bigquery_agent_analytics_plugin.EventData(
|
|
parent_span_id_override="custom-parent"
|
|
)
|
|
with (
|
|
mock.patch.object(
|
|
bigquery_agent_analytics_plugin.TraceManager,
|
|
"get_current_span_and_parent",
|
|
return_value=("span-1", "parent-1"),
|
|
),
|
|
mock.patch.object(
|
|
bigquery_agent_analytics_plugin.TraceManager,
|
|
"get_trace_id",
|
|
return_value="trace-1",
|
|
),
|
|
):
|
|
trace_id, span_id, parent_id = self._resolve(ed, callback_context)
|
|
assert span_id == "span-1"
|
|
assert parent_id == "custom-parent"
|
|
|
|
def test_none_override_keeps_default(self, callback_context):
|
|
"""None overrides should keep the TraceManager defaults."""
|
|
ed = bigquery_agent_analytics_plugin.EventData(
|
|
span_id_override=None, parent_span_id_override=None
|
|
)
|
|
with (
|
|
mock.patch.object(
|
|
bigquery_agent_analytics_plugin.TraceManager,
|
|
"get_current_span_and_parent",
|
|
return_value=("span-1", "parent-1"),
|
|
),
|
|
mock.patch.object(
|
|
bigquery_agent_analytics_plugin.TraceManager,
|
|
"get_trace_id",
|
|
return_value="trace-1",
|
|
),
|
|
):
|
|
trace_id, span_id, parent_id = self._resolve(ed, callback_context)
|
|
assert span_id == "span-1"
|
|
assert parent_id == "parent-1"
|
|
|
|
def test_ambient_provides_trace_id_only_when_stack_present(
|
|
self, callback_context
|
|
):
|
|
"""Plugin stack owns span_id/parent; ambient only provides trace_id."""
|
|
from opentelemetry.sdk.trace import TracerProvider as SdkProvider
|
|
from opentelemetry.sdk.trace.export import SimpleSpanProcessor
|
|
from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter
|
|
|
|
provider = SdkProvider()
|
|
provider.add_span_processor(SimpleSpanProcessor(InMemorySpanExporter()))
|
|
real_tracer = provider.get_tracer("test")
|
|
|
|
ed = bigquery_agent_analytics_plugin.EventData()
|
|
|
|
with real_tracer.start_as_current_span("invocation") as parent_span:
|
|
with real_tracer.start_as_current_span("agent") as agent_span:
|
|
ambient_ctx = agent_span.get_span_context()
|
|
expected_trace = format(ambient_ctx.trace_id, "032x")
|
|
|
|
# Plugin stack has spans — these should win for span/parent.
|
|
with (
|
|
mock.patch.object(
|
|
bigquery_agent_analytics_plugin.TraceManager,
|
|
"get_current_span_and_parent",
|
|
return_value=("plugin-span", "plugin-parent"),
|
|
),
|
|
mock.patch.object(
|
|
bigquery_agent_analytics_plugin.TraceManager,
|
|
"get_trace_id",
|
|
return_value="plugin-trace",
|
|
),
|
|
):
|
|
trace_id, span_id, parent_id = self._resolve(ed, callback_context)
|
|
|
|
# trace_id comes from ambient OTel.
|
|
assert trace_id == expected_trace
|
|
# span_id and parent_span_id come from plugin stack.
|
|
assert span_id == "plugin-span"
|
|
assert parent_id == "plugin-parent"
|
|
provider.shutdown()
|
|
|
|
def test_ambient_fallback_when_no_plugin_stack(self, callback_context):
|
|
"""Ambient OTel provides span_id/parent when plugin stack is empty."""
|
|
from opentelemetry.sdk.trace import TracerProvider as SdkProvider
|
|
from opentelemetry.sdk.trace.export import SimpleSpanProcessor
|
|
from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter
|
|
|
|
provider = SdkProvider()
|
|
provider.add_span_processor(SimpleSpanProcessor(InMemorySpanExporter()))
|
|
real_tracer = provider.get_tracer("test")
|
|
|
|
ed = bigquery_agent_analytics_plugin.EventData()
|
|
|
|
with real_tracer.start_as_current_span("invocation") as parent_span:
|
|
with real_tracer.start_as_current_span("agent") as agent_span:
|
|
ambient_ctx = agent_span.get_span_context()
|
|
expected_trace = format(ambient_ctx.trace_id, "032x")
|
|
expected_span = format(ambient_ctx.span_id, "016x")
|
|
expected_parent = format(parent_span.get_span_context().span_id, "016x")
|
|
|
|
# Plugin stack returns None — ambient is the fallback.
|
|
with (
|
|
mock.patch.object(
|
|
bigquery_agent_analytics_plugin.TraceManager,
|
|
"get_current_span_and_parent",
|
|
return_value=(None, None),
|
|
),
|
|
mock.patch.object(
|
|
bigquery_agent_analytics_plugin.TraceManager,
|
|
"get_trace_id",
|
|
return_value=None,
|
|
),
|
|
):
|
|
trace_id, span_id, parent_id = self._resolve(ed, callback_context)
|
|
|
|
assert trace_id == expected_trace
|
|
assert span_id == expected_span
|
|
assert parent_id == expected_parent
|
|
provider.shutdown()
|
|
|
|
def test_override_beats_ambient(self, callback_context):
|
|
"""EventData overrides take priority over ambient OTel span."""
|
|
from opentelemetry.sdk.trace import TracerProvider as SdkProvider
|
|
from opentelemetry.sdk.trace.export import SimpleSpanProcessor
|
|
from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter
|
|
|
|
provider = SdkProvider()
|
|
provider.add_span_processor(SimpleSpanProcessor(InMemorySpanExporter()))
|
|
real_tracer = provider.get_tracer("test")
|
|
|
|
ed = bigquery_agent_analytics_plugin.EventData(
|
|
trace_id_override="forced-trace",
|
|
span_id_override="forced-span",
|
|
parent_span_id_override="forced-parent",
|
|
)
|
|
|
|
with real_tracer.start_as_current_span("invocation"):
|
|
trace_id, span_id, parent_id = self._resolve(ed, callback_context)
|
|
|
|
assert trace_id == "forced-trace"
|
|
assert span_id == "forced-span"
|
|
assert parent_id == "forced-parent"
|
|
provider.shutdown()
|
|
|
|
def test_plugin_stack_wins_over_ambient_root_span(self, callback_context):
|
|
"""Plugin stack span is used even when ambient root span exists."""
|
|
from opentelemetry.sdk.trace import TracerProvider as SdkProvider
|
|
from opentelemetry.sdk.trace.export import SimpleSpanProcessor
|
|
from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter
|
|
|
|
provider = SdkProvider()
|
|
provider.add_span_processor(SimpleSpanProcessor(InMemorySpanExporter()))
|
|
real_tracer = provider.get_tracer("test")
|
|
|
|
# Seed the plugin stack with a span.
|
|
bigquery_agent_analytics_plugin._span_records_ctx.set(None)
|
|
bigquery_agent_analytics_plugin.TraceManager.push_span(
|
|
callback_context, "plugin-child"
|
|
)
|
|
|
|
# Capture the plugin span_id that was pushed.
|
|
plugin_span_id, _ = (
|
|
bigquery_agent_analytics_plugin.TraceManager.get_current_span_and_parent()
|
|
)
|
|
|
|
ed = bigquery_agent_analytics_plugin.EventData()
|
|
|
|
# Single root ambient span — no parent.
|
|
with real_tracer.start_as_current_span("root_invocation") as root:
|
|
trace_id, span_id, parent_id = self._resolve(ed, callback_context)
|
|
ambient_trace = format(root.get_span_context().trace_id, "032x")
|
|
|
|
# trace_id comes from ambient.
|
|
assert trace_id == ambient_trace
|
|
# span_id comes from plugin stack, not ambient.
|
|
assert span_id == plugin_span_id
|
|
# parent is None — only one span in plugin stack.
|
|
assert parent_id is None
|
|
|
|
# Cleanup
|
|
bigquery_agent_analytics_plugin.TraceManager.pop_span()
|
|
provider.shutdown()
|
|
|
|
def test_ambient_root_fallback_no_self_parent(self, callback_context):
|
|
"""Ambient root span fallback must not produce self-parent."""
|
|
from opentelemetry.sdk.trace import TracerProvider as SdkProvider
|
|
from opentelemetry.sdk.trace.export import SimpleSpanProcessor
|
|
from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter
|
|
|
|
provider = SdkProvider()
|
|
provider.add_span_processor(SimpleSpanProcessor(InMemorySpanExporter()))
|
|
real_tracer = provider.get_tracer("test")
|
|
|
|
ed = bigquery_agent_analytics_plugin.EventData()
|
|
|
|
# Plugin stack empty — ambient provides the fallback.
|
|
with (
|
|
mock.patch.object(
|
|
bigquery_agent_analytics_plugin.TraceManager,
|
|
"get_current_span_and_parent",
|
|
return_value=(None, None),
|
|
),
|
|
mock.patch.object(
|
|
bigquery_agent_analytics_plugin.TraceManager,
|
|
"get_trace_id",
|
|
return_value=None,
|
|
),
|
|
):
|
|
with real_tracer.start_as_current_span("root") as root:
|
|
trace_id, span_id, parent_id = self._resolve(ed, callback_context)
|
|
root_span_id = format(root.get_span_context().span_id, "016x")
|
|
|
|
assert span_id == root_span_id
|
|
assert parent_id is None
|
|
provider.shutdown()
|
|
|
|
def test_plugin_stack_pairs_starting_completed(self, callback_context):
|
|
"""STARTING/COMPLETED pairing uses plugin stack, not ambient.
|
|
|
|
Post-pop callbacks now always pass explicit overrides from the
|
|
plugin stack. The plugin stack span_id is used for both events
|
|
regardless of ambient OTel state.
|
|
"""
|
|
from opentelemetry.sdk.trace import TracerProvider as SdkProvider
|
|
from opentelemetry.sdk.trace.export import SimpleSpanProcessor
|
|
from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter
|
|
|
|
provider = SdkProvider()
|
|
provider.add_span_processor(SimpleSpanProcessor(InMemorySpanExporter()))
|
|
real_tracer = provider.get_tracer("test")
|
|
|
|
with real_tracer.start_as_current_span("invoke_agent"):
|
|
# Simulate STARTING: plugin stack provides span_id.
|
|
with (
|
|
mock.patch.object(
|
|
bigquery_agent_analytics_plugin.TraceManager,
|
|
"get_current_span_and_parent",
|
|
return_value=("plugin-agent", "plugin-inv"),
|
|
),
|
|
mock.patch.object(
|
|
bigquery_agent_analytics_plugin.TraceManager,
|
|
"get_trace_id",
|
|
return_value="plugin-trace",
|
|
),
|
|
):
|
|
ed_starting = bigquery_agent_analytics_plugin.EventData()
|
|
_, span_starting, _ = self._resolve(ed_starting, callback_context)
|
|
|
|
# Simulate COMPLETED: explicit override from popped span.
|
|
ed_completed = bigquery_agent_analytics_plugin.EventData(
|
|
span_id_override="plugin-agent",
|
|
parent_span_id_override="plugin-inv",
|
|
latency_ms=42,
|
|
)
|
|
_, span_completed, _ = self._resolve(ed_completed, callback_context)
|
|
|
|
assert span_starting == "plugin-agent"
|
|
assert span_completed == "plugin-agent"
|
|
assert span_starting == span_completed
|
|
|
|
provider.shutdown()
|
|
|
|
|
|
class TestExtractLatency:
|
|
"""Tests for the _extract_latency static helper."""
|
|
|
|
def test_no_latency_returns_none(self):
|
|
"""Should return None when no latency fields present."""
|
|
ed = bigquery_agent_analytics_plugin.EventData(
|
|
extra_attributes={"other": "val"}
|
|
)
|
|
result = bigquery_agent_analytics_plugin.BigQueryAgentAnalyticsPlugin._extract_latency(
|
|
ed
|
|
)
|
|
assert result is None
|
|
|
|
def test_total_latency_only(self):
|
|
"""Should extract latency_ms into total_ms."""
|
|
ed = bigquery_agent_analytics_plugin.EventData(latency_ms=42.5)
|
|
result = bigquery_agent_analytics_plugin.BigQueryAgentAnalyticsPlugin._extract_latency(
|
|
ed
|
|
)
|
|
assert result == {"total_ms": 42.5}
|
|
|
|
def test_tfft_only(self):
|
|
"""Should extract time_to_first_token_ms."""
|
|
ed = bigquery_agent_analytics_plugin.EventData(time_to_first_token_ms=10.0)
|
|
result = bigquery_agent_analytics_plugin.BigQueryAgentAnalyticsPlugin._extract_latency(
|
|
ed
|
|
)
|
|
assert result == {"time_to_first_token_ms": 10.0}
|
|
|
|
def test_both_latencies(self):
|
|
"""Should extract both latency fields."""
|
|
ed = bigquery_agent_analytics_plugin.EventData(
|
|
latency_ms=100, time_to_first_token_ms=20
|
|
)
|
|
result = bigquery_agent_analytics_plugin.BigQueryAgentAnalyticsPlugin._extract_latency(
|
|
ed
|
|
)
|
|
assert result == {"total_ms": 100, "time_to_first_token_ms": 20}
|
|
|
|
|
|
class TestEnrichAttributes:
|
|
"""Tests for the _enrich_attributes helper."""
|
|
|
|
def _make_plugin(self):
|
|
with (
|
|
mock.patch(
|
|
"google.auth.default",
|
|
return_value=(mock.Mock(), PROJECT_ID),
|
|
),
|
|
mock.patch(
|
|
"google.cloud.bigquery.Client",
|
|
),
|
|
):
|
|
plugin = bigquery_agent_analytics_plugin.BigQueryAgentAnalyticsPlugin(
|
|
project_id=PROJECT_ID,
|
|
dataset_id=DATASET_ID,
|
|
)
|
|
plugin.config.max_content_length = 10000
|
|
plugin.config.log_session_metadata = False
|
|
plugin.config.custom_tags = None
|
|
return plugin
|
|
|
|
def _make_callback_context(self):
|
|
ctx = mock.MagicMock()
|
|
session = mock.MagicMock()
|
|
session.id = "sess-001"
|
|
session.app_name = "test-app"
|
|
session.user_id = "user-001"
|
|
session.state = {"env": "test"}
|
|
ctx._invocation_context.session = session
|
|
return ctx
|
|
|
|
def test_adds_root_agent_name(self):
|
|
"""Should always add root_agent_name."""
|
|
plugin = self._make_plugin()
|
|
ed = bigquery_agent_analytics_plugin.EventData()
|
|
with mock.patch.object(
|
|
bigquery_agent_analytics_plugin.TraceManager,
|
|
"get_root_agent_name",
|
|
return_value="my-agent",
|
|
):
|
|
attrs = plugin._enrich_attributes(ed, self._make_callback_context())
|
|
assert attrs["root_agent_name"] == "my-agent"
|
|
|
|
def test_includes_model(self):
|
|
"""Should include model from EventData."""
|
|
plugin = self._make_plugin()
|
|
ed = bigquery_agent_analytics_plugin.EventData(model="gemini-pro")
|
|
with mock.patch.object(
|
|
bigquery_agent_analytics_plugin.TraceManager,
|
|
"get_root_agent_name",
|
|
return_value="agent",
|
|
):
|
|
attrs = plugin._enrich_attributes(ed, self._make_callback_context())
|
|
assert attrs["model"] == "gemini-pro"
|
|
|
|
def test_session_metadata_when_enabled(self):
|
|
"""Should add session_metadata when log_session_metadata is True."""
|
|
plugin = self._make_plugin()
|
|
plugin.config.log_session_metadata = True
|
|
ctx = self._make_callback_context()
|
|
ed = bigquery_agent_analytics_plugin.EventData()
|
|
with mock.patch.object(
|
|
bigquery_agent_analytics_plugin.TraceManager,
|
|
"get_root_agent_name",
|
|
return_value="agent",
|
|
):
|
|
attrs = plugin._enrich_attributes(ed, ctx)
|
|
meta = attrs["session_metadata"]
|
|
assert meta["session_id"] == "sess-001"
|
|
assert meta["app_name"] == "test-app"
|
|
assert meta["user_id"] == "user-001"
|
|
assert meta["state"] == {"env": "test"}
|
|
|
|
def test_session_metadata_when_disabled(self):
|
|
"""Should not add session_metadata when log_session_metadata is False."""
|
|
plugin = self._make_plugin()
|
|
plugin.config.log_session_metadata = False
|
|
ed = bigquery_agent_analytics_plugin.EventData()
|
|
with mock.patch.object(
|
|
bigquery_agent_analytics_plugin.TraceManager,
|
|
"get_root_agent_name",
|
|
return_value="agent",
|
|
):
|
|
attrs = plugin._enrich_attributes(ed, self._make_callback_context())
|
|
assert "session_metadata" not in attrs
|
|
|
|
def test_custom_tags_added(self):
|
|
"""Should add custom_tags when configured."""
|
|
plugin = self._make_plugin()
|
|
plugin.config.custom_tags = {"team": "infra"}
|
|
ed = bigquery_agent_analytics_plugin.EventData()
|
|
with mock.patch.object(
|
|
bigquery_agent_analytics_plugin.TraceManager,
|
|
"get_root_agent_name",
|
|
return_value="agent",
|
|
):
|
|
attrs = plugin._enrich_attributes(ed, self._make_callback_context())
|
|
assert attrs["custom_tags"] == {"team": "infra"}
|
|
|
|
def test_usage_metadata_truncated(self):
|
|
"""Should smart-truncate usage_metadata."""
|
|
plugin = self._make_plugin()
|
|
ed = bigquery_agent_analytics_plugin.EventData(
|
|
usage_metadata={"input_tokens": 100, "output_tokens": 50}
|
|
)
|
|
with mock.patch.object(
|
|
bigquery_agent_analytics_plugin.TraceManager,
|
|
"get_root_agent_name",
|
|
return_value="agent",
|
|
):
|
|
attrs = plugin._enrich_attributes(ed, self._make_callback_context())
|
|
assert attrs["usage_metadata"] == {
|
|
"input_tokens": 100,
|
|
"output_tokens": 50,
|
|
}
|
|
|
|
|
|
class TestMultiSubagentToolLogging:
|
|
"""Tests that tool events from different subagents are attributed correctly.
|
|
|
|
Covers:
|
|
- Tool calls from different subagents have the correct `agent` field
|
|
- Multi-turn (different invocation_ids, same session) logs correctly
|
|
- Full callback sequence across multiple subagents in one turn
|
|
- Span hierarchy is maintained per-subagent
|
|
"""
|
|
|
|
@staticmethod
|
|
def _make_invocation_context(agent_name, session, invocation_id="inv-001"):
|
|
"""Create an InvocationContext with a specific agent name."""
|
|
mock_a = mock.create_autospec(
|
|
base_agent.BaseAgent, instance=True, spec_set=True
|
|
)
|
|
type(mock_a).name = mock.PropertyMock(return_value=agent_name)
|
|
type(mock_a).instruction = mock.PropertyMock(
|
|
return_value=f"{agent_name} instruction"
|
|
)
|
|
mock_session_service = mock.create_autospec(
|
|
base_session_service_lib.BaseSessionService,
|
|
instance=True,
|
|
spec_set=True,
|
|
)
|
|
mock_plugin_manager = mock.create_autospec(
|
|
plugin_manager_lib.PluginManager,
|
|
instance=True,
|
|
spec_set=True,
|
|
)
|
|
return InvocationContext(
|
|
agent=mock_a,
|
|
session=session,
|
|
invocation_id=invocation_id,
|
|
session_service=mock_session_service,
|
|
plugin_manager=mock_plugin_manager,
|
|
)
|
|
|
|
@staticmethod
|
|
def _make_session(session_id="session-multi", user_id="user-multi"):
|
|
mock_s = mock.create_autospec(
|
|
session_lib.Session, instance=True, spec_set=True
|
|
)
|
|
type(mock_s).id = mock.PropertyMock(return_value=session_id)
|
|
type(mock_s).user_id = mock.PropertyMock(return_value=user_id)
|
|
type(mock_s).app_name = mock.PropertyMock(return_value="test_app")
|
|
type(mock_s).state = mock.PropertyMock(return_value={})
|
|
return mock_s
|
|
|
|
@staticmethod
|
|
def _make_tool(name):
|
|
mock_tool = mock.create_autospec(
|
|
base_tool_lib.BaseTool, instance=True, spec_set=True
|
|
)
|
|
type(mock_tool).name = mock.PropertyMock(return_value=name)
|
|
type(mock_tool).description = mock.PropertyMock(
|
|
return_value=f"{name} description"
|
|
)
|
|
return mock_tool
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_tool_calls_attributed_to_correct_subagent(
|
|
self,
|
|
mock_auth_default,
|
|
mock_bq_client,
|
|
mock_write_client,
|
|
mock_to_arrow_schema,
|
|
dummy_arrow_schema,
|
|
mock_asyncio_to_thread,
|
|
):
|
|
"""Tool events from different subagents carry the correct agent name."""
|
|
session = self._make_session()
|
|
|
|
async with managed_plugin(
|
|
PROJECT_ID, DATASET_ID, table_id=TABLE_ID
|
|
) as plugin:
|
|
await plugin._ensure_started()
|
|
mock_write_client.append_rows.reset_mock()
|
|
|
|
# --- Subagent A: schema_explorer calls list_datasets ---
|
|
inv_ctx_a = self._make_invocation_context("schema_explorer", session)
|
|
ctx_a = tool_context_lib.ToolContext(invocation_context=inv_ctx_a)
|
|
tool_a = self._make_tool("list_dataset_ids")
|
|
|
|
bigquery_agent_analytics_plugin.TraceManager.push_span(ctx_a, "tool")
|
|
await plugin.before_tool_callback(
|
|
tool=tool_a,
|
|
tool_args={"project_id": "my-project"},
|
|
tool_context=ctx_a,
|
|
)
|
|
await plugin.flush()
|
|
|
|
# --- Subagent B: image_describer calls describe_this_image ---
|
|
inv_ctx_b = self._make_invocation_context("image_describer", session)
|
|
ctx_b = tool_context_lib.ToolContext(invocation_context=inv_ctx_b)
|
|
tool_b = self._make_tool("describe_this_image")
|
|
|
|
bigquery_agent_analytics_plugin.TraceManager.push_span(ctx_b, "tool")
|
|
await plugin.before_tool_callback(
|
|
tool=tool_b,
|
|
tool_args={"image_uri": "gs://bucket/image.jpg"},
|
|
tool_context=ctx_b,
|
|
)
|
|
await plugin.flush()
|
|
|
|
rows = await _get_captured_rows_async(
|
|
mock_write_client, dummy_arrow_schema
|
|
)
|
|
|
|
assert len(rows) == 2
|
|
|
|
# First row: schema_explorer's tool
|
|
assert rows[0]["event_type"] == "TOOL_STARTING"
|
|
assert rows[0]["agent"] == "schema_explorer"
|
|
content_a = json.loads(rows[0]["content"])
|
|
assert content_a["tool"] == "list_dataset_ids"
|
|
assert content_a["args"] == {"project_id": "my-project"}
|
|
|
|
# Second row: image_describer's tool
|
|
assert rows[1]["event_type"] == "TOOL_STARTING"
|
|
assert rows[1]["agent"] == "image_describer"
|
|
content_b = json.loads(rows[1]["content"])
|
|
assert content_b["tool"] == "describe_this_image"
|
|
assert content_b["args"] == {"image_uri": "gs://bucket/image.jpg"}
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_multi_turn_tool_calls_different_invocations(
|
|
self,
|
|
mock_auth_default,
|
|
mock_bq_client,
|
|
mock_write_client,
|
|
mock_to_arrow_schema,
|
|
dummy_arrow_schema,
|
|
mock_asyncio_to_thread,
|
|
):
|
|
"""Multi-turn: same session, different invocation IDs, tools logged."""
|
|
session = self._make_session()
|
|
|
|
async with managed_plugin(
|
|
PROJECT_ID, DATASET_ID, table_id=TABLE_ID
|
|
) as plugin:
|
|
await plugin._ensure_started()
|
|
mock_write_client.append_rows.reset_mock()
|
|
|
|
# --- Turn 1: schema_explorer calls list_dataset_ids ---
|
|
inv_ctx_1 = self._make_invocation_context(
|
|
"schema_explorer", session, invocation_id="inv-turn1"
|
|
)
|
|
ctx_1 = tool_context_lib.ToolContext(invocation_context=inv_ctx_1)
|
|
tool_1 = self._make_tool("list_dataset_ids")
|
|
|
|
bigquery_agent_analytics_plugin.TraceManager.push_span(ctx_1, "tool")
|
|
await plugin.before_tool_callback(
|
|
tool=tool_1,
|
|
tool_args={"project_id": "proj"},
|
|
tool_context=ctx_1,
|
|
)
|
|
await plugin.flush()
|
|
await plugin.after_tool_callback(
|
|
tool=tool_1,
|
|
tool_args={"project_id": "proj"},
|
|
tool_context=ctx_1,
|
|
result={"datasets": ["ds1", "ds2"]},
|
|
)
|
|
await plugin.flush()
|
|
|
|
# --- Turn 2: query_analyst calls execute_sql ---
|
|
inv_ctx_2 = self._make_invocation_context(
|
|
"query_analyst", session, invocation_id="inv-turn2"
|
|
)
|
|
ctx_2 = tool_context_lib.ToolContext(invocation_context=inv_ctx_2)
|
|
tool_2 = self._make_tool("execute_sql")
|
|
|
|
bigquery_agent_analytics_plugin.TraceManager.push_span(ctx_2, "tool")
|
|
await plugin.before_tool_callback(
|
|
tool=tool_2,
|
|
tool_args={"sql": "SELECT * FROM t"},
|
|
tool_context=ctx_2,
|
|
)
|
|
await plugin.flush()
|
|
await plugin.after_tool_callback(
|
|
tool=tool_2,
|
|
tool_args={"sql": "SELECT * FROM t"},
|
|
tool_context=ctx_2,
|
|
result={"rows": [{"col": "val"}]},
|
|
)
|
|
await plugin.flush()
|
|
|
|
rows = await _get_captured_rows_async(
|
|
mock_write_client, dummy_arrow_schema
|
|
)
|
|
|
|
assert len(rows) == 4
|
|
|
|
# Turn 1: TOOL_STARTING + TOOL_COMPLETED for schema_explorer
|
|
assert rows[0]["event_type"] == "TOOL_STARTING"
|
|
assert rows[0]["agent"] == "schema_explorer"
|
|
assert rows[0]["invocation_id"] == "inv-turn1"
|
|
assert rows[0]["session_id"] == "session-multi"
|
|
|
|
assert rows[1]["event_type"] == "TOOL_COMPLETED"
|
|
assert rows[1]["agent"] == "schema_explorer"
|
|
assert rows[1]["invocation_id"] == "inv-turn1"
|
|
content_1 = json.loads(rows[1]["content"])
|
|
assert content_1["tool"] == "list_dataset_ids"
|
|
assert content_1["result"] == {"datasets": ["ds1", "ds2"]}
|
|
|
|
# Turn 2: TOOL_STARTING + TOOL_COMPLETED for query_analyst
|
|
assert rows[2]["event_type"] == "TOOL_STARTING"
|
|
assert rows[2]["agent"] == "query_analyst"
|
|
assert rows[2]["invocation_id"] == "inv-turn2"
|
|
|
|
assert rows[3]["event_type"] == "TOOL_COMPLETED"
|
|
assert rows[3]["agent"] == "query_analyst"
|
|
assert rows[3]["invocation_id"] == "inv-turn2"
|
|
content_2 = json.loads(rows[3]["content"])
|
|
assert content_2["tool"] == "execute_sql"
|
|
assert content_2["result"] == {"rows": [{"col": "val"}]}
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_full_subagent_callback_sequence(
|
|
self,
|
|
mock_auth_default,
|
|
mock_bq_client,
|
|
mock_write_client,
|
|
mock_to_arrow_schema,
|
|
dummy_arrow_schema,
|
|
mock_asyncio_to_thread,
|
|
):
|
|
"""Full lifecycle: agent_start → LLM → tool → tool_done → LLM → agent_done.
|
|
|
|
Simulates a subagent that makes an LLM call, then a tool call,
|
|
then another LLM call, and completes.
|
|
"""
|
|
session = self._make_session()
|
|
inv_ctx = self._make_invocation_context("schema_explorer", session)
|
|
cb_ctx = CallbackContext(invocation_context=inv_ctx)
|
|
tool_ctx = tool_context_lib.ToolContext(invocation_context=inv_ctx)
|
|
mock_agent = inv_ctx.agent
|
|
tool = self._make_tool("get_table_info")
|
|
|
|
async with managed_plugin(
|
|
PROJECT_ID, DATASET_ID, table_id=TABLE_ID
|
|
) as plugin:
|
|
await plugin._ensure_started()
|
|
mock_write_client.append_rows.reset_mock()
|
|
|
|
# 1. AGENT_STARTING
|
|
await plugin.before_agent_callback(
|
|
agent=mock_agent, callback_context=cb_ctx
|
|
)
|
|
await plugin.flush()
|
|
|
|
# 2. LLM_REQUEST (agent decides to call a tool)
|
|
llm_req = llm_request_lib.LlmRequest(
|
|
model="gemini-2.5-flash",
|
|
contents=[
|
|
types.Content(parts=[types.Part(text="What tables exist?")])
|
|
],
|
|
)
|
|
await plugin.before_model_callback(
|
|
callback_context=cb_ctx, llm_request=llm_req
|
|
)
|
|
await plugin.flush()
|
|
|
|
# 3. LLM_RESPONSE (function call)
|
|
llm_resp = llm_response_lib.LlmResponse(
|
|
content=types.Content(
|
|
parts=[
|
|
types.Part(
|
|
function_call=types.FunctionCall(
|
|
name="get_table_info",
|
|
args={"table": "events"},
|
|
)
|
|
)
|
|
]
|
|
)
|
|
)
|
|
await plugin.after_model_callback(
|
|
callback_context=cb_ctx, llm_response=llm_resp
|
|
)
|
|
await plugin.flush()
|
|
|
|
# 4. TOOL_STARTING
|
|
bigquery_agent_analytics_plugin.TraceManager.push_span(tool_ctx, "tool")
|
|
await plugin.before_tool_callback(
|
|
tool=tool,
|
|
tool_args={"table": "events"},
|
|
tool_context=tool_ctx,
|
|
)
|
|
await plugin.flush()
|
|
|
|
# 5. TOOL_COMPLETED
|
|
await plugin.after_tool_callback(
|
|
tool=tool,
|
|
tool_args={"table": "events"},
|
|
tool_context=tool_ctx,
|
|
result={"schema": [{"name": "id", "type": "INT64"}]},
|
|
)
|
|
await plugin.flush()
|
|
|
|
# 6. AGENT_COMPLETED
|
|
await plugin.after_agent_callback(
|
|
agent=mock_agent, callback_context=cb_ctx
|
|
)
|
|
await plugin.flush()
|
|
|
|
rows = await _get_captured_rows_async(
|
|
mock_write_client, dummy_arrow_schema
|
|
)
|
|
|
|
assert len(rows) == 6
|
|
|
|
expected_sequence = [
|
|
"AGENT_STARTING",
|
|
"LLM_REQUEST",
|
|
"LLM_RESPONSE",
|
|
"TOOL_STARTING",
|
|
"TOOL_COMPLETED",
|
|
"AGENT_COMPLETED",
|
|
]
|
|
for i, expected_type in enumerate(expected_sequence):
|
|
assert (
|
|
rows[i]["event_type"] == expected_type
|
|
), f"Row {i}: expected {expected_type}, got {rows[i]['event_type']}"
|
|
assert rows[i]["agent"] == "schema_explorer"
|
|
assert rows[i]["session_id"] == "session-multi"
|
|
|
|
# TOOL rows have correct content
|
|
tool_start = json.loads(rows[3]["content"])
|
|
assert tool_start["tool"] == "get_table_info"
|
|
assert tool_start["args"] == {"table": "events"}
|
|
|
|
tool_done = json.loads(rows[4]["content"])
|
|
assert tool_done["tool"] == "get_table_info"
|
|
assert tool_done["result"] == {"schema": [{"name": "id", "type": "INT64"}]}
|
|
|
|
# AGENT_COMPLETED and TOOL_COMPLETED should have latency
|
|
assert rows[4]["latency_ms"] is not None # TOOL_COMPLETED
|
|
assert rows[5]["latency_ms"] is not None # AGENT_COMPLETED
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_tool_error_attributed_to_subagent(
|
|
self,
|
|
mock_auth_default,
|
|
mock_bq_client,
|
|
mock_write_client,
|
|
mock_to_arrow_schema,
|
|
dummy_arrow_schema,
|
|
mock_asyncio_to_thread,
|
|
):
|
|
"""TOOL_ERROR events carry the correct subagent name."""
|
|
session = self._make_session()
|
|
inv_ctx = self._make_invocation_context("query_analyst", session)
|
|
tool_ctx = tool_context_lib.ToolContext(invocation_context=inv_ctx)
|
|
tool = self._make_tool("execute_sql")
|
|
|
|
async with managed_plugin(
|
|
PROJECT_ID, DATASET_ID, table_id=TABLE_ID
|
|
) as plugin:
|
|
await plugin._ensure_started()
|
|
mock_write_client.append_rows.reset_mock()
|
|
|
|
bigquery_agent_analytics_plugin.TraceManager.push_span(tool_ctx, "tool")
|
|
await plugin.on_tool_error_callback(
|
|
tool=tool,
|
|
tool_args={"sql": "SELECT * FROM bad_table"},
|
|
tool_context=tool_ctx,
|
|
error=RuntimeError("Table not found"),
|
|
)
|
|
await plugin.flush()
|
|
|
|
rows = await _get_captured_rows_async(
|
|
mock_write_client, dummy_arrow_schema
|
|
)
|
|
|
|
assert len(rows) == 1
|
|
assert rows[0]["event_type"] == "TOOL_ERROR"
|
|
assert rows[0]["agent"] == "query_analyst"
|
|
assert rows[0]["error_message"] == "Table not found"
|
|
content = json.loads(rows[0]["content"])
|
|
assert content["tool"] == "execute_sql"
|
|
assert content["args"] == {"sql": "SELECT * FROM bad_table"}
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_multi_subagent_interleaved_tool_calls(
|
|
self,
|
|
mock_auth_default,
|
|
mock_bq_client,
|
|
mock_write_client,
|
|
mock_to_arrow_schema,
|
|
dummy_arrow_schema,
|
|
mock_asyncio_to_thread,
|
|
):
|
|
"""Two subagents call tools in same invocation — agent field is correct.
|
|
|
|
Simulates orchestrator delegating to schema_explorer first, then
|
|
image_describer, all within the same invocation.
|
|
"""
|
|
session = self._make_session()
|
|
|
|
async with managed_plugin(
|
|
PROJECT_ID, DATASET_ID, table_id=TABLE_ID
|
|
) as plugin:
|
|
await plugin._ensure_started()
|
|
mock_write_client.append_rows.reset_mock()
|
|
|
|
# Subagent 1: schema_explorer — full tool cycle
|
|
inv_ctx_1 = self._make_invocation_context(
|
|
"schema_explorer", session, invocation_id="inv-shared"
|
|
)
|
|
ctx_1 = tool_context_lib.ToolContext(invocation_context=inv_ctx_1)
|
|
tool_1 = self._make_tool("list_table_ids")
|
|
bigquery_agent_analytics_plugin.TraceManager.push_span(ctx_1, "tool")
|
|
await plugin.before_tool_callback(
|
|
tool=tool_1,
|
|
tool_args={"dataset": "analytics"},
|
|
tool_context=ctx_1,
|
|
)
|
|
await plugin.flush()
|
|
await plugin.after_tool_callback(
|
|
tool=tool_1,
|
|
tool_args={"dataset": "analytics"},
|
|
tool_context=ctx_1,
|
|
result={"tables": ["events", "metrics"]},
|
|
)
|
|
await plugin.flush()
|
|
|
|
# Subagent 2: image_describer — full tool cycle
|
|
inv_ctx_2 = self._make_invocation_context(
|
|
"image_describer", session, invocation_id="inv-shared"
|
|
)
|
|
ctx_2 = tool_context_lib.ToolContext(invocation_context=inv_ctx_2)
|
|
tool_2 = self._make_tool("describe_this_image")
|
|
bigquery_agent_analytics_plugin.TraceManager.push_span(ctx_2, "tool")
|
|
await plugin.before_tool_callback(
|
|
tool=tool_2,
|
|
tool_args={"image_uri": "https://example.com/img.jpg"},
|
|
tool_context=ctx_2,
|
|
)
|
|
await plugin.flush()
|
|
await plugin.after_tool_callback(
|
|
tool=tool_2,
|
|
tool_args={"image_uri": "https://example.com/img.jpg"},
|
|
tool_context=ctx_2,
|
|
result={"description": "A photo of scones"},
|
|
)
|
|
await plugin.flush()
|
|
|
|
rows = await _get_captured_rows_async(
|
|
mock_write_client, dummy_arrow_schema
|
|
)
|
|
|
|
assert len(rows) == 4
|
|
|
|
# schema_explorer tool events
|
|
assert rows[0]["agent"] == "schema_explorer"
|
|
assert rows[0]["event_type"] == "TOOL_STARTING"
|
|
assert rows[0]["invocation_id"] == "inv-shared"
|
|
assert json.loads(rows[0]["content"])["tool"] == "list_table_ids"
|
|
|
|
assert rows[1]["agent"] == "schema_explorer"
|
|
assert rows[1]["event_type"] == "TOOL_COMPLETED"
|
|
assert json.loads(rows[1]["content"])["result"]["tables"] == [
|
|
"events",
|
|
"metrics",
|
|
]
|
|
|
|
# image_describer tool events
|
|
assert rows[2]["agent"] == "image_describer"
|
|
assert rows[2]["event_type"] == "TOOL_STARTING"
|
|
assert rows[2]["invocation_id"] == "inv-shared"
|
|
assert json.loads(rows[2]["content"])["tool"] == "describe_this_image"
|
|
|
|
assert rows[3]["agent"] == "image_describer"
|
|
assert rows[3]["event_type"] == "TOOL_COMPLETED"
|
|
assert (
|
|
json.loads(rows[3]["content"])["result"]["description"]
|
|
== "A photo of scones"
|
|
)
|
|
|
|
# All share the same session and invocation
|
|
for row in rows:
|
|
assert row["session_id"] == "session-multi"
|
|
assert row["invocation_id"] == "inv-shared"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_multi_turn_multi_subagent_full_sequence(
|
|
self,
|
|
mock_auth_default,
|
|
mock_bq_client,
|
|
mock_write_client,
|
|
mock_to_arrow_schema,
|
|
dummy_arrow_schema,
|
|
mock_asyncio_to_thread,
|
|
):
|
|
"""Multi-turn + multi-subagent: two turns, each with different subagents.
|
|
|
|
Turn 1: user asks about data → orchestrator → schema_explorer (tool)
|
|
Turn 2: user asks about image → orchestrator → image_describer (tool)
|
|
Verifies invocation_id changes, agent name changes, session stays same.
|
|
"""
|
|
session = self._make_session()
|
|
|
|
async with managed_plugin(
|
|
PROJECT_ID, DATASET_ID, table_id=TABLE_ID
|
|
) as plugin:
|
|
await plugin._ensure_started()
|
|
mock_write_client.append_rows.reset_mock()
|
|
|
|
# ===== Turn 1: schema_explorer =====
|
|
inv_ctx_t1_orch = self._make_invocation_context(
|
|
"orchestrator", session, invocation_id="inv-t1"
|
|
)
|
|
cb_ctx_t1_orch = CallbackContext(invocation_context=inv_ctx_t1_orch)
|
|
|
|
# Orchestrator agent_starting
|
|
await plugin.before_agent_callback(
|
|
agent=inv_ctx_t1_orch.agent,
|
|
callback_context=cb_ctx_t1_orch,
|
|
)
|
|
await plugin.flush()
|
|
|
|
# Orchestrator delegates to schema_explorer
|
|
inv_ctx_t1_sub = self._make_invocation_context(
|
|
"schema_explorer", session, invocation_id="inv-t1"
|
|
)
|
|
cb_ctx_t1_sub = CallbackContext(invocation_context=inv_ctx_t1_sub)
|
|
tool_ctx_t1 = tool_context_lib.ToolContext(
|
|
invocation_context=inv_ctx_t1_sub
|
|
)
|
|
|
|
await plugin.before_agent_callback(
|
|
agent=inv_ctx_t1_sub.agent,
|
|
callback_context=cb_ctx_t1_sub,
|
|
)
|
|
await plugin.flush()
|
|
|
|
# schema_explorer calls tool
|
|
tool_1 = self._make_tool("list_dataset_ids")
|
|
bigquery_agent_analytics_plugin.TraceManager.push_span(
|
|
tool_ctx_t1, "tool"
|
|
)
|
|
await plugin.before_tool_callback(
|
|
tool=tool_1,
|
|
tool_args={"project_id": "proj"},
|
|
tool_context=tool_ctx_t1,
|
|
)
|
|
await plugin.flush()
|
|
await plugin.after_tool_callback(
|
|
tool=tool_1,
|
|
tool_args={"project_id": "proj"},
|
|
tool_context=tool_ctx_t1,
|
|
result={"datasets": ["ds1"]},
|
|
)
|
|
await plugin.flush()
|
|
|
|
# schema_explorer done
|
|
await plugin.after_agent_callback(
|
|
agent=inv_ctx_t1_sub.agent,
|
|
callback_context=cb_ctx_t1_sub,
|
|
)
|
|
await plugin.flush()
|
|
|
|
# Orchestrator done
|
|
await plugin.after_agent_callback(
|
|
agent=inv_ctx_t1_orch.agent,
|
|
callback_context=cb_ctx_t1_orch,
|
|
)
|
|
await plugin.flush()
|
|
|
|
# ===== Turn 2: image_describer =====
|
|
inv_ctx_t2_orch = self._make_invocation_context(
|
|
"orchestrator", session, invocation_id="inv-t2"
|
|
)
|
|
cb_ctx_t2_orch = CallbackContext(invocation_context=inv_ctx_t2_orch)
|
|
|
|
await plugin.before_agent_callback(
|
|
agent=inv_ctx_t2_orch.agent,
|
|
callback_context=cb_ctx_t2_orch,
|
|
)
|
|
await plugin.flush()
|
|
|
|
# Orchestrator delegates to image_describer
|
|
inv_ctx_t2_sub = self._make_invocation_context(
|
|
"image_describer", session, invocation_id="inv-t2"
|
|
)
|
|
cb_ctx_t2_sub = CallbackContext(invocation_context=inv_ctx_t2_sub)
|
|
tool_ctx_t2 = tool_context_lib.ToolContext(
|
|
invocation_context=inv_ctx_t2_sub
|
|
)
|
|
|
|
await plugin.before_agent_callback(
|
|
agent=inv_ctx_t2_sub.agent,
|
|
callback_context=cb_ctx_t2_sub,
|
|
)
|
|
await plugin.flush()
|
|
|
|
# image_describer calls tool
|
|
tool_2 = self._make_tool("describe_this_image")
|
|
bigquery_agent_analytics_plugin.TraceManager.push_span(
|
|
tool_ctx_t2, "tool"
|
|
)
|
|
await plugin.before_tool_callback(
|
|
tool=tool_2,
|
|
tool_args={"image_uri": "gs://b/img.jpg"},
|
|
tool_context=tool_ctx_t2,
|
|
)
|
|
await plugin.flush()
|
|
await plugin.after_tool_callback(
|
|
tool=tool_2,
|
|
tool_args={"image_uri": "gs://b/img.jpg"},
|
|
tool_context=tool_ctx_t2,
|
|
result={"desc": "Scones on a table"},
|
|
)
|
|
await plugin.flush()
|
|
|
|
# image_describer done
|
|
await plugin.after_agent_callback(
|
|
agent=inv_ctx_t2_sub.agent,
|
|
callback_context=cb_ctx_t2_sub,
|
|
)
|
|
await plugin.flush()
|
|
|
|
# Orchestrator done
|
|
await plugin.after_agent_callback(
|
|
agent=inv_ctx_t2_orch.agent,
|
|
callback_context=cb_ctx_t2_orch,
|
|
)
|
|
await plugin.flush()
|
|
|
|
rows = await _get_captured_rows_async(
|
|
mock_write_client, dummy_arrow_schema
|
|
)
|
|
|
|
# Turn 1: 6 rows (orch_start, sub_start, tool_start, tool_done,
|
|
# sub_done, orch_done)
|
|
# Turn 2: 6 rows (same pattern)
|
|
assert len(rows) == 12
|
|
|
|
# --- Turn 1 validation ---
|
|
t1_rows = [r for r in rows if r["invocation_id"] == "inv-t1"]
|
|
assert len(t1_rows) == 6
|
|
|
|
assert t1_rows[0]["event_type"] == "AGENT_STARTING"
|
|
assert t1_rows[0]["agent"] == "orchestrator"
|
|
|
|
assert t1_rows[1]["event_type"] == "AGENT_STARTING"
|
|
assert t1_rows[1]["agent"] == "schema_explorer"
|
|
|
|
assert t1_rows[2]["event_type"] == "TOOL_STARTING"
|
|
assert t1_rows[2]["agent"] == "schema_explorer"
|
|
assert json.loads(t1_rows[2]["content"])["tool"] == "list_dataset_ids"
|
|
|
|
assert t1_rows[3]["event_type"] == "TOOL_COMPLETED"
|
|
assert t1_rows[3]["agent"] == "schema_explorer"
|
|
|
|
assert t1_rows[4]["event_type"] == "AGENT_COMPLETED"
|
|
assert t1_rows[4]["agent"] == "schema_explorer"
|
|
|
|
assert t1_rows[5]["event_type"] == "AGENT_COMPLETED"
|
|
assert t1_rows[5]["agent"] == "orchestrator"
|
|
|
|
# --- Turn 2 validation ---
|
|
t2_rows = [r for r in rows if r["invocation_id"] == "inv-t2"]
|
|
assert len(t2_rows) == 6
|
|
|
|
assert t2_rows[0]["event_type"] == "AGENT_STARTING"
|
|
assert t2_rows[0]["agent"] == "orchestrator"
|
|
|
|
assert t2_rows[1]["event_type"] == "AGENT_STARTING"
|
|
assert t2_rows[1]["agent"] == "image_describer"
|
|
|
|
assert t2_rows[2]["event_type"] == "TOOL_STARTING"
|
|
assert t2_rows[2]["agent"] == "image_describer"
|
|
assert json.loads(t2_rows[2]["content"])["tool"] == "describe_this_image"
|
|
|
|
assert t2_rows[3]["event_type"] == "TOOL_COMPLETED"
|
|
assert t2_rows[3]["agent"] == "image_describer"
|
|
|
|
assert t2_rows[4]["event_type"] == "AGENT_COMPLETED"
|
|
assert t2_rows[4]["agent"] == "image_describer"
|
|
|
|
assert t2_rows[5]["event_type"] == "AGENT_COMPLETED"
|
|
assert t2_rows[5]["agent"] == "orchestrator"
|
|
|
|
# All rows share the same session
|
|
for row in rows:
|
|
assert row["session_id"] == "session-multi"
|
|
|
|
|
|
class TestEventId:
|
|
"""Rows carry a stable identifier for query-time retry deduplication."""
|
|
|
|
def test_schema_and_views_expose_event_id(self):
|
|
"""The physical schema and every typed view expose the row identifier."""
|
|
schema_fields = {
|
|
field.name: field
|
|
for field in bigquery_agent_analytics_plugin._get_events_schema()
|
|
}
|
|
|
|
assert schema_fields["event_id"].field_type == "STRING"
|
|
assert schema_fields["event_id"].mode == "NULLABLE"
|
|
assert "event_id" in bigquery_agent_analytics_plugin._VIEW_COMMON_COLUMNS
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_each_emitted_row_has_a_distinct_hex_event_id(
|
|
self,
|
|
bq_plugin_inst,
|
|
mock_write_client,
|
|
invocation_context,
|
|
dummy_arrow_schema,
|
|
):
|
|
"""Separate plugin rows receive distinct UUID-derived identifiers."""
|
|
user_message = types.Content(parts=[types.Part(text="hello")])
|
|
|
|
await bq_plugin_inst.on_user_message_callback(
|
|
invocation_context=invocation_context,
|
|
user_message=user_message,
|
|
)
|
|
await bq_plugin_inst.on_user_message_callback(
|
|
invocation_context=invocation_context,
|
|
user_message=user_message,
|
|
)
|
|
await bq_plugin_inst.flush()
|
|
|
|
rows = await _get_captured_rows_async(mock_write_client, dummy_arrow_schema)
|
|
event_ids = [row["event_id"] for row in rows]
|
|
assert len(event_ids) == 2
|
|
assert len(set(event_ids)) == 2
|
|
for event_id in event_ids:
|
|
assert len(event_id) == 32
|
|
assert event_id == event_id.lower()
|
|
assert int(event_id, 16) >= 0
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_bigquery_retry_reuses_the_same_event_id(
|
|
self,
|
|
bq_plugin_inst,
|
|
mock_write_client,
|
|
invocation_context,
|
|
dummy_arrow_schema,
|
|
):
|
|
"""A transport retry resends the original row identifier unchanged."""
|
|
state = next(iter(bq_plugin_inst._loop_state_by_loop.values()))
|
|
state.batch_processor.retry_config = (
|
|
bigquery_agent_analytics_plugin.RetryConfig(
|
|
max_retries=1,
|
|
initial_delay=0,
|
|
multiplier=1,
|
|
max_delay=0,
|
|
)
|
|
)
|
|
event_ids = []
|
|
|
|
async def append_then_lose_ack(requests, **kwargs):
|
|
del kwargs
|
|
request = [request async for request in requests][0]
|
|
batch = pa.ipc.read_record_batch(
|
|
pa.py_buffer(request.arrow_rows.rows.serialized_record_batch),
|
|
dummy_arrow_schema,
|
|
)
|
|
event_ids.append(batch.to_pylist()[0]["event_id"])
|
|
if len(event_ids) == 1:
|
|
raise bigquery_agent_analytics_plugin.ServiceUnavailable("ack lost")
|
|
response = mock.MagicMock()
|
|
response.error.code = 0
|
|
response.row_errors = []
|
|
return _async_gen(response)
|
|
|
|
mock_write_client.append_rows.side_effect = append_then_lose_ack
|
|
|
|
await bq_plugin_inst.on_user_message_callback(
|
|
invocation_context=invocation_context,
|
|
user_message=types.Content(parts=[types.Part(text="hello")]),
|
|
)
|
|
await bq_plugin_inst.flush()
|
|
|
|
assert len(event_ids) == 2
|
|
assert event_ids[0] is not None
|
|
assert event_ids[0] == event_ids[1]
|
|
|
|
|
|
class TestSchemaAutoUpgrade:
|
|
"""Tests for _ensure_schema_exists with auto_schema_upgrade."""
|
|
|
|
def _make_plugin(self, auto_schema_upgrade=False):
|
|
config = bigquery_agent_analytics_plugin.BigQueryLoggerConfig(
|
|
auto_schema_upgrade=auto_schema_upgrade,
|
|
)
|
|
with mock.patch("google.cloud.bigquery.Client"):
|
|
plugin = bigquery_agent_analytics_plugin.BigQueryAgentAnalyticsPlugin(
|
|
project_id=PROJECT_ID,
|
|
dataset_id=DATASET_ID,
|
|
table_id=TABLE_ID,
|
|
config=config,
|
|
)
|
|
plugin.client = mock.MagicMock()
|
|
plugin.full_table_id = f"{PROJECT_ID}.{DATASET_ID}.{TABLE_ID}"
|
|
plugin._schema = bigquery_agent_analytics_plugin._get_events_schema()
|
|
return plugin
|
|
|
|
def test_create_table_sets_version_label(self):
|
|
"""New tables get the schema version label."""
|
|
plugin = self._make_plugin()
|
|
plugin.client.get_table.side_effect = cloud_exceptions.NotFound("not found")
|
|
plugin._ensure_schema_exists()
|
|
plugin.client.create_table.assert_called_once()
|
|
tbl = plugin.client.create_table.call_args[0][0]
|
|
assert (
|
|
tbl.labels[bigquery_agent_analytics_plugin._SCHEMA_VERSION_LABEL_KEY]
|
|
== bigquery_agent_analytics_plugin._SCHEMA_VERSION
|
|
)
|
|
|
|
def test_no_upgrade_when_disabled(self):
|
|
"""Auto-upgrade disabled: existing table is not modified."""
|
|
plugin = self._make_plugin(auto_schema_upgrade=False)
|
|
existing = mock.MagicMock()
|
|
existing.schema = [
|
|
bigquery.SchemaField("timestamp", "TIMESTAMP"),
|
|
]
|
|
existing.labels = {}
|
|
plugin.client.get_table.return_value = existing
|
|
plugin._ensure_schema_exists()
|
|
plugin.client.update_table.assert_not_called()
|
|
|
|
def test_upgrade_adds_missing_columns(self):
|
|
"""Auto-upgrade adds columns missing from existing table."""
|
|
plugin = self._make_plugin(auto_schema_upgrade=True)
|
|
existing = mock.MagicMock(spec=bigquery.Table)
|
|
existing.schema = [
|
|
bigquery.SchemaField("timestamp", "TIMESTAMP", mode="REQUIRED"),
|
|
]
|
|
existing.labels = {"other": "label"}
|
|
plugin.client.get_table.return_value = existing
|
|
plugin._ensure_schema_exists()
|
|
plugin.client.update_table.assert_called_once()
|
|
updated_table = plugin.client.update_table.call_args[0][0]
|
|
updated_names = {f.name for f in updated_table.schema}
|
|
assert "event_type" in updated_names
|
|
assert "event_id" in updated_names
|
|
assert "agent" in updated_names
|
|
assert "content" in updated_names
|
|
assert (
|
|
updated_table.labels[
|
|
bigquery_agent_analytics_plugin._SCHEMA_VERSION_LABEL_KEY
|
|
]
|
|
== bigquery_agent_analytics_plugin._SCHEMA_VERSION
|
|
)
|
|
|
|
def test_skip_upgrade_when_version_matches(self):
|
|
"""No update when stored version matches current."""
|
|
plugin = self._make_plugin(auto_schema_upgrade=True)
|
|
existing = mock.MagicMock(spec=bigquery.Table)
|
|
existing.schema = plugin._schema
|
|
existing.labels = {
|
|
bigquery_agent_analytics_plugin._SCHEMA_VERSION_LABEL_KEY: (
|
|
bigquery_agent_analytics_plugin._SCHEMA_VERSION
|
|
),
|
|
}
|
|
plugin.client.get_table.return_value = existing
|
|
plugin._ensure_schema_exists()
|
|
plugin.client.update_table.assert_not_called()
|
|
|
|
def test_upgrade_error_propagates_when_fields_missing(self):
|
|
"""Schema upgrade failure raises when required fields are missing.
|
|
|
|
Swallowing it let _ensure_started mark the plugin ready against a
|
|
table every later write can fail on, with no readiness retry.
|
|
"""
|
|
plugin = self._make_plugin(auto_schema_upgrade=True)
|
|
existing = mock.MagicMock(spec=bigquery.Table)
|
|
existing.schema = [
|
|
bigquery.SchemaField("timestamp", "TIMESTAMP", mode="REQUIRED"),
|
|
]
|
|
existing.labels = {}
|
|
plugin.client.get_table.return_value = existing
|
|
plugin.client.update_table.side_effect = Exception("boom")
|
|
with pytest.raises(Exception, match="boom"):
|
|
plugin._ensure_schema_exists()
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize(
|
|
("existing_type", "existing_mode"),
|
|
[("STRING", "REQUIRED"), ("TIMESTAMP", "NULLABLE")],
|
|
ids=("type", "mode"),
|
|
)
|
|
async def test_incompatible_existing_field_blocks_startup(
|
|
self, existing_type, existing_mode
|
|
):
|
|
"""Same-name fields with incompatible type/mode are not ready."""
|
|
plugin = self._make_plugin(auto_schema_upgrade=True)
|
|
plugin.config.create_views = False
|
|
existing = mock.MagicMock(spec=bigquery.Table)
|
|
existing.schema = [
|
|
bigquery.SchemaField("timestamp", existing_type, mode=existing_mode),
|
|
]
|
|
existing.labels = {}
|
|
plugin.client.get_table.return_value = existing
|
|
|
|
try:
|
|
outcome = await plugin._ensure_started()
|
|
assert outcome == "failed"
|
|
assert plugin._started is False
|
|
assert isinstance(plugin._startup_error, ValueError)
|
|
assert "timestamp" in str(plugin._startup_error)
|
|
plugin.client.update_table.assert_not_called()
|
|
finally:
|
|
await plugin.shutdown()
|
|
|
|
def test_upgrade_preserves_existing_columns(self):
|
|
"""Existing columns are never dropped or altered during upgrade."""
|
|
plugin = self._make_plugin(auto_schema_upgrade=True)
|
|
# Simulate a table with a subset of canonical columns plus a
|
|
# user-added custom column that is NOT in the canonical schema.
|
|
custom_field = bigquery.SchemaField("my_custom_col", "STRING")
|
|
existing = mock.MagicMock(spec=bigquery.Table)
|
|
existing.schema = [
|
|
bigquery.SchemaField("timestamp", "TIMESTAMP", mode="REQUIRED"),
|
|
bigquery.SchemaField("event_type", "STRING"),
|
|
custom_field,
|
|
]
|
|
existing.labels = {}
|
|
plugin.client.get_table.return_value = existing
|
|
plugin._ensure_schema_exists()
|
|
|
|
updated_table = plugin.client.update_table.call_args[0][0]
|
|
updated_names = [f.name for f in updated_table.schema]
|
|
# Original columns are still present and in original order.
|
|
assert updated_names[0] == "timestamp"
|
|
assert updated_names[1] == "event_type"
|
|
assert updated_names[2] == "my_custom_col"
|
|
# New canonical columns were appended after existing ones.
|
|
assert "agent" in updated_names
|
|
assert "content" in updated_names
|
|
|
|
def test_upgrade_from_no_label_treats_as_outdated(self):
|
|
"""A table with no version label is treated as needing upgrade."""
|
|
plugin = self._make_plugin(auto_schema_upgrade=True)
|
|
existing = mock.MagicMock(spec=bigquery.Table)
|
|
existing.schema = list(plugin._schema) # All columns present
|
|
existing.labels = {} # No version label
|
|
plugin.client.get_table.return_value = existing
|
|
plugin._ensure_schema_exists()
|
|
|
|
# update_table should be called to stamp the version label even
|
|
# though no new columns were needed.
|
|
plugin.client.update_table.assert_called_once()
|
|
updated_table = plugin.client.update_table.call_args[0][0]
|
|
assert (
|
|
updated_table.labels[
|
|
bigquery_agent_analytics_plugin._SCHEMA_VERSION_LABEL_KEY
|
|
]
|
|
== bigquery_agent_analytics_plugin._SCHEMA_VERSION
|
|
)
|
|
|
|
def test_upgrade_from_older_version_label(self):
|
|
"""A table with an older version label triggers upgrade."""
|
|
plugin = self._make_plugin(auto_schema_upgrade=True)
|
|
existing = mock.MagicMock(spec=bigquery.Table)
|
|
existing.schema = [
|
|
bigquery.SchemaField("timestamp", "TIMESTAMP", mode="REQUIRED"),
|
|
bigquery.SchemaField("event_type", "STRING"),
|
|
]
|
|
# Simulate a table stamped with an older version.
|
|
existing.labels = {
|
|
bigquery_agent_analytics_plugin._SCHEMA_VERSION_LABEL_KEY: "0",
|
|
}
|
|
plugin.client.get_table.return_value = existing
|
|
plugin._ensure_schema_exists()
|
|
|
|
plugin.client.update_table.assert_called_once()
|
|
updated_table = plugin.client.update_table.call_args[0][0]
|
|
# Version label should be updated to current.
|
|
assert (
|
|
updated_table.labels[
|
|
bigquery_agent_analytics_plugin._SCHEMA_VERSION_LABEL_KEY
|
|
]
|
|
== bigquery_agent_analytics_plugin._SCHEMA_VERSION
|
|
)
|
|
# Missing columns should have been added.
|
|
updated_names = {f.name for f in updated_table.schema}
|
|
assert "agent" in updated_names
|
|
assert "content" in updated_names
|
|
|
|
def test_upgrade_is_idempotent(self):
|
|
"""Calling _ensure_schema_exists twice doesn't double-update."""
|
|
plugin = self._make_plugin(auto_schema_upgrade=True)
|
|
|
|
# First call: table exists with old schema.
|
|
existing = mock.MagicMock(spec=bigquery.Table)
|
|
existing.schema = [
|
|
bigquery.SchemaField("timestamp", "TIMESTAMP", mode="REQUIRED"),
|
|
]
|
|
existing.labels = {}
|
|
plugin.client.get_table.return_value = existing
|
|
plugin._ensure_schema_exists()
|
|
assert plugin.client.update_table.call_count == 1
|
|
|
|
# Second call: table now has current version label.
|
|
existing.labels = {
|
|
bigquery_agent_analytics_plugin._SCHEMA_VERSION_LABEL_KEY: (
|
|
bigquery_agent_analytics_plugin._SCHEMA_VERSION
|
|
),
|
|
}
|
|
plugin.client.update_table.reset_mock()
|
|
plugin._ensure_schema_exists()
|
|
plugin.client.update_table.assert_not_called()
|
|
|
|
def test_update_table_receives_schema_and_labels_fields(self):
|
|
"""update_table is called with update_fields=['schema', 'labels']."""
|
|
plugin = self._make_plugin(auto_schema_upgrade=True)
|
|
existing = mock.MagicMock(spec=bigquery.Table)
|
|
existing.schema = [
|
|
bigquery.SchemaField("timestamp", "TIMESTAMP", mode="REQUIRED"),
|
|
]
|
|
existing.labels = {}
|
|
plugin.client.get_table.return_value = existing
|
|
plugin._ensure_schema_exists()
|
|
|
|
call_args = plugin.client.update_table.call_args
|
|
update_fields = call_args[0][1]
|
|
assert "schema" in update_fields
|
|
assert "labels" in update_fields
|
|
|
|
def test_auto_schema_upgrade_defaults_to_true(self):
|
|
"""Default config has auto_schema_upgrade enabled."""
|
|
config = bigquery_agent_analytics_plugin.BigQueryLoggerConfig()
|
|
assert config.auto_schema_upgrade is True
|
|
|
|
def test_create_table_conflict_refetches_concurrent_table(self):
|
|
"""Conflict during create_table re-fetches the concurrently created
|
|
|
|
table instead of blindly trusting it.
|
|
"""
|
|
plugin = self._make_plugin()
|
|
existing = mock.MagicMock(spec=bigquery.Table)
|
|
existing.schema = plugin._schema
|
|
existing.labels = {}
|
|
plugin.client.get_table.side_effect = [
|
|
cloud_exceptions.NotFound("not found"),
|
|
existing,
|
|
]
|
|
plugin.client.create_table.side_effect = cloud_exceptions.Conflict(
|
|
"already exists"
|
|
)
|
|
# Should not raise.
|
|
plugin._ensure_schema_exists()
|
|
assert plugin.client.get_table.call_count == 2
|
|
|
|
def test_create_table_conflict_upgrades_incompatible_table(self):
|
|
"""A concurrently created table missing required columns goes through
|
|
|
|
the normal upgrade path after Conflict.
|
|
"""
|
|
plugin = self._make_plugin(auto_schema_upgrade=True)
|
|
incompatible = mock.MagicMock(spec=bigquery.Table)
|
|
incompatible.schema = [
|
|
bigquery.SchemaField("timestamp", "TIMESTAMP", mode="REQUIRED")
|
|
]
|
|
incompatible.labels = {}
|
|
plugin.client.get_table.side_effect = [
|
|
cloud_exceptions.NotFound("not found"),
|
|
incompatible,
|
|
]
|
|
plugin.client.create_table.side_effect = cloud_exceptions.Conflict(
|
|
"already exists"
|
|
)
|
|
plugin._ensure_schema_exists()
|
|
assert plugin.client.get_table.call_count == 2
|
|
plugin.client.update_table.assert_called_once()
|
|
updated_names = {
|
|
f.name for f in plugin.client.update_table.call_args[0][0].schema
|
|
}
|
|
assert "event_type" in updated_names
|
|
|
|
def test_create_table_conflict_refetch_failure_propagates(self):
|
|
"""If the post-Conflict readiness check fails, setup must fail so
|
|
|
|
_ensure_started retries later.
|
|
"""
|
|
plugin = self._make_plugin(auto_schema_upgrade=True)
|
|
plugin.client.get_table.side_effect = [
|
|
cloud_exceptions.NotFound("not found"),
|
|
cloud_exceptions.ServiceUnavailable("control plane down"),
|
|
]
|
|
plugin.client.create_table.side_effect = cloud_exceptions.Conflict(
|
|
"already exists"
|
|
)
|
|
with pytest.raises(cloud_exceptions.ServiceUnavailable):
|
|
plugin._ensure_schema_exists()
|
|
|
|
|
|
class TestToolProvenance:
|
|
"""Tests for _get_tool_origin helper."""
|
|
|
|
def test_function_tool_returns_local(self):
|
|
from google.adk.tools.function_tool import FunctionTool
|
|
|
|
def dummy():
|
|
pass
|
|
|
|
tool = FunctionTool(dummy)
|
|
result = bigquery_agent_analytics_plugin._get_tool_origin(tool)
|
|
assert result == "LOCAL"
|
|
|
|
def test_agent_tool_returns_sub_agent(self):
|
|
from google.adk.tools.agent_tool import AgentTool
|
|
|
|
agent = mock.MagicMock()
|
|
agent.name = "sub"
|
|
tool = AgentTool.__new__(AgentTool)
|
|
tool.agent = agent
|
|
tool._name = "sub"
|
|
result = bigquery_agent_analytics_plugin._get_tool_origin(tool)
|
|
assert result == "SUB_AGENT"
|
|
|
|
def test_transfer_tool_returns_transfer_agent(self):
|
|
from google.adk.tools.transfer_to_agent_tool import TransferToAgentTool
|
|
|
|
tool = TransferToAgentTool(agent_names=["other"])
|
|
result = bigquery_agent_analytics_plugin._get_tool_origin(tool)
|
|
assert result == "TRANSFER_AGENT"
|
|
|
|
def test_transfer_tool_without_args_returns_transfer_agent(self):
|
|
"""TransferToAgentTool without tool_args falls back to TRANSFER_AGENT."""
|
|
from google.adk.tools.transfer_to_agent_tool import TransferToAgentTool
|
|
|
|
tool = TransferToAgentTool(agent_names=["remote_a2a"])
|
|
result = bigquery_agent_analytics_plugin._get_tool_origin(
|
|
tool, tool_args=None, tool_context=None
|
|
)
|
|
assert result == "TRANSFER_AGENT"
|
|
|
|
def test_transfer_to_remote_a2a_sub_agent_returns_transfer_a2a(self):
|
|
"""Transfer to a RemoteA2aAgent sub-agent is classified TRANSFER_A2A."""
|
|
from google.adk.tools.transfer_to_agent_tool import TransferToAgentTool
|
|
|
|
try:
|
|
from google.adk.agents.remote_a2a_agent import RemoteA2aAgent
|
|
except ImportError:
|
|
pytest.skip("A2A agent not available")
|
|
|
|
remote_agent = mock.MagicMock(spec=RemoteA2aAgent)
|
|
remote_agent.name = "remote_a2a"
|
|
|
|
current_agent = mock.MagicMock()
|
|
current_agent.name = "root"
|
|
current_agent.sub_agents = [remote_agent]
|
|
current_agent.parent_agent = None
|
|
|
|
inv_ctx = mock.MagicMock()
|
|
inv_ctx.agent = current_agent
|
|
tool_context = mock.MagicMock()
|
|
tool_context._invocation_context = inv_ctx
|
|
|
|
tool = TransferToAgentTool(agent_names=["remote_a2a"])
|
|
result = bigquery_agent_analytics_plugin._get_tool_origin(
|
|
tool,
|
|
tool_args={"agent_name": "remote_a2a"},
|
|
tool_context=tool_context,
|
|
)
|
|
assert result == "TRANSFER_A2A"
|
|
|
|
def test_transfer_to_local_sub_agent_returns_transfer_agent(self):
|
|
"""Transfer to a local sub-agent is still classified TRANSFER_AGENT."""
|
|
from google.adk.tools.transfer_to_agent_tool import TransferToAgentTool
|
|
|
|
local_agent = mock.MagicMock()
|
|
local_agent.name = "local_sub"
|
|
|
|
current_agent = mock.MagicMock()
|
|
current_agent.name = "root"
|
|
current_agent.sub_agents = [local_agent]
|
|
current_agent.parent_agent = None
|
|
|
|
inv_ctx = mock.MagicMock()
|
|
inv_ctx.agent = current_agent
|
|
tool_context = mock.MagicMock()
|
|
tool_context._invocation_context = inv_ctx
|
|
|
|
tool = TransferToAgentTool(agent_names=["local_sub"])
|
|
result = bigquery_agent_analytics_plugin._get_tool_origin(
|
|
tool,
|
|
tool_args={"agent_name": "local_sub"},
|
|
tool_context=tool_context,
|
|
)
|
|
assert result == "TRANSFER_AGENT"
|
|
|
|
def test_transfer_to_a2a_peer_returns_transfer_a2a(self):
|
|
"""Transfer to a RemoteA2aAgent peer is classified TRANSFER_A2A."""
|
|
from google.adk.tools.transfer_to_agent_tool import TransferToAgentTool
|
|
|
|
try:
|
|
from google.adk.agents.remote_a2a_agent import RemoteA2aAgent
|
|
except ImportError:
|
|
pytest.skip("A2A agent not available")
|
|
|
|
remote_peer = mock.MagicMock(spec=RemoteA2aAgent)
|
|
remote_peer.name = "remote_peer"
|
|
|
|
current_agent = mock.MagicMock()
|
|
current_agent.name = "child"
|
|
current_agent.sub_agents = []
|
|
|
|
parent_agent = mock.MagicMock()
|
|
parent_agent.name = "parent"
|
|
parent_agent.sub_agents = [current_agent, remote_peer]
|
|
current_agent.parent_agent = parent_agent
|
|
|
|
inv_ctx = mock.MagicMock()
|
|
inv_ctx.agent = current_agent
|
|
tool_context = mock.MagicMock()
|
|
tool_context._invocation_context = inv_ctx
|
|
|
|
tool = TransferToAgentTool(
|
|
agent_names=["remote_peer"],
|
|
)
|
|
result = bigquery_agent_analytics_plugin._get_tool_origin(
|
|
tool,
|
|
tool_args={"agent_name": "remote_peer"},
|
|
tool_context=tool_context,
|
|
)
|
|
assert result == "TRANSFER_A2A"
|
|
|
|
def test_transfer_mixed_targets_classifies_per_call(self):
|
|
"""A single TransferToAgentTool with mixed targets classifies per call."""
|
|
from google.adk.tools.transfer_to_agent_tool import TransferToAgentTool
|
|
|
|
try:
|
|
from google.adk.agents.remote_a2a_agent import RemoteA2aAgent
|
|
except ImportError:
|
|
pytest.skip("A2A agent not available")
|
|
|
|
remote_agent = mock.MagicMock(spec=RemoteA2aAgent)
|
|
remote_agent.name = "remote_a2a"
|
|
local_agent = mock.MagicMock()
|
|
local_agent.name = "local_sub"
|
|
|
|
current_agent = mock.MagicMock()
|
|
current_agent.name = "root"
|
|
current_agent.sub_agents = [remote_agent, local_agent]
|
|
current_agent.parent_agent = None
|
|
|
|
inv_ctx = mock.MagicMock()
|
|
inv_ctx.agent = current_agent
|
|
tool_context = mock.MagicMock()
|
|
tool_context._invocation_context = inv_ctx
|
|
|
|
tool = TransferToAgentTool(
|
|
agent_names=["remote_a2a", "local_sub"],
|
|
)
|
|
|
|
# Transfer to remote target → TRANSFER_A2A
|
|
result = bigquery_agent_analytics_plugin._get_tool_origin(
|
|
tool,
|
|
tool_args={"agent_name": "remote_a2a"},
|
|
tool_context=tool_context,
|
|
)
|
|
assert result == "TRANSFER_A2A"
|
|
|
|
# Transfer to local target → TRANSFER_AGENT
|
|
result = bigquery_agent_analytics_plugin._get_tool_origin(
|
|
tool,
|
|
tool_args={"agent_name": "local_sub"},
|
|
tool_context=tool_context,
|
|
)
|
|
assert result == "TRANSFER_AGENT"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_tool_error_callback_classifies_a2a_transfer(
|
|
self,
|
|
mock_auth_default,
|
|
mock_bq_client,
|
|
mock_write_client,
|
|
mock_to_arrow_schema,
|
|
dummy_arrow_schema,
|
|
mock_asyncio_to_thread,
|
|
):
|
|
"""on_tool_error_callback produces TRANSFER_A2A for RemoteA2aAgent."""
|
|
from google.adk.tools.transfer_to_agent_tool import TransferToAgentTool
|
|
|
|
try:
|
|
from google.adk.agents.remote_a2a_agent import RemoteA2aAgent
|
|
except ImportError:
|
|
pytest.skip("A2A agent not available")
|
|
|
|
remote_agent = mock.MagicMock(spec=RemoteA2aAgent)
|
|
remote_agent.name = "remote_a2a"
|
|
|
|
mock_agent = mock.MagicMock(spec=base_agent.BaseAgent)
|
|
mock_agent.name = "root"
|
|
mock_agent.instruction = ""
|
|
mock_agent.sub_agents = [remote_agent]
|
|
mock_agent.parent_agent = None
|
|
|
|
mock_s = mock.create_autospec(
|
|
session_lib.Session, instance=True, spec_set=True
|
|
)
|
|
type(mock_s).id = mock.PropertyMock(return_value="sess-1")
|
|
type(mock_s).user_id = mock.PropertyMock(return_value="user-1")
|
|
type(mock_s).app_name = mock.PropertyMock(return_value="test_app")
|
|
type(mock_s).state = mock.PropertyMock(return_value={})
|
|
|
|
inv_ctx = InvocationContext(
|
|
agent=mock_agent,
|
|
session=mock_s,
|
|
invocation_id="inv-err",
|
|
session_service=mock.create_autospec(
|
|
base_session_service_lib.BaseSessionService,
|
|
instance=True,
|
|
spec_set=True,
|
|
),
|
|
plugin_manager=mock.create_autospec(
|
|
plugin_manager_lib.PluginManager,
|
|
instance=True,
|
|
spec_set=True,
|
|
),
|
|
)
|
|
tool_ctx = tool_context_lib.ToolContext(invocation_context=inv_ctx)
|
|
tool = TransferToAgentTool(agent_names=["remote_a2a"])
|
|
|
|
async with managed_plugin(
|
|
PROJECT_ID, DATASET_ID, table_id=TABLE_ID
|
|
) as plugin:
|
|
await plugin._ensure_started()
|
|
mock_write_client.append_rows.reset_mock()
|
|
|
|
bigquery_agent_analytics_plugin.TraceManager.push_span(tool_ctx, "tool")
|
|
await plugin.on_tool_error_callback(
|
|
tool=tool,
|
|
tool_args={"agent_name": "remote_a2a"},
|
|
tool_context=tool_ctx,
|
|
error=RuntimeError("connection refused"),
|
|
)
|
|
await plugin.flush()
|
|
|
|
rows = await _get_captured_rows_async(
|
|
mock_write_client, dummy_arrow_schema
|
|
)
|
|
|
|
assert len(rows) == 1
|
|
assert rows[0]["event_type"] == "TOOL_ERROR"
|
|
content = json.loads(rows[0]["content"])
|
|
assert content["tool_origin"] == "TRANSFER_A2A"
|
|
|
|
def test_mcp_tool_returns_mcp(self):
|
|
try:
|
|
from google.adk.tools.mcp_tool.mcp_tool import McpTool
|
|
except ImportError:
|
|
pytest.skip("MCP not installed")
|
|
tool = McpTool.__new__(McpTool)
|
|
result = bigquery_agent_analytics_plugin._get_tool_origin(tool)
|
|
assert result == "MCP"
|
|
|
|
def test_a2a_agent_tool_returns_a2a(self):
|
|
from google.adk.tools.agent_tool import AgentTool
|
|
|
|
try:
|
|
from google.adk.agents.remote_a2a_agent import RemoteA2aAgent
|
|
except ImportError:
|
|
pytest.skip("A2A agent not available")
|
|
|
|
remote_agent = mock.MagicMock(spec=RemoteA2aAgent)
|
|
remote_agent.name = "remote"
|
|
remote_agent.description = "remote a2a agent"
|
|
tool = AgentTool.__new__(AgentTool)
|
|
tool.agent = remote_agent
|
|
tool._name = "remote"
|
|
result = bigquery_agent_analytics_plugin._get_tool_origin(tool)
|
|
assert result == "A2A"
|
|
|
|
def test_unknown_tool_returns_unknown(self):
|
|
tool = mock.MagicMock(spec=base_tool_lib.BaseTool)
|
|
tool.name = "mystery"
|
|
result = bigquery_agent_analytics_plugin._get_tool_origin(tool)
|
|
assert result == "UNKNOWN"
|
|
|
|
|
|
class TestHITLTracing:
|
|
"""Tests for HITL-specific event emission via on_event_callback.
|
|
|
|
HITL events (``adk_request_credential``, ``adk_request_confirmation``,
|
|
``adk_request_input``) are synthetic function calls injected by the
|
|
framework — they never pass through ``before_tool_callback`` /
|
|
``after_tool_callback``. Detection therefore lives in
|
|
``on_event_callback``, which inspects the event stream for these
|
|
function calls and their corresponding function responses.
|
|
"""
|
|
|
|
def _make_fc_event(self, fc_name, args=None):
|
|
"""Build a mock Event containing a function call."""
|
|
event = mock.MagicMock(spec=event_lib.Event)
|
|
fc = types.FunctionCall(name=fc_name, args=args or {})
|
|
part = types.Part(function_call=fc)
|
|
event.content = types.Content(role="model", parts=[part])
|
|
event.actions = event_actions_lib.EventActions()
|
|
# Pydantic fields are not in the spec; without this, on_event_callback
|
|
# raises AttributeError and _safe_callback hides the truncation.
|
|
event.partial = None
|
|
return event
|
|
|
|
def _make_fr_event(self, fr_name, response=None):
|
|
"""Build a mock Event containing a function response."""
|
|
event = mock.MagicMock(spec=event_lib.Event)
|
|
fr = types.FunctionResponse(name=fr_name, response=response or {})
|
|
part = types.Part(function_response=fr)
|
|
event.content = types.Content(role="user", parts=[part])
|
|
event.actions = event_actions_lib.EventActions()
|
|
# Pydantic fields are not in the spec; without this, on_event_callback
|
|
# raises AttributeError and _safe_callback hides the truncation.
|
|
event.partial = None
|
|
return event
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_hitl_confirmation_emits_additional_event(
|
|
self,
|
|
bq_plugin_inst,
|
|
mock_write_client,
|
|
invocation_context,
|
|
dummy_arrow_schema,
|
|
):
|
|
event = self._make_fc_event("adk_request_confirmation", {"confirm": True})
|
|
await bq_plugin_inst.on_event_callback(
|
|
invocation_context=invocation_context, event=event
|
|
)
|
|
await bq_plugin_inst.flush()
|
|
rows = await _get_captured_rows_async(mock_write_client, dummy_arrow_schema)
|
|
event_types = [r["event_type"] for r in rows]
|
|
assert "HITL_CONFIRMATION_REQUEST" in event_types
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_hitl_credential_emits_additional_event(
|
|
self,
|
|
bq_plugin_inst,
|
|
mock_write_client,
|
|
invocation_context,
|
|
dummy_arrow_schema,
|
|
):
|
|
event = self._make_fc_event("adk_request_credential", {"auth": "oauth2"})
|
|
await bq_plugin_inst.on_event_callback(
|
|
invocation_context=invocation_context, event=event
|
|
)
|
|
await bq_plugin_inst.flush()
|
|
rows = await _get_captured_rows_async(mock_write_client, dummy_arrow_schema)
|
|
event_types = [r["event_type"] for r in rows]
|
|
assert "HITL_CREDENTIAL_REQUEST" in event_types
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_hitl_completion_emits_additional_event(
|
|
self,
|
|
bq_plugin_inst,
|
|
mock_write_client,
|
|
invocation_context,
|
|
dummy_arrow_schema,
|
|
):
|
|
event = self._make_fr_event("adk_request_confirmation", {"confirmed": True})
|
|
await bq_plugin_inst.on_event_callback(
|
|
invocation_context=invocation_context, event=event
|
|
)
|
|
await bq_plugin_inst.flush()
|
|
rows = await _get_captured_rows_async(mock_write_client, dummy_arrow_schema)
|
|
event_types = [r["event_type"] for r in rows]
|
|
assert "HITL_CONFIRMATION_REQUEST_COMPLETED" in event_types
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_regular_tool_no_hitl_event(
|
|
self,
|
|
bq_plugin_inst,
|
|
mock_write_client,
|
|
invocation_context,
|
|
dummy_arrow_schema,
|
|
caplog,
|
|
):
|
|
event = self._make_fc_event("regular_tool", {"x": 1})
|
|
with caplog.at_level(logging.ERROR):
|
|
await bq_plugin_inst.on_event_callback(
|
|
invocation_context=invocation_context, event=event
|
|
)
|
|
await bq_plugin_inst.flush()
|
|
# _safe_callback swallows callback exceptions, so an empty row set does
|
|
# not by itself prove the callback ran; a truncated one emits none either.
|
|
assert "plugin error in on_event_callback" not in caplog.text
|
|
# No HITL events should be emitted for non-HITL function calls.
|
|
# on_event_callback only logs STATE_DELTA and HITL events; a regular
|
|
# function call produces neither.
|
|
assert mock_write_client.append_rows.call_count == 0
|
|
|
|
|
|
# ==============================================================================
|
|
# TEST CLASS: Span Hierarchy Isolation
|
|
# ==============================================================================
|
|
|
|
|
|
class TestSpanHierarchyIsolation:
|
|
"""Regression tests for span hierarchy isolation.
|
|
|
|
``push_span()`` must NOT attach its span to the ambient OTel context.
|
|
If it does, any subsequent ``tracer.start_as_current_span()`` in the
|
|
framework (e.g. ``call_llm``, ``execute_tool``) will be incorrectly
|
|
re-parented under the plugin's span.
|
|
"""
|
|
|
|
def test_push_span_does_not_change_ambient_context(self, callback_context):
|
|
"""push_span must not mutate the current OTel span."""
|
|
span_before = trace.get_current_span()
|
|
|
|
bigquery_agent_analytics_plugin.TraceManager.push_span(
|
|
callback_context, "test_span"
|
|
)
|
|
|
|
span_after = trace.get_current_span()
|
|
assert span_after is span_before
|
|
|
|
# Cleanup
|
|
bigquery_agent_analytics_plugin.TraceManager.pop_span()
|
|
|
|
def test_attach_current_span_does_not_change_ambient_context(
|
|
self, callback_context
|
|
):
|
|
"""attach_current_span must not mutate the current OTel span."""
|
|
span_before = trace.get_current_span()
|
|
|
|
bigquery_agent_analytics_plugin.TraceManager.attach_current_span(
|
|
callback_context
|
|
)
|
|
|
|
span_after = trace.get_current_span()
|
|
assert span_after is span_before
|
|
|
|
# Cleanup
|
|
bigquery_agent_analytics_plugin.TraceManager.pop_span()
|
|
|
|
def test_pop_span_does_not_change_ambient_context(self, callback_context):
|
|
"""pop_span must not mutate the current OTel span."""
|
|
bigquery_agent_analytics_plugin.TraceManager.push_span(
|
|
callback_context, "test_span"
|
|
)
|
|
span_before = trace.get_current_span()
|
|
|
|
bigquery_agent_analytics_plugin.TraceManager.pop_span()
|
|
|
|
span_after = trace.get_current_span()
|
|
assert span_after is span_before
|
|
|
|
def test_push_span_with_real_tracer_does_not_reparent(self, callback_context):
|
|
"""With a real OTel tracer, plugin spans must not become parents
|
|
|
|
of subsequently created framework spans.
|
|
"""
|
|
from opentelemetry.sdk.trace import TracerProvider
|
|
from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter
|
|
|
|
exporter = InMemorySpanExporter()
|
|
provider = TracerProvider()
|
|
from opentelemetry.sdk.trace.export import SimpleSpanProcessor
|
|
|
|
provider.add_span_processor(SimpleSpanProcessor(exporter))
|
|
framework_tracer = provider.get_tracer("test-framework")
|
|
|
|
# Simulate: plugin pushes a span BEFORE the framework span
|
|
bigquery_agent_analytics_plugin.TraceManager.push_span(
|
|
callback_context, "llm_request"
|
|
)
|
|
|
|
# Framework creates its own span via start_as_current_span
|
|
with framework_tracer.start_as_current_span("call_llm") as fw_span:
|
|
fw_context = fw_span.get_span_context()
|
|
|
|
# Pop the plugin span
|
|
bigquery_agent_analytics_plugin.TraceManager.pop_span()
|
|
|
|
provider.shutdown()
|
|
|
|
# Verify the framework span was NOT re-parented under the
|
|
# plugin's llm_request span
|
|
finished = exporter.get_finished_spans()
|
|
call_llm_spans = [s for s in finished if s.name == "call_llm"]
|
|
assert len(call_llm_spans) == 1
|
|
fw_finished = call_llm_spans[0]
|
|
|
|
# The framework span's parent should NOT be the plugin's
|
|
# llm_request span. With the fix, the plugin never
|
|
# attaches to the ambient context, so ``call_llm`` will
|
|
# have whatever parent existed before (None in this test).
|
|
assert fw_finished.parent is None
|
|
|
|
def test_multiple_push_pop_cycles_leave_context_clean(self, callback_context):
|
|
"""Multiple push/pop cycles must not leak context changes."""
|
|
original_span = trace.get_current_span()
|
|
|
|
for _ in range(5):
|
|
bigquery_agent_analytics_plugin.TraceManager.push_span(
|
|
callback_context, "cycle_span"
|
|
)
|
|
bigquery_agent_analytics_plugin.TraceManager.pop_span()
|
|
|
|
assert trace.get_current_span() is original_span
|
|
|
|
|
|
# ==============================================================================
|
|
# TEST CLASS: End-to-End HITL Tracing via Runner
|
|
# ==============================================================================
|
|
|
|
|
|
def _hitl_my_action(
|
|
tool_context: tool_context_lib.ToolContext,
|
|
) -> dict[str, str]:
|
|
"""Tool function used by HITL end-to-end tests."""
|
|
return {"result": f"confirmed={tool_context.tool_confirmation.confirmed}"}
|
|
|
|
|
|
class TestHITLTracingEndToEnd:
|
|
"""End-to-end tests that run the full Runner + Plugin pipeline with
|
|
|
|
``FunctionTool(require_confirmation=True)`` and verify that HITL events
|
|
are logged alongside normal TOOL_* events in the BQ analytics plugin.
|
|
"""
|
|
|
|
@pytest.fixture
|
|
def _mock_bq_infra(
|
|
self,
|
|
mock_auth_default,
|
|
mock_bq_client,
|
|
mock_write_client,
|
|
mock_to_arrow_schema,
|
|
mock_asyncio_to_thread,
|
|
):
|
|
"""Bundle all BQ mocking fixtures."""
|
|
yield mock_write_client
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_confirmation_flow_emits_hitl_events(
|
|
self,
|
|
_mock_bq_infra,
|
|
dummy_arrow_schema,
|
|
):
|
|
"""Full Runner pipeline: tool with require_confirmation emits
|
|
|
|
HITL_CONFIRMATION_REQUEST and HITL_CONFIRMATION_REQUEST_COMPLETED.
|
|
"""
|
|
from google.adk.flows.llm_flows.functions import REQUEST_CONFIRMATION_FUNCTION_CALL_NAME
|
|
from google.adk.tools.function_tool import FunctionTool
|
|
from google.genai.types import FunctionCall
|
|
from google.genai.types import FunctionResponse
|
|
from google.genai.types import Part
|
|
|
|
from .. import testing_utils
|
|
|
|
mock_write_client = _mock_bq_infra
|
|
|
|
tool = FunctionTool(func=_hitl_my_action, require_confirmation=True)
|
|
|
|
# -- Mock LLM: first response calls the tool, second is final text --
|
|
llm_responses = [
|
|
testing_utils.LlmResponse(
|
|
content=testing_utils.ModelContent(
|
|
parts=[
|
|
Part(function_call=FunctionCall(name=tool.name, args={}))
|
|
]
|
|
)
|
|
),
|
|
testing_utils.LlmResponse(
|
|
content=testing_utils.ModelContent(
|
|
parts=[Part(text="Done, action confirmed.")]
|
|
)
|
|
),
|
|
]
|
|
mock_model = testing_utils.MockModel(responses=llm_responses)
|
|
|
|
# -- Build the plugin --
|
|
bq_plugin = bigquery_agent_analytics_plugin.BigQueryAgentAnalyticsPlugin(
|
|
project_id=PROJECT_ID,
|
|
dataset_id=DATASET_ID,
|
|
table_id=TABLE_ID,
|
|
)
|
|
await bq_plugin._ensure_started()
|
|
mock_write_client.append_rows.reset_mock()
|
|
|
|
# -- Build agent + runner WITH the plugin --
|
|
from google.adk.agents.llm_agent import LlmAgent
|
|
|
|
agent = LlmAgent(name="hitl_agent", model=mock_model, tools=[tool])
|
|
runner = testing_utils.InMemoryRunner(root_agent=agent, plugins=[bq_plugin])
|
|
|
|
try:
|
|
# -- Turn 1: user query → LLM calls tool → HITL pause --
|
|
events_turn1 = await runner.run_async(
|
|
testing_utils.UserContent("run my_action")
|
|
)
|
|
|
|
# Find the adk_request_confirmation function call
|
|
confirmation_fc_id = None
|
|
for ev in events_turn1:
|
|
if ev.content and ev.content.parts:
|
|
for part in ev.content.parts:
|
|
if (
|
|
hasattr(part, "function_call")
|
|
and part.function_call
|
|
and part.function_call.name
|
|
== REQUEST_CONFIRMATION_FUNCTION_CALL_NAME
|
|
):
|
|
confirmation_fc_id = part.function_call.id
|
|
break
|
|
if confirmation_fc_id:
|
|
break
|
|
|
|
assert (
|
|
confirmation_fc_id is not None
|
|
), "Expected adk_request_confirmation function call in turn 1"
|
|
|
|
# -- Turn 2: user sends confirmation → tool re-executes --
|
|
user_confirmation = testing_utils.UserContent(
|
|
Part(
|
|
function_response=FunctionResponse(
|
|
id=confirmation_fc_id,
|
|
name=REQUEST_CONFIRMATION_FUNCTION_CALL_NAME,
|
|
response={"confirmed": True},
|
|
)
|
|
)
|
|
)
|
|
events_turn2 = await runner.run_async(user_confirmation)
|
|
|
|
# -- Deterministically wait for the async BQ writer to drain --
|
|
await bq_plugin.flush()
|
|
|
|
# -- Collect all BQ rows --
|
|
rows = await _get_captured_rows_async(
|
|
mock_write_client, dummy_arrow_schema
|
|
)
|
|
event_types = [r["event_type"] for r in rows]
|
|
|
|
# -- Verify standard events are present --
|
|
assert "TOOL_STARTING" in event_types
|
|
assert "TOOL_COMPLETED" in event_types
|
|
|
|
# -- Verify HITL-specific events are present --
|
|
assert (
|
|
"HITL_CONFIRMATION_REQUEST" in event_types
|
|
), f"Expected HITL_CONFIRMATION_REQUEST in {event_types}"
|
|
assert (
|
|
"HITL_CONFIRMATION_REQUEST_COMPLETED" in event_types
|
|
), f"Expected HITL_CONFIRMATION_REQUEST_COMPLETED in {event_types}"
|
|
|
|
# -- Verify HITL events have correct tool name in content --
|
|
hitl_rows = [r for r in rows if r["event_type"].startswith("HITL_")]
|
|
for row in hitl_rows:
|
|
content = json.loads(row["content"]) if row["content"] else {}
|
|
assert content.get("tool") == "adk_request_confirmation", (
|
|
"HITL event should reference 'adk_request_confirmation',"
|
|
f" got {content.get('tool')}"
|
|
)
|
|
finally:
|
|
await bq_plugin.shutdown()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_regular_tool_does_not_emit_hitl_events(
|
|
self,
|
|
_mock_bq_infra,
|
|
dummy_arrow_schema,
|
|
):
|
|
"""A tool WITHOUT require_confirmation should not produce HITL events."""
|
|
from google.adk.tools.function_tool import FunctionTool
|
|
from google.genai.types import FunctionCall
|
|
from google.genai.types import Part
|
|
|
|
from .. import testing_utils
|
|
|
|
mock_write_client = _mock_bq_infra
|
|
|
|
def regular_tool() -> str:
|
|
return "done"
|
|
|
|
tool = FunctionTool(func=regular_tool)
|
|
|
|
llm_responses = [
|
|
testing_utils.LlmResponse(
|
|
content=testing_utils.ModelContent(
|
|
parts=[
|
|
Part(function_call=FunctionCall(name=tool.name, args={}))
|
|
]
|
|
)
|
|
),
|
|
testing_utils.LlmResponse(
|
|
content=testing_utils.ModelContent(parts=[Part(text="All done.")])
|
|
),
|
|
]
|
|
mock_model = testing_utils.MockModel(responses=llm_responses)
|
|
|
|
bq_plugin = bigquery_agent_analytics_plugin.BigQueryAgentAnalyticsPlugin(
|
|
project_id=PROJECT_ID,
|
|
dataset_id=DATASET_ID,
|
|
table_id=TABLE_ID,
|
|
)
|
|
await bq_plugin._ensure_started()
|
|
mock_write_client.append_rows.reset_mock()
|
|
|
|
from google.adk.agents.llm_agent import LlmAgent
|
|
|
|
agent = LlmAgent(name="regular_agent", model=mock_model, tools=[tool])
|
|
runner = testing_utils.InMemoryRunner(root_agent=agent, plugins=[bq_plugin])
|
|
|
|
try:
|
|
await runner.run_async(testing_utils.UserContent("run regular_tool"))
|
|
await bq_plugin.flush()
|
|
|
|
rows = await _get_captured_rows_async(
|
|
mock_write_client, dummy_arrow_schema
|
|
)
|
|
event_types = [r["event_type"] for r in rows]
|
|
|
|
# Standard tool events should be present
|
|
assert "TOOL_STARTING" in event_types
|
|
assert "TOOL_COMPLETED" in event_types
|
|
|
|
# No HITL events
|
|
hitl_events = [et for et in event_types if et.startswith("HITL_")]
|
|
assert (
|
|
hitl_events == []
|
|
), f"Expected no HITL events for regular tool, got {hitl_events}"
|
|
finally:
|
|
await bq_plugin.shutdown()
|
|
|
|
|
|
# ==============================================================================
|
|
# Fork-Safety Tests
|
|
# ==============================================================================
|
|
class TestForkSafety:
|
|
"""Tests for fork-safety via PID tracking."""
|
|
|
|
def _make_plugin(self):
|
|
config = bigquery_agent_analytics_plugin.BigQueryLoggerConfig()
|
|
plugin = bigquery_agent_analytics_plugin.BigQueryAgentAnalyticsPlugin(
|
|
project_id=PROJECT_ID,
|
|
dataset_id=DATASET_ID,
|
|
table_id=TABLE_ID,
|
|
config=config,
|
|
)
|
|
return plugin
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_pid_change_triggers_reinit(
|
|
self, mock_auth_default, mock_bq_client, mock_write_client
|
|
):
|
|
"""Simulating a fork by changing _init_pid forces re-init."""
|
|
plugin = self._make_plugin()
|
|
await plugin._ensure_started()
|
|
assert plugin._started is True
|
|
|
|
# Simulate a fork: set _init_pid to a stale value
|
|
plugin._init_pid = -1
|
|
assert plugin._started is True # still True before check
|
|
|
|
# _ensure_started should detect PID mismatch and reset
|
|
await plugin._ensure_started()
|
|
# After reset + re-init, _init_pid should match current
|
|
|
|
assert plugin._init_pid == os.getpid()
|
|
assert plugin._started is True
|
|
await plugin.shutdown()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_pid_unchanged_skips_reset(
|
|
self, mock_auth_default, mock_bq_client, mock_write_client
|
|
):
|
|
"""Same PID should not trigger a reset."""
|
|
plugin = self._make_plugin()
|
|
await plugin._ensure_started()
|
|
|
|
# Save references to verify they are not recreated
|
|
original_client = plugin.client
|
|
original_parser = plugin.parser
|
|
|
|
await plugin._ensure_started()
|
|
assert plugin.client is original_client
|
|
assert plugin.parser is original_parser
|
|
await plugin.shutdown()
|
|
|
|
def test_reset_runtime_state_clears_fields(self):
|
|
"""_reset_runtime_state clears all runtime fields."""
|
|
plugin = self._make_plugin()
|
|
# Fake some runtime state
|
|
plugin._started = True
|
|
plugin._is_shutting_down = True
|
|
plugin.client = mock.MagicMock()
|
|
plugin._loop_state_by_loop = {"fake": "state"}
|
|
plugin._write_stream_name = "some/stream"
|
|
plugin._executor = mock.MagicMock()
|
|
plugin.offloader = mock.MagicMock()
|
|
plugin.parser = mock.MagicMock()
|
|
plugin._setup_future = mock.MagicMock()
|
|
# Keep pure-data fields
|
|
plugin._schema = ["kept"]
|
|
plugin.arrow_schema = "kept_arrow"
|
|
|
|
plugin._reset_runtime_state()
|
|
|
|
assert plugin._started is False
|
|
assert plugin._is_shutting_down is False
|
|
assert plugin.client is None
|
|
assert plugin._loop_state_by_loop == {}
|
|
assert plugin._write_stream_name is None
|
|
assert plugin._executor is None
|
|
assert plugin.offloader is None
|
|
assert plugin.parser is None
|
|
assert plugin._setup_future is None
|
|
# Pure-data fields are preserved
|
|
assert plugin._schema == ["kept"]
|
|
assert plugin.arrow_schema == "kept_arrow"
|
|
|
|
assert plugin._init_pid == os.getpid()
|
|
|
|
def test_getstate_resets_pid(self):
|
|
"""Pickle state should have _init_pid = 0 to force re-init."""
|
|
plugin = self._make_plugin()
|
|
state = plugin.__getstate__()
|
|
assert state["_init_pid"] == 0
|
|
assert state["_started"] is False
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_unpickle_legacy_state_missing_init_pid(
|
|
self, mock_auth_default, mock_bq_client, mock_write_client
|
|
):
|
|
"""Unpickling state from older code without _init_pid should not crash."""
|
|
plugin = self._make_plugin()
|
|
state = plugin.__getstate__()
|
|
# Simulate legacy pickle state that lacks _init_pid entirely
|
|
del state["_init_pid"]
|
|
|
|
new_plugin = (
|
|
bigquery_agent_analytics_plugin.BigQueryAgentAnalyticsPlugin.__new__(
|
|
bigquery_agent_analytics_plugin.BigQueryAgentAnalyticsPlugin
|
|
)
|
|
)
|
|
new_plugin.__setstate__(state)
|
|
|
|
# _init_pid should be backfilled to 0, triggering re-init
|
|
assert new_plugin._init_pid == 0
|
|
# _ensure_started should not raise AttributeError
|
|
await new_plugin._ensure_started()
|
|
assert new_plugin._started is True
|
|
await new_plugin.shutdown()
|
|
|
|
|
|
class TestForkGrpcSafety:
|
|
"""Tests for gRPC fork safety enhancements."""
|
|
|
|
def _make_plugin(self):
|
|
config = bigquery_agent_analytics_plugin.BigQueryLoggerConfig()
|
|
return bigquery_agent_analytics_plugin.BigQueryAgentAnalyticsPlugin(
|
|
project_id=PROJECT_ID,
|
|
dataset_id=DATASET_ID,
|
|
table_id=TABLE_ID,
|
|
config=config,
|
|
)
|
|
|
|
def test_grpc_fork_env_var_set(self):
|
|
"""GRPC_ENABLE_FORK_SUPPORT should be '1' after import."""
|
|
|
|
assert os.environ.get("GRPC_ENABLE_FORK_SUPPORT") == "1"
|
|
|
|
def test_register_at_fork_resets_all_instances(self):
|
|
"""_after_fork_in_child resets all living plugin instances."""
|
|
p1 = self._make_plugin()
|
|
p2 = self._make_plugin()
|
|
p1._started = True
|
|
p2._started = True
|
|
p1._init_pid = -1
|
|
p2._init_pid = -1
|
|
|
|
bigquery_agent_analytics_plugin._after_fork_in_child()
|
|
|
|
assert p1._started is False
|
|
assert p2._started is False
|
|
assert p1._init_pid == os.getpid()
|
|
assert p2._init_pid == os.getpid()
|
|
|
|
def test_dead_plugin_removed_from_live_set(self):
|
|
"""WeakSet should not hold dead plugin references."""
|
|
p = self._make_plugin()
|
|
assert p in bigquery_agent_analytics_plugin._LIVE_PLUGINS
|
|
pid = id(p)
|
|
del p
|
|
# After deletion, the WeakSet should no longer contain it.
|
|
for alive in bigquery_agent_analytics_plugin._LIVE_PLUGINS:
|
|
assert id(alive) != pid
|
|
|
|
def test_reset_closes_inherited_sync_transports(self):
|
|
"""_reset_runtime_state closes inherited sync gRPC channels."""
|
|
plugin = self._make_plugin()
|
|
mock_channel = mock.MagicMock()
|
|
mock_channel.close.return_value = None # sync close
|
|
mock_transport = mock.MagicMock()
|
|
mock_transport._grpc_channel = mock_channel
|
|
mock_wc = mock.MagicMock()
|
|
mock_wc.transport = mock_transport
|
|
|
|
mock_loop_state = mock.MagicMock()
|
|
mock_loop_state.write_client = mock_wc
|
|
|
|
plugin._loop_state_by_loop = {mock.MagicMock(): mock_loop_state}
|
|
plugin._init_pid = -1
|
|
|
|
plugin._reset_runtime_state()
|
|
|
|
mock_channel.close.assert_called_once()
|
|
|
|
def test_reset_discards_async_channel_close_coroutine(self):
|
|
"""Async channel close() returns a coroutine; must not warn."""
|
|
import warnings
|
|
|
|
plugin = self._make_plugin()
|
|
|
|
async def _async_close():
|
|
pass
|
|
|
|
mock_channel = mock.MagicMock()
|
|
mock_channel.close.return_value = _async_close()
|
|
mock_transport = mock.MagicMock()
|
|
mock_transport._grpc_channel = mock_channel
|
|
mock_wc = mock.MagicMock()
|
|
mock_wc.transport = mock_transport
|
|
|
|
mock_loop_state = mock.MagicMock()
|
|
mock_loop_state.write_client = mock_wc
|
|
|
|
plugin._loop_state_by_loop = {mock.MagicMock(): mock_loop_state}
|
|
plugin._init_pid = -1
|
|
|
|
with warnings.catch_warnings():
|
|
warnings.simplefilter("error", RuntimeWarning)
|
|
# Must not raise RuntimeWarning for unawaited coroutine
|
|
plugin._reset_runtime_state()
|
|
|
|
mock_channel.close.assert_called_once()
|
|
|
|
def test_transport_close_exception_swallowed(self):
|
|
"""close() raising should not prevent reset from completing."""
|
|
plugin = self._make_plugin()
|
|
mock_channel = mock.MagicMock()
|
|
mock_channel.close.side_effect = RuntimeError("broken channel")
|
|
mock_transport = mock.MagicMock()
|
|
mock_transport._grpc_channel = mock_channel
|
|
mock_wc = mock.MagicMock()
|
|
mock_wc.transport = mock_transport
|
|
|
|
mock_loop_state = mock.MagicMock()
|
|
mock_loop_state.write_client = mock_wc
|
|
|
|
plugin._loop_state_by_loop = {mock.MagicMock(): mock_loop_state}
|
|
plugin._init_pid = -1
|
|
|
|
# Should not raise
|
|
plugin._reset_runtime_state()
|
|
|
|
assert plugin._started is False
|
|
assert plugin._loop_state_by_loop == {}
|
|
|
|
def test_reset_logs_fork_warning(self):
|
|
"""_reset_runtime_state logs a warning with 'Fork detected'."""
|
|
plugin = self._make_plugin()
|
|
plugin._init_pid = -1
|
|
|
|
with mock.patch.object(
|
|
bigquery_agent_analytics_plugin.logger, "warning"
|
|
) as mock_warn:
|
|
plugin._reset_runtime_state()
|
|
|
|
mock_warn.assert_called_once()
|
|
assert "Fork detected" in mock_warn.call_args[0][0]
|
|
|
|
|
|
# ==============================================================================
|
|
# Analytics Views Tests
|
|
# ==============================================================================
|
|
class TestAnalyticsViews:
|
|
"""Tests for auto-created per-event-type BigQuery views."""
|
|
|
|
def _make_plugin(self, create_views=True, view_prefix="v", table_id=TABLE_ID):
|
|
config = bigquery_agent_analytics_plugin.BigQueryLoggerConfig(
|
|
create_views=create_views,
|
|
view_prefix=view_prefix,
|
|
)
|
|
plugin = bigquery_agent_analytics_plugin.BigQueryAgentAnalyticsPlugin(
|
|
project_id=PROJECT_ID,
|
|
dataset_id=DATASET_ID,
|
|
table_id=table_id,
|
|
config=config,
|
|
)
|
|
plugin.client = mock.MagicMock()
|
|
plugin.full_table_id = f"{PROJECT_ID}.{DATASET_ID}.{table_id}"
|
|
plugin._schema = bigquery_agent_analytics_plugin._get_events_schema()
|
|
return plugin
|
|
|
|
def test_views_created_on_new_table(self):
|
|
"""NotFound path creates all views."""
|
|
plugin = self._make_plugin(create_views=True)
|
|
plugin.client.get_table.side_effect = cloud_exceptions.NotFound("not found")
|
|
mock_query_job = mock.MagicMock()
|
|
plugin.client.query.return_value = mock_query_job
|
|
|
|
plugin._ensure_schema_exists()
|
|
|
|
expected_count = len(bigquery_agent_analytics_plugin._EVENT_VIEW_DEFS)
|
|
assert plugin.client.query.call_count == expected_count
|
|
|
|
def test_views_created_for_existing_table(self):
|
|
"""Existing table path also creates views."""
|
|
plugin = self._make_plugin(create_views=True)
|
|
existing = mock.MagicMock(spec=bigquery.Table)
|
|
existing.schema = plugin._schema
|
|
existing.labels = {
|
|
bigquery_agent_analytics_plugin._SCHEMA_VERSION_LABEL_KEY: (
|
|
bigquery_agent_analytics_plugin._SCHEMA_VERSION
|
|
),
|
|
}
|
|
plugin.client.get_table.return_value = existing
|
|
mock_query_job = mock.MagicMock()
|
|
plugin.client.query.return_value = mock_query_job
|
|
|
|
plugin._ensure_schema_exists()
|
|
|
|
expected_count = len(bigquery_agent_analytics_plugin._EVENT_VIEW_DEFS)
|
|
assert plugin.client.query.call_count == expected_count
|
|
|
|
def test_views_not_created_when_disabled(self):
|
|
"""create_views=False skips view creation."""
|
|
plugin = self._make_plugin(create_views=False)
|
|
plugin.client.get_table.side_effect = cloud_exceptions.NotFound("not found")
|
|
|
|
plugin._ensure_schema_exists()
|
|
|
|
plugin.client.query.assert_not_called()
|
|
|
|
def test_view_creation_error_logged_not_raised(self):
|
|
"""Errors during view creation don't crash the plugin."""
|
|
plugin = self._make_plugin(create_views=True)
|
|
plugin.client.get_table.side_effect = cloud_exceptions.NotFound("not found")
|
|
plugin.client.query.side_effect = Exception("BQ error")
|
|
|
|
# Should not raise
|
|
plugin._ensure_schema_exists()
|
|
|
|
# Verify it tried to create views (and failed gracefully)
|
|
assert plugin.client.query.call_count > 0
|
|
|
|
def test_view_sql_contains_correct_event_filter(self):
|
|
"""Each SQL has correct WHERE clause and view name."""
|
|
plugin = self._make_plugin(create_views=True)
|
|
plugin.client.get_table.side_effect = cloud_exceptions.NotFound("not found")
|
|
mock_query_job = mock.MagicMock()
|
|
plugin.client.query.return_value = mock_query_job
|
|
|
|
plugin._ensure_schema_exists()
|
|
|
|
calls = plugin.client.query.call_args_list
|
|
for call in calls:
|
|
sql = call[0][0]
|
|
# Each SQL should have CREATE OR REPLACE VIEW
|
|
assert "CREATE OR REPLACE VIEW" in sql
|
|
# Each SQL should filter by event_type
|
|
assert "WHERE" in sql
|
|
assert "event_type = " in sql
|
|
# View name should start with v_
|
|
assert ".v_" in sql
|
|
|
|
# Verify specific views exist
|
|
all_sql = " ".join(c[0][0] for c in calls)
|
|
for event_type in bigquery_agent_analytics_plugin._EVENT_VIEW_DEFS:
|
|
view_name = "v_" + event_type.lower()
|
|
assert view_name in all_sql, f"View {view_name} not found in SQL"
|
|
|
|
def test_error_views_contain_traceback_column(self):
|
|
"""AGENT_ERROR and INVOCATION_ERROR views include error_traceback."""
|
|
plugin = self._make_plugin(create_views=True)
|
|
plugin.client.get_table.side_effect = cloud_exceptions.NotFound("not found")
|
|
mock_query_job = mock.MagicMock()
|
|
plugin.client.query.return_value = mock_query_job
|
|
|
|
plugin._ensure_schema_exists()
|
|
|
|
calls = plugin.client.query.call_args_list
|
|
all_sqls = {c[0][0] for c in calls}
|
|
|
|
agent_error_sqls = [s for s in all_sqls if "v_agent_error" in s]
|
|
assert len(agent_error_sqls) == 1
|
|
assert "error_traceback" in agent_error_sqls[0]
|
|
assert "total_ms" in agent_error_sqls[0]
|
|
|
|
inv_error_sqls = [s for s in all_sqls if "v_invocation_error" in s]
|
|
assert len(inv_error_sqls) == 1
|
|
assert "error_traceback" in inv_error_sqls[0]
|
|
|
|
def test_llm_response_view_exposes_token_usage_columns(self):
|
|
"""LLM_RESPONSE view surfaces cached/thinking/tool-use token columns.
|
|
|
|
These are read from the full ``usage_metadata`` proto that is already
|
|
logged to ``attributes.usage_metadata``, so they are sourced from
|
|
``attributes`` rather than the ``content.usage`` summary.
|
|
"""
|
|
plugin = self._make_plugin(create_views=True)
|
|
plugin.client.get_table.side_effect = cloud_exceptions.NotFound("not found")
|
|
plugin.client.query.return_value = mock.MagicMock()
|
|
|
|
plugin._ensure_schema_exists()
|
|
|
|
all_sql = " ".join(c[0][0] for c in plugin.client.query.call_args_list)
|
|
assert "usage_cached_tokens" in all_sql
|
|
assert "usage_thinking_tokens" in all_sql
|
|
assert "usage_tool_use_tokens" in all_sql
|
|
assert "$.usage_metadata.thoughts_token_count" in all_sql
|
|
assert "$.usage_metadata.tool_use_prompt_token_count" in all_sql
|
|
|
|
def test_llm_response_view_exposes_finish_reason(self):
|
|
"""LLM_RESPONSE views expose the termination reason as a typed column."""
|
|
columns = bigquery_agent_analytics_plugin._EVENT_VIEW_DEFS["LLM_RESPONSE"]
|
|
|
|
assert (
|
|
"JSON_VALUE(attributes, '$.finish_reason') AS finish_reason" in columns
|
|
)
|
|
|
|
@pytest.mark.parametrize("event_type", ["NODE_OUTPUT", "NODE_ERROR"])
|
|
def test_node_views_expose_workflow_identity(self, event_type):
|
|
"""Workflow-node views expose stable node identity columns."""
|
|
columns = bigquery_agent_analytics_plugin._EVENT_VIEW_DEFS[event_type]
|
|
|
|
assert "JSON_VALUE(attributes, '$.adk.node.path') AS node_path" in columns
|
|
assert (
|
|
"JSON_VALUE(attributes, '$.adk.node.run_id') AS node_run_id" in columns
|
|
)
|
|
|
|
def test_config_create_views_default_true(self):
|
|
"""Config create_views defaults to True."""
|
|
config = bigquery_agent_analytics_plugin.BigQueryLoggerConfig()
|
|
assert config.create_views is True
|
|
assert config.view_prefix == "v"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_create_analytics_views_ensures_started(
|
|
self, mock_auth_default, mock_bq_client, mock_write_client
|
|
):
|
|
"""Public create_analytics_views() initializes plugin first."""
|
|
plugin = bigquery_agent_analytics_plugin.BigQueryAgentAnalyticsPlugin(
|
|
project_id=PROJECT_ID,
|
|
dataset_id=DATASET_ID,
|
|
table_id=TABLE_ID,
|
|
)
|
|
assert plugin._started is False
|
|
|
|
await plugin.create_analytics_views()
|
|
|
|
# Plugin should be started after the call
|
|
assert plugin._started is True
|
|
# Views should have been created (query called)
|
|
expected_count = len(bigquery_agent_analytics_plugin._EVENT_VIEW_DEFS)
|
|
# _ensure_schema_exists also creates views, so total calls
|
|
# = schema-creation views + explicit views
|
|
assert mock_bq_client.query.call_count >= expected_count
|
|
await plugin.shutdown()
|
|
|
|
def test_views_not_created_after_table_creation_failure(self):
|
|
"""create_table failure raises (fail setup) and skips views."""
|
|
plugin = self._make_plugin(create_views=True)
|
|
plugin.client.get_table.side_effect = cloud_exceptions.NotFound("not found")
|
|
plugin.client.create_table.side_effect = RuntimeError("BQ down")
|
|
|
|
# Table readiness is a startup requirement: the failure propagates so
|
|
# _ensure_started keeps _started=False and retries later, instead of
|
|
# marking the plugin started against a missing table.
|
|
with pytest.raises(RuntimeError, match="BQ down"):
|
|
plugin._ensure_schema_exists()
|
|
|
|
# Views should NOT be attempted since table creation failed
|
|
plugin.client.query.assert_not_called()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_create_analytics_views_raises_on_startup_failure(
|
|
self, mock_auth_default, mock_write_client
|
|
):
|
|
"""create_analytics_views() raises if plugin init fails."""
|
|
# Make the BQ Client constructor raise so _lazy_setup fails
|
|
# before _started is set to True.
|
|
with mock.patch.object(
|
|
bigquery, "Client", side_effect=Exception("client boom")
|
|
):
|
|
plugin = bigquery_agent_analytics_plugin.BigQueryAgentAnalyticsPlugin(
|
|
project_id=PROJECT_ID,
|
|
dataset_id=DATASET_ID,
|
|
table_id=TABLE_ID,
|
|
)
|
|
with pytest.raises(
|
|
RuntimeError, match="Plugin initialization failed"
|
|
) as exc_info:
|
|
await plugin.create_analytics_views()
|
|
# Root cause should be chained for debuggability
|
|
assert exc_info.value.__cause__ is not None
|
|
assert "client boom" in str(exc_info.value.__cause__)
|
|
|
|
def test_custom_view_prefix(self):
|
|
"""Custom view_prefix namespaces view names."""
|
|
plugin = self._make_plugin(view_prefix="v_staging")
|
|
plugin.client.get_table.side_effect = cloud_exceptions.NotFound("not found")
|
|
mock_query_job = mock.MagicMock()
|
|
plugin.client.query.return_value = mock_query_job
|
|
|
|
plugin._ensure_schema_exists()
|
|
|
|
calls = plugin.client.query.call_args_list
|
|
all_sql = " ".join(c[0][0] for c in calls)
|
|
# All views should use the custom prefix
|
|
for event_type in bigquery_agent_analytics_plugin._EVENT_VIEW_DEFS:
|
|
expected_name = "v_staging_" + event_type.lower()
|
|
assert expected_name in all_sql, f"View {expected_name} not found in SQL"
|
|
# Default prefix should NOT appear
|
|
assert ".v_llm_request" not in all_sql
|
|
|
|
def test_default_view_prefix_preserves_names(self):
|
|
"""Default view_prefix='v' produces the same names as before."""
|
|
plugin = self._make_plugin() # default view_prefix="v"
|
|
plugin.client.get_table.side_effect = cloud_exceptions.NotFound("not found")
|
|
mock_query_job = mock.MagicMock()
|
|
plugin.client.query.return_value = mock_query_job
|
|
|
|
plugin._ensure_schema_exists()
|
|
|
|
calls = plugin.client.query.call_args_list
|
|
all_sql = " ".join(c[0][0] for c in calls)
|
|
for event_type in bigquery_agent_analytics_plugin._EVENT_VIEW_DEFS:
|
|
view_name = "v_" + event_type.lower()
|
|
assert view_name in all_sql
|
|
|
|
def test_distinct_tables_and_prefixes_no_collision(self):
|
|
"""Two plugins targeting different tables produce disjoint views."""
|
|
plugin_a = self._make_plugin(
|
|
table_id="agent_events_prod", view_prefix="v_prod"
|
|
)
|
|
plugin_b = self._make_plugin(
|
|
table_id="agent_events_staging", view_prefix="v_staging"
|
|
)
|
|
|
|
for plugin in (plugin_a, plugin_b):
|
|
plugin.client.get_table.side_effect = cloud_exceptions.NotFound(
|
|
"not found"
|
|
)
|
|
mock_query_job = mock.MagicMock()
|
|
plugin.client.query.return_value = mock_query_job
|
|
plugin._ensure_schema_exists()
|
|
|
|
sql_a = " ".join(c[0][0] for c in plugin_a.client.query.call_args_list)
|
|
sql_b = " ".join(c[0][0] for c in plugin_b.client.query.call_args_list)
|
|
|
|
# View names use their own prefix
|
|
assert "v_prod_llm_request" in sql_a
|
|
assert "v_staging_llm_request" in sql_b
|
|
# No cross-contamination
|
|
assert "v_staging_" not in sql_a
|
|
assert "v_prod_" not in sql_b
|
|
|
|
# FROM clauses point at the correct table
|
|
assert "agent_events_prod" in sql_a
|
|
assert "agent_events_staging" not in sql_a
|
|
assert "agent_events_staging" in sql_b
|
|
assert "agent_events_prod" not in sql_b
|
|
|
|
def test_empty_view_prefix_raises(self):
|
|
"""Empty view_prefix is rejected at init."""
|
|
with pytest.raises(ValueError, match="view_prefix"):
|
|
self._make_plugin(view_prefix="")
|
|
|
|
|
|
# ==============================================================================
|
|
# Trace-ID Continuity Tests
|
|
# ==============================================================================
|
|
class TestTraceIdContinuity:
|
|
"""Tests for trace_id continuity across all events in an invocation.
|
|
|
|
When there is no ambient OTel span (e.g. Agent Engine, custom runners),
|
|
early events (USER_MESSAGE_RECEIVED, INVOCATION_STARTING) used to fall
|
|
back to ``invocation_id`` while AGENT_STARTING got a new OTel hex
|
|
trace_id from ``push_span()``. The ``ensure_invocation_span()`` fix
|
|
guarantees a root span is always on the stack before any events fire.
|
|
"""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_trace_id_continuity_no_ambient_span(self, callback_context):
|
|
"""All events share one trace_id when no ambient OTel span exists.
|
|
|
|
Simulates the broken-continuity scenario: OTel IS configured (real
|
|
TracerProvider) but the Runner's ambient span is NOT present (e.g. Agent
|
|
Engine, custom runners).
|
|
"""
|
|
from opentelemetry.sdk.trace import TracerProvider as SdkProvider
|
|
from opentelemetry.sdk.trace.export import SimpleSpanProcessor
|
|
from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter
|
|
|
|
TM = bigquery_agent_analytics_plugin.TraceManager
|
|
|
|
# Wire a real TracerProvider with an in-memory exporter so we can
|
|
# also assert the plugin path does NOT export anything through it.
|
|
# (push_span no longer creates OTel spans — see _SpanRecord; the
|
|
# exporter is here as a regression guard, not a span source.)
|
|
exporter = InMemorySpanExporter()
|
|
provider = SdkProvider()
|
|
provider.add_span_processor(SimpleSpanProcessor(exporter))
|
|
real_tracer = provider.get_tracer("test-plugin")
|
|
|
|
# Reset the span records contextvar for a clean invocation.
|
|
bigquery_agent_analytics_plugin._span_records_ctx.set(None)
|
|
|
|
# No ambient OTel span — we do NOT start_as_current_span.
|
|
ambient = trace.get_current_span()
|
|
assert not ambient.get_span_context().is_valid
|
|
|
|
# ensure_invocation_span should push a new span.
|
|
TM.ensure_invocation_span(callback_context)
|
|
trace_id_early = TM.get_trace_id(callback_context)
|
|
assert trace_id_early is not None
|
|
# Should NOT fall back to invocation_id — it should be
|
|
# a 32-char hex OTel trace_id.
|
|
assert trace_id_early != callback_context.invocation_id
|
|
assert len(trace_id_early) == 32
|
|
|
|
# Simulate agent callback: push_span("agent")
|
|
TM.push_span(callback_context, "agent")
|
|
trace_id_agent = TM.get_trace_id(callback_context)
|
|
|
|
# Both trace_ids must be identical.
|
|
assert trace_id_early == trace_id_agent
|
|
|
|
# Cleanup
|
|
TM.pop_span() # agent
|
|
TM.pop_span() # invocation
|
|
|
|
provider.shutdown()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_invocation_completed_trace_continuity_no_ambient(
|
|
self, callback_context
|
|
):
|
|
"""INVOCATION_COMPLETED must share trace_id with earlier events.
|
|
|
|
Reproduces the completion-event fracture: after_run_callback pops
|
|
the invocation span, then _log_event would resolve trace_id via
|
|
the fallback to invocation_id. The trace_id_override ensures the
|
|
completion event keeps the same trace_id.
|
|
"""
|
|
from opentelemetry.sdk.trace import TracerProvider as SdkProvider
|
|
from opentelemetry.sdk.trace.export import SimpleSpanProcessor
|
|
from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter
|
|
|
|
TM = bigquery_agent_analytics_plugin.TraceManager
|
|
|
|
exporter = InMemorySpanExporter()
|
|
provider = SdkProvider()
|
|
provider.add_span_processor(SimpleSpanProcessor(exporter))
|
|
real_tracer = provider.get_tracer("test-plugin")
|
|
|
|
# Reset for a clean invocation; no ambient span.
|
|
bigquery_agent_analytics_plugin._span_records_ctx.set(None)
|
|
assert not trace.get_current_span().get_span_context().is_valid
|
|
|
|
# --- Simulate the full callback lifecycle ---
|
|
# 1. before_run / on_user_message: ensure invocation span
|
|
TM.ensure_invocation_span(callback_context)
|
|
trace_id_start = TM.get_trace_id(callback_context)
|
|
|
|
# 2. before_agent: push agent span
|
|
TM.push_span(callback_context, "agent")
|
|
assert TM.get_trace_id(callback_context) == trace_id_start
|
|
|
|
# 3. after_agent: pop agent span
|
|
TM.pop_span()
|
|
|
|
# 4. after_run: capture trace_id THEN pop invocation span
|
|
trace_id_before_pop = TM.get_trace_id(callback_context)
|
|
assert trace_id_before_pop == trace_id_start
|
|
|
|
TM.pop_span()
|
|
|
|
# After popping, get_trace_id falls back to invocation_id
|
|
trace_id_after_pop = TM.get_trace_id(callback_context)
|
|
assert trace_id_after_pop == callback_context.invocation_id
|
|
|
|
# The trace_id_override preserves continuity
|
|
assert trace_id_before_pop == trace_id_start
|
|
assert trace_id_before_pop != trace_id_after_pop
|
|
|
|
provider.shutdown()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_callbacks_emit_same_trace_id_no_ambient(
|
|
self,
|
|
bq_plugin_inst,
|
|
mock_write_client,
|
|
invocation_context,
|
|
callback_context,
|
|
mock_agent,
|
|
dummy_arrow_schema,
|
|
):
|
|
"""Full callback path: all emitted rows share one trace_id.
|
|
|
|
Exercises the real before_run → before_agent → after_agent →
|
|
after_run callback chain via the plugin instance, then checks
|
|
every emitted BQ row has the same trace_id.
|
|
"""
|
|
from opentelemetry.sdk.trace import TracerProvider as SdkProvider
|
|
from opentelemetry.sdk.trace.export import SimpleSpanProcessor
|
|
from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter
|
|
|
|
exporter = InMemorySpanExporter()
|
|
provider = SdkProvider()
|
|
provider.add_span_processor(SimpleSpanProcessor(exporter))
|
|
real_tracer = provider.get_tracer("test-plugin")
|
|
|
|
# Reset span records for a clean invocation.
|
|
bigquery_agent_analytics_plugin._span_records_ctx.set(None)
|
|
|
|
# No ambient span — simulates Agent Engine / custom runner.
|
|
assert not trace.get_current_span().get_span_context().is_valid
|
|
|
|
# Run the full callback lifecycle.
|
|
await bq_plugin_inst.before_run_callback(
|
|
invocation_context=invocation_context
|
|
)
|
|
await bq_plugin_inst.before_agent_callback(
|
|
agent=mock_agent, callback_context=callback_context
|
|
)
|
|
await bq_plugin_inst.after_agent_callback(
|
|
agent=mock_agent, callback_context=callback_context
|
|
)
|
|
await bq_plugin_inst.after_run_callback(
|
|
invocation_context=invocation_context
|
|
)
|
|
await bq_plugin_inst.flush()
|
|
|
|
# Collect all emitted rows.
|
|
rows = await _get_captured_rows_async(mock_write_client, dummy_arrow_schema)
|
|
event_types = [r["event_type"] for r in rows]
|
|
assert "INVOCATION_STARTING" in event_types
|
|
assert "INVOCATION_COMPLETED" in event_types
|
|
|
|
# Every row must share the same trace_id.
|
|
trace_ids = {r["trace_id"] for r in rows}
|
|
assert len(trace_ids) == 1, (
|
|
"Expected 1 unique trace_id across all events, got"
|
|
f" {len(trace_ids)}: {trace_ids}"
|
|
)
|
|
# Should be a 32-char hex OTel trace, not the invocation_id.
|
|
sole_trace_id = trace_ids.pop()
|
|
assert sole_trace_id != invocation_context.invocation_id
|
|
assert len(sole_trace_id) == 32
|
|
|
|
provider.shutdown()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_trace_id_continuity_with_ambient_span(self, callback_context):
|
|
"""All events share one trace_id when an ambient OTel span exists."""
|
|
from opentelemetry.sdk.trace import TracerProvider as SdkProvider
|
|
from opentelemetry.sdk.trace.export import SimpleSpanProcessor
|
|
from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter
|
|
|
|
TM = bigquery_agent_analytics_plugin.TraceManager
|
|
|
|
# Set up a real OTel tracer.
|
|
exporter = InMemorySpanExporter()
|
|
provider = SdkProvider()
|
|
provider.add_span_processor(SimpleSpanProcessor(exporter))
|
|
real_tracer = provider.get_tracer("test")
|
|
|
|
# Reset the span records contextvar.
|
|
bigquery_agent_analytics_plugin._span_records_ctx.set(None)
|
|
|
|
with real_tracer.start_as_current_span("runner_invocation"):
|
|
ambient = trace.get_current_span()
|
|
assert ambient.get_span_context().is_valid
|
|
ambient_trace_id = format(ambient.get_span_context().trace_id, "032x")
|
|
|
|
# ensure_invocation_span should attach the ambient span.
|
|
TM.ensure_invocation_span(callback_context)
|
|
trace_id_early = TM.get_trace_id(callback_context)
|
|
assert trace_id_early == ambient_trace_id
|
|
|
|
# Simulate agent callback: push_span("agent")
|
|
TM.push_span(callback_context, "agent")
|
|
trace_id_agent = TM.get_trace_id(callback_context)
|
|
assert trace_id_agent == ambient_trace_id
|
|
|
|
# Cleanup
|
|
TM.pop_span() # agent
|
|
TM.pop_span() # invocation (attached, not owned)
|
|
|
|
provider.shutdown()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_invocation_root_span_isolated_across_turns(
|
|
self, callback_context
|
|
):
|
|
"""Each invocation gets its own root span; turns don't leak."""
|
|
from opentelemetry.sdk.trace import TracerProvider as SdkProvider
|
|
from opentelemetry.sdk.trace.export import SimpleSpanProcessor
|
|
from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter
|
|
|
|
TM = bigquery_agent_analytics_plugin.TraceManager
|
|
|
|
exporter = InMemorySpanExporter()
|
|
provider = SdkProvider()
|
|
provider.add_span_processor(SimpleSpanProcessor(exporter))
|
|
real_tracer = provider.get_tracer("test")
|
|
|
|
# --- Turn 1 ---
|
|
bigquery_agent_analytics_plugin._span_records_ctx.set(None)
|
|
TM.ensure_invocation_span(callback_context)
|
|
trace_id_turn1 = TM.get_trace_id(callback_context)
|
|
|
|
TM.push_span(callback_context, "agent")
|
|
assert TM.get_trace_id(callback_context) == trace_id_turn1
|
|
TM.pop_span() # agent
|
|
TM.pop_span() # invocation
|
|
|
|
# After popping, the stack should be empty.
|
|
records = bigquery_agent_analytics_plugin._span_records_ctx.get()
|
|
assert not records
|
|
|
|
# --- Turn 2 ---
|
|
bigquery_agent_analytics_plugin._span_records_ctx.set(None)
|
|
TM.ensure_invocation_span(callback_context)
|
|
trace_id_turn2 = TM.get_trace_id(callback_context)
|
|
|
|
TM.push_span(callback_context, "agent")
|
|
assert TM.get_trace_id(callback_context) == trace_id_turn2
|
|
TM.pop_span() # agent
|
|
TM.pop_span() # invocation
|
|
|
|
# The two turns must have DIFFERENT trace_ids (different
|
|
# root spans).
|
|
assert trace_id_turn1 != trace_id_turn2
|
|
|
|
provider.shutdown()
|
|
|
|
|
|
class TestSpanIdConsistency:
|
|
"""Tests that STARTING/COMPLETED event pairs share span IDs.
|
|
|
|
Span-ID resolution contract:
|
|
- When OTel is active: BQ rows use the same trace/span/parent IDs as
|
|
Cloud Trace (ambient framework spans). STARTING and COMPLETED events
|
|
in the same lifecycle share the same span_id.
|
|
- When OTel is not active: BQ rows use the plugin's internal span
|
|
stack. STARTING gets the current top-of-stack; COMPLETED gets the
|
|
popped span.
|
|
"""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_starting_completed_same_span_with_ambient(
|
|
self,
|
|
bq_plugin_inst,
|
|
mock_write_client,
|
|
invocation_context,
|
|
callback_context,
|
|
mock_agent,
|
|
dummy_arrow_schema,
|
|
):
|
|
"""With ambient OTel, STARTING and COMPLETED get the same span_id."""
|
|
from opentelemetry.sdk.trace import TracerProvider as SdkProvider
|
|
from opentelemetry.sdk.trace.export import SimpleSpanProcessor
|
|
from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter
|
|
|
|
provider = SdkProvider()
|
|
provider.add_span_processor(SimpleSpanProcessor(InMemorySpanExporter()))
|
|
real_tracer = provider.get_tracer("test")
|
|
|
|
bigquery_agent_analytics_plugin._span_records_ctx.set(None)
|
|
|
|
# Simulate the framework's ambient spans.
|
|
with real_tracer.start_as_current_span("invocation"):
|
|
await bq_plugin_inst.before_run_callback(
|
|
invocation_context=invocation_context
|
|
)
|
|
with real_tracer.start_as_current_span("invoke_agent"):
|
|
await bq_plugin_inst.before_agent_callback(
|
|
agent=mock_agent, callback_context=callback_context
|
|
)
|
|
await bq_plugin_inst.after_agent_callback(
|
|
agent=mock_agent, callback_context=callback_context
|
|
)
|
|
await bq_plugin_inst.after_run_callback(
|
|
invocation_context=invocation_context
|
|
)
|
|
|
|
await bq_plugin_inst.flush()
|
|
|
|
rows = await _get_captured_rows_async(mock_write_client, dummy_arrow_schema)
|
|
agent_starting = [r for r in rows if r["event_type"] == "AGENT_STARTING"]
|
|
agent_completed = [r for r in rows if r["event_type"] == "AGENT_COMPLETED"]
|
|
|
|
assert len(agent_starting) == 1
|
|
assert len(agent_completed) == 1
|
|
|
|
# Both events must share the same span_id (the plugin-internal
|
|
# agent span pushed by before_agent_callback and popped by
|
|
# after_agent_callback). The lifecycle-pair invariant holds
|
|
# regardless of whether the id comes from a plugin-minted hex
|
|
# string or an ambient OTel span.
|
|
assert agent_starting[0]["span_id"] == agent_completed[0]["span_id"]
|
|
assert (
|
|
agent_starting[0]["parent_span_id"]
|
|
== agent_completed[0]["parent_span_id"]
|
|
)
|
|
|
|
provider.shutdown()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_starting_completed_use_plugin_span_without_ambient(
|
|
self,
|
|
bq_plugin_inst,
|
|
mock_write_client,
|
|
invocation_context,
|
|
callback_context,
|
|
mock_agent,
|
|
dummy_arrow_schema,
|
|
):
|
|
"""Without ambient OTel, COMPLETED gets the popped plugin span."""
|
|
from opentelemetry.sdk.trace import TracerProvider as SdkProvider
|
|
from opentelemetry.sdk.trace.export import SimpleSpanProcessor
|
|
from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter
|
|
|
|
provider = SdkProvider()
|
|
provider.add_span_processor(SimpleSpanProcessor(InMemorySpanExporter()))
|
|
real_tracer = provider.get_tracer("test")
|
|
|
|
bigquery_agent_analytics_plugin._span_records_ctx.set(None)
|
|
|
|
# No ambient OTel span.
|
|
assert not trace.get_current_span().get_span_context().is_valid
|
|
|
|
await bq_plugin_inst.before_run_callback(
|
|
invocation_context=invocation_context
|
|
)
|
|
await bq_plugin_inst.before_agent_callback(
|
|
agent=mock_agent, callback_context=callback_context
|
|
)
|
|
await bq_plugin_inst.after_agent_callback(
|
|
agent=mock_agent, callback_context=callback_context
|
|
)
|
|
await bq_plugin_inst.after_run_callback(
|
|
invocation_context=invocation_context
|
|
)
|
|
|
|
await bq_plugin_inst.flush()
|
|
|
|
rows = await _get_captured_rows_async(mock_write_client, dummy_arrow_schema)
|
|
agent_starting = [r for r in rows if r["event_type"] == "AGENT_STARTING"]
|
|
agent_completed = [r for r in rows if r["event_type"] == "AGENT_COMPLETED"]
|
|
|
|
assert len(agent_starting) == 1
|
|
assert len(agent_completed) == 1
|
|
|
|
# AGENT_STARTING gets the top-of-stack span; AGENT_COMPLETED
|
|
# gets the popped span via override — they should match.
|
|
assert agent_starting[0]["span_id"] == agent_completed[0]["span_id"]
|
|
|
|
provider.shutdown()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_tool_error_captures_span_id(
|
|
self,
|
|
bq_plugin_inst,
|
|
mock_write_client,
|
|
invocation_context,
|
|
callback_context,
|
|
dummy_arrow_schema,
|
|
):
|
|
"""on_tool_error_callback uses the popped span_id (bonus fix)."""
|
|
from opentelemetry.sdk.trace import TracerProvider as SdkProvider
|
|
from opentelemetry.sdk.trace.export import SimpleSpanProcessor
|
|
from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter
|
|
|
|
provider = SdkProvider()
|
|
provider.add_span_processor(SimpleSpanProcessor(InMemorySpanExporter()))
|
|
real_tracer = provider.get_tracer("test")
|
|
|
|
mock_tool = mock.create_autospec(base_tool_lib.BaseTool, instance=True)
|
|
type(mock_tool).name = mock.PropertyMock(return_value="my_tool")
|
|
tool_ctx = tool_context_lib.ToolContext(
|
|
invocation_context=invocation_context
|
|
)
|
|
|
|
bigquery_agent_analytics_plugin._span_records_ctx.set(None)
|
|
|
|
# No ambient OTel — plugin span stack provides IDs.
|
|
assert not trace.get_current_span().get_span_context().is_valid
|
|
|
|
await bq_plugin_inst.before_run_callback(
|
|
invocation_context=invocation_context
|
|
)
|
|
# Push tool span via before_tool_callback
|
|
await bq_plugin_inst.before_tool_callback(
|
|
tool=mock_tool,
|
|
tool_args={"a": 1},
|
|
tool_context=tool_ctx,
|
|
)
|
|
# Error callback should pop the tool span and use its ID
|
|
await bq_plugin_inst.on_tool_error_callback(
|
|
tool=mock_tool,
|
|
tool_args={"a": 1},
|
|
tool_context=tool_ctx,
|
|
error=RuntimeError("boom"),
|
|
)
|
|
await bq_plugin_inst.after_run_callback(
|
|
invocation_context=invocation_context
|
|
)
|
|
await bq_plugin_inst.flush()
|
|
|
|
rows = await _get_captured_rows_async(mock_write_client, dummy_arrow_schema)
|
|
tool_starting = [r for r in rows if r["event_type"] == "TOOL_STARTING"]
|
|
tool_error = [r for r in rows if r["event_type"] == "TOOL_ERROR"]
|
|
|
|
assert len(tool_starting) == 1
|
|
assert len(tool_error) == 1
|
|
|
|
# The TOOL_ERROR event must have the same span_id as
|
|
# TOOL_STARTING (both correspond to the same tool span).
|
|
assert tool_starting[0]["span_id"] == tool_error[0]["span_id"]
|
|
assert tool_error[0]["span_id"] is not None
|
|
|
|
provider.shutdown()
|
|
|
|
|
|
class TestStackLeakSafety:
|
|
"""Tests for stack leak safety (P2).
|
|
|
|
Ensures the plugin's internal span stack doesn't leak records
|
|
across invocations when after_run_callback is skipped.
|
|
"""
|
|
|
|
def test_ensure_invocation_span_clears_stale_records(self, callback_context):
|
|
"""Pre-populated stack from a different invocation is cleared."""
|
|
from opentelemetry.sdk.trace import TracerProvider as SdkProvider
|
|
from opentelemetry.sdk.trace.export import SimpleSpanProcessor
|
|
from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter
|
|
|
|
TM = bigquery_agent_analytics_plugin.TraceManager
|
|
|
|
provider = SdkProvider()
|
|
provider.add_span_processor(SimpleSpanProcessor(InMemorySpanExporter()))
|
|
real_tracer = provider.get_tracer("test")
|
|
|
|
# Simulate stale records from incomplete previous invocation.
|
|
bigquery_agent_analytics_plugin._span_records_ctx.set(None)
|
|
# Mark the stale records as belonging to a different invocation.
|
|
bigquery_agent_analytics_plugin._active_invocation_id_ctx.set(
|
|
"old-inv-stale"
|
|
)
|
|
TM.push_span(callback_context, "stale-invocation")
|
|
TM.push_span(callback_context, "stale-agent")
|
|
|
|
stale_records = bigquery_agent_analytics_plugin._span_records_ctx.get()
|
|
assert len(stale_records) == 2
|
|
|
|
# ensure_invocation_span with the *current* invocation_id should
|
|
# detect the mismatch, clear stale records, and re-init.
|
|
TM.ensure_invocation_span(callback_context)
|
|
|
|
records = bigquery_agent_analytics_plugin._span_records_ctx.get()
|
|
# Should have exactly 1 fresh entry (the new invocation span).
|
|
assert len(records) == 1
|
|
# The fresh span should NOT be one of the stale ones.
|
|
assert records[0].span_id != stale_records[0].span_id
|
|
assert records[0].span_id != stale_records[1].span_id
|
|
|
|
provider.shutdown()
|
|
|
|
def test_clear_stack_does_not_export_spans(self, callback_context):
|
|
"""``clear_stack()`` clears the internal records but does NOT
|
|
|
|
export any OTel spans (duplicate-Cloud-Trace regression guard).
|
|
|
|
Pre-fix, ``clear_stack()`` called ``record.span.end()`` for every
|
|
owned record, which delivered the now-finished span to whatever
|
|
exporter the host had wired — duplicating it next to the
|
|
framework's real span in Cloud Trace. Post-fix the plugin owns
|
|
no OTel span at all; ``clear_stack()`` only resets the contextvar.
|
|
"""
|
|
from opentelemetry.sdk.trace import TracerProvider as SdkProvider
|
|
from opentelemetry.sdk.trace.export import SimpleSpanProcessor
|
|
from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter
|
|
|
|
TM = bigquery_agent_analytics_plugin.TraceManager
|
|
|
|
provider = SdkProvider()
|
|
exporter = InMemorySpanExporter()
|
|
provider.add_span_processor(SimpleSpanProcessor(exporter))
|
|
real_tracer = provider.get_tracer("test")
|
|
|
|
bigquery_agent_analytics_plugin._span_records_ctx.set(None)
|
|
TM.push_span(callback_context, "span-a")
|
|
TM.push_span(callback_context, "span-b")
|
|
|
|
records = list(bigquery_agent_analytics_plugin._span_records_ctx.get())
|
|
assert all(r.owns_span for r in records)
|
|
# No exported spans yet (the plugin never creates any).
|
|
assert exporter.get_finished_spans() == ()
|
|
|
|
TM.clear_stack()
|
|
|
|
# Stack must be empty after clear.
|
|
result = bigquery_agent_analytics_plugin._span_records_ctx.get()
|
|
assert result == []
|
|
|
|
# Still no exported spans — the duplicate-Cloud-Trace guard.
|
|
assert exporter.get_finished_spans() == (), (
|
|
"clear_stack() must not export OTel spans; any owned span"
|
|
" would surface as a duplicate in Cloud Trace alongside the"
|
|
" framework's real spans."
|
|
)
|
|
|
|
provider.shutdown()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_after_run_callback_clears_remaining_stack(
|
|
self,
|
|
bq_plugin_inst,
|
|
mock_write_client,
|
|
invocation_context,
|
|
callback_context,
|
|
mock_agent,
|
|
dummy_arrow_schema,
|
|
):
|
|
"""after_run_callback clears any leftover stack entries."""
|
|
from opentelemetry.sdk.trace import TracerProvider as SdkProvider
|
|
from opentelemetry.sdk.trace.export import SimpleSpanProcessor
|
|
from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter
|
|
|
|
TM = bigquery_agent_analytics_plugin.TraceManager
|
|
|
|
provider = SdkProvider()
|
|
provider.add_span_processor(SimpleSpanProcessor(InMemorySpanExporter()))
|
|
real_tracer = provider.get_tracer("test")
|
|
|
|
bigquery_agent_analytics_plugin._span_records_ctx.set(None)
|
|
|
|
# No ambient span.
|
|
assert not trace.get_current_span().get_span_context().is_valid
|
|
|
|
await bq_plugin_inst.before_run_callback(
|
|
invocation_context=invocation_context
|
|
)
|
|
# Push an agent span but DON'T pop it (simulate missing
|
|
# after_agent_callback due to exception).
|
|
await bq_plugin_inst.before_agent_callback(
|
|
agent=mock_agent, callback_context=callback_context
|
|
)
|
|
# Stack now has [invocation, agent].
|
|
|
|
# after_run_callback should pop invocation + clear remaining.
|
|
await bq_plugin_inst.after_run_callback(
|
|
invocation_context=invocation_context
|
|
)
|
|
|
|
# Stack must be empty.
|
|
records = bigquery_agent_analytics_plugin._span_records_ctx.get()
|
|
assert records == []
|
|
|
|
provider.shutdown()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_next_invocation_clean_after_incomplete_previous(
|
|
self,
|
|
bq_plugin_inst,
|
|
mock_write_client,
|
|
invocation_context,
|
|
callback_context,
|
|
mock_agent,
|
|
dummy_arrow_schema,
|
|
mock_session,
|
|
):
|
|
"""Next invocation starts clean even if previous was incomplete."""
|
|
from opentelemetry.sdk.trace import TracerProvider as SdkProvider
|
|
from opentelemetry.sdk.trace.export import SimpleSpanProcessor
|
|
from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter
|
|
|
|
TM = bigquery_agent_analytics_plugin.TraceManager
|
|
|
|
provider = SdkProvider()
|
|
provider.add_span_processor(SimpleSpanProcessor(InMemorySpanExporter()))
|
|
real_tracer = provider.get_tracer("test")
|
|
|
|
bigquery_agent_analytics_plugin._span_records_ctx.set(None)
|
|
bigquery_agent_analytics_plugin._active_invocation_id_ctx.set(None)
|
|
|
|
# --- Incomplete invocation 1: no after_run_callback ---
|
|
await bq_plugin_inst.before_run_callback(
|
|
invocation_context=invocation_context
|
|
)
|
|
await bq_plugin_inst.before_agent_callback(
|
|
agent=mock_agent, callback_context=callback_context
|
|
)
|
|
# Skip after_agent and after_run — simulates exception.
|
|
|
|
stale = bigquery_agent_analytics_plugin._span_records_ctx.get()
|
|
assert len(stale) >= 2 # invocation + agent
|
|
|
|
# --- Invocation 2 with a different invocation_id ---
|
|
mock_write_client.append_rows.reset_mock()
|
|
inv_ctx_2 = InvocationContext(
|
|
agent=mock_agent,
|
|
session=mock_session,
|
|
invocation_id="inv-NEW-002",
|
|
session_service=invocation_context.session_service,
|
|
plugin_manager=invocation_context.plugin_manager,
|
|
)
|
|
await bq_plugin_inst.before_run_callback(invocation_context=inv_ctx_2)
|
|
|
|
records = bigquery_agent_analytics_plugin._span_records_ctx.get()
|
|
# Should have exactly 1 fresh invocation span.
|
|
assert len(records) == 1
|
|
|
|
# Cleanup
|
|
await bq_plugin_inst.after_run_callback(invocation_context=inv_ctx_2)
|
|
|
|
provider.shutdown()
|
|
|
|
def test_ensure_invocation_span_idempotent_same_invocation(
|
|
self, callback_context
|
|
):
|
|
"""Calling ensure_invocation_span twice in the same invocation is a no-op."""
|
|
from opentelemetry.sdk.trace import TracerProvider as SdkProvider
|
|
from opentelemetry.sdk.trace.export import SimpleSpanProcessor
|
|
from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter
|
|
|
|
TM = bigquery_agent_analytics_plugin.TraceManager
|
|
|
|
provider = SdkProvider()
|
|
provider.add_span_processor(SimpleSpanProcessor(InMemorySpanExporter()))
|
|
real_tracer = provider.get_tracer("test")
|
|
|
|
bigquery_agent_analytics_plugin._span_records_ctx.set(None)
|
|
bigquery_agent_analytics_plugin._active_invocation_id_ctx.set(None)
|
|
|
|
# First call: creates invocation span.
|
|
TM.ensure_invocation_span(callback_context)
|
|
records_after_first = list(
|
|
bigquery_agent_analytics_plugin._span_records_ctx.get()
|
|
)
|
|
assert len(records_after_first) == 1
|
|
first_span_id = records_after_first[0].span_id
|
|
|
|
# Second call (same invocation): must be a no-op.
|
|
TM.ensure_invocation_span(callback_context)
|
|
records_after_second = (
|
|
bigquery_agent_analytics_plugin._span_records_ctx.get()
|
|
)
|
|
assert len(records_after_second) == 1
|
|
assert records_after_second[0].span_id == first_span_id
|
|
|
|
# Cleanup
|
|
TM.pop_span()
|
|
|
|
provider.shutdown()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_user_message_then_before_run_same_trace_no_ambient(
|
|
self,
|
|
bq_plugin_inst,
|
|
mock_write_client,
|
|
invocation_context,
|
|
callback_context,
|
|
mock_agent,
|
|
dummy_arrow_schema,
|
|
):
|
|
"""Regression: on_user_message → before_run must share one trace_id.
|
|
|
|
Without the invocation-ID guard, the second ensure_invocation_span()
|
|
call would clear the stack and create a new root span with a
|
|
different trace_id, fracturing USER_MESSAGE_RECEIVED from
|
|
INVOCATION_STARTING.
|
|
"""
|
|
from opentelemetry.sdk.trace import TracerProvider as SdkProvider
|
|
from opentelemetry.sdk.trace.export import SimpleSpanProcessor
|
|
from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter
|
|
|
|
provider = SdkProvider()
|
|
provider.add_span_processor(SimpleSpanProcessor(InMemorySpanExporter()))
|
|
real_tracer = provider.get_tracer("test")
|
|
|
|
bigquery_agent_analytics_plugin._span_records_ctx.set(None)
|
|
bigquery_agent_analytics_plugin._active_invocation_id_ctx.set(None)
|
|
|
|
# No ambient span.
|
|
assert not trace.get_current_span().get_span_context().is_valid
|
|
|
|
user_msg = types.Content(parts=[types.Part(text="hello")], role="user")
|
|
await bq_plugin_inst.on_user_message_callback(
|
|
invocation_context=invocation_context,
|
|
user_message=user_msg,
|
|
)
|
|
await bq_plugin_inst.before_run_callback(
|
|
invocation_context=invocation_context
|
|
)
|
|
await bq_plugin_inst.before_agent_callback(
|
|
agent=mock_agent, callback_context=callback_context
|
|
)
|
|
await bq_plugin_inst.after_agent_callback(
|
|
agent=mock_agent, callback_context=callback_context
|
|
)
|
|
await bq_plugin_inst.after_run_callback(
|
|
invocation_context=invocation_context
|
|
)
|
|
await bq_plugin_inst.flush()
|
|
|
|
rows = await _get_captured_rows_async(mock_write_client, dummy_arrow_schema)
|
|
event_types = [r["event_type"] for r in rows]
|
|
assert "USER_MESSAGE_RECEIVED" in event_types
|
|
assert "INVOCATION_STARTING" in event_types
|
|
|
|
# Every row must share the same trace_id.
|
|
trace_ids = {r["trace_id"] for r in rows}
|
|
assert len(trace_ids) == 1, (
|
|
"Expected 1 unique trace_id across all events, got"
|
|
f" {len(trace_ids)}: {trace_ids}"
|
|
)
|
|
|
|
provider.shutdown()
|
|
|
|
|
|
def test_init_trace_clears_stale_root_name_without_current_agent():
|
|
callback_context = mock.MagicMock()
|
|
callback_context._invocation_context.agent = None
|
|
bigquery_agent_analytics_plugin._root_agent_name_ctx.set("stale-root")
|
|
|
|
bigquery_agent_analytics_plugin.TraceManager.init_trace(callback_context)
|
|
|
|
assert (
|
|
bigquery_agent_analytics_plugin.TraceManager.get_root_agent_name() is None
|
|
)
|
|
|
|
|
|
class TestRootAgentNameAcrossInvocations:
|
|
"""Regression: root_agent_name must refresh across invocations."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_root_agent_name_updates_between_invocations(
|
|
self,
|
|
bq_plugin_inst,
|
|
mock_write_client,
|
|
mock_session,
|
|
dummy_arrow_schema,
|
|
):
|
|
"""Two invocations with different root agents must log correct names.
|
|
|
|
Previously init_trace() only set _root_agent_name_ctx when it was
|
|
None, so the second invocation would inherit the first's root agent.
|
|
"""
|
|
from opentelemetry.sdk.trace import TracerProvider as SdkProvider
|
|
from opentelemetry.sdk.trace.export import SimpleSpanProcessor
|
|
from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter
|
|
|
|
provider = SdkProvider()
|
|
provider.add_span_processor(SimpleSpanProcessor(InMemorySpanExporter()))
|
|
real_tracer = provider.get_tracer("test")
|
|
|
|
mock_session_service = mock.create_autospec(
|
|
base_session_service_lib.BaseSessionService,
|
|
instance=True,
|
|
spec_set=True,
|
|
)
|
|
mock_plugin_manager = mock.create_autospec(
|
|
plugin_manager_lib.PluginManager,
|
|
instance=True,
|
|
spec_set=True,
|
|
)
|
|
|
|
def _make_inv_ctx(agent_name, inv_id):
|
|
agent = mock.create_autospec(
|
|
base_agent.BaseAgent, instance=True, spec_set=True
|
|
)
|
|
type(agent).name = mock.PropertyMock(return_value=agent_name)
|
|
type(agent).instruction = mock.PropertyMock(return_value="")
|
|
# root_agent returns itself (no parent).
|
|
agent.root_agent = agent
|
|
return InvocationContext(
|
|
agent=agent,
|
|
session=mock_session,
|
|
invocation_id=inv_id,
|
|
session_service=mock_session_service,
|
|
plugin_manager=mock_plugin_manager,
|
|
)
|
|
|
|
# --- Invocation 1: root agent = "RootA" ---
|
|
bigquery_agent_analytics_plugin._span_records_ctx.set(None)
|
|
bigquery_agent_analytics_plugin._active_invocation_id_ctx.set(None)
|
|
bigquery_agent_analytics_plugin._root_agent_name_ctx.set(None)
|
|
|
|
inv1 = _make_inv_ctx("RootA", "inv-001")
|
|
cb1 = CallbackContext(inv1)
|
|
await bq_plugin_inst.before_run_callback(invocation_context=inv1)
|
|
await bq_plugin_inst.before_agent_callback(
|
|
agent=inv1.agent, callback_context=cb1
|
|
)
|
|
await bq_plugin_inst.after_agent_callback(
|
|
agent=inv1.agent, callback_context=cb1
|
|
)
|
|
await bq_plugin_inst.after_run_callback(invocation_context=inv1)
|
|
await bq_plugin_inst.flush()
|
|
|
|
rows_inv1 = await _get_captured_rows_async(
|
|
mock_write_client, dummy_arrow_schema
|
|
)
|
|
|
|
# --- Invocation 2: root agent = "RootB" ---
|
|
mock_write_client.append_rows.reset_mock()
|
|
|
|
inv2 = _make_inv_ctx("RootB", "inv-002")
|
|
cb2 = CallbackContext(inv2)
|
|
await bq_plugin_inst.before_run_callback(invocation_context=inv2)
|
|
await bq_plugin_inst.before_agent_callback(
|
|
agent=inv2.agent, callback_context=cb2
|
|
)
|
|
await bq_plugin_inst.after_agent_callback(
|
|
agent=inv2.agent, callback_context=cb2
|
|
)
|
|
await bq_plugin_inst.after_run_callback(invocation_context=inv2)
|
|
await bq_plugin_inst.flush()
|
|
|
|
rows_inv2 = await _get_captured_rows_async(
|
|
mock_write_client, dummy_arrow_schema
|
|
)
|
|
|
|
# Parse root_agent_name from the attributes JSON column.
|
|
def _get_root_names(rows):
|
|
names = set()
|
|
for r in rows:
|
|
attrs = r.get("attributes")
|
|
if attrs:
|
|
parsed = json.loads(attrs) if isinstance(attrs, str) else attrs
|
|
if "root_agent_name" in parsed:
|
|
names.add(parsed["root_agent_name"])
|
|
return names
|
|
|
|
names_inv1 = _get_root_names(rows_inv1)
|
|
names_inv2 = _get_root_names(rows_inv2)
|
|
|
|
# Invocation 1 should only have "RootA".
|
|
assert names_inv1 == {"RootA"}, f"Expected {{'RootA'}}, got {names_inv1}"
|
|
# Invocation 2 must have "RootB", NOT stale "RootA".
|
|
assert names_inv2 == {"RootB"}, f"Expected {{'RootB'}}, got {names_inv2}"
|
|
|
|
provider.shutdown()
|
|
|
|
|
|
class TestAfterRunCleanupExceptionSafety:
|
|
"""after_run_callback cleanup must execute even if _log_event fails."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_cleanup_runs_when_log_event_raises(
|
|
self,
|
|
bq_plugin_inst,
|
|
mock_write_client,
|
|
invocation_context,
|
|
callback_context,
|
|
mock_agent,
|
|
):
|
|
"""Stale state is cleared even when _log_event raises."""
|
|
from opentelemetry.sdk.trace import TracerProvider as SdkProvider
|
|
from opentelemetry.sdk.trace.export import SimpleSpanProcessor
|
|
from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter
|
|
|
|
provider = SdkProvider()
|
|
provider.add_span_processor(SimpleSpanProcessor(InMemorySpanExporter()))
|
|
real_tracer = provider.get_tracer("test")
|
|
|
|
bigquery_agent_analytics_plugin._span_records_ctx.set(None)
|
|
bigquery_agent_analytics_plugin._active_invocation_id_ctx.set(None)
|
|
bigquery_agent_analytics_plugin._root_agent_name_ctx.set(None)
|
|
|
|
# Run a normal before_run to initialise state.
|
|
await bq_plugin_inst.before_run_callback(
|
|
invocation_context=invocation_context
|
|
)
|
|
await bq_plugin_inst.before_agent_callback(
|
|
agent=mock_agent, callback_context=callback_context
|
|
)
|
|
|
|
# Verify state is populated.
|
|
assert bigquery_agent_analytics_plugin._span_records_ctx.get()
|
|
assert (
|
|
bigquery_agent_analytics_plugin._active_invocation_id_ctx.get()
|
|
is not None
|
|
)
|
|
|
|
# Make _log_event raise inside after_run_callback.
|
|
with mock.patch.object(
|
|
bq_plugin_inst,
|
|
"_log_event",
|
|
side_effect=RuntimeError("boom"),
|
|
):
|
|
# _safe_callback swallows the exception, but cleanup in
|
|
# the finally block must still execute.
|
|
await bq_plugin_inst.after_run_callback(
|
|
invocation_context=invocation_context
|
|
)
|
|
|
|
# All invocation state must be cleaned up despite the error.
|
|
records = bigquery_agent_analytics_plugin._span_records_ctx.get()
|
|
assert records == [] or records is None
|
|
assert (
|
|
bigquery_agent_analytics_plugin._active_invocation_id_ctx.get() is None
|
|
)
|
|
assert bigquery_agent_analytics_plugin._root_agent_name_ctx.get() is None
|
|
|
|
provider.shutdown()
|
|
|
|
|
|
class TestStringSystemPromptTruncation:
|
|
"""Tests that a string system prompt is truncated in parse()."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_long_string_system_prompt_is_truncated(self):
|
|
"""A string system_instruction exceeding max_content_length is truncated."""
|
|
parser = bigquery_agent_analytics_plugin.HybridContentParser(
|
|
offloader=None,
|
|
trace_id="test-trace",
|
|
span_id="test-span",
|
|
max_length=50,
|
|
)
|
|
long_prompt = "A" * 200
|
|
llm_request = llm_request_lib.LlmRequest(
|
|
model="gemini-pro",
|
|
contents=[types.Content(parts=[types.Part(text="Hi")])],
|
|
config=types.GenerateContentConfig(
|
|
system_instruction=long_prompt,
|
|
),
|
|
)
|
|
payload, _, is_truncated = await parser.parse(llm_request)
|
|
assert is_truncated
|
|
assert len(payload["system_prompt"]) < 200
|
|
assert "TRUNCATED" in payload["system_prompt"]
|
|
|
|
|
|
class TestSessionStateTruncation:
|
|
"""Tests that session state is truncated in _enrich_attributes."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_oversized_session_state_is_truncated(
|
|
self,
|
|
mock_auth_default,
|
|
mock_bq_client,
|
|
mock_write_client,
|
|
mock_to_arrow_schema,
|
|
mock_asyncio_to_thread,
|
|
mock_session,
|
|
invocation_context,
|
|
):
|
|
"""Session state with large values is truncated."""
|
|
config = bigquery_agent_analytics_plugin.BigQueryLoggerConfig(
|
|
max_content_length=30,
|
|
)
|
|
plugin = bigquery_agent_analytics_plugin.BigQueryAgentAnalyticsPlugin(
|
|
project_id=PROJECT_ID,
|
|
dataset_id=DATASET_ID,
|
|
table_id=TABLE_ID,
|
|
config=config,
|
|
)
|
|
await plugin._ensure_started()
|
|
|
|
# Set a large session state value.
|
|
large_value = "X" * 200
|
|
type(mock_session).state = mock.PropertyMock(
|
|
return_value={"big_key": large_value}
|
|
)
|
|
|
|
callback_ctx = CallbackContext(invocation_context=invocation_context)
|
|
event_data = bigquery_agent_analytics_plugin.EventData()
|
|
attrs = plugin._enrich_attributes(event_data, callback_ctx)
|
|
state = attrs["session_metadata"]["state"]
|
|
assert len(state["big_key"]) < 200
|
|
assert "TRUNCATED" in state["big_key"]
|
|
await plugin.shutdown()
|
|
|
|
|
|
class TestSchemaUpgradeNestedFields:
|
|
"""Tests for nested RECORD field detection in schema upgrade."""
|
|
|
|
def _make_plugin(self):
|
|
config = bigquery_agent_analytics_plugin.BigQueryLoggerConfig(
|
|
auto_schema_upgrade=True,
|
|
)
|
|
with mock.patch("google.cloud.bigquery.Client"):
|
|
plugin = bigquery_agent_analytics_plugin.BigQueryAgentAnalyticsPlugin(
|
|
project_id=PROJECT_ID,
|
|
dataset_id=DATASET_ID,
|
|
table_id=TABLE_ID,
|
|
config=config,
|
|
)
|
|
plugin.client = mock.MagicMock()
|
|
plugin.full_table_id = f"{PROJECT_ID}.{DATASET_ID}.{TABLE_ID}"
|
|
return plugin
|
|
|
|
def test_nested_field_detected(self):
|
|
"""A new sub-field in a RECORD triggers an upgrade."""
|
|
plugin = self._make_plugin()
|
|
|
|
existing_record = bigquery.SchemaField(
|
|
"metadata",
|
|
"RECORD",
|
|
fields=[
|
|
bigquery.SchemaField("key", "STRING"),
|
|
],
|
|
)
|
|
desired_record = bigquery.SchemaField(
|
|
"metadata",
|
|
"RECORD",
|
|
fields=[
|
|
bigquery.SchemaField("key", "STRING"),
|
|
bigquery.SchemaField("value", "STRING"),
|
|
],
|
|
)
|
|
plugin._schema = [
|
|
bigquery.SchemaField("timestamp", "TIMESTAMP"),
|
|
desired_record,
|
|
]
|
|
|
|
existing = mock.MagicMock(spec=bigquery.Table)
|
|
existing.schema = [
|
|
bigquery.SchemaField("timestamp", "TIMESTAMP"),
|
|
existing_record,
|
|
]
|
|
existing.labels = {}
|
|
plugin.client.get_table.return_value = existing
|
|
plugin._ensure_schema_exists()
|
|
|
|
plugin.client.update_table.assert_called_once()
|
|
updated_table = plugin.client.update_table.call_args[0][0]
|
|
# Find the metadata field and check it has both sub-fields.
|
|
metadata_field = next(
|
|
f for f in updated_table.schema if f.name == "metadata"
|
|
)
|
|
sub_names = {sf.name for sf in metadata_field.fields}
|
|
assert "key" in sub_names
|
|
assert "value" in sub_names
|
|
|
|
def test_nested_field_mode_mismatch_is_rejected(self):
|
|
"""Nested same-name fields must match type and mode too."""
|
|
plugin = self._make_plugin()
|
|
plugin._schema = [
|
|
bigquery.SchemaField(
|
|
"metadata",
|
|
"RECORD",
|
|
fields=[bigquery.SchemaField("key", "STRING", mode="REQUIRED")],
|
|
)
|
|
]
|
|
existing = mock.MagicMock(spec=bigquery.Table)
|
|
existing.schema = [
|
|
bigquery.SchemaField(
|
|
"metadata",
|
|
"RECORD",
|
|
fields=[bigquery.SchemaField("key", "STRING", mode="NULLABLE")],
|
|
)
|
|
]
|
|
existing.labels = {}
|
|
plugin.client.get_table.return_value = existing
|
|
|
|
with pytest.raises(ValueError, match=r"metadata\.key"):
|
|
plugin._ensure_schema_exists()
|
|
plugin.client.update_table.assert_not_called()
|
|
|
|
def test_version_label_not_stamped_on_failure(self):
|
|
"""A failed update_table does not persist the version label."""
|
|
plugin = self._make_plugin()
|
|
plugin._schema = [
|
|
bigquery.SchemaField("timestamp", "TIMESTAMP"),
|
|
bigquery.SchemaField("new_col", "STRING"),
|
|
]
|
|
|
|
existing = mock.MagicMock(spec=bigquery.Table)
|
|
existing.schema = [
|
|
bigquery.SchemaField("timestamp", "TIMESTAMP"),
|
|
]
|
|
existing.labels = {}
|
|
plugin.client.get_table.return_value = existing
|
|
plugin.client.update_table.side_effect = Exception("network error")
|
|
|
|
# Raises so setup is not marked ready against a table with missing
|
|
# fields.
|
|
with pytest.raises(Exception, match="network error"):
|
|
plugin._ensure_schema_exists()
|
|
|
|
# The label is set on the table object before update_table is
|
|
# called, but since update_table failed the label was never
|
|
# persisted remotely. On the next run the stored_version will
|
|
# still be None (from the real BQ table) so the upgrade retries.
|
|
# We verify that update_table was actually attempted.
|
|
plugin.client.update_table.assert_called_once()
|
|
|
|
def test_nested_upgrade_preserves_policy_tags(self):
|
|
"""RECORD field metadata (e.g. policy_tags) is preserved on upgrade."""
|
|
from google.cloud.bigquery import schema as bq_schema
|
|
|
|
plugin = self._make_plugin()
|
|
|
|
existing_record = bigquery.SchemaField(
|
|
"metadata",
|
|
"RECORD",
|
|
policy_tags=bq_schema.PolicyTagList(
|
|
names=["projects/p/locations/us/taxonomies/t/policyTags/pt"]
|
|
),
|
|
fields=[
|
|
bigquery.SchemaField("key", "STRING"),
|
|
],
|
|
)
|
|
desired_record = bigquery.SchemaField(
|
|
"metadata",
|
|
"RECORD",
|
|
fields=[
|
|
bigquery.SchemaField("key", "STRING"),
|
|
bigquery.SchemaField("value", "STRING"),
|
|
],
|
|
)
|
|
plugin._schema = [
|
|
bigquery.SchemaField("timestamp", "TIMESTAMP"),
|
|
desired_record,
|
|
]
|
|
|
|
existing = mock.MagicMock(spec=bigquery.Table)
|
|
existing.schema = [
|
|
bigquery.SchemaField("timestamp", "TIMESTAMP"),
|
|
existing_record,
|
|
]
|
|
existing.labels = {}
|
|
plugin.client.get_table.return_value = existing
|
|
plugin._ensure_schema_exists()
|
|
|
|
plugin.client.update_table.assert_called_once()
|
|
updated_table = plugin.client.update_table.call_args[0][0]
|
|
metadata_field = next(
|
|
f for f in updated_table.schema if f.name == "metadata"
|
|
)
|
|
# Sub-fields were merged.
|
|
sub_names = {sf.name for sf in metadata_field.fields}
|
|
assert "key" in sub_names
|
|
assert "value" in sub_names
|
|
# policy_tags preserved from the existing field.
|
|
assert metadata_field.policy_tags is not None
|
|
assert (
|
|
"projects/p/locations/us/taxonomies/t/policyTags/pt"
|
|
in metadata_field.policy_tags.names
|
|
)
|
|
|
|
|
|
class TestMultiLoopShutdownDrainsOtherLoops:
|
|
"""Tests that shutdown() drains batch processors on other loops."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_other_loop_batch_processor_drained(
|
|
self,
|
|
mock_auth_default,
|
|
mock_bq_client,
|
|
mock_write_client,
|
|
mock_to_arrow_schema,
|
|
mock_asyncio_to_thread,
|
|
):
|
|
"""Shutdown drains batch_processor.shutdown on non-current loops.
|
|
|
|
Uses a REAL second loop: the drain task is
|
|
created inside the remote loop's own callback (no
|
|
run_coroutine_threadsafe), so the drain must actually execute there.
|
|
"""
|
|
plugin = bigquery_agent_analytics_plugin.BigQueryAgentAnalyticsPlugin(
|
|
project_id=PROJECT_ID,
|
|
dataset_id=DATASET_ID,
|
|
table_id=TABLE_ID,
|
|
)
|
|
await plugin._ensure_started()
|
|
|
|
other_loop = asyncio.new_event_loop()
|
|
thread = platform_thread.create_thread(target=other_loop.run_forever)
|
|
thread.daemon = True
|
|
thread.start()
|
|
try:
|
|
drain_thread_ids = []
|
|
|
|
async def record_shutdown(timeout=None):
|
|
del timeout
|
|
drain_thread_ids.append(threading.get_ident())
|
|
|
|
# get_drop_stats() is synchronous; a blanket AsyncMock makes it return a
|
|
# coroutine, and the AttributeError shutdown() then swallows truncates the
|
|
# rest of its body.
|
|
mock_other_bp = mock.create_autospec(
|
|
bigquery_agent_analytics_plugin.BatchProcessor,
|
|
instance=True,
|
|
spec_set=True,
|
|
)
|
|
mock_other_bp.shutdown = record_shutdown
|
|
mock_other_bp.get_drop_stats.return_value = {}
|
|
mock_other_write_client = mock.MagicMock()
|
|
mock_other_write_client.transport = mock.AsyncMock()
|
|
|
|
other_state = bigquery_agent_analytics_plugin._LoopState(
|
|
write_client=mock_other_write_client,
|
|
batch_processor=mock_other_bp,
|
|
)
|
|
plugin._loop_state_by_loop[other_loop] = other_state
|
|
|
|
await plugin.shutdown(timeout=5)
|
|
|
|
# The drain ran on the OTHER loop's thread and the state was
|
|
# claimed after a clean completion.
|
|
assert drain_thread_ids == [thread.ident]
|
|
assert other_loop not in plugin._loop_state_by_loop
|
|
mock_other_write_client.transport.close.assert_awaited()
|
|
# shutdown() swallows exceptions, so only its tail work proves the body
|
|
# ran past the drop-stat fold.
|
|
assert plugin._loop_state_by_loop == {}
|
|
assert plugin.client is None
|
|
finally:
|
|
other_loop.call_soon_threadsafe(other_loop.stop)
|
|
thread.join(timeout=5)
|
|
other_loop.close()
|
|
|
|
|
|
class TestCacheMetadataLogging:
|
|
"""Tests for logging cache_metadata from LlmResponse."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_cache_metadata_logged_when_present(
|
|
self,
|
|
bq_plugin_inst,
|
|
mock_write_client,
|
|
callback_context,
|
|
dummy_arrow_schema,
|
|
):
|
|
"""Verifies cache_metadata is logged into BigQuery attributes when present."""
|
|
llm_response = llm_response_lib.LlmResponse(
|
|
content=types.Content(parts=[types.Part(text="Cache test")]),
|
|
cache_metadata={"fingerprint": "abc-123", "contents_count": 2},
|
|
)
|
|
bigquery_agent_analytics_plugin.TraceManager.push_span(callback_context)
|
|
await bq_plugin_inst.after_model_callback(
|
|
callback_context=callback_context,
|
|
llm_response=llm_response,
|
|
)
|
|
await bq_plugin_inst.flush()
|
|
rows = await _get_captured_rows_async(mock_write_client, dummy_arrow_schema)
|
|
log_entry = next(r for r in rows if r["event_type"] == "LLM_RESPONSE")
|
|
|
|
attributes = json.loads(log_entry["attributes"])
|
|
assert "cache_metadata" in attributes
|
|
assert attributes["cache_metadata"]["fingerprint"] == "abc-123"
|
|
assert attributes["cache_metadata"]["contents_count"] == 2
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_missing_cache_metadata_does_not_crash(
|
|
self,
|
|
bq_plugin_inst,
|
|
mock_write_client,
|
|
callback_context,
|
|
dummy_arrow_schema,
|
|
):
|
|
"""Verifies missing cache_metadata gracefully defaults using getattr."""
|
|
|
|
class LegacyLlmResponse:
|
|
|
|
def __init__(self):
|
|
self.content = types.Content(parts=[types.Part(text="Mock text")])
|
|
self.usage_metadata = None
|
|
self.model_version = "v1"
|
|
self.partial = False
|
|
# Deliberately omitting cache_metadata
|
|
|
|
mock_response = LegacyLlmResponse()
|
|
|
|
bigquery_agent_analytics_plugin.TraceManager.push_span(callback_context)
|
|
await bq_plugin_inst.after_model_callback(
|
|
callback_context=callback_context,
|
|
llm_response=mock_response,
|
|
)
|
|
await bq_plugin_inst.flush()
|
|
rows = await _get_captured_rows_async(mock_write_client, dummy_arrow_schema)
|
|
log_entry = next(r for r in rows if r["event_type"] == "LLM_RESPONSE")
|
|
|
|
attributes = json.loads(log_entry["attributes"])
|
|
assert "cache_metadata" not in attributes
|
|
|
|
async def _run_after_model(
|
|
self,
|
|
bq_plugin_inst,
|
|
mock_write_client,
|
|
callback_context,
|
|
dummy_arrow_schema,
|
|
llm_response,
|
|
):
|
|
"""Drives after_model_callback and returns the LLM_RESPONSE attributes."""
|
|
bigquery_agent_analytics_plugin.TraceManager.push_span(callback_context)
|
|
await bq_plugin_inst.after_model_callback(
|
|
callback_context=callback_context,
|
|
llm_response=llm_response,
|
|
)
|
|
await asyncio.sleep(0.05)
|
|
rows = await _get_captured_rows_async(mock_write_client, dummy_arrow_schema)
|
|
log_entry = next(r for r in rows if r["event_type"] == "LLM_RESPONSE")
|
|
return json.loads(log_entry["attributes"])
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_cache_type_explicit(
|
|
self,
|
|
bq_plugin_inst,
|
|
mock_write_client,
|
|
callback_context,
|
|
dummy_arrow_schema,
|
|
):
|
|
"""cache_name set + cached tokens -> explicit (ADK-managed cache)."""
|
|
llm_response = llm_response_lib.LlmResponse(
|
|
content=types.Content(parts=[types.Part(text="hi")]),
|
|
usage_metadata=types.GenerateContentResponseUsageMetadata(
|
|
prompt_token_count=100,
|
|
candidates_token_count=20,
|
|
total_token_count=120,
|
|
cached_content_token_count=80,
|
|
),
|
|
cache_metadata={
|
|
"cache_name": "projects/p/locations/us-central1/cachedContents/c",
|
|
"expire_time": 9999999999.0,
|
|
"fingerprint": "fp-1",
|
|
"invocations_used": 1,
|
|
"contents_count": 2,
|
|
"created_at": 1.0,
|
|
},
|
|
)
|
|
attributes = await self._run_after_model(
|
|
bq_plugin_inst,
|
|
mock_write_client,
|
|
callback_context,
|
|
dummy_arrow_schema,
|
|
llm_response,
|
|
)
|
|
assert attributes["cache_type"] == "explicit"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_cache_type_implicit(
|
|
self,
|
|
bq_plugin_inst,
|
|
mock_write_client,
|
|
callback_context,
|
|
dummy_arrow_schema,
|
|
):
|
|
"""Cached tokens with no cache_metadata -> implicit (provider prefix)."""
|
|
llm_response = llm_response_lib.LlmResponse(
|
|
content=types.Content(parts=[types.Part(text="hi")]),
|
|
usage_metadata=types.GenerateContentResponseUsageMetadata(
|
|
prompt_token_count=100,
|
|
candidates_token_count=20,
|
|
total_token_count=120,
|
|
cached_content_token_count=80,
|
|
),
|
|
)
|
|
attributes = await self._run_after_model(
|
|
bq_plugin_inst,
|
|
mock_write_client,
|
|
callback_context,
|
|
dummy_arrow_schema,
|
|
llm_response,
|
|
)
|
|
assert attributes["cache_type"] == "implicit"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_cache_type_explicit_fingerprint_only(
|
|
self,
|
|
bq_plugin_inst,
|
|
mock_write_client,
|
|
callback_context,
|
|
dummy_arrow_schema,
|
|
):
|
|
"""Fingerprint-only cache_metadata (cache_name=None) is still explicit."""
|
|
llm_response = llm_response_lib.LlmResponse(
|
|
content=types.Content(parts=[types.Part(text="hi")]),
|
|
usage_metadata=types.GenerateContentResponseUsageMetadata(
|
|
prompt_token_count=100,
|
|
candidates_token_count=20,
|
|
total_token_count=120,
|
|
cached_content_token_count=80,
|
|
),
|
|
cache_metadata={"fingerprint": "fp-1", "contents_count": 2},
|
|
)
|
|
attributes = await self._run_after_model(
|
|
bq_plugin_inst,
|
|
mock_write_client,
|
|
callback_context,
|
|
dummy_arrow_schema,
|
|
llm_response,
|
|
)
|
|
assert attributes["cache_type"] == "explicit"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_cache_type_none_with_active_cache(
|
|
self,
|
|
bq_plugin_inst,
|
|
mock_write_client,
|
|
callback_context,
|
|
dummy_arrow_schema,
|
|
):
|
|
"""Active cache but no cached tokens (creation turn / miss) -> none."""
|
|
llm_response = llm_response_lib.LlmResponse(
|
|
content=types.Content(parts=[types.Part(text="hi")]),
|
|
usage_metadata=types.GenerateContentResponseUsageMetadata(
|
|
prompt_token_count=100,
|
|
candidates_token_count=20,
|
|
total_token_count=120,
|
|
),
|
|
cache_metadata={
|
|
"cache_name": "projects/p/locations/us-central1/cachedContents/c",
|
|
"expire_time": 9999999999.0,
|
|
"fingerprint": "fp-1",
|
|
"invocations_used": 1,
|
|
"contents_count": 2,
|
|
"created_at": 1.0,
|
|
},
|
|
)
|
|
attributes = await self._run_after_model(
|
|
bq_plugin_inst,
|
|
mock_write_client,
|
|
callback_context,
|
|
dummy_arrow_schema,
|
|
llm_response,
|
|
)
|
|
assert attributes["cache_type"] == "none"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_cache_type_none(
|
|
self,
|
|
bq_plugin_inst,
|
|
mock_write_client,
|
|
callback_context,
|
|
dummy_arrow_schema,
|
|
):
|
|
"""No cached tokens -> none."""
|
|
llm_response = llm_response_lib.LlmResponse(
|
|
content=types.Content(parts=[types.Part(text="hi")]),
|
|
usage_metadata=types.GenerateContentResponseUsageMetadata(
|
|
prompt_token_count=100,
|
|
candidates_token_count=20,
|
|
total_token_count=120,
|
|
),
|
|
)
|
|
attributes = await self._run_after_model(
|
|
bq_plugin_inst,
|
|
mock_write_client,
|
|
callback_context,
|
|
dummy_arrow_schema,
|
|
llm_response,
|
|
)
|
|
assert attributes["cache_type"] == "none"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_cache_type_absent_on_partial_response(
|
|
self,
|
|
bq_plugin_inst,
|
|
mock_write_client,
|
|
callback_context,
|
|
dummy_arrow_schema,
|
|
):
|
|
"""Partial streaming rows carry no cache_type, even with cached tokens."""
|
|
llm_response = llm_response_lib.LlmResponse(
|
|
content=types.Content(parts=[types.Part(text="hi")]),
|
|
partial=True,
|
|
usage_metadata=types.GenerateContentResponseUsageMetadata(
|
|
prompt_token_count=100,
|
|
candidates_token_count=20,
|
|
total_token_count=120,
|
|
cached_content_token_count=80,
|
|
),
|
|
)
|
|
attributes = await self._run_after_model(
|
|
bq_plugin_inst,
|
|
mock_write_client,
|
|
callback_context,
|
|
dummy_arrow_schema,
|
|
llm_response,
|
|
)
|
|
assert "cache_type" not in attributes
|
|
|
|
|
|
# ==============================================================
|
|
# TEST CLASS: A2A_INTERACTION event logging via on_event_callback
|
|
# ==============================================================
|
|
class TestA2AInteractionLogging:
|
|
"""Tests for A2A interaction event emission via on_event_callback.
|
|
|
|
When a RemoteA2aAgent processes a response, it attaches A2A
|
|
metadata (``a2a:task_id``, ``a2a:context_id``, ``a2a:request``,
|
|
``a2a:response``) to the event's ``custom_metadata``. The
|
|
plugin's ``on_event_callback`` should detect events carrying
|
|
``a2a:request`` or ``a2a:response`` and log an
|
|
``A2A_INTERACTION`` event so the remote agent's response and
|
|
cross-reference IDs are visible in BigQuery.
|
|
"""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_a2a_interaction_logged_for_response_metadata(
|
|
self,
|
|
bq_plugin_inst,
|
|
mock_write_client,
|
|
invocation_context,
|
|
dummy_arrow_schema,
|
|
):
|
|
"""Event with a2a:response in custom_metadata emits A2A_INTERACTION."""
|
|
a2a_meta = {
|
|
"a2a:task_id": "task-abc",
|
|
"a2a:context_id": "ctx-123",
|
|
"a2a:response": {"status": "completed", "text": "result"},
|
|
}
|
|
event = event_lib.Event(
|
|
author="remote_agent",
|
|
custom_metadata=a2a_meta,
|
|
)
|
|
|
|
bigquery_agent_analytics_plugin.TraceManager.push_span(invocation_context)
|
|
result = await bq_plugin_inst.on_event_callback(
|
|
invocation_context=invocation_context, event=event
|
|
)
|
|
assert result is None
|
|
|
|
await bq_plugin_inst.flush()
|
|
rows = await _get_captured_rows_async(mock_write_client, dummy_arrow_schema)
|
|
event_types = [r["event_type"] for r in rows]
|
|
assert "A2A_INTERACTION" in event_types
|
|
|
|
a2a_row = next(r for r in rows if r["event_type"] == "A2A_INTERACTION")
|
|
attributes = json.loads(a2a_row["attributes"])
|
|
assert "a2a_metadata" in attributes
|
|
assert attributes["a2a_metadata"]["a2a:task_id"] == "task-abc"
|
|
assert attributes["a2a_metadata"]["a2a:context_id"] == "ctx-123"
|
|
|
|
# Content should contain the a2a:response payload.
|
|
content = json.loads(a2a_row["content"])
|
|
assert content["status"] == "completed"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_a2a_interaction_logged_for_request_metadata(
|
|
self,
|
|
bq_plugin_inst,
|
|
mock_write_client,
|
|
invocation_context,
|
|
dummy_arrow_schema,
|
|
):
|
|
"""Event with a2a:request (no a2a:response) emits A2A_INTERACTION."""
|
|
a2a_meta = {
|
|
"a2a:task_id": "task-xyz",
|
|
"a2a:request": {"message": "hello"},
|
|
}
|
|
event = event_lib.Event(
|
|
author="remote_agent",
|
|
custom_metadata=a2a_meta,
|
|
)
|
|
|
|
bigquery_agent_analytics_plugin.TraceManager.push_span(invocation_context)
|
|
result = await bq_plugin_inst.on_event_callback(
|
|
invocation_context=invocation_context, event=event
|
|
)
|
|
assert result is None
|
|
|
|
await bq_plugin_inst.flush()
|
|
rows = await _get_captured_rows_async(mock_write_client, dummy_arrow_schema)
|
|
event_types = [r["event_type"] for r in rows]
|
|
assert "A2A_INTERACTION" in event_types
|
|
|
|
a2a_row = next(r for r in rows if r["event_type"] == "A2A_INTERACTION")
|
|
attributes = json.loads(a2a_row["attributes"])
|
|
assert attributes["a2a_metadata"]["a2a:request"] == {"message": "hello"}
|
|
# No a2a:response → content should be None.
|
|
assert a2a_row["content"] is None
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_no_a2a_interaction_for_irrelevant_metadata(
|
|
self,
|
|
bq_plugin_inst,
|
|
mock_write_client,
|
|
invocation_context,
|
|
):
|
|
"""Events with only a2a:task_id (no request/response) are skipped."""
|
|
a2a_meta = {
|
|
"a2a:task_id": "task-only",
|
|
"a2a:context_id": "ctx-only",
|
|
}
|
|
event = event_lib.Event(
|
|
author="remote_agent",
|
|
custom_metadata=a2a_meta,
|
|
)
|
|
|
|
result = await bq_plugin_inst.on_event_callback(
|
|
invocation_context=invocation_context, event=event
|
|
)
|
|
assert result is None
|
|
|
|
await bq_plugin_inst.flush()
|
|
# No events logged — a2a:task_id alone is not a meaningful
|
|
# interaction payload.
|
|
assert mock_write_client.append_rows.call_count == 0
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_no_a2a_interaction_for_no_metadata(
|
|
self,
|
|
bq_plugin_inst,
|
|
mock_write_client,
|
|
invocation_context,
|
|
):
|
|
"""Events without custom_metadata produce no A2A_INTERACTION."""
|
|
event = event_lib.Event(author="regular_agent")
|
|
|
|
result = await bq_plugin_inst.on_event_callback(
|
|
invocation_context=invocation_context, event=event
|
|
)
|
|
assert result is None
|
|
|
|
await bq_plugin_inst.flush()
|
|
assert mock_write_client.append_rows.call_count == 0
|
|
|
|
|
|
# ================================================================
|
|
# TEST CLASS: Dataset location handling
|
|
# ================================================================
|
|
class TestDatasetLocationHandling:
|
|
"""Tests that BQ client is created without a default location.
|
|
|
|
When location is omitted from bigquery.Client(), client.query()
|
|
sends no location field in the API request, letting BigQuery
|
|
infer location from the referenced dataset. This prevents
|
|
silent view-creation failures for non-US datasets.
|
|
"""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_client_created_without_location(
|
|
self,
|
|
mock_auth_default,
|
|
mock_to_arrow_schema,
|
|
mock_asyncio_to_thread,
|
|
):
|
|
"""bigquery.Client is created without a location parameter."""
|
|
with mock.patch.object(bigquery, "Client", autospec=True) as mock_bq_cls:
|
|
mock_bq_cls.return_value.get_table.side_effect = (
|
|
cloud_exceptions.NotFound("table")
|
|
)
|
|
mock_bq_cls.return_value.create_table.return_value = None
|
|
|
|
async with managed_plugin(
|
|
project_id=PROJECT_ID,
|
|
dataset_id=DATASET_ID,
|
|
table_id=TABLE_ID,
|
|
location="europe-west1",
|
|
config=bigquery_agent_analytics_plugin.BigQueryLoggerConfig(
|
|
create_views=False,
|
|
),
|
|
) as plugin:
|
|
await plugin._ensure_started()
|
|
|
|
mock_bq_cls.assert_called_once()
|
|
_, kwargs = mock_bq_cls.call_args
|
|
assert "location" not in kwargs
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_view_query_omits_location(
|
|
self,
|
|
mock_auth_default,
|
|
mock_to_arrow_schema,
|
|
mock_asyncio_to_thread,
|
|
):
|
|
"""View creation DDL queries do not pass an explicit location."""
|
|
with mock.patch.object(bigquery, "Client", autospec=True) as mock_bq_cls:
|
|
mock_client = mock_bq_cls.return_value
|
|
mock_client.get_table.return_value = mock.MagicMock()
|
|
mock_client.query.return_value.result.return_value = None
|
|
|
|
async with managed_plugin(
|
|
project_id=PROJECT_ID,
|
|
dataset_id=DATASET_ID,
|
|
table_id=TABLE_ID,
|
|
config=bigquery_agent_analytics_plugin.BigQueryLoggerConfig(
|
|
create_views=True,
|
|
),
|
|
) as plugin:
|
|
await plugin._ensure_started()
|
|
|
|
assert mock_client.query.call_count > 0
|
|
for call in mock_client.query.call_args_list:
|
|
_, kwargs = call
|
|
# No explicit location — BQ infers from dataset
|
|
assert "location" not in kwargs
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_view_error_still_logged(
|
|
self,
|
|
mock_auth_default,
|
|
mock_to_arrow_schema,
|
|
mock_asyncio_to_thread,
|
|
):
|
|
"""View creation errors are logged but not raised."""
|
|
with mock.patch.object(bigquery, "Client", autospec=True) as mock_bq_cls:
|
|
mock_client = mock_bq_cls.return_value
|
|
mock_client.get_table.return_value = mock.MagicMock()
|
|
mock_client.query.return_value.result.side_effect = Exception(
|
|
"view error"
|
|
)
|
|
|
|
# Should not raise
|
|
async with managed_plugin(
|
|
project_id=PROJECT_ID,
|
|
dataset_id=DATASET_ID,
|
|
table_id=TABLE_ID,
|
|
config=bigquery_agent_analytics_plugin.BigQueryLoggerConfig(
|
|
create_views=True,
|
|
),
|
|
) as plugin:
|
|
await plugin._ensure_started()
|
|
assert plugin._started
|
|
|
|
|
|
# ================================================================
|
|
# TEST CLASS: Fork detection after pickle
|
|
# ================================================================
|
|
class TestForkDetectionAfterPickle:
|
|
"""Tests that unpickled plugins do not false-positive fork detection."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_no_reset_after_unpickle(
|
|
self,
|
|
mock_auth_default,
|
|
mock_bq_client,
|
|
mock_write_client,
|
|
mock_to_arrow_schema,
|
|
mock_asyncio_to_thread,
|
|
):
|
|
"""Unpickled plugin does not trigger _reset_runtime_state and
|
|
|
|
records os.getpid() after startup.
|
|
"""
|
|
import pickle
|
|
|
|
config = bigquery_agent_analytics_plugin.BigQueryLoggerConfig(
|
|
create_views=False,
|
|
)
|
|
plugin = bigquery_agent_analytics_plugin.BigQueryAgentAnalyticsPlugin(
|
|
PROJECT_ID, DATASET_ID, table_id=TABLE_ID, config=config
|
|
)
|
|
pickled = pickle.dumps(plugin)
|
|
unpickled = pickle.loads(pickled)
|
|
|
|
assert unpickled._init_pid == 0
|
|
|
|
with mock.patch.object(unpickled, "_reset_runtime_state") as mock_reset:
|
|
await unpickled._ensure_started()
|
|
mock_reset.assert_not_called()
|
|
|
|
assert unpickled._started
|
|
assert unpickled._init_pid == os.getpid()
|
|
await unpickled.shutdown()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_reset_on_real_fork(
|
|
self,
|
|
mock_auth_default,
|
|
mock_bq_client,
|
|
mock_write_client,
|
|
mock_to_arrow_schema,
|
|
mock_asyncio_to_thread,
|
|
):
|
|
"""Plugin detects real fork when _init_pid is a real non-zero PID."""
|
|
config = bigquery_agent_analytics_plugin.BigQueryLoggerConfig(
|
|
create_views=False,
|
|
)
|
|
async with managed_plugin(
|
|
project_id=PROJECT_ID,
|
|
dataset_id=DATASET_ID,
|
|
table_id=TABLE_ID,
|
|
config=config,
|
|
) as plugin:
|
|
await plugin._ensure_started()
|
|
plugin._init_pid = max(os.getpid() - 1, 1)
|
|
plugin._started = True
|
|
|
|
with mock.patch.object(
|
|
plugin, "_reset_runtime_state", wraps=plugin._reset_runtime_state
|
|
) as mock_reset:
|
|
await plugin._ensure_started()
|
|
mock_reset.assert_called_once()
|
|
|
|
|
|
# ================================================================
|
|
# TEST CLASS: GCS offload unit mismatch fix
|
|
# ================================================================
|
|
@pytest.mark.asyncio
|
|
async def test_content_parser_accepts_content_without_parts():
|
|
parser = bigquery_agent_analytics_plugin.HybridContentParser(
|
|
offloader=None,
|
|
trace_id="trace",
|
|
span_id="span",
|
|
)
|
|
|
|
summary, parts, is_truncated = await parser._parse_content_object(
|
|
types.Content(role="user")
|
|
)
|
|
|
|
assert summary == ""
|
|
assert parts == []
|
|
assert not is_truncated
|
|
|
|
|
|
class TestOffloadUnitSeparation:
|
|
"""Tests that byte-based inline limit and character-based truncation
|
|
|
|
limit are evaluated independently for the GCS offload decision.
|
|
"""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_multibyte_text_offloaded_by_byte_limit(self):
|
|
"""Multi-byte text exceeding inline_text_limit bytes is offloaded."""
|
|
mock_offloader = mock.AsyncMock()
|
|
mock_offloader.upload_content.return_value = "gs://bucket/offloaded.txt"
|
|
|
|
parser = bigquery_agent_analytics_plugin.HybridContentParser(
|
|
offloader=mock_offloader,
|
|
trace_id="t",
|
|
span_id="s",
|
|
max_length=-1,
|
|
)
|
|
text = "\U0001f600" * 10000
|
|
assert len(text) == 10000
|
|
assert len(text.encode("utf-8")) > 32 * 1024
|
|
|
|
content = types.Content(parts=[types.Part(text=text)])
|
|
_, parts, _ = await parser._parse_content_object(content)
|
|
|
|
mock_offloader.upload_content.assert_called_once()
|
|
assert parts[0]["storage_mode"] == "GCS_REFERENCE"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_ascii_under_both_limits_stays_inline(self):
|
|
"""ASCII text under both byte and character limits stays inline."""
|
|
mock_offloader = mock.AsyncMock()
|
|
|
|
parser = bigquery_agent_analytics_plugin.HybridContentParser(
|
|
offloader=mock_offloader,
|
|
trace_id="t",
|
|
span_id="s",
|
|
max_length=50000,
|
|
)
|
|
text = "A" * 1000
|
|
content = types.Content(parts=[types.Part(text=text)])
|
|
_, parts, _ = await parser._parse_content_object(content)
|
|
|
|
mock_offloader.upload_content.assert_not_called()
|
|
assert parts[0]["storage_mode"] == "INLINE"
|
|
assert parts[0]["text"] == text
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_text_exceeding_char_limit_offloaded(self):
|
|
"""ASCII text exceeding max_length characters is offloaded."""
|
|
mock_offloader = mock.AsyncMock()
|
|
mock_offloader.upload_content.return_value = "gs://bucket/big.txt"
|
|
|
|
parser = bigquery_agent_analytics_plugin.HybridContentParser(
|
|
offloader=mock_offloader,
|
|
trace_id="t",
|
|
span_id="s",
|
|
max_length=100,
|
|
)
|
|
text = "X" * 200
|
|
assert len(text.encode("utf-8")) < 32 * 1024
|
|
assert len(text) > 100
|
|
|
|
content = types.Content(parts=[types.Part(text=text)])
|
|
_, parts, _ = await parser._parse_content_object(content)
|
|
|
|
mock_offloader.upload_content.assert_called_once()
|
|
assert parts[0]["storage_mode"] == "GCS_REFERENCE"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_multibyte_under_char_and_byte_limits_stays_inline(self):
|
|
"""Regression test: 3K emoji (12K bytes) with max_length=10000
|
|
|
|
should stay inline — under both real limits.
|
|
"""
|
|
mock_offloader = mock.AsyncMock()
|
|
parser = bigquery_agent_analytics_plugin.HybridContentParser(
|
|
offloader=mock_offloader,
|
|
trace_id="t",
|
|
span_id="s",
|
|
max_length=10000,
|
|
)
|
|
|
|
text = "\U0001f600" * 3000
|
|
assert len(text) < 10000
|
|
assert len(text.encode("utf-8")) > 10000
|
|
assert len(text.encode("utf-8")) < 32 * 1024
|
|
|
|
content = types.Content(parts=[types.Part(text=text)])
|
|
_, parts, _ = await parser._parse_content_object(content)
|
|
|
|
mock_offloader.upload_content.assert_not_called()
|
|
assert parts[0]["storage_mode"] == "INLINE"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_list_content_is_unpacked_into_parts(self):
|
|
"""A list system_instruction keeps the text of every part it holds."""
|
|
parser = bigquery_agent_analytics_plugin.HybridContentParser(
|
|
offloader=None,
|
|
trace_id="t",
|
|
span_id="s",
|
|
max_length=50000,
|
|
)
|
|
|
|
summary, parts, is_truncated = await parser._parse_content_object(
|
|
[types.Part(text="hi"), types.Part(text="there")]
|
|
)
|
|
|
|
assert summary == "hi | there"
|
|
assert not is_truncated
|
|
assert [part["text"] for part in parts] == ["hi", "there"]
|
|
assert parts[0]["storage_mode"] == "INLINE"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_non_content_union_member_does_not_raise(self):
|
|
"""A member carrying no part fields yields an empty entry, not an error."""
|
|
parser = bigquery_agent_analytics_plugin.HybridContentParser(
|
|
offloader=None,
|
|
trace_id="t",
|
|
span_id="s",
|
|
max_length=50000,
|
|
)
|
|
|
|
summary, parts, is_truncated = await parser._parse_content_object(
|
|
types.File(name="f")
|
|
)
|
|
|
|
assert summary == ""
|
|
assert not is_truncated
|
|
assert len(parts) == 1
|
|
assert parts[0]["text"] is None
|
|
assert parts[0]["storage_mode"] == "INLINE"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_no_offloader_falls_back_to_truncate(self):
|
|
"""Without offloader, text exceeding char limit is truncated inline."""
|
|
parser = bigquery_agent_analytics_plugin.HybridContentParser(
|
|
offloader=None,
|
|
trace_id="t",
|
|
span_id="s",
|
|
max_length=50,
|
|
)
|
|
text = "Z" * 200
|
|
content = types.Content(parts=[types.Part(text=text)])
|
|
_, parts, is_truncated = await parser._parse_content_object(content)
|
|
|
|
assert is_truncated
|
|
assert parts[0]["storage_mode"] == "INLINE"
|
|
assert "TRUNCATED" in parts[0]["text"]
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_raw_prompt_text_is_sanitized_inline(self):
|
|
"""Prompt, role, and system strings are redacted before row storage."""
|
|
parser = bigquery_agent_analytics_plugin.HybridContentParser(
|
|
offloader=None,
|
|
trace_id="t",
|
|
span_id="s",
|
|
max_length=-1,
|
|
)
|
|
secret = "INLINE-CONTENT-SECRET"
|
|
request = llm_request_lib.LlmRequest(
|
|
contents=[
|
|
types.Content(
|
|
role=json.dumps({"authorization": secret}),
|
|
parts=[types.Part(text=json.dumps({"secret": secret}))],
|
|
)
|
|
],
|
|
config=types.GenerateContentConfig(
|
|
system_instruction=json.dumps({"private_key": secret})
|
|
),
|
|
)
|
|
|
|
payload, parts, _ = await parser.parse(request)
|
|
stored = json.dumps({"content": payload, "content_parts": parts})
|
|
assert secret not in stored
|
|
assert stored.count("[REDACTED]") >= 3
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_raw_prompt_text_is_redacted_at_row_boundary(
|
|
self,
|
|
bq_plugin_inst,
|
|
mock_write_client,
|
|
callback_context,
|
|
dummy_arrow_schema,
|
|
):
|
|
"""The serialized BigQuery row never regains parser-redacted text."""
|
|
secret = "ROW-BOUNDARY-CONTENT-SECRET"
|
|
request = llm_request_lib.LlmRequest(
|
|
model="gemini-pro",
|
|
contents=[
|
|
types.Content(
|
|
role="user",
|
|
parts=[types.Part(text=json.dumps({"access_token": secret}))],
|
|
)
|
|
],
|
|
)
|
|
|
|
await bq_plugin_inst._log_event(
|
|
"LLM_REQUEST",
|
|
callback_context,
|
|
raw_content=request,
|
|
event_data=bigquery_agent_analytics_plugin.EventData(
|
|
model=request.model
|
|
),
|
|
)
|
|
await bq_plugin_inst.flush()
|
|
row = await _get_captured_event_dict_async(
|
|
mock_write_client, dummy_arrow_schema
|
|
)
|
|
stored = json.dumps(row, default=str)
|
|
assert secret not in stored
|
|
assert "[REDACTED]" in stored
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_gcs_text_upload_receives_only_sanitized_content(self):
|
|
"""Raw text is sanitized before either its GCS or row representation."""
|
|
mock_offloader = mock.AsyncMock()
|
|
mock_offloader.upload_content.return_value = "gs://bucket/safe.txt"
|
|
parser = bigquery_agent_analytics_plugin.HybridContentParser(
|
|
offloader=mock_offloader,
|
|
trace_id="t",
|
|
span_id="s",
|
|
max_length=-1,
|
|
)
|
|
secret = "GCS-CONTENT-SECRET"
|
|
text = json.dumps({"token": secret, "padding": "x" * (33 * 1024)})
|
|
|
|
payload, parts, _ = await parser.parse(
|
|
types.Content(parts=[types.Part(text=text)])
|
|
)
|
|
|
|
uploaded = mock_offloader.upload_content.call_args.args[0]
|
|
assert secret not in uploaded
|
|
assert "[REDACTED]" in uploaded
|
|
stored = json.dumps({"content": payload, "content_parts": parts})
|
|
assert secret not in stored
|
|
assert "[REDACTED]" in stored
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_internal_formatter_sentinel_is_preserved(self):
|
|
"""Raw-text sanitization never corrupts generated formatter sentinels."""
|
|
parser = bigquery_agent_analytics_plugin.HybridContentParser(
|
|
offloader=None, trace_id="t", span_id="s"
|
|
)
|
|
payload, _, _ = await parser.parse(
|
|
bigquery_agent_analytics_plugin._FORMATTER_FAILED_SENTINEL
|
|
)
|
|
assert payload == bigquery_agent_analytics_plugin._FORMATTER_FAILED_SENTINEL
|
|
|
|
|
|
# ================================================================
|
|
# TEST CLASS: AGENT_RESPONSE logging
|
|
# ================================================================
|
|
class TestAgentResponseLogging:
|
|
"""Tests that final agent response events are captured correctly."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_logs_final_text_response(
|
|
self,
|
|
bq_plugin_inst,
|
|
mock_write_client,
|
|
invocation_context,
|
|
dummy_arrow_schema,
|
|
):
|
|
"""Final text response is logged as AGENT_RESPONSE with
|
|
|
|
source_event_author from event.author.
|
|
"""
|
|
event = event_lib.Event(
|
|
author="sub_agent",
|
|
content=types.Content(parts=[types.Part(text="Here is your answer.")]),
|
|
)
|
|
|
|
bigquery_agent_analytics_plugin.TraceManager.push_span(invocation_context)
|
|
await bq_plugin_inst.on_event_callback(
|
|
invocation_context=invocation_context, event=event
|
|
)
|
|
await bq_plugin_inst.flush()
|
|
rows = await _get_captured_rows_async(mock_write_client, dummy_arrow_schema)
|
|
agent_resp_rows = [r for r in rows if r["event_type"] == "AGENT_RESPONSE"]
|
|
assert len(agent_resp_rows) == 1
|
|
row = agent_resp_rows[0]
|
|
content = json.loads(row["content"])
|
|
assert "Here is your answer" in content["response"]
|
|
attributes = json.loads(row["attributes"])
|
|
# source_event_author must come from event.author
|
|
assert attributes["source_event_author"] == "sub_agent"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_skips_function_call_events(
|
|
self,
|
|
bq_plugin_inst,
|
|
mock_write_client,
|
|
invocation_context,
|
|
):
|
|
"""Events with function calls are not logged as AGENT_RESPONSE."""
|
|
fc = types.FunctionCall(name="my_tool", args={"x": 1})
|
|
event = event_lib.Event(
|
|
author="agent",
|
|
content=types.Content(parts=[types.Part(function_call=fc)]),
|
|
)
|
|
|
|
await bq_plugin_inst.on_event_callback(
|
|
invocation_context=invocation_context, event=event
|
|
)
|
|
await bq_plugin_inst.flush()
|
|
assert mock_write_client.append_rows.call_count == 0
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_skips_function_response_events(
|
|
self,
|
|
bq_plugin_inst,
|
|
mock_write_client,
|
|
invocation_context,
|
|
):
|
|
"""Events with function responses are not logged as AGENT_RESPONSE."""
|
|
fr = types.FunctionResponse(name="my_tool", response={"result": "ok"})
|
|
event = event_lib.Event(
|
|
author="agent",
|
|
content=types.Content(parts=[types.Part(function_response=fr)]),
|
|
actions=event_actions_lib.EventActions(skip_summarization=True),
|
|
)
|
|
|
|
await bq_plugin_inst.on_event_callback(
|
|
invocation_context=invocation_context, event=event
|
|
)
|
|
await bq_plugin_inst.flush()
|
|
assert mock_write_client.append_rows.call_count == 0
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_skips_partial_events(
|
|
self,
|
|
bq_plugin_inst,
|
|
mock_write_client,
|
|
invocation_context,
|
|
):
|
|
"""Partial streaming events are not logged as AGENT_RESPONSE."""
|
|
event = event_lib.Event(
|
|
author="agent",
|
|
content=types.Content(parts=[types.Part(text="partial chunk")]),
|
|
partial=True,
|
|
)
|
|
|
|
await bq_plugin_inst.on_event_callback(
|
|
invocation_context=invocation_context, event=event
|
|
)
|
|
await bq_plugin_inst.flush()
|
|
assert mock_write_client.append_rows.call_count == 0
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_skips_long_running_tool_events(
|
|
self,
|
|
bq_plugin_inst,
|
|
mock_write_client,
|
|
invocation_context,
|
|
dummy_arrow_schema,
|
|
):
|
|
"""Long-running tool events are not logged as AGENT_RESPONSE.
|
|
|
|
They DO emit TOOL_PAUSED — here via the unmatched-id fallback, since
|
|
the function_call part has no id matching the long_running_tool_id.
|
|
"""
|
|
fc = types.FunctionCall(name="long_tool", args={})
|
|
event = event_lib.Event(
|
|
author="agent",
|
|
content=types.Content(parts=[types.Part(function_call=fc)]),
|
|
long_running_tool_ids={"call-1"},
|
|
)
|
|
|
|
bigquery_agent_analytics_plugin.TraceManager.push_span(invocation_context)
|
|
await bq_plugin_inst.on_event_callback(
|
|
invocation_context=invocation_context, event=event
|
|
)
|
|
await bq_plugin_inst.flush()
|
|
rows = await _get_captured_rows_async(mock_write_client, dummy_arrow_schema)
|
|
types_emitted = [r["event_type"] for r in rows]
|
|
assert "AGENT_RESPONSE" not in types_emitted
|
|
# The pause is still observable via the fallback TOOL_PAUSED row.
|
|
assert types_emitted == ["TOOL_PAUSED"]
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_skips_thought_only_events(
|
|
self,
|
|
bq_plugin_inst,
|
|
mock_write_client,
|
|
invocation_context,
|
|
):
|
|
"""Thought-only final events are not logged as AGENT_RESPONSE."""
|
|
event = event_lib.Event(
|
|
author="agent",
|
|
content=types.Content(
|
|
parts=[types.Part(text="internal reasoning...", thought=True)]
|
|
),
|
|
)
|
|
|
|
await bq_plugin_inst.on_event_callback(
|
|
invocation_context=invocation_context, event=event
|
|
)
|
|
await bq_plugin_inst.flush()
|
|
assert mock_write_client.append_rows.call_count == 0
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_mixed_thought_and_visible_logs_only_visible(
|
|
self,
|
|
bq_plugin_inst,
|
|
mock_write_client,
|
|
invocation_context,
|
|
dummy_arrow_schema,
|
|
):
|
|
"""Mixed thought + visible text logs only the visible portion."""
|
|
event = event_lib.Event(
|
|
author="agent",
|
|
content=types.Content(
|
|
parts=[
|
|
types.Part(text="thinking step 1...", thought=True),
|
|
types.Part(text="Here is the answer."),
|
|
]
|
|
),
|
|
)
|
|
|
|
bigquery_agent_analytics_plugin.TraceManager.push_span(invocation_context)
|
|
await bq_plugin_inst.on_event_callback(
|
|
invocation_context=invocation_context, event=event
|
|
)
|
|
await bq_plugin_inst.flush()
|
|
rows = await _get_captured_rows_async(mock_write_client, dummy_arrow_schema)
|
|
agent_resp_rows = [r for r in rows if r["event_type"] == "AGENT_RESPONSE"]
|
|
assert len(agent_resp_rows) == 1
|
|
content = json.loads(agent_resp_rows[0]["content"])
|
|
assert "Here is the answer" in content["response"]
|
|
assert "thinking step" not in content["response"]
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_skips_empty_part_events(
|
|
self,
|
|
bq_plugin_inst,
|
|
mock_write_client,
|
|
invocation_context,
|
|
):
|
|
"""Events with only empty Part() do not log AGENT_RESPONSE."""
|
|
event = event_lib.Event(
|
|
author="agent",
|
|
content=types.Content(parts=[types.Part()]),
|
|
)
|
|
|
|
await bq_plugin_inst.on_event_callback(
|
|
invocation_context=invocation_context, event=event
|
|
)
|
|
await bq_plugin_inst.flush()
|
|
assert mock_write_client.append_rows.call_count == 0
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_skips_empty_text_events(
|
|
self,
|
|
bq_plugin_inst,
|
|
mock_write_client,
|
|
invocation_context,
|
|
):
|
|
"""Events with Part(text='') do not log AGENT_RESPONSE."""
|
|
event = event_lib.Event(
|
|
author="agent",
|
|
content=types.Content(parts=[types.Part(text="")]),
|
|
)
|
|
|
|
await bq_plugin_inst.on_event_callback(
|
|
invocation_context=invocation_context, event=event
|
|
)
|
|
await bq_plugin_inst.flush()
|
|
assert mock_write_client.append_rows.call_count == 0
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_skips_executable_code_only_events(
|
|
self,
|
|
bq_plugin_inst,
|
|
mock_write_client,
|
|
invocation_context,
|
|
):
|
|
"""Events with only executable_code parts do not log AGENT_RESPONSE."""
|
|
event = event_lib.Event(
|
|
author="agent",
|
|
content=types.Content(
|
|
parts=[
|
|
types.Part(
|
|
executable_code=types.ExecutableCode(
|
|
code="print('hi')", language="PYTHON"
|
|
)
|
|
)
|
|
]
|
|
),
|
|
)
|
|
|
|
await bq_plugin_inst.on_event_callback(
|
|
invocation_context=invocation_context, event=event
|
|
)
|
|
await bq_plugin_inst.flush()
|
|
assert mock_write_client.append_rows.call_count == 0
|
|
|
|
|
|
class TestDropStats:
|
|
"""Tests that dropped events are counted and exposed via get_drop_stats."""
|
|
|
|
def _make_processor(
|
|
self, arrow_schema, *, queue_max_size=10, retry_config=None
|
|
):
|
|
"""Builds a BatchProcessor with a mock write client (writer not started)."""
|
|
return bigquery_agent_analytics_plugin.BatchProcessor(
|
|
write_client=mock.MagicMock(),
|
|
arrow_schema=arrow_schema,
|
|
write_stream=DEFAULT_STREAM_NAME,
|
|
batch_size=1,
|
|
flush_interval=1.0,
|
|
retry_config=(
|
|
retry_config or bigquery_agent_analytics_plugin.RetryConfig()
|
|
),
|
|
queue_max_size=queue_max_size,
|
|
shutdown_timeout=10.0,
|
|
)
|
|
|
|
def _stub_arrow_prep(self, bp):
|
|
"""Stubs Arrow serialization so write tests need no real row schema."""
|
|
fake_batch = mock.MagicMock()
|
|
fake_batch.serialize.return_value.to_pybytes.return_value = b"batch"
|
|
bp._prepare_arrow_batch = mock.MagicMock(return_value=fake_batch)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_flush_waits_for_dequeued_write(self, dummy_arrow_schema):
|
|
bp = self._make_processor(dummy_arrow_schema)
|
|
await bp.append({"event": 0})
|
|
await bp._queue.get()
|
|
|
|
flush_task = asyncio.create_task(bp.flush())
|
|
await asyncio.sleep(0)
|
|
|
|
assert not flush_task.done()
|
|
bp._queue.task_done()
|
|
await flush_task
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_queue_full_drops_are_counted(self, dummy_arrow_schema):
|
|
# Writer is not started, so a size-1 queue fills after one append and the
|
|
# next two appends overflow and are dropped.
|
|
bp = self._make_processor(dummy_arrow_schema, queue_max_size=1)
|
|
await bp.append({"event": 0})
|
|
await bp.append({"event": 1})
|
|
await bp.append({"event": 2})
|
|
assert bp.get_drop_stats()["queue_full"] == 2
|
|
assert bp.dropped_event_count == 2
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_retry_exhaustion_drops_are_counted(self, dummy_arrow_schema):
|
|
# max_retries=0 with zero delay drops on the first failure without sleeping.
|
|
retry_config = bigquery_agent_analytics_plugin.RetryConfig(
|
|
max_retries=0, initial_delay=0.0, multiplier=1.0, max_delay=0.0
|
|
)
|
|
bp = self._make_processor(dummy_arrow_schema, retry_config=retry_config)
|
|
self._stub_arrow_prep(bp)
|
|
|
|
async def fake_append_rows(requests, **kwargs):
|
|
del requests, kwargs
|
|
resp = mock.MagicMock()
|
|
resp.row_errors = []
|
|
resp.error = mock.MagicMock()
|
|
resp.error.code = bigquery_agent_analytics_plugin._GRPC_UNAVAILABLE
|
|
resp.error.message = "unavailable"
|
|
return _async_gen(resp)
|
|
|
|
bp.write_client.append_rows.side_effect = fake_append_rows
|
|
|
|
await bp._write_rows_with_retry([{"a": 1}, {"a": 2}])
|
|
|
|
assert bp.get_drop_stats()["retry_exhausted"] == 2
|
|
assert bp.dropped_event_count == 2
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_non_retryable_drops_are_counted(
|
|
self, dummy_arrow_schema, caplog
|
|
):
|
|
bp = self._make_processor(dummy_arrow_schema)
|
|
self._stub_arrow_prep(bp)
|
|
|
|
async def fake_append_rows(requests, **kwargs):
|
|
del requests, kwargs
|
|
resp = mock.MagicMock()
|
|
resp.row_errors = []
|
|
resp.error = mock.MagicMock()
|
|
resp.error.code = 3 # INVALID_ARGUMENT, non-retryable.
|
|
resp.error.message = "bad request"
|
|
return _async_gen(resp)
|
|
|
|
bp.write_client.append_rows.side_effect = fake_append_rows
|
|
|
|
secret = "NONRETRYABLE-ROW-SECRET"
|
|
with caplog.at_level(
|
|
logging.ERROR,
|
|
logger="google_adk.google.adk.plugins.bigquery_agent_analytics_plugin",
|
|
):
|
|
await bp._write_rows_with_retry([{"a": secret}])
|
|
|
|
assert bp.get_drop_stats()["non_retryable"] == 1
|
|
assert bp.dropped_event_count == 1
|
|
assert secret not in caplog.text
|
|
assert "1 row(s) dropped" in caplog.text
|
|
|
|
def test_plugin_get_drop_stats_aggregates_across_loops(
|
|
self, dummy_arrow_schema
|
|
):
|
|
plugin = bigquery_agent_analytics_plugin.BigQueryAgentAnalyticsPlugin(
|
|
project_id=PROJECT_ID, dataset_id=DATASET_ID, table_id=TABLE_ID
|
|
)
|
|
bp1 = self._make_processor(dummy_arrow_schema)
|
|
bp2 = self._make_processor(dummy_arrow_schema)
|
|
bp1._dropped["queue_full"] = 3
|
|
bp1._dropped["retry_exhausted"] = 1
|
|
bp2._dropped["queue_full"] = 4
|
|
loop1 = mock.MagicMock(spec=asyncio.AbstractEventLoop)
|
|
loop2 = mock.MagicMock(spec=asyncio.AbstractEventLoop)
|
|
plugin._loop_state_by_loop[loop1] = (
|
|
bigquery_agent_analytics_plugin._LoopState(mock.MagicMock(), bp1)
|
|
)
|
|
plugin._loop_state_by_loop[loop2] = (
|
|
bigquery_agent_analytics_plugin._LoopState(mock.MagicMock(), bp2)
|
|
)
|
|
|
|
stats = plugin.get_drop_stats()
|
|
|
|
assert stats["queue_full"] == 7
|
|
assert stats["retry_exhausted"] == 1
|
|
|
|
def test_plugin_get_drop_stats_empty_without_processor(self):
|
|
plugin = bigquery_agent_analytics_plugin.BigQueryAgentAnalyticsPlugin(
|
|
project_id=PROJECT_ID, dataset_id=DATASET_ID, table_id=TABLE_ID
|
|
)
|
|
assert plugin.get_drop_stats() == {}
|
|
|
|
|
|
class TestExactlyOnceDelivery:
|
|
"""Tests the opt-in committed-stream offset protocol."""
|
|
|
|
_STREAM = (
|
|
f"projects/{PROJECT_ID}/datasets/{DATASET_ID}/tables/{TABLE_ID}"
|
|
"/streams/committed-1"
|
|
)
|
|
|
|
def _make_processor(
|
|
self,
|
|
arrow_schema,
|
|
*,
|
|
write_client=None,
|
|
create_stream=None,
|
|
max_retries=0,
|
|
):
|
|
write_client = write_client or mock.MagicMock()
|
|
processor = bigquery_agent_analytics_plugin.BatchProcessor(
|
|
write_client=write_client,
|
|
arrow_schema=arrow_schema,
|
|
write_stream=self._STREAM,
|
|
batch_size=2,
|
|
flush_interval=1.0,
|
|
retry_config=bigquery_agent_analytics_plugin.RetryConfig(
|
|
max_retries=max_retries,
|
|
initial_delay=0.0,
|
|
multiplier=1.0,
|
|
max_delay=0.0,
|
|
),
|
|
queue_max_size=10,
|
|
shutdown_timeout=1.0,
|
|
exactly_once_delivery=True,
|
|
create_stream=create_stream,
|
|
)
|
|
fake_batch = mock.MagicMock()
|
|
fake_batch.serialize.return_value.to_pybytes.return_value = b"batch"
|
|
processor._prepare_arrow_batch = mock.MagicMock(return_value=fake_batch)
|
|
return processor
|
|
|
|
@staticmethod
|
|
def _response(code=0, message=""):
|
|
response = mock.MagicMock()
|
|
response.error.code = code
|
|
response.error.message = message
|
|
response.row_errors = []
|
|
return response
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_default_mode_omits_offset(self, dummy_arrow_schema):
|
|
assert (
|
|
not bigquery_agent_analytics_plugin.BigQueryLoggerConfig().exactly_once_delivery
|
|
)
|
|
client = mock.MagicMock()
|
|
captured = []
|
|
|
|
async def append_rows(requests, **kwargs):
|
|
del kwargs
|
|
captured.extend([request async for request in requests])
|
|
return _async_gen(self._response())
|
|
|
|
client.append_rows.side_effect = append_rows
|
|
processor = TestDropStats()._make_processor(dummy_arrow_schema)
|
|
processor.write_client = client
|
|
TestDropStats()._stub_arrow_prep(processor)
|
|
|
|
await processor._write_rows_with_retry([{"a": 1}])
|
|
|
|
assert len(captured) == 1
|
|
assert not captured[0]._pb.HasField("offset")
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_default_mode_keeps_empty_response_as_success(
|
|
self, dummy_arrow_schema
|
|
):
|
|
client = mock.MagicMock()
|
|
|
|
async def empty_responses():
|
|
if False:
|
|
yield None
|
|
|
|
async def append_rows(requests, **kwargs):
|
|
del kwargs
|
|
await anext(requests)
|
|
return empty_responses()
|
|
|
|
client.append_rows.side_effect = append_rows
|
|
processor = TestDropStats()._make_processor(
|
|
dummy_arrow_schema,
|
|
retry_config=bigquery_agent_analytics_plugin.RetryConfig(
|
|
max_retries=1,
|
|
initial_delay=0.0,
|
|
multiplier=1.0,
|
|
max_delay=0.0,
|
|
),
|
|
)
|
|
processor.write_client = client
|
|
TestDropStats()._stub_arrow_prep(processor)
|
|
|
|
await processor._write_rows_with_retry([{"a": 1}])
|
|
|
|
assert client.append_rows.call_count == 1
|
|
assert processor.dropped_event_count == 0
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_default_mode_never_finalizes_default_stream(
|
|
self, dummy_arrow_schema
|
|
):
|
|
"""Closing the default-stream writer never invokes stream finalization."""
|
|
client = mock.MagicMock()
|
|
client.finalize_write_stream = mock.AsyncMock()
|
|
processor = TestDropStats()._make_processor(dummy_arrow_schema)
|
|
processor.write_client = client
|
|
|
|
await processor.close()
|
|
|
|
client.finalize_write_stream.assert_not_awaited()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_exactly_once_empty_response_poison_stream(
|
|
self, dummy_arrow_schema
|
|
):
|
|
client = mock.MagicMock()
|
|
|
|
async def empty_responses():
|
|
if False:
|
|
yield None
|
|
|
|
async def append_rows(requests, **kwargs):
|
|
del kwargs
|
|
await anext(requests)
|
|
return empty_responses()
|
|
|
|
client.append_rows.side_effect = append_rows
|
|
processor = self._make_processor(dummy_arrow_schema, write_client=client)
|
|
|
|
await processor._write_rows_with_retry([{"a": 1}])
|
|
|
|
assert client.append_rows.call_count == 1
|
|
assert processor.get_drop_stats()["retry_exhausted"] == 1
|
|
assert processor._offset_desynced
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_offsets_advance_only_after_confirmed_batches(
|
|
self, dummy_arrow_schema
|
|
):
|
|
client = mock.MagicMock()
|
|
offsets = []
|
|
|
|
async def append_rows(requests, **kwargs):
|
|
del kwargs
|
|
request = [request async for request in requests][0]
|
|
offsets.append(request.offset)
|
|
return _async_gen(self._response())
|
|
|
|
client.append_rows.side_effect = append_rows
|
|
processor = self._make_processor(dummy_arrow_schema, write_client=client)
|
|
|
|
await processor._write_rows_with_retry([{"a": 1}, {"a": 2}])
|
|
await processor._write_rows_with_retry([{"a": 3}])
|
|
|
|
assert offsets == [0, 2]
|
|
assert processor._next_offset == 3
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("already_exists_in_band", [False, True])
|
|
async def test_retry_reuses_offset_and_already_exists_confirms_delivery(
|
|
self, dummy_arrow_schema, already_exists_in_band
|
|
):
|
|
client = mock.MagicMock()
|
|
offsets = []
|
|
calls = 0
|
|
|
|
async def append_rows(requests, **kwargs):
|
|
nonlocal calls
|
|
del kwargs
|
|
request = [request async for request in requests][0]
|
|
offsets.append(request.offset)
|
|
calls += 1
|
|
if calls == 1:
|
|
raise api_exceptions.ServiceUnavailable("retry")
|
|
if already_exists_in_band:
|
|
return _async_gen(self._response(6, "offset already exists"))
|
|
raise api_exceptions.AlreadyExists("offset already exists")
|
|
|
|
client.append_rows.side_effect = append_rows
|
|
processor = self._make_processor(
|
|
dummy_arrow_schema, write_client=client, max_retries=1
|
|
)
|
|
|
|
await processor._write_rows_with_retry([{"a": 1}, {"a": 2}])
|
|
|
|
assert offsets == [0, 0]
|
|
assert processor._next_offset == 2
|
|
assert processor.dropped_event_count == 0
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_ambiguous_attempt_stays_desynchronized_after_later_rejection(
|
|
self, dummy_arrow_schema
|
|
):
|
|
"""A later rejected retry cannot make an earlier sent attempt safe."""
|
|
client = mock.MagicMock()
|
|
streams = []
|
|
calls = 0
|
|
replacement = self._STREAM.replace("committed-1", "committed-2")
|
|
create_stream = mock.AsyncMock(return_value=replacement)
|
|
|
|
async def append_rows(requests, **kwargs):
|
|
nonlocal calls
|
|
del kwargs
|
|
request = await anext(requests)
|
|
streams.append(request.write_stream)
|
|
calls += 1
|
|
if calls == 1:
|
|
raise asyncio.TimeoutError()
|
|
if calls == 2:
|
|
return _async_gen(self._response(14, "unavailable"))
|
|
if request.write_stream == self._STREAM:
|
|
return _async_gen(self._response(6, "offset already exists"))
|
|
return _async_gen(self._response())
|
|
|
|
client.append_rows.side_effect = append_rows
|
|
client.finalize_write_stream = mock.AsyncMock()
|
|
processor = self._make_processor(
|
|
dummy_arrow_schema,
|
|
write_client=client,
|
|
create_stream=create_stream,
|
|
max_retries=1,
|
|
)
|
|
|
|
await processor._write_rows_with_retry([{"batch": "a"}, {"batch": "a"}])
|
|
await processor._write_rows_with_retry([{"batch": "b"}])
|
|
|
|
assert streams == [self._STREAM, self._STREAM, replacement]
|
|
assert processor._next_offset == 1
|
|
assert processor.get_drop_stats()["retry_exhausted"] == 2
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_non_retryable_rejection_after_ambiguity_rotates_stream(
|
|
self, dummy_arrow_schema
|
|
):
|
|
"""A terminal rejection cannot make an earlier sent attempt safe."""
|
|
client = mock.MagicMock()
|
|
streams = []
|
|
calls = 0
|
|
replacement = self._STREAM.replace("committed-1", "committed-2")
|
|
create_stream = mock.AsyncMock(return_value=replacement)
|
|
|
|
async def append_rows(requests, **kwargs):
|
|
nonlocal calls
|
|
del kwargs
|
|
request = await anext(requests)
|
|
streams.append(request.write_stream)
|
|
calls += 1
|
|
if calls == 1:
|
|
raise asyncio.TimeoutError()
|
|
if calls == 2:
|
|
return _async_gen(self._response(7, "permission denied"))
|
|
if request.write_stream == self._STREAM:
|
|
if calls == 3:
|
|
raise asyncio.TimeoutError()
|
|
return _async_gen(self._response(6, "offset already exists"))
|
|
return _async_gen(self._response())
|
|
|
|
client.append_rows.side_effect = append_rows
|
|
client.finalize_write_stream = mock.AsyncMock()
|
|
processor = self._make_processor(
|
|
dummy_arrow_schema,
|
|
write_client=client,
|
|
create_stream=create_stream,
|
|
max_retries=1,
|
|
)
|
|
|
|
await processor._write_rows_with_retry([{"batch": "a"}])
|
|
await processor._write_rows_with_retry([{"batch": "b"}])
|
|
|
|
assert streams == [self._STREAM, self._STREAM, replacement]
|
|
assert processor._next_offset == 1
|
|
assert processor.get_drop_stats()["non_retryable"] == 1
|
|
create_stream.assert_awaited_once_with()
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("already_exists_in_band", [False, True])
|
|
async def test_first_attempt_already_exists_desynchronizes_stream(
|
|
self, dummy_arrow_schema, already_exists_in_band
|
|
):
|
|
"""An occupied offset cannot confirm a batch with no ambiguous attempt."""
|
|
client = mock.MagicMock()
|
|
|
|
async def append_rows(requests, **kwargs):
|
|
del kwargs
|
|
await anext(requests)
|
|
if already_exists_in_band:
|
|
return _async_gen(self._response(6, "offset already exists"))
|
|
raise api_exceptions.AlreadyExists("offset already exists")
|
|
|
|
client.append_rows.side_effect = append_rows
|
|
processor = self._make_processor(
|
|
dummy_arrow_schema, write_client=client, max_retries=1
|
|
)
|
|
|
|
await processor._write_rows_with_retry([{"a": 1}])
|
|
|
|
assert processor._next_offset == 0
|
|
assert processor._offset_desynced
|
|
assert processor.get_drop_stats()["offset_conflict"] == 1
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize(
|
|
("error", "code"),
|
|
[
|
|
(api_exceptions.NotFound("stream gone"), None),
|
|
(api_exceptions.OutOfRange("offset rejected"), None),
|
|
(None, 5),
|
|
(None, 11),
|
|
],
|
|
)
|
|
async def test_offset_conflict_rotates_before_next_batch(
|
|
self, dummy_arrow_schema, error, code
|
|
):
|
|
client = mock.MagicMock()
|
|
offsets = []
|
|
streams = []
|
|
calls = 0
|
|
replacement = self._STREAM.replace("committed-1", "committed-2")
|
|
create_stream = mock.AsyncMock(return_value=replacement)
|
|
|
|
async def append_rows(requests, **kwargs):
|
|
nonlocal calls
|
|
del kwargs
|
|
request = [request async for request in requests][0]
|
|
offsets.append(request.offset)
|
|
streams.append(request.write_stream)
|
|
calls += 1
|
|
if calls == 1:
|
|
if error is not None:
|
|
raise error
|
|
return _async_gen(self._response(code, "offset rejected"))
|
|
return _async_gen(self._response())
|
|
|
|
client.append_rows.side_effect = append_rows
|
|
client.finalize_write_stream = mock.AsyncMock()
|
|
processor = self._make_processor(
|
|
dummy_arrow_schema,
|
|
write_client=client,
|
|
create_stream=create_stream,
|
|
)
|
|
|
|
await processor._write_rows_with_retry([{"a": 1}])
|
|
await processor._write_rows_with_retry([{"a": 2}])
|
|
|
|
assert processor.get_drop_stats()["offset_conflict"] == 1
|
|
assert offsets == [0, 0]
|
|
assert streams == [self._STREAM, replacement]
|
|
create_stream.assert_awaited_once_with()
|
|
client.finalize_write_stream.assert_not_awaited()
|
|
assert self._STREAM in processor._pending_finalize_streams
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_rotation_does_not_wait_for_old_stream_finalization(
|
|
self, dummy_arrow_schema
|
|
):
|
|
"""A stuck finalizer cannot block writes on a replacement stream."""
|
|
client = mock.MagicMock()
|
|
replacement = self._STREAM.replace("committed-1", "committed-2")
|
|
create_stream = mock.AsyncMock(return_value=replacement)
|
|
|
|
async def append_rows(requests, **kwargs):
|
|
del kwargs
|
|
request = await anext(requests)
|
|
assert request.write_stream == replacement
|
|
return _async_gen(self._response())
|
|
|
|
async def never_finalize(**kwargs):
|
|
del kwargs
|
|
await asyncio.Event().wait()
|
|
|
|
client.append_rows.side_effect = append_rows
|
|
client.finalize_write_stream = mock.AsyncMock(side_effect=never_finalize)
|
|
processor = self._make_processor(
|
|
dummy_arrow_schema,
|
|
write_client=client,
|
|
create_stream=create_stream,
|
|
)
|
|
processor._offset_desynced = True
|
|
|
|
await asyncio.wait_for(
|
|
processor._write_rows_with_retry([{"a": 1}]), timeout=0.1
|
|
)
|
|
|
|
assert processor.write_stream == replacement
|
|
assert processor._next_offset == 1
|
|
assert self._STREAM in processor._pending_finalize_streams
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_rotation_creation_failure_drops_during_backoff(
|
|
self, dummy_arrow_schema
|
|
):
|
|
"""A failed replacement counts later backoff-window batches as dropped."""
|
|
client = mock.MagicMock()
|
|
client.append_rows = mock.AsyncMock()
|
|
create_stream = mock.AsyncMock(
|
|
side_effect=api_exceptions.ServiceUnavailable("quota unavailable")
|
|
)
|
|
processor = self._make_processor(
|
|
dummy_arrow_schema,
|
|
write_client=client,
|
|
create_stream=create_stream,
|
|
)
|
|
processor._offset_desynced = True
|
|
|
|
await processor._write_rows_with_retry([{"a": 1}])
|
|
await processor._write_rows_with_retry([{"a": 2}, {"a": 3}])
|
|
|
|
create_stream.assert_awaited_once_with()
|
|
client.append_rows.assert_not_awaited()
|
|
assert processor.get_drop_stats()["offset_conflict"] == 3
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_ambiguous_exhaustion_poison_stream_and_rotates(
|
|
self, dummy_arrow_schema
|
|
):
|
|
client = mock.MagicMock()
|
|
calls = 0
|
|
replacement = self._STREAM.replace("committed-1", "committed-2")
|
|
create_stream = mock.AsyncMock(return_value=replacement)
|
|
|
|
async def append_rows(requests, **kwargs):
|
|
nonlocal calls
|
|
del kwargs
|
|
await anext(requests)
|
|
calls += 1
|
|
if calls == 1:
|
|
raise asyncio.TimeoutError()
|
|
return _async_gen(self._response())
|
|
|
|
client.append_rows.side_effect = append_rows
|
|
client.finalize_write_stream = mock.AsyncMock()
|
|
processor = self._make_processor(
|
|
dummy_arrow_schema,
|
|
write_client=client,
|
|
create_stream=create_stream,
|
|
)
|
|
|
|
await processor._write_rows_with_retry([{"a": 1}])
|
|
await processor._write_rows_with_retry([{"a": 2}])
|
|
|
|
assert processor.get_drop_stats()["retry_exhausted"] == 1
|
|
assert processor._next_offset == 1
|
|
create_stream.assert_awaited_once_with()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_shutdown_finalizes_terminal_worker_and_retries_failure(
|
|
self, dummy_arrow_schema
|
|
):
|
|
client = mock.MagicMock()
|
|
client.finalize_write_stream = mock.AsyncMock(
|
|
side_effect=[api_exceptions.ServiceUnavailable("try again"), None]
|
|
)
|
|
processor = self._make_processor(dummy_arrow_schema, write_client=client)
|
|
terminal_worker = asyncio.create_task(asyncio.sleep(0))
|
|
await terminal_worker
|
|
processor._batch_processor_task = terminal_worker
|
|
|
|
await processor.shutdown()
|
|
await processor.shutdown()
|
|
|
|
assert client.finalize_write_stream.await_count == 2
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("method", ["shutdown", "close"])
|
|
async def test_finalization_respects_remaining_close_budget(
|
|
self, dummy_arrow_schema, method
|
|
):
|
|
client = mock.MagicMock()
|
|
finalize_started = asyncio.Event()
|
|
finalize_cancelled = asyncio.Event()
|
|
|
|
async def hang_during_finalize(**kwargs):
|
|
del kwargs
|
|
finalize_started.set()
|
|
try:
|
|
await asyncio.Event().wait()
|
|
except asyncio.CancelledError:
|
|
finalize_cancelled.set()
|
|
raise
|
|
|
|
client.finalize_write_stream = mock.AsyncMock(
|
|
side_effect=hang_during_finalize
|
|
)
|
|
processor = self._make_processor(dummy_arrow_schema, write_client=client)
|
|
processor.shutdown_timeout = 0.05
|
|
if method == "shutdown":
|
|
processor._batch_processor_task = asyncio.create_task(asyncio.sleep(0.03))
|
|
|
|
started_at = asyncio.get_running_loop().time()
|
|
if method == "shutdown":
|
|
await processor.shutdown(timeout=0.05)
|
|
else:
|
|
await processor.close()
|
|
elapsed = asyncio.get_running_loop().time() - started_at
|
|
|
|
assert elapsed < 0.2
|
|
assert finalize_started.is_set()
|
|
assert finalize_cancelled.is_set()
|
|
|
|
def test_missing_committed_offset_desynchronizes_without_assertion(
|
|
self, dummy_arrow_schema
|
|
):
|
|
processor = self._make_processor(dummy_arrow_schema)
|
|
|
|
processor._confirm_committed_delivery(None, row_count=2)
|
|
|
|
assert processor._next_offset == 0
|
|
assert processor._offset_desynced
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_plugin_creates_committed_stream(self):
|
|
plugin = bigquery_agent_analytics_plugin.BigQueryAgentAnalyticsPlugin(
|
|
project_id=PROJECT_ID,
|
|
dataset_id=DATASET_ID,
|
|
table_id=TABLE_ID,
|
|
)
|
|
client = mock.MagicMock()
|
|
client.create_write_stream = mock.AsyncMock(
|
|
return_value=mock.MagicMock(name=self._STREAM)
|
|
)
|
|
client.create_write_stream.return_value.name = self._STREAM
|
|
|
|
stream_name = await plugin._create_committed_write_stream(client)
|
|
|
|
assert stream_name == self._STREAM
|
|
kwargs = client.create_write_stream.await_args.kwargs
|
|
assert kwargs["parent"] == (
|
|
f"projects/{PROJECT_ID}/datasets/{DATASET_ID}/tables/{TABLE_ID}"
|
|
)
|
|
assert kwargs["write_stream"].type_.name == "COMMITTED"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_config_wires_committed_stream_into_batch_processor(
|
|
self, dummy_arrow_schema
|
|
):
|
|
"""The public opt-in config constructs an offset-aware processor."""
|
|
config = bigquery_agent_analytics_plugin.BigQueryLoggerConfig(
|
|
exactly_once_delivery=True
|
|
)
|
|
plugin = bigquery_agent_analytics_plugin.BigQueryAgentAnalyticsPlugin(
|
|
project_id=PROJECT_ID,
|
|
dataset_id=DATASET_ID,
|
|
table_id=TABLE_ID,
|
|
config=config,
|
|
)
|
|
plugin.arrow_schema = dummy_arrow_schema
|
|
plugin._credentials = mock.MagicMock(quota_project_id=None)
|
|
client = mock.MagicMock()
|
|
client.finalize_write_stream = mock.AsyncMock()
|
|
client.close = mock.AsyncMock()
|
|
create_stream = mock.AsyncMock(return_value=self._STREAM)
|
|
|
|
with (
|
|
mock.patch.object(
|
|
bigquery_agent_analytics_plugin,
|
|
"BigQueryWriteAsyncClient",
|
|
return_value=client,
|
|
),
|
|
mock.patch.object(
|
|
plugin, "_create_committed_write_stream", create_stream
|
|
),
|
|
):
|
|
state = await plugin._get_loop_state()
|
|
|
|
assert state.batch_processor.exactly_once_delivery
|
|
assert state.batch_processor.write_stream == self._STREAM
|
|
assert state.batch_processor._create_stream is not None
|
|
|
|
await plugin.shutdown()
|
|
|
|
create_stream.assert_awaited_once_with(client)
|
|
|
|
|
|
# -----------------------------------------------------------------------------
|
|
# ADK 2.0 minimum producer cut
|
|
#
|
|
# Coverage matrix:
|
|
# A1 / A2 attributes.adk.{schema_version, app_name} on every row
|
|
# A3 attributes.adk.source_event_id on Event-originating rows
|
|
# C1 attributes.adk.node {path, run_id, parent_run_id}
|
|
# C2 attributes.adk.branch
|
|
# C3 attributes.adk.scope {id, kind}
|
|
# C4 AGENT_TRANSFER emit
|
|
# C5 EVENT_COMPACTION emit (preserves fractional float epoch)
|
|
# C6 AGENT_STATE_CHECKPOINT emit (both shapes) + id-stabilization
|
|
# C7 TOOL_PAUSED with pause_kind / function_call_id
|
|
# HITL non-routing to TOOL_COMPLETED
|
|
# user-message TOOL_COMPLETED with pause_kind='tool'
|
|
# C8 attributes.adk.{route, render_ui_widgets, rewind_before_invocation_id}
|
|
# D1 on_state_change_callback removed
|
|
# -----------------------------------------------------------------------------
|
|
|
|
|
|
def test_derive_scope_unscoped():
|
|
"""C3: None isolation_scope → scope = null."""
|
|
assert bigquery_agent_analytics_plugin._derive_scope(None) is None
|
|
|
|
|
|
def test_derive_scope_node_run_bare():
|
|
"""C3: bare 'name@run_id' classifies as node_run (not function_call)."""
|
|
scope = bigquery_agent_analytics_plugin._derive_scope("loopA@42")
|
|
assert scope == {"id": "loopA@42", "kind": "node_run"}
|
|
|
|
|
|
def test_derive_scope_node_run_path():
|
|
"""C3: 'parent/name@run_id' classifies as node_run."""
|
|
scope = bigquery_agent_analytics_plugin._derive_scope("wf/A@1/B@2")
|
|
assert scope == {"id": "wf/A@1/B@2", "kind": "node_run"}
|
|
|
|
|
|
def test_derive_scope_function_call_provider_id():
|
|
"""C3: model-provided FC IDs (call_*, toolu_*) classify as function_call."""
|
|
for fc_id in ("call_abc123", "toolu_xyz", "adk-fc-1"):
|
|
scope = bigquery_agent_analytics_plugin._derive_scope(fc_id)
|
|
assert scope == {"id": fc_id, "kind": "function_call"}, fc_id
|
|
|
|
|
|
def test_derive_scope_empty_string_unknown():
|
|
"""C3: empty/non-string anomalies classify as unknown."""
|
|
scope = bigquery_agent_analytics_plugin._derive_scope("")
|
|
assert scope == {"id": "", "kind": "unknown"}
|
|
|
|
|
|
def test_d1_on_state_change_callback_removed():
|
|
"""D1: the deprecated stub is gone from the public surface."""
|
|
assert not hasattr(
|
|
bigquery_agent_analytics_plugin.BigQueryAgentAnalyticsPlugin,
|
|
"on_state_change_callback",
|
|
)
|
|
|
|
|
|
class TestAdkEnvelope:
|
|
"""A1 / A2 / A3 / C1 / C2 / C3 / C8 envelope shape on emitted rows."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_envelope_on_non_event_row(
|
|
self,
|
|
bq_plugin_inst,
|
|
mock_write_client,
|
|
invocation_context,
|
|
dummy_arrow_schema,
|
|
):
|
|
"""USER_MESSAGE_RECEIVED has no source Event → A1/A2 only, A3/C1/C2/C3 null."""
|
|
bigquery_agent_analytics_plugin.TraceManager.push_span(invocation_context)
|
|
await bq_plugin_inst.on_user_message_callback(
|
|
invocation_context=invocation_context,
|
|
user_message=types.Content(role="user", parts=[types.Part(text="hi")]),
|
|
)
|
|
await bq_plugin_inst.flush()
|
|
log_entry = await _get_captured_event_dict_async(
|
|
mock_write_client, dummy_arrow_schema
|
|
)
|
|
_assert_common_fields(log_entry, "USER_MESSAGE_RECEIVED")
|
|
attributes = json.loads(log_entry["attributes"])
|
|
adk = attributes["adk"]
|
|
# A1: schema_version always present.
|
|
assert adk["schema_version"] == (
|
|
bigquery_agent_analytics_plugin._ADK_ENVELOPE_SCHEMA_VERSION
|
|
)
|
|
# A2: app_name always present (from session).
|
|
assert adk["app_name"] == "test_app"
|
|
# A3 / C1 / C2 / C3 absent on rows without an originating Event.
|
|
assert "source_event_id" not in adk
|
|
assert "node" not in adk
|
|
assert "branch" not in adk
|
|
assert "scope" not in adk
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_envelope_on_event_row(
|
|
self,
|
|
bq_plugin_inst,
|
|
mock_write_client,
|
|
invocation_context,
|
|
dummy_arrow_schema,
|
|
):
|
|
"""STATE_DELTA from on_event_callback carries the full envelope."""
|
|
state_delta = {"k": "v"}
|
|
event = event_lib.Event(
|
|
author="agent_a",
|
|
branch="branch-x",
|
|
actions=event_actions_lib.EventActions(state_delta=state_delta),
|
|
)
|
|
bigquery_agent_analytics_plugin.TraceManager.push_span(invocation_context)
|
|
await bq_plugin_inst.on_event_callback(
|
|
invocation_context=invocation_context, event=event
|
|
)
|
|
await bq_plugin_inst.flush()
|
|
log_entry = await _get_captured_event_dict_async(
|
|
mock_write_client, dummy_arrow_schema
|
|
)
|
|
_assert_common_fields(log_entry, "STATE_DELTA")
|
|
attributes = json.loads(log_entry["attributes"])
|
|
adk = attributes["adk"]
|
|
assert adk["schema_version"] == (
|
|
bigquery_agent_analytics_plugin._ADK_ENVELOPE_SCHEMA_VERSION
|
|
)
|
|
assert adk["app_name"] == "test_app"
|
|
# A3: real Event.id (model_post_init auto-assigns a UUID).
|
|
assert adk["source_event_id"] == event.id
|
|
assert len(event.id) == 36 # sanity
|
|
# C2: branch passthrough.
|
|
assert adk["branch"] == "branch-x"
|
|
# C1: node defaults to path="" with run_id="" and parent_run_id=null
|
|
# (no synthesis). run_id / parent_run_id are NodeInfo @property values
|
|
# parsed from path.
|
|
assert adk["node"]["path"] == ""
|
|
assert adk["node"]["run_id"] == ""
|
|
assert adk["node"]["parent_run_id"] is None
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_envelope_node_with_parent_run_id(
|
|
self,
|
|
bq_plugin_inst,
|
|
mock_write_client,
|
|
invocation_context,
|
|
dummy_arrow_schema,
|
|
):
|
|
"""C1: run_id / parent_run_id are derived from NodeInfo for a nested path.
|
|
|
|
For path "wf/A@1/B@2": run_id is the leaf node's run_id ("2") and
|
|
parent_run_id is the parent node's run_id ("1").
|
|
"""
|
|
event = event_lib.Event(
|
|
author="agent_b",
|
|
actions=event_actions_lib.EventActions(state_delta={"k": "v"}),
|
|
)
|
|
event.node_info.path = "wf/A@1/B@2"
|
|
bigquery_agent_analytics_plugin.TraceManager.push_span(invocation_context)
|
|
await bq_plugin_inst.on_event_callback(
|
|
invocation_context=invocation_context, event=event
|
|
)
|
|
await bq_plugin_inst.flush()
|
|
log_entry = await _get_captured_event_dict_async(
|
|
mock_write_client, dummy_arrow_schema
|
|
)
|
|
adk = json.loads(log_entry["attributes"])["adk"]
|
|
assert adk["node"]["path"] == "wf/A@1/B@2"
|
|
assert adk["node"]["run_id"] == "2"
|
|
assert adk["node"]["parent_run_id"] == "1"
|
|
|
|
|
|
class TestC4AgentTransfer:
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_agent_transfer_emits_from_to_payload(
|
|
self,
|
|
bq_plugin_inst,
|
|
mock_write_client,
|
|
invocation_context,
|
|
dummy_arrow_schema,
|
|
):
|
|
event = event_lib.Event(
|
|
author="root_agent",
|
|
actions=event_actions_lib.EventActions(
|
|
transfer_to_agent="specialist_agent"
|
|
),
|
|
)
|
|
bigquery_agent_analytics_plugin.TraceManager.push_span(invocation_context)
|
|
await bq_plugin_inst.on_event_callback(
|
|
invocation_context=invocation_context, event=event
|
|
)
|
|
await bq_plugin_inst.flush()
|
|
rows = await _get_captured_rows_async(mock_write_client, dummy_arrow_schema)
|
|
transfers = [r for r in rows if r["event_type"] == "AGENT_TRANSFER"]
|
|
assert len(transfers) == 1
|
|
content = json.loads(transfers[0]["content"])
|
|
assert content == {
|
|
"from_agent": "root_agent",
|
|
"to_agent": "specialist_agent",
|
|
}
|
|
|
|
|
|
class TestC5EventCompaction:
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_event_compaction_preserves_float_precision(
|
|
self,
|
|
bq_plugin_inst,
|
|
mock_write_client,
|
|
invocation_context,
|
|
dummy_arrow_schema,
|
|
):
|
|
"""C5: fractional float-epoch seconds must survive the producer."""
|
|
compaction = event_actions_lib.EventCompaction(
|
|
start_timestamp=1700000000.125,
|
|
end_timestamp=1700000003.875,
|
|
compacted_content=types.Content(
|
|
role="model", parts=[types.Part(text="summary")]
|
|
),
|
|
)
|
|
event = event_lib.Event(
|
|
author="agent",
|
|
actions=event_actions_lib.EventActions(compaction=compaction),
|
|
)
|
|
bigquery_agent_analytics_plugin.TraceManager.push_span(invocation_context)
|
|
await bq_plugin_inst.on_event_callback(
|
|
invocation_context=invocation_context, event=event
|
|
)
|
|
await bq_plugin_inst.flush()
|
|
rows = await _get_captured_rows_async(mock_write_client, dummy_arrow_schema)
|
|
compactions = [r for r in rows if r["event_type"] == "EVENT_COMPACTION"]
|
|
assert len(compactions) == 1
|
|
content = json.loads(compactions[0]["content"])
|
|
assert content["start_timestamp"] == 1700000000.125
|
|
assert content["end_timestamp"] == 1700000003.875
|
|
assert content["start_timestamp"] != int(content["start_timestamp"])
|
|
|
|
|
|
class TestC6AgentStateCheckpoint:
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_checkpoint_state_only(
|
|
self,
|
|
bq_plugin_inst,
|
|
mock_write_client,
|
|
invocation_context,
|
|
dummy_arrow_schema,
|
|
):
|
|
"""{agent_state: {...}, end_of_agent: None} emits a CHECKPOINT row."""
|
|
event = event_lib.Event(
|
|
author="agent",
|
|
actions=event_actions_lib.EventActions(
|
|
agent_state={"step": 3, "ctx": "abc"}
|
|
),
|
|
)
|
|
bigquery_agent_analytics_plugin.TraceManager.push_span(invocation_context)
|
|
await bq_plugin_inst.on_event_callback(
|
|
invocation_context=invocation_context, event=event
|
|
)
|
|
await bq_plugin_inst.flush()
|
|
rows = await _get_captured_rows_async(mock_write_client, dummy_arrow_schema)
|
|
cps = [r for r in rows if r["event_type"] == "AGENT_STATE_CHECKPOINT"]
|
|
assert len(cps) == 1
|
|
content = json.loads(cps[0]["content"])
|
|
assert content["agent_state"] == {"step": 3, "ctx": "abc"}
|
|
assert content["end_of_agent"] is False
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_checkpoint_end_of_agent_only(
|
|
self,
|
|
bq_plugin_inst,
|
|
mock_write_client,
|
|
invocation_context,
|
|
dummy_arrow_schema,
|
|
):
|
|
"""{agent_state: None, end_of_agent: True} is a valid CHECKPOINT shape."""
|
|
event = event_lib.Event(
|
|
author="agent",
|
|
actions=event_actions_lib.EventActions(end_of_agent=True),
|
|
)
|
|
bigquery_agent_analytics_plugin.TraceManager.push_span(invocation_context)
|
|
await bq_plugin_inst.on_event_callback(
|
|
invocation_context=invocation_context, event=event
|
|
)
|
|
await bq_plugin_inst.flush()
|
|
rows = await _get_captured_rows_async(mock_write_client, dummy_arrow_schema)
|
|
cps = [r for r in rows if r["event_type"] == "AGENT_STATE_CHECKPOINT"]
|
|
assert len(cps) == 1
|
|
content = json.loads(cps[0]["content"])
|
|
assert content["agent_state"] is None
|
|
assert content["end_of_agent"] is True
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_checkpoint_carries_real_source_event_id(
|
|
self,
|
|
bq_plugin_inst,
|
|
mock_write_client,
|
|
invocation_context,
|
|
dummy_arrow_schema,
|
|
):
|
|
"""v3 regression guard: Event.model_post_init auto-assigns id, so a
|
|
checkpoint Event constructed without explicit id still surfaces a real
|
|
36-char UUID in attributes.adk.source_event_id."""
|
|
event = event_lib.Event(
|
|
author="agent",
|
|
actions=event_actions_lib.EventActions(end_of_agent=True),
|
|
)
|
|
assert event.id and len(event.id) == 36
|
|
bigquery_agent_analytics_plugin.TraceManager.push_span(invocation_context)
|
|
await bq_plugin_inst.on_event_callback(
|
|
invocation_context=invocation_context, event=event
|
|
)
|
|
await bq_plugin_inst.flush()
|
|
rows = await _get_captured_rows_async(mock_write_client, dummy_arrow_schema)
|
|
cps = [r for r in rows if r["event_type"] == "AGENT_STATE_CHECKPOINT"]
|
|
assert len(cps) == 1
|
|
adk = json.loads(cps[0]["attributes"])["adk"]
|
|
assert adk["source_event_id"] == event.id
|
|
|
|
|
|
class TestC7ToolPauseAndComplete:
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_tool_paused_non_hitl_pause_kind_tool(
|
|
self,
|
|
bq_plugin_inst,
|
|
mock_write_client,
|
|
invocation_context,
|
|
dummy_arrow_schema,
|
|
):
|
|
fc = types.FunctionCall(
|
|
id="call-1", name="long_running_search", args={"q": "x"}
|
|
)
|
|
event = event_lib.Event(
|
|
author="agent",
|
|
content=types.Content(
|
|
role="model", parts=[types.Part(function_call=fc)]
|
|
),
|
|
long_running_tool_ids={"call-1"},
|
|
actions=event_actions_lib.EventActions(),
|
|
)
|
|
bigquery_agent_analytics_plugin.TraceManager.push_span(invocation_context)
|
|
await bq_plugin_inst.on_event_callback(
|
|
invocation_context=invocation_context, event=event
|
|
)
|
|
await bq_plugin_inst.flush()
|
|
rows = await _get_captured_rows_async(mock_write_client, dummy_arrow_schema)
|
|
pauses = [r for r in rows if r["event_type"] == "TOOL_PAUSED"]
|
|
assert len(pauses) == 1
|
|
# C7 pair keys live UNDER ``attributes.adk`` so the consumer SQL on
|
|
# ``JSON_VALUE(attributes, '$.adk.function_call_id')`` resolves.
|
|
adk = json.loads(pauses[0]["attributes"])["adk"]
|
|
assert adk["pause_kind"] == "tool"
|
|
assert adk["function_call_id"] == "call-1"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_tool_paused_hitl_pause_kind(
|
|
self,
|
|
bq_plugin_inst,
|
|
mock_write_client,
|
|
invocation_context,
|
|
dummy_arrow_schema,
|
|
):
|
|
"""C7: HITL long-running call → pause_kind derived from NAME, not id."""
|
|
fc = types.FunctionCall(
|
|
id="call-hitl-1", name="adk_request_confirmation", args={}
|
|
)
|
|
event = event_lib.Event(
|
|
author="agent",
|
|
content=types.Content(
|
|
role="model", parts=[types.Part(function_call=fc)]
|
|
),
|
|
long_running_tool_ids={"call-hitl-1"},
|
|
actions=event_actions_lib.EventActions(),
|
|
)
|
|
bigquery_agent_analytics_plugin.TraceManager.push_span(invocation_context)
|
|
await bq_plugin_inst.on_event_callback(
|
|
invocation_context=invocation_context, event=event
|
|
)
|
|
await bq_plugin_inst.flush()
|
|
rows = await _get_captured_rows_async(mock_write_client, dummy_arrow_schema)
|
|
pauses = [r for r in rows if r["event_type"] == "TOOL_PAUSED"]
|
|
assert len(pauses) == 1
|
|
adk = json.loads(pauses[0]["attributes"])["adk"]
|
|
assert adk["pause_kind"] == "hitl_confirmation"
|
|
assert adk["function_call_id"] == "call-hitl-1"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_user_message_function_response_emits_tool_completed(
|
|
self,
|
|
bq_plugin_inst,
|
|
mock_write_client,
|
|
invocation_context,
|
|
dummy_arrow_schema,
|
|
):
|
|
"""C7: non-HITL function_response in a user message → TOOL_COMPLETED
|
|
with pause_kind='tool' (this is the long-running resume path)."""
|
|
fr = types.FunctionResponse(
|
|
id="call-1", name="long_running_search", response={"hits": 7}
|
|
)
|
|
bigquery_agent_analytics_plugin.TraceManager.push_span(invocation_context)
|
|
await bq_plugin_inst.on_user_message_callback(
|
|
invocation_context=invocation_context,
|
|
user_message=types.Content(
|
|
role="user", parts=[types.Part(function_response=fr)]
|
|
),
|
|
)
|
|
await bq_plugin_inst.flush()
|
|
rows = await _get_captured_rows_async(mock_write_client, dummy_arrow_schema)
|
|
completed = [r for r in rows if r["event_type"] == "TOOL_COMPLETED"]
|
|
assert len(completed) == 1
|
|
adk = json.loads(completed[0]["attributes"])["adk"]
|
|
assert adk["pause_kind"] == "tool"
|
|
assert adk["function_call_id"] == "call-1"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_hitl_user_message_does_not_emit_tool_completed(
|
|
self,
|
|
bq_plugin_inst,
|
|
mock_write_client,
|
|
invocation_context,
|
|
dummy_arrow_schema,
|
|
):
|
|
"""C7 HITL non-routing: an adk_request_confirmation function_response in
|
|
a user message emits ONLY HITL_CONFIRMATION_REQUEST_COMPLETED, never
|
|
TOOL_COMPLETED."""
|
|
fr = types.FunctionResponse(
|
|
id="call-hitl-1",
|
|
name="adk_request_confirmation",
|
|
response={"approved": True},
|
|
)
|
|
bigquery_agent_analytics_plugin.TraceManager.push_span(invocation_context)
|
|
await bq_plugin_inst.on_user_message_callback(
|
|
invocation_context=invocation_context,
|
|
user_message=types.Content(
|
|
role="user", parts=[types.Part(function_response=fr)]
|
|
),
|
|
)
|
|
await bq_plugin_inst.flush()
|
|
rows = await _get_captured_rows_async(mock_write_client, dummy_arrow_schema)
|
|
types_emitted = {r["event_type"] for r in rows}
|
|
assert "HITL_CONFIRMATION_REQUEST_COMPLETED" in types_emitted
|
|
assert "TOOL_COMPLETED" not in types_emitted
|
|
|
|
|
|
class TestC8ActionAttributes:
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_route_and_rewind_flat_under_attributes_adk(
|
|
self,
|
|
bq_plugin_inst,
|
|
mock_write_client,
|
|
invocation_context,
|
|
dummy_arrow_schema,
|
|
):
|
|
"""C8: route / rewind_before_invocation_id mirror under
|
|
attributes.adk.* (flat-with-prefix, NOT nested under .actions.)."""
|
|
event = event_lib.Event(
|
|
author="agent",
|
|
actions=event_actions_lib.EventActions(
|
|
state_delta={"k": "v"}, # to ensure an emit happens
|
|
route="branch_b",
|
|
rewind_before_invocation_id="inv-earlier",
|
|
),
|
|
)
|
|
bigquery_agent_analytics_plugin.TraceManager.push_span(invocation_context)
|
|
await bq_plugin_inst.on_event_callback(
|
|
invocation_context=invocation_context, event=event
|
|
)
|
|
await bq_plugin_inst.flush()
|
|
log_entry = await _get_captured_event_dict_async(
|
|
mock_write_client, dummy_arrow_schema
|
|
)
|
|
adk = json.loads(log_entry["attributes"])["adk"]
|
|
# Flat-with-prefix mirror under attributes.adk.*.
|
|
assert adk["route"] == "branch_b"
|
|
assert adk["rewind_before_invocation_id"] == "inv-earlier"
|
|
# Not nested under .actions.
|
|
assert "actions" not in adk
|
|
|
|
|
|
class TestWorkflowNodeEvents:
|
|
"""Workflow node outputs and failures are observable through the plugin."""
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("output", [{"id": 7}, ["a", "b"], "done"])
|
|
async def test_node_output_preserves_payload_and_identity(
|
|
self,
|
|
output,
|
|
bq_plugin_inst,
|
|
mock_write_client,
|
|
invocation_context,
|
|
dummy_arrow_schema,
|
|
):
|
|
"""Function-node payloads produce one identity-bearing NODE_OUTPUT row."""
|
|
event = event_lib.Event(
|
|
author="step",
|
|
output=output,
|
|
node_info=event_lib.NodeInfo(path="wf@1/step@2"),
|
|
)
|
|
|
|
await bq_plugin_inst.on_event_callback(
|
|
invocation_context=invocation_context, event=event
|
|
)
|
|
await bq_plugin_inst.flush()
|
|
|
|
row = await _get_captured_event_dict_async(
|
|
mock_write_client, dummy_arrow_schema
|
|
)
|
|
assert row["event_type"] == "NODE_OUTPUT"
|
|
stored_output = (
|
|
json.loads(row["content"])
|
|
if isinstance(output, (dict, list))
|
|
else row["content"]
|
|
)
|
|
assert stored_output == output
|
|
node = json.loads(row["attributes"])["adk"]["node"]
|
|
assert node["path"] == "wf@1/step@2"
|
|
assert node["run_id"] == "2"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_node_output_preserves_pydantic_payload(
|
|
self,
|
|
bq_plugin_inst,
|
|
mock_write_client,
|
|
invocation_context,
|
|
dummy_arrow_schema,
|
|
):
|
|
"""Pydantic node results remain queryable as structured JSON."""
|
|
|
|
class Result(BaseModel):
|
|
answer: int
|
|
|
|
event = event_lib.Event(
|
|
author="step",
|
|
output=Result(answer=42),
|
|
node_info=event_lib.NodeInfo(path="wf@1/step@2"),
|
|
)
|
|
|
|
await bq_plugin_inst.on_event_callback(
|
|
invocation_context=invocation_context, event=event
|
|
)
|
|
await bq_plugin_inst.flush()
|
|
|
|
row = await _get_captured_event_dict_async(
|
|
mock_write_client, dummy_arrow_schema
|
|
)
|
|
assert json.loads(row["content"]) == {"answer": 42}
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_output_and_state_delta_emit_separate_rows(
|
|
self,
|
|
bq_plugin_inst,
|
|
mock_write_client,
|
|
invocation_context,
|
|
dummy_arrow_schema,
|
|
):
|
|
"""A node event preserves both its state change and returned output."""
|
|
event = event_lib.Event(
|
|
author="step",
|
|
output={"result": 1},
|
|
actions=event_actions_lib.EventActions(state_delta={"count": 1}),
|
|
node_info=event_lib.NodeInfo(path="wf@1/step@2"),
|
|
)
|
|
|
|
await bq_plugin_inst.on_event_callback(
|
|
invocation_context=invocation_context, event=event
|
|
)
|
|
await bq_plugin_inst.flush()
|
|
|
|
rows = await _get_captured_rows_async(mock_write_client, dummy_arrow_schema)
|
|
assert [row["event_type"] for row in rows] == [
|
|
"STATE_DELTA",
|
|
"NODE_OUTPUT",
|
|
]
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_node_error_uses_sanitized_error_column(
|
|
self,
|
|
bq_plugin_inst,
|
|
mock_write_client,
|
|
invocation_context,
|
|
dummy_arrow_schema,
|
|
):
|
|
"""Workflow failures produce an error row with their node identity."""
|
|
event = event_lib.Event(
|
|
author="step",
|
|
error_code="ValueError",
|
|
error_message="invalid input",
|
|
node_info=event_lib.NodeInfo(path="wf@1/step@2"),
|
|
)
|
|
|
|
await bq_plugin_inst.on_event_callback(
|
|
invocation_context=invocation_context, event=event
|
|
)
|
|
await bq_plugin_inst.flush()
|
|
|
|
row = await _get_captured_event_dict_async(
|
|
mock_write_client, dummy_arrow_schema
|
|
)
|
|
assert row["event_type"] == "NODE_ERROR"
|
|
assert row["status"] == "ERROR"
|
|
assert row["error_message"] == "invalid input"
|
|
assert json.loads(row["content"])["error_code"] == "ValueError"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_partial_node_error_does_not_duplicate_failure_row(
|
|
self,
|
|
bq_plugin_inst,
|
|
mock_write_client,
|
|
invocation_context,
|
|
):
|
|
"""Partial events cannot produce durable NODE_ERROR rows."""
|
|
event = event_lib.Event(
|
|
author="step",
|
|
error_code="ValueError",
|
|
error_message="invalid input",
|
|
partial=True,
|
|
node_info=event_lib.NodeInfo(path="wf@1/step@2"),
|
|
)
|
|
|
|
await bq_plugin_inst.on_event_callback(
|
|
invocation_context=invocation_context, event=event
|
|
)
|
|
await bq_plugin_inst.flush()
|
|
|
|
mock_write_client.append_rows.assert_not_called()
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize(
|
|
("error_code", "finish_reason"),
|
|
[
|
|
("MAX_TOKENS", types.FinishReason.MAX_TOKENS),
|
|
("MODEL_ARMOR", None),
|
|
# An enum-valued error_code must classify the same as its string
|
|
# form, whether or not the model layer normalizes it first.
|
|
(types.FinishReason.MAX_TOKENS, types.FinishReason.MAX_TOKENS),
|
|
(types.BlockedReason.SAFETY, None),
|
|
],
|
|
)
|
|
async def test_model_termination_does_not_produce_node_error(
|
|
self,
|
|
error_code,
|
|
finish_reason,
|
|
bq_plugin_inst,
|
|
mock_write_client,
|
|
invocation_context,
|
|
):
|
|
"""Model termination diagnostics remain LLM_RESPONSE-only telemetry."""
|
|
event = event_lib.Event(
|
|
author="agent",
|
|
error_code=error_code,
|
|
error_message="model stopped",
|
|
finish_reason=finish_reason,
|
|
node_info=event_lib.NodeInfo(path="wf@1/agent@2"),
|
|
)
|
|
|
|
await bq_plugin_inst.on_event_callback(
|
|
invocation_context=invocation_context, event=event
|
|
)
|
|
await bq_plugin_inst.flush()
|
|
|
|
mock_write_client.append_rows.assert_not_called()
|
|
|
|
def test_model_termination_codes_match_enum_instances(self):
|
|
"""Enum-valued termination codes match the string-valued lookup set.
|
|
|
|
The set is built from ``reason.value``, so membership relies on the genai
|
|
reason enums subclassing ``str``. Pin both that property and the pydantic
|
|
coercion that normalizes an enum-valued ``error_code`` on ``Event``, so a
|
|
change to either is caught here rather than silently reclassifying model
|
|
terminations as node failures.
|
|
"""
|
|
codes = bigquery_agent_analytics_plugin._LLM_RESPONSE_ERROR_CODES
|
|
for reason in (types.FinishReason.MAX_TOKENS, types.BlockedReason.SAFETY):
|
|
assert isinstance(reason, str)
|
|
assert reason in codes
|
|
assert reason.value in codes
|
|
assert event_lib.Event(author="a", error_code=reason).error_code in codes
|
|
|
|
assert "ValueError" not in codes
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_content_and_output_event_preserves_node_output(
|
|
self,
|
|
bq_plugin_inst,
|
|
mock_write_client,
|
|
invocation_context,
|
|
dummy_arrow_schema,
|
|
):
|
|
"""A node's distinct message and output both remain observable."""
|
|
event = event_lib.Event(
|
|
author="step",
|
|
content=types.Content(parts=[types.Part(text="progress")]),
|
|
output={"result": 1},
|
|
node_info=event_lib.NodeInfo(path="wf@1/step@2"),
|
|
)
|
|
|
|
await bq_plugin_inst.on_event_callback(
|
|
invocation_context=invocation_context, event=event
|
|
)
|
|
await bq_plugin_inst.flush()
|
|
|
|
rows = await _get_captured_rows_async(mock_write_client, dummy_arrow_schema)
|
|
node_outputs = [row for row in rows if row["event_type"] == "NODE_OUTPUT"]
|
|
assert len(node_outputs) == 1
|
|
assert json.loads(node_outputs[0]["content"]) == {"result": 1}
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_error_and_output_event_preserves_both_node_rows(
|
|
self,
|
|
bq_plugin_inst,
|
|
mock_write_client,
|
|
invocation_context,
|
|
dummy_arrow_schema,
|
|
):
|
|
"""A failing node can retain a diagnostic output beside its error."""
|
|
event = event_lib.Event(
|
|
author="step",
|
|
error_code="ValueError",
|
|
error_message="partial result",
|
|
output={"processed": 3},
|
|
node_info=event_lib.NodeInfo(path="wf@1/step@2"),
|
|
)
|
|
|
|
await bq_plugin_inst.on_event_callback(
|
|
invocation_context=invocation_context, event=event
|
|
)
|
|
await bq_plugin_inst.flush()
|
|
|
|
rows = await _get_captured_rows_async(mock_write_client, dummy_arrow_schema)
|
|
assert [row["event_type"] for row in rows] == [
|
|
"NODE_ERROR",
|
|
"NODE_OUTPUT",
|
|
]
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize(
|
|
"event",
|
|
[
|
|
event_lib.Event(
|
|
author="step",
|
|
output=None,
|
|
node_info=event_lib.NodeInfo(path="wf@1/step@2"),
|
|
),
|
|
event_lib.Event(
|
|
author="agent",
|
|
content=types.Content(parts=[types.Part(text="answer")]),
|
|
output="answer",
|
|
node_info=event_lib.NodeInfo(
|
|
path="wf@1/agent@2", message_as_output=True
|
|
),
|
|
),
|
|
],
|
|
ids=("none", "message-as-output"),
|
|
)
|
|
async def test_non_output_events_do_not_duplicate_node_rows(
|
|
self,
|
|
event,
|
|
bq_plugin_inst,
|
|
mock_write_client,
|
|
invocation_context,
|
|
dummy_arrow_schema,
|
|
):
|
|
"""Empty and message-delegated events do not add NODE_OUTPUT rows."""
|
|
await bq_plugin_inst.on_event_callback(
|
|
invocation_context=invocation_context, event=event
|
|
)
|
|
await bq_plugin_inst.flush()
|
|
|
|
rows = await _get_captured_rows_async(mock_write_client, dummy_arrow_schema)
|
|
assert all(row["event_type"] != "NODE_OUTPUT" for row in rows)
|
|
|
|
|
|
class TestViewDefsRegistration:
|
|
"""The plugin's own per-event-type view defs cover the new types."""
|
|
|
|
def test_new_event_types_registered_in_view_defs(self):
|
|
defs = bigquery_agent_analytics_plugin._EVENT_VIEW_DEFS
|
|
for event_type in (
|
|
"AGENT_TRANSFER",
|
|
"EVENT_COMPACTION",
|
|
"AGENT_STATE_CHECKPOINT",
|
|
"TOOL_PAUSED",
|
|
):
|
|
assert event_type in defs, f"{event_type} missing from _EVENT_VIEW_DEFS"
|
|
assert isinstance(defs[event_type], list)
|
|
|
|
def test_tool_paused_view_extracts_pair_keys(self):
|
|
cols = "\n".join(
|
|
bigquery_agent_analytics_plugin._EVENT_VIEW_DEFS["TOOL_PAUSED"]
|
|
)
|
|
assert "$.adk.pause_kind" in cols
|
|
assert "$.adk.function_call_id" in cols
|
|
|
|
def test_compaction_view_preserves_float_and_widens(self):
|
|
cols = "\n".join(
|
|
bigquery_agent_analytics_plugin._EVENT_VIEW_DEFS["EVENT_COMPACTION"]
|
|
)
|
|
# Float passthrough for diagnostics + TIMESTAMP_MICROS widening
|
|
# (TIMESTAMP_SECONDS would truncate fractional windows).
|
|
assert "AS FLOAT64) AS start_seconds" in cols
|
|
assert "TIMESTAMP_MICROS" in cols
|
|
assert "TIMESTAMP_SECONDS" not in cols
|
|
|
|
def test_tool_completed_view_exposes_pair_keys(self):
|
|
"""v_tool_completed can do the pause/completion join end-to-end."""
|
|
cols = "\n".join(
|
|
bigquery_agent_analytics_plugin._EVENT_VIEW_DEFS["TOOL_COMPLETED"]
|
|
)
|
|
assert "$.adk.pause_kind" in cols
|
|
assert "$.adk.function_call_id" in cols
|
|
|
|
def test_checkpoint_view_exposes_agent_state_type(self):
|
|
"""v_agent_state_checkpoint discriminates explicit JSON null from
|
|
object-valued agent_state via JSON_TYPE(JSON_QUERY(...))."""
|
|
cols = "\n".join(
|
|
bigquery_agent_analytics_plugin._EVENT_VIEW_DEFS[
|
|
"AGENT_STATE_CHECKPOINT"
|
|
]
|
|
)
|
|
assert "JSON_TYPE(JSON_QUERY(content," in cols
|
|
assert "AS agent_state_type" in cols
|
|
|
|
|
|
class TestUnmatchedLongRunningIdFallback:
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_unmatched_long_running_id_emits_tool_paused(
|
|
self,
|
|
bq_plugin_inst,
|
|
mock_write_client,
|
|
invocation_context,
|
|
dummy_arrow_schema,
|
|
caplog,
|
|
):
|
|
"""A long_running_tool_id with no matching function_call part still
|
|
emits a pairable TOOL_PAUSED row with pause_kind='tool' + warning."""
|
|
event = event_lib.Event(
|
|
author="agent",
|
|
content=types.Content(
|
|
role="model", parts=[types.Part(text="thinking...")]
|
|
),
|
|
long_running_tool_ids={"orphan-pause-1"},
|
|
actions=event_actions_lib.EventActions(),
|
|
)
|
|
bigquery_agent_analytics_plugin.TraceManager.push_span(invocation_context)
|
|
with caplog.at_level("WARNING"):
|
|
await bq_plugin_inst.on_event_callback(
|
|
invocation_context=invocation_context, event=event
|
|
)
|
|
await bq_plugin_inst.flush()
|
|
rows = await _get_captured_rows_async(mock_write_client, dummy_arrow_schema)
|
|
pauses = [r for r in rows if r["event_type"] == "TOOL_PAUSED"]
|
|
assert len(pauses) == 1
|
|
adk = json.loads(pauses[0]["attributes"])["adk"]
|
|
assert adk["pause_kind"] == "tool"
|
|
assert adk["function_call_id"] == "orphan-pause-1"
|
|
assert any(
|
|
"no matching function_call part" in rec.message
|
|
for rec in caplog.records
|
|
)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_matched_id_not_double_emitted_by_fallback(
|
|
self,
|
|
bq_plugin_inst,
|
|
mock_write_client,
|
|
invocation_context,
|
|
dummy_arrow_schema,
|
|
):
|
|
"""An id with a matching part emits exactly one TOOL_PAUSED row."""
|
|
fc = types.FunctionCall(id="call-1", name="long_search", args={})
|
|
event = event_lib.Event(
|
|
author="agent",
|
|
content=types.Content(
|
|
role="model", parts=[types.Part(function_call=fc)]
|
|
),
|
|
long_running_tool_ids={"call-1"},
|
|
actions=event_actions_lib.EventActions(),
|
|
)
|
|
bigquery_agent_analytics_plugin.TraceManager.push_span(invocation_context)
|
|
await bq_plugin_inst.on_event_callback(
|
|
invocation_context=invocation_context, event=event
|
|
)
|
|
await bq_plugin_inst.flush()
|
|
rows = await _get_captured_rows_async(mock_write_client, dummy_arrow_schema)
|
|
pauses = [r for r in rows if r["event_type"] == "TOOL_PAUSED"]
|
|
assert len(pauses) == 1
|
|
|
|
|
|
# ==============================================================================
|
|
# Observability controls (otel correlation, custom_metadata allowlist,
|
|
# column projection)
|
|
# ==============================================================================
|
|
|
|
|
|
class _FakeMetaEvent:
|
|
"""Minimal stand-in for an Event carrying custom_metadata."""
|
|
|
|
def __init__(self, custom_metadata=None):
|
|
self.custom_metadata = custom_metadata
|
|
|
|
|
|
def _make_offline_plugin(config):
|
|
"""Constructs a plugin without starting the BQ/network path."""
|
|
return bigquery_agent_analytics_plugin.BigQueryAgentAnalyticsPlugin(
|
|
PROJECT_ID, DATASET_ID, config=config
|
|
)
|
|
|
|
|
|
# --- custom_metadata allowlist ---
|
|
|
|
|
|
def test_parse_custom_metadata_allowlist_exact_and_prefix():
|
|
exact, prefixes = (
|
|
bigquery_agent_analytics_plugin._parse_custom_metadata_allowlist(
|
|
["citation_metadata", "a2a:*", "tool:*"]
|
|
)
|
|
)
|
|
assert exact == frozenset({"citation_metadata"})
|
|
assert prefixes == ("a2a:", "tool:")
|
|
|
|
|
|
def test_parse_custom_metadata_allowlist_none():
|
|
exact, prefixes = (
|
|
bigquery_agent_analytics_plugin._parse_custom_metadata_allowlist(None)
|
|
)
|
|
assert exact == frozenset()
|
|
assert prefixes == ()
|
|
|
|
|
|
def test_custom_metadata_allowed_exact_and_prefix():
|
|
plugin = _make_offline_plugin(
|
|
bigquery_agent_analytics_plugin.BigQueryLoggerConfig(
|
|
custom_metadata_allowlist=["citation_metadata", "trace:*"]
|
|
)
|
|
)
|
|
assert plugin._custom_metadata_allowed("citation_metadata")
|
|
assert plugin._custom_metadata_allowed("trace:foo")
|
|
# a plain key is never treated as a prefix
|
|
assert not plugin._custom_metadata_allowed("citation")
|
|
assert not plugin._custom_metadata_allowed("other")
|
|
assert not plugin._custom_metadata_allowed(123)
|
|
|
|
|
|
def test_capture_custom_metadata_namespace_and_allowlist():
|
|
plugin = _make_offline_plugin(
|
|
bigquery_agent_analytics_plugin.BigQueryLoggerConfig(
|
|
custom_metadata_allowlist=["citation_metadata"]
|
|
)
|
|
)
|
|
event_data = bigquery_agent_analytics_plugin.EventData(
|
|
source_event=_FakeMetaEvent(
|
|
{"citation_metadata": {"c1": "sql1"}, "other": "drop"}
|
|
)
|
|
)
|
|
attrs: dict = {}
|
|
truncated = plugin._capture_custom_metadata(event_data, attrs)
|
|
assert truncated is False
|
|
assert attrs["custom_metadata"] == {"citation_metadata": {"c1": "sql1"}}
|
|
assert "other" not in attrs["custom_metadata"]
|
|
|
|
|
|
def test_capture_custom_metadata_redaction_does_not_set_flag():
|
|
plugin = _make_offline_plugin(
|
|
bigquery_agent_analytics_plugin.BigQueryLoggerConfig(
|
|
custom_metadata_allowlist=["secrets"]
|
|
)
|
|
)
|
|
event_data = bigquery_agent_analytics_plugin.EventData(
|
|
source_event=_FakeMetaEvent({"secrets": {"api_key": "abc", "ok": "v"}})
|
|
)
|
|
attrs: dict = {}
|
|
truncated = plugin._capture_custom_metadata(event_data, attrs)
|
|
# redaction returns [REDACTED] without flipping is_truncated
|
|
assert truncated is False
|
|
assert attrs["custom_metadata"]["secrets"]["api_key"] == "[REDACTED]"
|
|
assert attrs["custom_metadata"]["secrets"]["ok"] == "v"
|
|
|
|
|
|
def test_capture_custom_metadata_truncation_sets_flag():
|
|
plugin = _make_offline_plugin(
|
|
bigquery_agent_analytics_plugin.BigQueryLoggerConfig(
|
|
custom_metadata_allowlist=["big"], max_content_length=5
|
|
)
|
|
)
|
|
event_data = bigquery_agent_analytics_plugin.EventData(
|
|
source_event=_FakeMetaEvent({"big": "x" * 100})
|
|
)
|
|
attrs: dict = {}
|
|
truncated = plugin._capture_custom_metadata(event_data, attrs)
|
|
assert truncated is True
|
|
assert attrs["custom_metadata"]["big"].endswith("...[TRUNCATED]")
|
|
|
|
|
|
def test_capture_custom_metadata_non_allowlisted_absent():
|
|
plugin = _make_offline_plugin(
|
|
bigquery_agent_analytics_plugin.BigQueryLoggerConfig(
|
|
custom_metadata_allowlist=["citation_metadata"]
|
|
)
|
|
)
|
|
event_data = bigquery_agent_analytics_plugin.EventData(
|
|
source_event=_FakeMetaEvent({"unrelated": "v"})
|
|
)
|
|
attrs: dict = {}
|
|
assert plugin._capture_custom_metadata(event_data, attrs) is False
|
|
assert attrs == {}
|
|
|
|
|
|
def test_capture_custom_metadata_no_source_event():
|
|
plugin = _make_offline_plugin(
|
|
bigquery_agent_analytics_plugin.BigQueryLoggerConfig(
|
|
custom_metadata_allowlist=["x"]
|
|
)
|
|
)
|
|
attrs: dict = {}
|
|
assert (
|
|
plugin._capture_custom_metadata(
|
|
bigquery_agent_analytics_plugin.EventData(), attrs
|
|
)
|
|
is False
|
|
)
|
|
assert attrs == {}
|
|
|
|
|
|
def test_default_config_has_no_custom_metadata_capture():
|
|
plugin = _make_offline_plugin(
|
|
bigquery_agent_analytics_plugin.BigQueryLoggerConfig()
|
|
)
|
|
assert plugin._custom_metadata_exact == frozenset()
|
|
assert plugin._custom_metadata_prefixes == ()
|
|
|
|
|
|
# --- payload column projection ---
|
|
|
|
|
|
def test_validate_payload_column_denylist_accepts_payload_columns():
|
|
denied = bigquery_agent_analytics_plugin._validate_payload_column_denylist(
|
|
["content", "attributes"]
|
|
)
|
|
assert denied == frozenset({"content", "attributes"})
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"bad",
|
|
["span_id", "trace_id", "timestamp", "event_type", "is_truncated", "nope"],
|
|
)
|
|
def test_validate_payload_column_denylist_rejects_protected_or_unknown(bad):
|
|
with pytest.raises(ValueError):
|
|
bigquery_agent_analytics_plugin._validate_payload_column_denylist([bad])
|
|
|
|
|
|
def test_plugin_construction_rejects_protected_denylist():
|
|
with pytest.raises(ValueError):
|
|
_make_offline_plugin(
|
|
bigquery_agent_analytics_plugin.BigQueryLoggerConfig(
|
|
payload_column_denylist=["span_id"]
|
|
)
|
|
)
|
|
|
|
|
|
def test_project_schema_removes_denied_keeps_protected():
|
|
full = bigquery_agent_analytics_plugin._get_events_schema()
|
|
full_names = {f.name for f in full}
|
|
projected = bigquery_agent_analytics_plugin._project_schema(
|
|
full, frozenset({"content", "attributes"})
|
|
)
|
|
names = {f.name for f in projected}
|
|
assert "content" not in names and "attributes" not in names
|
|
for col in (
|
|
"timestamp",
|
|
"event_type",
|
|
"span_id",
|
|
"parent_span_id",
|
|
"is_truncated",
|
|
"latency_ms",
|
|
):
|
|
assert col in names
|
|
assert names == full_names - {"content", "attributes"}
|
|
|
|
|
|
def test_project_schema_to_arrow_consistency():
|
|
# schema-first: the Arrow schema derived from the projected BQ schema
|
|
# omits the denied column too.
|
|
projected = bigquery_agent_analytics_plugin._project_schema(
|
|
bigquery_agent_analytics_plugin._get_events_schema(),
|
|
frozenset({"content"}),
|
|
)
|
|
arrow = bigquery_agent_analytics_plugin.to_arrow_schema(projected)
|
|
assert "content" not in arrow.names
|
|
assert "span_id" in arrow.names
|
|
|
|
|
|
def test_project_view_columns_drops_denied_refs():
|
|
plugin = _make_offline_plugin(
|
|
bigquery_agent_analytics_plugin.BigQueryLoggerConfig(
|
|
payload_column_denylist=["attributes"]
|
|
)
|
|
)
|
|
exprs = [
|
|
"JSON_VALUE(attributes, '$.model') AS model",
|
|
"content AS request_content",
|
|
"CAST(JSON_VALUE(latency_ms, '$.total_ms') AS INT64) AS total_ms",
|
|
]
|
|
kept = plugin._project_view_columns(exprs)
|
|
assert "JSON_VALUE(attributes, '$.model') AS model" not in kept
|
|
assert "content AS request_content" in kept
|
|
assert any("latency_ms" in e for e in kept)
|
|
|
|
|
|
def test_project_view_columns_drops_content_and_latency_refs():
|
|
# view degradation is not attributes-only: content and latency_ms too.
|
|
plugin = _make_offline_plugin(
|
|
bigquery_agent_analytics_plugin.BigQueryLoggerConfig(
|
|
payload_column_denylist=["content", "latency_ms"]
|
|
)
|
|
)
|
|
exprs = [
|
|
"JSON_QUERY(content, '$.response') AS response",
|
|
"CAST(JSON_VALUE(latency_ms, '$.total_ms') AS INT64) AS total_ms",
|
|
"JSON_VALUE(attributes, '$.model') AS model",
|
|
]
|
|
kept = plugin._project_view_columns(exprs)
|
|
assert kept == ["JSON_VALUE(attributes, '$.model') AS model"]
|
|
|
|
|
|
def test_project_view_columns_noop_without_denylist():
|
|
plugin = _make_offline_plugin(
|
|
bigquery_agent_analytics_plugin.BigQueryLoggerConfig()
|
|
)
|
|
exprs = ["JSON_VALUE(attributes, '$.model') AS model"]
|
|
assert plugin._project_view_columns(exprs) == exprs
|
|
|
|
|
|
# --- otel correlation ---
|
|
|
|
|
|
def test_enrich_attributes_captures_valid_ambient_otel_span(callback_context):
|
|
plugin = _make_offline_plugin(
|
|
bigquery_agent_analytics_plugin.BigQueryLoggerConfig(
|
|
enable_otel_correlation=True
|
|
)
|
|
)
|
|
ctx = trace.SpanContext(
|
|
trace_id=0x1234567890ABCDEF1234567890ABCDEF,
|
|
span_id=0xFEEDFACECAFEBEEF,
|
|
is_remote=False,
|
|
trace_flags=trace.TraceFlags(trace.TraceFlags.SAMPLED),
|
|
)
|
|
fake_span = mock.Mock()
|
|
fake_span.get_span_context.return_value = ctx
|
|
with (
|
|
mock.patch.object(plugin, "_build_adk_envelope", return_value={}),
|
|
mock.patch.object(
|
|
bigquery_agent_analytics_plugin.trace,
|
|
"get_current_span",
|
|
return_value=fake_span,
|
|
),
|
|
):
|
|
attrs = plugin._enrich_attributes(
|
|
bigquery_agent_analytics_plugin.EventData(), callback_context
|
|
)
|
|
assert attrs["otel"]["span_id"] == format(0xFEEDFACECAFEBEEF, "016x")
|
|
assert attrs["otel"]["trace_id"] == format(
|
|
0x1234567890ABCDEF1234567890ABCDEF, "032x"
|
|
)
|
|
|
|
|
|
def test_enrich_attributes_no_otel_when_correlation_disabled(callback_context):
|
|
# enable_otel_correlation defaults to False: even with a valid ambient span,
|
|
# no attributes.otel is emitted (the feature is opt-in / off by default).
|
|
plugin = _make_offline_plugin(
|
|
bigquery_agent_analytics_plugin.BigQueryLoggerConfig()
|
|
)
|
|
ctx = trace.SpanContext(
|
|
trace_id=0x1234567890ABCDEF1234567890ABCDEF,
|
|
span_id=0xFEEDFACECAFEBEEF,
|
|
is_remote=False,
|
|
trace_flags=trace.TraceFlags(trace.TraceFlags.SAMPLED),
|
|
)
|
|
fake_span = mock.Mock()
|
|
fake_span.get_span_context.return_value = ctx
|
|
with (
|
|
mock.patch.object(plugin, "_build_adk_envelope", return_value={}),
|
|
mock.patch.object(
|
|
bigquery_agent_analytics_plugin.trace,
|
|
"get_current_span",
|
|
return_value=fake_span,
|
|
),
|
|
):
|
|
attrs = plugin._enrich_attributes(
|
|
bigquery_agent_analytics_plugin.EventData(), callback_context
|
|
)
|
|
assert "otel" not in attrs
|
|
|
|
|
|
def test_enrich_attributes_no_otel_when_span_invalid(callback_context):
|
|
plugin = _make_offline_plugin(
|
|
bigquery_agent_analytics_plugin.BigQueryLoggerConfig(
|
|
enable_otel_correlation=True
|
|
)
|
|
)
|
|
fake_span = mock.Mock()
|
|
fake_span.get_span_context.return_value = trace.INVALID_SPAN_CONTEXT
|
|
with (
|
|
mock.patch.object(plugin, "_build_adk_envelope", return_value={}),
|
|
mock.patch.object(
|
|
bigquery_agent_analytics_plugin.trace,
|
|
"get_current_span",
|
|
return_value=fake_span,
|
|
),
|
|
):
|
|
attrs = plugin._enrich_attributes(
|
|
bigquery_agent_analytics_plugin.EventData(), callback_context
|
|
)
|
|
assert "otel" not in attrs
|
|
|
|
|
|
class _FakeTable:
|
|
"""Minimal stand-in for a bigquery.Table for schema-upgrade tests."""
|
|
|
|
def __init__(self, schema, labels):
|
|
self.schema = schema
|
|
self.labels = labels
|
|
|
|
|
|
def test_schema_upgrade_adds_columns_when_denylist_relaxed():
|
|
# Table was created under a restrictive projection (missing content +
|
|
# attributes) but its version label is current. Relaxing the denylist must
|
|
# still add the now-desired columns instead of early-returning on the label.
|
|
plugin = _make_offline_plugin(
|
|
bigquery_agent_analytics_plugin.BigQueryLoggerConfig()
|
|
)
|
|
full = bigquery_agent_analytics_plugin._get_events_schema()
|
|
plugin._schema = full # desired = full schema (denylist relaxed)
|
|
plugin.full_table_id = "p.d.t"
|
|
plugin.client = mock.Mock()
|
|
projected = [f for f in full if f.name not in ("content", "attributes")]
|
|
existing = _FakeTable(
|
|
schema=list(projected),
|
|
labels={
|
|
bigquery_agent_analytics_plugin._SCHEMA_VERSION_LABEL_KEY: (
|
|
bigquery_agent_analytics_plugin._SCHEMA_VERSION
|
|
)
|
|
},
|
|
)
|
|
plugin._maybe_upgrade_schema(existing)
|
|
plugin.client.update_table.assert_called_once()
|
|
names = {f.name for f in existing.schema}
|
|
assert "content" in names and "attributes" in names
|
|
|
|
|
|
def test_schema_upgrade_noop_when_current_and_complete():
|
|
plugin = _make_offline_plugin(
|
|
bigquery_agent_analytics_plugin.BigQueryLoggerConfig()
|
|
)
|
|
full = bigquery_agent_analytics_plugin._get_events_schema()
|
|
plugin._schema = full
|
|
plugin.full_table_id = "p.d.t"
|
|
plugin.client = mock.Mock()
|
|
existing = _FakeTable(
|
|
schema=list(full),
|
|
labels={
|
|
bigquery_agent_analytics_plugin._SCHEMA_VERSION_LABEL_KEY: (
|
|
bigquery_agent_analytics_plugin._SCHEMA_VERSION
|
|
)
|
|
},
|
|
)
|
|
plugin._maybe_upgrade_schema(existing)
|
|
plugin.client.update_table.assert_not_called()
|
|
|
|
|
|
def test_attributes_denylist_with_custom_metadata_rejected():
|
|
with pytest.raises(ValueError):
|
|
_make_offline_plugin(
|
|
bigquery_agent_analytics_plugin.BigQueryLoggerConfig(
|
|
payload_column_denylist=["attributes"],
|
|
custom_metadata_allowlist=["citation_metadata"],
|
|
)
|
|
)
|
|
|
|
|
|
def test_attributes_denylist_without_custom_metadata_ok():
|
|
plugin = _make_offline_plugin(
|
|
bigquery_agent_analytics_plugin.BigQueryLoggerConfig(
|
|
payload_column_denylist=["attributes"]
|
|
)
|
|
)
|
|
assert "attributes" in plugin._denied_columns
|
|
|
|
|
|
def test_enrich_attributes_skips_otel_when_attributes_denied(callback_context):
|
|
plugin = _make_offline_plugin(
|
|
bigquery_agent_analytics_plugin.BigQueryLoggerConfig(
|
|
enable_otel_correlation=True,
|
|
payload_column_denylist=["attributes"],
|
|
)
|
|
)
|
|
ctx = trace.SpanContext(
|
|
trace_id=0x1234567890ABCDEF1234567890ABCDEF,
|
|
span_id=0xFEEDFACECAFEBEEF,
|
|
is_remote=False,
|
|
trace_flags=trace.TraceFlags(trace.TraceFlags.SAMPLED),
|
|
)
|
|
fake_span = mock.Mock()
|
|
fake_span.get_span_context.return_value = ctx
|
|
with (
|
|
mock.patch.object(plugin, "_build_adk_envelope", return_value={}),
|
|
mock.patch.object(
|
|
bigquery_agent_analytics_plugin.trace,
|
|
"get_current_span",
|
|
return_value=fake_span,
|
|
),
|
|
):
|
|
attrs = plugin._enrich_attributes(
|
|
bigquery_agent_analytics_plugin.EventData(), callback_context
|
|
)
|
|
assert "otel" not in attrs
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_content_parts_denied_disables_gcs_offload(
|
|
mock_write_client,
|
|
callback_context,
|
|
mock_auth_default,
|
|
mock_bq_client,
|
|
mock_to_arrow_schema,
|
|
dummy_arrow_schema,
|
|
mock_storage_client,
|
|
):
|
|
# denying content_parts (which holds the offload object reference)
|
|
# must disable GCS offload, otherwise the payload is uploaded with no
|
|
# retained reference (leak + cost).
|
|
config = bigquery_agent_analytics_plugin.BigQueryLoggerConfig(
|
|
gcs_bucket_name="test-bucket",
|
|
payload_column_denylist=["content_parts"],
|
|
)
|
|
async with managed_plugin(
|
|
PROJECT_ID, DATASET_ID, table_id=TABLE_ID, config=config
|
|
) as plugin:
|
|
await plugin._ensure_started(
|
|
storage_client=mock_storage_client.return_value
|
|
)
|
|
assert plugin.offloader is None
|
|
mock_blob = (
|
|
mock_storage_client.return_value.bucket.return_value.blob.return_value
|
|
)
|
|
large_text = "A" * (32 * 1024 + 1)
|
|
llm_request = llm_request_lib.LlmRequest(
|
|
model="gemini-pro",
|
|
contents=[types.Content(parts=[types.Part(text=large_text)])],
|
|
)
|
|
await plugin.before_model_callback(
|
|
callback_context=callback_context, llm_request=llm_request
|
|
)
|
|
await plugin.flush()
|
|
mock_blob.upload_from_string.assert_not_called()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_after_run_callback_flush_on_run_end(
|
|
bq_plugin_inst,
|
|
invocation_context,
|
|
):
|
|
"""after_run_callback skips flush() when flush_on_run_end is False."""
|
|
bq_plugin_inst.config.flush_on_run_end = False
|
|
bigquery_agent_analytics_plugin.TraceManager.push_span(
|
|
invocation_context, "invocation"
|
|
)
|
|
|
|
with mock.patch.object(
|
|
bq_plugin_inst, "flush", new_callable=mock.AsyncMock
|
|
) as mock_flush:
|
|
await bq_plugin_inst.after_run_callback(
|
|
invocation_context=invocation_context
|
|
)
|
|
mock_flush.assert_not_called()
|
|
|
|
bq_plugin_inst.config.flush_on_run_end = True
|
|
bigquery_agent_analytics_plugin.TraceManager.push_span(
|
|
invocation_context, "invocation"
|
|
)
|
|
with mock.patch.object(
|
|
bq_plugin_inst, "flush", new_callable=mock.AsyncMock
|
|
) as mock_flush:
|
|
await bq_plugin_inst.after_run_callback(
|
|
invocation_context=invocation_context
|
|
)
|
|
mock_flush.assert_called_once()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_on_run_error_callback_flush_on_run_end(
|
|
bq_plugin_inst,
|
|
invocation_context,
|
|
):
|
|
"""on_run_error_callback skips flush() when flush_on_run_end is False."""
|
|
bq_plugin_inst.config.flush_on_run_end = False
|
|
bigquery_agent_analytics_plugin.TraceManager.push_span(
|
|
invocation_context, "invocation"
|
|
)
|
|
|
|
with mock.patch.object(
|
|
bq_plugin_inst, "flush", new_callable=mock.AsyncMock
|
|
) as mock_flush:
|
|
await bq_plugin_inst.on_run_error_callback(
|
|
invocation_context=invocation_context, error=ValueError("Test Error")
|
|
)
|
|
mock_flush.assert_not_called()
|
|
|
|
bq_plugin_inst.config.flush_on_run_end = True
|
|
bigquery_agent_analytics_plugin.TraceManager.push_span(
|
|
invocation_context, "invocation"
|
|
)
|
|
with mock.patch.object(
|
|
bq_plugin_inst, "flush", new_callable=mock.AsyncMock
|
|
) as mock_flush:
|
|
await bq_plugin_inst.on_run_error_callback(
|
|
invocation_context=invocation_context, error=ValueError("Test Error")
|
|
)
|
|
mock_flush.assert_called_once()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_background_writer_drains_without_flush(
|
|
bq_plugin_inst,
|
|
invocation_context,
|
|
mock_write_client,
|
|
):
|
|
"""Background writer drains without explicit flush when flush_on_run_end is False."""
|
|
bq_plugin_inst.config.flush_on_run_end = False
|
|
bq_plugin_inst.config.batch_flush_interval = 0.1
|
|
bigquery_agent_analytics_plugin.TraceManager.push_span(
|
|
invocation_context, "invocation"
|
|
)
|
|
user_message = types.Content(parts=[types.Part(text="What is up?")])
|
|
await bq_plugin_inst.on_user_message_callback(
|
|
invocation_context=invocation_context, user_message=user_message
|
|
)
|
|
await bq_plugin_inst.after_run_callback(invocation_context=invocation_context)
|
|
deadline = time.time() + 2.0
|
|
while mock_write_client.append_rows.call_count < 1 and time.time() < deadline:
|
|
await asyncio.sleep(0.05)
|
|
assert mock_write_client.append_rows.call_count >= 1
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_both_payload_columns_denied_skips_parse_and_offload(
|
|
mock_write_client,
|
|
callback_context,
|
|
mock_auth_default,
|
|
mock_bq_client,
|
|
mock_to_arrow_schema,
|
|
dummy_arrow_schema,
|
|
mock_storage_client,
|
|
):
|
|
# with both content and content_parts denied, parsing is skipped
|
|
# entirely -- no inline summary, no parts, and no GCS upload work.
|
|
config = bigquery_agent_analytics_plugin.BigQueryLoggerConfig(
|
|
gcs_bucket_name="test-bucket",
|
|
payload_column_denylist=["content", "content_parts"],
|
|
)
|
|
async with managed_plugin(
|
|
PROJECT_ID, DATASET_ID, table_id=TABLE_ID, config=config
|
|
) as plugin:
|
|
await plugin._ensure_started(
|
|
storage_client=mock_storage_client.return_value
|
|
)
|
|
assert plugin.offloader is None
|
|
mock_blob = (
|
|
mock_storage_client.return_value.bucket.return_value.blob.return_value
|
|
)
|
|
large_text = "A" * (32 * 1024 + 1)
|
|
llm_request = llm_request_lib.LlmRequest(
|
|
model="gemini-pro",
|
|
contents=[types.Content(parts=[types.Part(text=large_text)])],
|
|
)
|
|
await plugin.before_model_callback(
|
|
callback_context=callback_context, llm_request=llm_request
|
|
)
|
|
await plugin.flush()
|
|
mock_blob.upload_from_string.assert_not_called()
|
|
|
|
|
|
class TestSafetyLifecycleHardening:
|
|
"""Safety and lifecycle invariants."""
|
|
|
|
def test_invalid_runtime_config_rejected_at_construction(
|
|
self, mock_auth_default, mock_bq_client
|
|
):
|
|
"""Invalid batch/queue/duration/retry settings fail at construction."""
|
|
_ = mock_auth_default, mock_bq_client
|
|
retry = bigquery_agent_analytics_plugin.RetryConfig
|
|
bad_configs = [
|
|
dict(batch_size=0),
|
|
dict(batch_flush_interval=0.0),
|
|
dict(shutdown_timeout=0.0),
|
|
dict(queue_max_size=0),
|
|
dict(max_content_length=0),
|
|
dict(retry_config=retry(max_retries=-1)),
|
|
dict(retry_config=retry(initial_delay=-1.0)),
|
|
dict(retry_config=retry(multiplier=0.5)),
|
|
dict(retry_config=retry(initial_delay=5.0, max_delay=1.0)),
|
|
]
|
|
for kwargs in bad_configs:
|
|
config = bigquery_agent_analytics_plugin.BigQueryLoggerConfig(**kwargs)
|
|
with pytest.raises(ValueError):
|
|
bigquery_agent_analytics_plugin.BigQueryAgentAnalyticsPlugin(
|
|
PROJECT_ID, DATASET_ID, table_id=TABLE_ID, config=config
|
|
)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_final_attributes_pass_redacts_direct_producers(
|
|
self,
|
|
mock_write_client,
|
|
invocation_context,
|
|
callback_context,
|
|
mock_auth_default,
|
|
mock_bq_client,
|
|
mock_to_arrow_schema,
|
|
dummy_arrow_schema,
|
|
mock_asyncio_to_thread,
|
|
):
|
|
"""state_delta, custom_tags, nested keys, and JSON blobs are redacted.
|
|
|
|
These producers copy values into attributes without going through
|
|
_recursive_smart_truncate; the final pre-serialization pass must
|
|
redact them.
|
|
"""
|
|
_ = mock_auth_default, mock_bq_client
|
|
config = bigquery_agent_analytics_plugin.BigQueryLoggerConfig(
|
|
custom_tags={"team": "sre", "password": "hunter2"},
|
|
)
|
|
async with managed_plugin(
|
|
PROJECT_ID, DATASET_ID, table_id=TABLE_ID, config=config
|
|
) as plugin:
|
|
await plugin._ensure_started()
|
|
mock_write_client.append_rows.reset_mock()
|
|
bigquery_agent_analytics_plugin.TraceManager.push_span(invocation_context)
|
|
await plugin._log_event(
|
|
"STATE_DELTA",
|
|
callback_context,
|
|
event_data=bigquery_agent_analytics_plugin.EventData(
|
|
extra_attributes={
|
|
"state_delta": {
|
|
"access_token": "ya29.SECRET",
|
|
"nested": {"refresh_token": "1//SECRET2"},
|
|
"temp:scratch": "ephemeral",
|
|
"plain": "keep-me",
|
|
},
|
|
"cred_blob": '{"access_token": "SECRETTOK", "expiry": 1}',
|
|
},
|
|
),
|
|
)
|
|
await plugin.flush()
|
|
log_entry = await _get_captured_event_dict_async(
|
|
mock_write_client, dummy_arrow_schema
|
|
)
|
|
attrs = json.loads(log_entry["attributes"])
|
|
blob = str(log_entry["attributes"])
|
|
assert "ya29.SECRET" not in blob
|
|
assert "1//SECRET2" not in blob
|
|
assert "SECRETTOK" not in blob
|
|
assert "hunter2" not in blob
|
|
assert attrs["state_delta"]["access_token"] == "[REDACTED]"
|
|
assert attrs["state_delta"]["nested"]["refresh_token"] == "[REDACTED]"
|
|
assert attrs["state_delta"]["temp:scratch"] == "[REDACTED]"
|
|
assert attrs["state_delta"]["plain"] == "keep-me"
|
|
assert attrs["custom_tags"]["password"] == "[REDACTED]"
|
|
assert attrs["custom_tags"]["team"] == "sre"
|
|
assert json.loads(attrs["cred_blob"])["access_token"] == "[REDACTED]"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_concurrent_parses_never_share_gcs_paths(self):
|
|
"""Two overlapping two-part parses keep call-local trace/span paths.
|
|
|
|
Regression: with identity stored on the shared parser,
|
|
event A resumed after event B's mutation and wrote under B's object
|
|
name, overwriting B's part.
|
|
"""
|
|
uploaded: list[str] = []
|
|
|
|
class _FakeOffloader:
|
|
|
|
async def upload_content(self, data, mime, path):
|
|
uploaded.append(path)
|
|
await asyncio.sleep(0) # force interleave between part uploads
|
|
return f"gs://bucket/{path}"
|
|
|
|
parser = bigquery_agent_analytics_plugin.HybridContentParser(
|
|
offloader=_FakeOffloader(), trace_id="ctor", span_id="ctor"
|
|
)
|
|
|
|
def two_parts():
|
|
return types.Content(
|
|
parts=[
|
|
types.Part.from_bytes(data=b"x", mime_type="image/png"),
|
|
types.Part.from_bytes(data=b"y", mime_type="image/png"),
|
|
]
|
|
)
|
|
|
|
await asyncio.gather(
|
|
parser.parse(two_parts(), trace_id="trace-a", span_id="span-a"),
|
|
parser.parse(two_parts(), trace_id="trace-b", span_id="span-b"),
|
|
)
|
|
assert len(uploaded) == 4
|
|
assert len(set(uploaded)) == 4, f"path collision: {uploaded}"
|
|
assert sum("trace-a/span-a" in p for p in uploaded) == 2
|
|
assert sum("trace-b/span-b" in p for p in uploaded) == 2
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_setup_failure_keeps_not_started_then_retries(
|
|
self,
|
|
mock_write_client,
|
|
invocation_context,
|
|
callback_context,
|
|
mock_auth_default,
|
|
mock_bq_client,
|
|
mock_to_arrow_schema,
|
|
dummy_arrow_schema,
|
|
mock_asyncio_to_thread,
|
|
):
|
|
"""Failed table readiness leaves _started=False, counts the loss, and
|
|
|
|
a later event retries successfully.
|
|
"""
|
|
_ = mock_auth_default
|
|
mock_bq_client.get_table.side_effect = cloud_exceptions.InternalServerError(
|
|
"control plane hiccup"
|
|
)
|
|
async with managed_plugin(
|
|
PROJECT_ID, DATASET_ID, table_id=TABLE_ID
|
|
) as plugin:
|
|
bigquery_agent_analytics_plugin.TraceManager.push_span(invocation_context)
|
|
await plugin.before_run_callback(invocation_context=invocation_context)
|
|
assert plugin._started is False
|
|
assert plugin._startup_error is not None
|
|
|
|
# A row logged while setup is unavailable is counted, not silent.
|
|
await plugin._log_event("USER_MESSAGE_RECEIVED", callback_context)
|
|
assert plugin.get_drop_stats().get("setup_unavailable", 0) >= 1
|
|
|
|
# Control plane recovers; retry succeeds on a later event once the
|
|
# backoff window elapses.
|
|
failed_calls = mock_bq_client.get_table.call_count
|
|
mock_bq_client.get_table.side_effect = None
|
|
plugin._setup_retry_at = 0.0
|
|
await plugin._ensure_started()
|
|
assert plugin._started is True
|
|
assert plugin._startup_error is None
|
|
# Table readiness must re-run on the retry: a cached _schema used to
|
|
# skip _ensure_schema_exists entirely, marking the plugin started
|
|
# without ever re-checking the table.
|
|
assert mock_bq_client.get_table.call_count == failed_calls + 1
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_enabled_false_has_zero_side_effects(
|
|
self, mock_auth_default, mock_bq_client, invocation_context
|
|
):
|
|
"""enabled=False performs no auth/client/table/writer side effects
|
|
|
|
through Runner callbacks or async context-manager use.
|
|
"""
|
|
config = bigquery_agent_analytics_plugin.BigQueryLoggerConfig(enabled=False)
|
|
plugin = bigquery_agent_analytics_plugin.BigQueryAgentAnalyticsPlugin(
|
|
PROJECT_ID, DATASET_ID, table_id=TABLE_ID, config=config
|
|
)
|
|
await plugin.before_run_callback(invocation_context=invocation_context)
|
|
async with plugin:
|
|
pass
|
|
assert plugin._started is False
|
|
assert plugin.client is None
|
|
assert plugin._loop_state_by_loop == {}
|
|
mock_auth_default.assert_not_called()
|
|
mock_bq_client.get_table.assert_not_called()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_drop_stats_survive_shutdown_and_include_local_reasons(
|
|
self, mock_auth_default, mock_bq_client
|
|
):
|
|
"""Pre-processor drop reasons are queryable, including after shutdown."""
|
|
_ = mock_auth_default, mock_bq_client
|
|
plugin = bigquery_agent_analytics_plugin.BigQueryAgentAnalyticsPlugin(
|
|
PROJECT_ID, DATASET_ID, table_id=TABLE_ID
|
|
)
|
|
plugin._count_local_drop("formatter_failed")
|
|
plugin._count_local_drop("setup_unavailable")
|
|
plugin._count_local_drop("setup_unavailable")
|
|
await plugin.shutdown()
|
|
stats = plugin.get_drop_stats()
|
|
assert stats["formatter_failed"] == 1
|
|
assert stats["setup_unavailable"] == 2
|
|
|
|
def test_json_blob_redaction_survives_escapes_and_arrays(self):
|
|
"""Decode-first blob sanitizing defeats raw-substring bypasses.
|
|
|
|
`{"access\\u005ftoken": ...}` contains no literal sensitive substring,
|
|
and arrays of credential objects have no top-level dict. Both must still be
|
|
redacted; innocent strings stay unchanged.
|
|
"""
|
|
truncate = bigquery_agent_analytics_plugin._recursive_smart_truncate
|
|
|
|
escaped = '{"access\\u005ftoken": "SECRET-A"}'
|
|
out, _ = truncate({"blob": escaped}, 10000)
|
|
assert "SECRET-A" not in json.dumps(out)
|
|
assert json.loads(out["blob"])["access_token"] == "[REDACTED]"
|
|
|
|
array_blob = '[{"api_key": "SECRET-B"}, {"plain": "ok"}]'
|
|
out, _ = truncate({"blob": array_blob}, 10000)
|
|
assert "SECRET-B" not in json.dumps(out)
|
|
decoded = json.loads(out["blob"])
|
|
assert decoded[0]["api_key"] == "[REDACTED]"
|
|
assert decoded[1]["plain"] == "ok"
|
|
|
|
# No redaction needed -> string returned byte-for-byte (no cosmetic
|
|
# re-serialization).
|
|
innocent = '{"note": "spacing preserved"}'
|
|
out, _ = truncate({"blob": innocent}, 10000)
|
|
assert out["blob"] == innocent
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_multi_message_offloads_get_unique_paths(self):
|
|
"""Two messages in ONE request must not collide at the same part index.
|
|
|
|
The part ordinal restarts per Content while trace/span are shared, so
|
|
paths need the per-parse uid + content ordinal.
|
|
"""
|
|
uploaded: list[str] = []
|
|
|
|
class _FakeOffloader:
|
|
|
|
async def upload_content(self, data, mime, path):
|
|
uploaded.append(path)
|
|
return f"gs://bucket/{path}"
|
|
|
|
parser = bigquery_agent_analytics_plugin.HybridContentParser(
|
|
offloader=_FakeOffloader(), trace_id="t", span_id="s"
|
|
)
|
|
request = llm_request_lib.LlmRequest(
|
|
contents=[
|
|
types.Content(
|
|
role="user",
|
|
parts=[types.Part.from_bytes(data=b"a", mime_type="image/png")],
|
|
),
|
|
types.Content(
|
|
role="user",
|
|
parts=[types.Part.from_bytes(data=b"b", mime_type="image/png")],
|
|
),
|
|
]
|
|
)
|
|
await parser.parse(request, trace_id="trace-x", span_id="span-x")
|
|
assert len(uploaded) == 2
|
|
assert len(set(uploaded)) == 2, f"collision within request: {uploaded}"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_formatter_failure_log_does_not_leak_payload(
|
|
self,
|
|
mock_write_client,
|
|
invocation_context,
|
|
mock_auth_default,
|
|
mock_bq_client,
|
|
mock_to_arrow_schema,
|
|
dummy_arrow_schema,
|
|
mock_asyncio_to_thread,
|
|
caplog,
|
|
):
|
|
"""The formatter-failure log line must not carry the protected content.
|
|
|
|
A formatter that embeds content in its exception message would leak it
|
|
through exc_info tracebacks; only the exception
|
|
class is logged.
|
|
"""
|
|
_ = mock_auth_default, mock_bq_client
|
|
|
|
def leaky_formatter(content, event_type):
|
|
raise ValueError(f"could not redact: {content}")
|
|
|
|
config = bigquery_agent_analytics_plugin.BigQueryLoggerConfig(
|
|
content_formatter=leaky_formatter
|
|
)
|
|
async with managed_plugin(
|
|
PROJECT_ID, DATASET_ID, table_id=TABLE_ID, config=config
|
|
) as plugin:
|
|
await plugin._ensure_started()
|
|
bigquery_agent_analytics_plugin.TraceManager.push_span(invocation_context)
|
|
with caplog.at_level(logging.WARNING):
|
|
await plugin.on_user_message_callback(
|
|
invocation_context=invocation_context,
|
|
user_message=types.Content(
|
|
parts=[types.Part(text="TOPSECRET-PAYLOAD")]
|
|
),
|
|
)
|
|
assert "TOPSECRET-PAYLOAD" not in caplog.text
|
|
# The message is CONSTANT — even the exception class
|
|
# name can be payload-derived, so it is no longer logged.
|
|
assert "Content formatter failed" in caplog.text
|
|
assert "ValueError" not in caplog.text
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_shutdown_folds_processor_drops_into_stats(
|
|
self, mock_auth_default, mock_bq_client
|
|
):
|
|
"""Processor drop counters survive shutdown via the plugin counters.
|
|
|
|
get_drop_stats() used to read only live loop states, which shutdown()
|
|
clears.
|
|
"""
|
|
_ = mock_auth_default, mock_bq_client
|
|
plugin = bigquery_agent_analytics_plugin.BigQueryAgentAnalyticsPlugin(
|
|
PROJECT_ID, DATASET_ID, table_id=TABLE_ID
|
|
)
|
|
processor = mock.create_autospec(
|
|
bigquery_agent_analytics_plugin.BatchProcessor,
|
|
instance=True,
|
|
spec_set=True,
|
|
)
|
|
processor.get_drop_stats.return_value = {"queue_full": 2}
|
|
state = mock.MagicMock()
|
|
state.batch_processor = processor
|
|
state.write_client = None
|
|
plugin._loop_state_by_loop[asyncio.get_running_loop()] = state
|
|
|
|
assert plugin.get_drop_stats() == {"queue_full": 2}
|
|
await plugin.shutdown()
|
|
assert plugin._loop_state_by_loop == {}
|
|
assert plugin.get_drop_stats() == {"queue_full": 2}
|
|
|
|
def test_setstate_backfills_new_runtime_fields(
|
|
self, mock_auth_default, mock_bq_client
|
|
):
|
|
"""Pickles from older code lack the new fields; __setstate__ must
|
|
|
|
backfill them so get_drop_stats()/_ensure_started don't raise.
|
|
"""
|
|
_ = mock_auth_default, mock_bq_client
|
|
plugin = bigquery_agent_analytics_plugin.BigQueryAgentAnalyticsPlugin(
|
|
PROJECT_ID, DATASET_ID, table_id=TABLE_ID
|
|
)
|
|
legacy_state = plugin.__getstate__()
|
|
for key in ("_local_drop_counts", "_setup_failures", "_setup_retry_at"):
|
|
legacy_state.pop(key, None)
|
|
restored = (
|
|
bigquery_agent_analytics_plugin.BigQueryAgentAnalyticsPlugin.__new__(
|
|
bigquery_agent_analytics_plugin.BigQueryAgentAnalyticsPlugin
|
|
)
|
|
)
|
|
restored.__setstate__(legacy_state)
|
|
assert restored.get_drop_stats() == {}
|
|
assert restored._setup_failures == 0
|
|
assert restored._setup_retry_at == 0.0
|
|
|
|
def test_invalid_config_rejects_nan_and_wrong_types(
|
|
self, mock_auth_default, mock_bq_client
|
|
):
|
|
"""NaN and wrong-typed values must fail construction: ordered comparisons alone let NaN pass every range check."""
|
|
_ = mock_auth_default, mock_bq_client
|
|
retry = bigquery_agent_analytics_plugin.RetryConfig
|
|
nan = float("nan")
|
|
bad_configs = [
|
|
dict(batch_size=nan),
|
|
dict(batch_size=2.0),
|
|
dict(batch_size=True),
|
|
dict(batch_flush_interval=nan),
|
|
dict(shutdown_timeout=float("inf")),
|
|
dict(queue_max_size="10"),
|
|
dict(max_content_length=1.5),
|
|
dict(retry_config=retry(max_retries=nan)),
|
|
dict(retry_config=retry(initial_delay=nan)),
|
|
dict(retry_config=retry(multiplier=nan)),
|
|
dict(retry_config=retry(max_delay=nan)),
|
|
]
|
|
for kwargs in bad_configs:
|
|
config = bigquery_agent_analytics_plugin.BigQueryLoggerConfig(**kwargs)
|
|
with pytest.raises(ValueError):
|
|
bigquery_agent_analytics_plugin.BigQueryAgentAnalyticsPlugin(
|
|
PROJECT_ID, DATASET_ID, table_id=TABLE_ID, config=config
|
|
)
|
|
|
|
def test_json_blob_duplicate_keys_always_reserialized(self):
|
|
"""Duplicate JSON members must not defeat the changed-blob check.
|
|
|
|
json.loads keeps only the last duplicate, so sanitized == parsed can
|
|
hold while the raw string still carries an earlier secret member
|
|
.
|
|
"""
|
|
truncate = bigquery_agent_analytics_plugin._recursive_smart_truncate
|
|
blob = '{"access_token": "SECRET-DUP", "access_token": "[REDACTED]"}'
|
|
out, _ = truncate({"blob": blob}, 10000)
|
|
assert "SECRET-DUP" not in json.dumps(out)
|
|
assert json.loads(out["blob"])["access_token"] == "[REDACTED]"
|
|
|
|
def test_mapping_views_are_redacted(self):
|
|
"""Mapping types beyond dict must be walked, not stringified.
|
|
|
|
MappingProxyType/UserDict used to hit the stringify fallback, leaking
|
|
sensitive members.
|
|
"""
|
|
import collections
|
|
from types import MappingProxyType
|
|
|
|
truncate = bigquery_agent_analytics_plugin._recursive_smart_truncate
|
|
proxy = MappingProxyType({"access_token": "SECRET-PROXY"})
|
|
userdict = collections.UserDict({"refresh_token": "SECRET-USERDICT"})
|
|
out, _ = truncate({"proxy": proxy, "userdict": userdict}, 10000)
|
|
dumped = json.dumps(out)
|
|
assert "SECRET-PROXY" not in dumped
|
|
assert "SECRET-USERDICT" not in dumped
|
|
assert out["proxy"]["access_token"] == "[REDACTED]"
|
|
assert out["userdict"]["refresh_token"] == "[REDACTED]"
|
|
|
|
def test_deep_json_blob_fails_closed(self):
|
|
"""A blob beyond the fixed nesting limit fails closed on every runtime.
|
|
|
|
Older Python runtimes raise RecursionError while Python 3.14's iterative
|
|
JSON decoder accepts this input. The row must keep flowing with the same
|
|
whole-blob sentinel regardless.
|
|
"""
|
|
truncate = bigquery_agent_analytics_plugin._recursive_smart_truncate
|
|
deep = "[" * 10000 + "]" * 10000
|
|
out, _ = truncate({"blob": deep}, 500 * 1024)
|
|
assert out["blob"] == "[UNPARSEABLE_JSON_BLOB]"
|
|
|
|
def test_json_nesting_limit_ignores_brackets_inside_strings(self):
|
|
"""Payload punctuation does not count as structural JSON nesting."""
|
|
truncate = bigquery_agent_analytics_plugin._recursive_smart_truncate
|
|
blob = json.dumps({"note": "prose " + "[" * 1001 + "]" * 1001})
|
|
out, truncated = truncate({"blob": blob}, 500 * 1024)
|
|
assert out["blob"] == blob
|
|
assert truncated is False
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_shutdown_timeout_counts_lost_rows(self):
|
|
"""Rows stranded by a shutdown timeout are counted, not silent.
|
|
|
|
In-flight batch rows are counted by the cancelled worker and queued
|
|
rows by the drain in shutdown().
|
|
"""
|
|
write_started = asyncio.Event()
|
|
|
|
async def hung_writer(batch):
|
|
write_started.set()
|
|
await asyncio.sleep(3600)
|
|
|
|
processor = bigquery_agent_analytics_plugin.BatchProcessor(
|
|
write_client=mock.MagicMock(),
|
|
arrow_schema=mock.MagicMock(),
|
|
write_stream="stream",
|
|
batch_size=1,
|
|
flush_interval=0.05,
|
|
retry_config=bigquery_agent_analytics_plugin.RetryConfig(),
|
|
queue_max_size=10,
|
|
shutdown_timeout=0.1,
|
|
)
|
|
with mock.patch.object(
|
|
processor, "_write_rows_with_retry", side_effect=hung_writer
|
|
):
|
|
await processor.start()
|
|
await processor.append({"row": 1})
|
|
await write_started.wait() # row 1 is in-flight in the hung writer
|
|
await processor.append({"row": 2}) # row 2 stays queued
|
|
await processor.shutdown(timeout=0.1)
|
|
|
|
stats = processor.get_drop_stats()
|
|
assert stats.get("shutdown_timeout") == 2
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_stale_loop_cleanup_preserves_drop_stats(
|
|
self, mock_auth_default, mock_bq_client
|
|
):
|
|
"""Closed-loop cleanup folds processor counters before deletion
|
|
|
|
.
|
|
"""
|
|
_ = mock_auth_default, mock_bq_client
|
|
plugin = bigquery_agent_analytics_plugin.BigQueryAgentAnalyticsPlugin(
|
|
PROJECT_ID, DATASET_ID, table_id=TABLE_ID
|
|
)
|
|
dead_loop = mock.MagicMock()
|
|
dead_loop.is_closed.return_value = True
|
|
state = mock.MagicMock()
|
|
state.batch_processor.get_drop_stats.return_value = {"write_failed": 7}
|
|
plugin._loop_state_by_loop[dead_loop] = state
|
|
|
|
plugin._cleanup_stale_loop_states()
|
|
|
|
assert plugin._loop_state_by_loop == {}
|
|
assert plugin.get_drop_stats().get("write_failed") == 7
|
|
|
|
def test_setstate_validates_restored_config(
|
|
self, mock_auth_default, mock_bq_client
|
|
):
|
|
"""Legacy pickles with invalid runtime config fail at restore, not as
|
|
|
|
a silent write-loop skip.
|
|
"""
|
|
_ = mock_auth_default, mock_bq_client
|
|
plugin = bigquery_agent_analytics_plugin.BigQueryAgentAnalyticsPlugin(
|
|
PROJECT_ID, DATASET_ID, table_id=TABLE_ID
|
|
)
|
|
state = plugin.__getstate__()
|
|
state["config"].retry_config.max_retries = float("nan")
|
|
restored = (
|
|
bigquery_agent_analytics_plugin.BigQueryAgentAnalyticsPlugin.__new__(
|
|
bigquery_agent_analytics_plugin.BigQueryAgentAnalyticsPlugin
|
|
)
|
|
)
|
|
with pytest.raises(ValueError):
|
|
restored.__setstate__(state)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_gcs_uploads_use_full_uid_and_create_only(self):
|
|
"""Object names carry the full 128-bit uid; uploads are create-only.
|
|
|
|
32 random bits reach ~50% birthday collision around 77k parses; a
|
|
collision must fail the upload instead of rebinding an existing row
|
|
to another event's bytes.
|
|
"""
|
|
uploaded: list[str] = []
|
|
|
|
class _FakeOffloader:
|
|
|
|
async def upload_content(self, data, mime, path):
|
|
uploaded.append(path)
|
|
return f"gs://bucket/{path}"
|
|
|
|
parser = bigquery_agent_analytics_plugin.HybridContentParser(
|
|
offloader=_FakeOffloader(), trace_id="t", span_id="s"
|
|
)
|
|
await parser.parse(
|
|
types.Content(
|
|
parts=[types.Part.from_bytes(data=b"a", mime_type="image/png")]
|
|
),
|
|
trace_id="trace-y",
|
|
span_id="span-y",
|
|
)
|
|
assert len(uploaded) == 1
|
|
# .../{span}_{32-hex-uid}_c{n}_p{idx}.png
|
|
uid_segment = uploaded[0].split("span-y_")[1].split("_c")[0]
|
|
assert len(uid_segment) == 32
|
|
|
|
# And the sync upload path passes create-only semantics.
|
|
bucket = mock.MagicMock()
|
|
offloader = bigquery_agent_analytics_plugin.GCSOffloader.__new__(
|
|
bigquery_agent_analytics_plugin.GCSOffloader
|
|
)
|
|
offloader.bucket = bucket
|
|
offloader._upload_sync(b"data", "image/png", "p")
|
|
_, kwargs = bucket.blob.return_value.upload_from_string.call_args
|
|
assert kwargs.get("if_generation_match") == 0
|
|
|
|
def test_unmaterializable_json_blob_fails_closed(self):
|
|
"""Valid JSON that Python cannot materialize becomes a sentinel.
|
|
|
|
Integers over the interpreter digit limit raise a plain ValueError
|
|
from json.loads on syntactically valid JSON; returning the raw string
|
|
would leak members the sanitizer never inspected.
|
|
"""
|
|
truncate = bigquery_agent_analytics_plugin._recursive_smart_truncate
|
|
blob = '{"access_token": "SECRET-BIGINT", "n": ' + "9" * 5000 + "}"
|
|
out, _ = truncate({"blob": blob}, 500 * 1024)
|
|
assert "SECRET-BIGINT" not in json.dumps(out)
|
|
assert out["blob"] == "[UNPARSEABLE_JSON_BLOB]"
|
|
|
|
def test_label_only_upgrade_failure_does_not_block_readiness(self):
|
|
"""A label-only update_table failure must not fail setup.
|
|
|
|
The table schema is write-compatible; only the governance label is
|
|
stale. Blocking readiness turned every event into setup_unavailable
|
|
although writes would succeed.
|
|
"""
|
|
config = bigquery_agent_analytics_plugin.BigQueryLoggerConfig(
|
|
auto_schema_upgrade=True,
|
|
)
|
|
with mock.patch("google.cloud.bigquery.Client"):
|
|
plugin = bigquery_agent_analytics_plugin.BigQueryAgentAnalyticsPlugin(
|
|
project_id=PROJECT_ID,
|
|
dataset_id=DATASET_ID,
|
|
table_id=TABLE_ID,
|
|
config=config,
|
|
)
|
|
plugin.client = mock.MagicMock()
|
|
plugin.full_table_id = f"{PROJECT_ID}.{DATASET_ID}.{TABLE_ID}"
|
|
plugin._schema = bigquery_agent_analytics_plugin._get_events_schema()
|
|
existing = mock.MagicMock(spec=bigquery.Table)
|
|
# Identical schema: no new fields, no updated records.
|
|
existing.schema = list(plugin._schema)
|
|
existing.labels = {} # stale version label only
|
|
plugin.client.get_table.return_value = existing
|
|
plugin.client.update_table.side_effect = Exception("labels forbidden")
|
|
|
|
# Does not raise; the stale label is retried on the next run.
|
|
plugin._ensure_schema_exists()
|
|
plugin.client.update_table.assert_called_once()
|
|
|
|
def test_ensure_started_coalesces_across_event_loops(
|
|
self, mock_auth_default, mock_bq_client
|
|
):
|
|
"""_ensure_started must be safe when called from multiple loops.
|
|
|
|
One shared asyncio.Lock is loop-bound: a second thread's loop raised
|
|
'Non-thread-safe operation' and could strand waiters. Per-loop locks make
|
|
each loop coalesce independently.
|
|
"""
|
|
_ = mock_auth_default, mock_bq_client
|
|
import threading
|
|
|
|
plugin = bigquery_agent_analytics_plugin.BigQueryAgentAnalyticsPlugin(
|
|
PROJECT_ID, DATASET_ID, table_id=TABLE_ID
|
|
)
|
|
setup_calls = []
|
|
|
|
async def fake_lazy_setup(**kwargs):
|
|
setup_calls.append(threading.get_ident())
|
|
await asyncio.sleep(0.05)
|
|
|
|
errors: list[BaseException] = []
|
|
|
|
def run_in_fresh_loop():
|
|
try:
|
|
asyncio.run(plugin._ensure_started())
|
|
except BaseException as e: # noqa: BLE001 - collecting for assertion
|
|
errors.append(e)
|
|
|
|
# Patch from this thread only. patch.object swaps a single shared
|
|
# attribute and is not itself thread safe, so entering it from both
|
|
# threads raced on _lazy_setup instead of on the code under test.
|
|
with mock.patch.object(plugin, "_lazy_setup", side_effect=fake_lazy_setup):
|
|
threads = [
|
|
platform_thread.create_thread(run_in_fresh_loop) for _ in range(2)
|
|
]
|
|
for t in threads:
|
|
t.start()
|
|
for t in threads:
|
|
t.join(timeout=10)
|
|
assert not errors, f"cross-loop startup raised: {errors}"
|
|
|
|
def test_concurrent_stale_cleanup_folds_once(
|
|
self, mock_auth_default, mock_bq_client
|
|
):
|
|
"""Repeated/concurrent cleanups fold a processor's counters exactly once.
|
|
|
|
Read-fold-delete raced: two cleanups produced doubled counts and a
|
|
KeyError; the pop-claim makes folding idempotent.
|
|
"""
|
|
_ = mock_auth_default, mock_bq_client
|
|
plugin = bigquery_agent_analytics_plugin.BigQueryAgentAnalyticsPlugin(
|
|
PROJECT_ID, DATASET_ID, table_id=TABLE_ID
|
|
)
|
|
dead_loop = mock.MagicMock()
|
|
dead_loop.is_closed.return_value = True
|
|
state = mock.MagicMock()
|
|
state.batch_processor.get_drop_stats.return_value = {"write_failed": 7}
|
|
plugin._loop_state_by_loop[dead_loop] = state
|
|
|
|
plugin._cleanup_stale_loop_states()
|
|
plugin._cleanup_stale_loop_states() # second pass: nothing left to claim
|
|
|
|
assert plugin.get_drop_stats().get("write_failed") == 7
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_close_counts_lost_rows_like_shutdown(self):
|
|
"""close() shares shutdown()'s drain/accounting for stranded rows
|
|
|
|
.
|
|
"""
|
|
write_started = asyncio.Event()
|
|
|
|
async def hung_writer(batch):
|
|
write_started.set()
|
|
await asyncio.sleep(3600)
|
|
|
|
processor = bigquery_agent_analytics_plugin.BatchProcessor(
|
|
write_client=mock.MagicMock(),
|
|
arrow_schema=mock.MagicMock(),
|
|
write_stream="stream",
|
|
batch_size=1,
|
|
flush_interval=0.05,
|
|
retry_config=bigquery_agent_analytics_plugin.RetryConfig(),
|
|
queue_max_size=10,
|
|
shutdown_timeout=0.1,
|
|
)
|
|
with mock.patch.object(
|
|
processor, "_write_rows_with_retry", side_effect=hung_writer
|
|
):
|
|
await processor.start()
|
|
await processor.append({"row": 1})
|
|
await write_started.wait()
|
|
await processor.append({"row": 2})
|
|
await processor.close()
|
|
|
|
assert processor.get_drop_stats().get("shutdown_timeout") == 2
|
|
assert processor._queue.empty()
|
|
|
|
def test_malformed_container_blobs_fail_closed(self):
|
|
"""Container-shaped strings that fail to parse become the sentinel.
|
|
|
|
One trailing character on valid credential JSON must not bypass
|
|
redaction, including with escaped keys.
|
|
"""
|
|
truncate = bigquery_agent_analytics_plugin._recursive_smart_truncate
|
|
cases = [
|
|
'{"access\\u005ftoken":"SECRET-TRAIL"} trailing',
|
|
'{"access_token":"SECRET-MALFORMED"',
|
|
'[{"api_key":"SECRET-ARRAY"}, oops]',
|
|
]
|
|
for blob in cases:
|
|
out, _ = truncate({"blob": blob}, 10000)
|
|
assert "SECRET" not in json.dumps(out), blob
|
|
assert out["blob"] == "[UNPARSEABLE_JSON_BLOB]", blob
|
|
|
|
def test_over_limit_blob_never_parsed(self):
|
|
"""json.loads must not run for container blobs over the content limit.
|
|
|
|
Materializing a multi-megabyte attribute blocks the callback loop and
|
|
allocates far beyond the configured limit.
|
|
"""
|
|
truncate = bigquery_agent_analytics_plugin._recursive_smart_truncate
|
|
big_blob = '{"k": "' + "x" * 5000 + '"}'
|
|
with mock.patch.object(
|
|
bigquery_agent_analytics_plugin.json,
|
|
"loads",
|
|
side_effect=AssertionError("json.loads must not be called"),
|
|
):
|
|
out, truncated = truncate({"blob": big_blob}, 100)
|
|
assert out["blob"] == "[UNPARSEABLE_JSON_BLOB]"
|
|
assert truncated
|
|
|
|
def test_shared_setup_runs_exactly_once_across_loops(
|
|
self, mock_auth_default, mock_bq_client
|
|
):
|
|
"""Concurrent loops coalesce onto ONE shared setup.
|
|
|
|
Per-loop locks let both loops run _lazy_setup, which mutates shared
|
|
clients/executor/parser state across awaits and is not idempotent.
|
|
"""
|
|
_ = mock_auth_default, mock_bq_client
|
|
import threading
|
|
|
|
plugin = bigquery_agent_analytics_plugin.BigQueryAgentAnalyticsPlugin(
|
|
PROJECT_ID, DATASET_ID, table_id=TABLE_ID
|
|
)
|
|
setup_calls = []
|
|
release = threading.Event()
|
|
entered = threading.Event()
|
|
|
|
async def slow_setup(**kwargs):
|
|
setup_calls.append(threading.get_ident())
|
|
entered.set()
|
|
await asyncio.get_running_loop().run_in_executor(None, release.wait)
|
|
|
|
errors: list[BaseException] = []
|
|
barrier = threading.Barrier(2)
|
|
|
|
def run_in_fresh_loop():
|
|
try:
|
|
barrier.wait(timeout=5)
|
|
asyncio.run(plugin._ensure_started())
|
|
except BaseException as e: # noqa: BLE001
|
|
errors.append(e)
|
|
|
|
# Patch from this thread only. patch.object swaps a single shared
|
|
# attribute and is not itself thread safe, so entering it from both
|
|
# threads raced on _lazy_setup instead of on the code under test.
|
|
with mock.patch.object(plugin, "_lazy_setup", side_effect=slow_setup):
|
|
threads = [
|
|
platform_thread.create_thread(run_in_fresh_loop) for _ in range(2)
|
|
]
|
|
for t in threads:
|
|
t.start()
|
|
# Deterministic rendezvous: hold the owner inside setup until BOTH
|
|
# threads have entered _ensure_started.
|
|
entered.wait(timeout=5)
|
|
release.set()
|
|
for t in threads:
|
|
t.join(timeout=10)
|
|
assert not t.is_alive(), "thread failed to terminate"
|
|
|
|
assert not errors, f"cross-loop startup raised: {errors}"
|
|
assert len(setup_calls) == 1, f"shared setup ran {len(setup_calls)} times"
|
|
assert plugin._started is True
|
|
assert plugin._startup_error is None
|
|
assert plugin._setup_future is None
|
|
|
|
def test_failed_shared_setup_is_consistent_across_loops(
|
|
self, mock_auth_default, mock_bq_client
|
|
):
|
|
"""A failing owner leaves consistent shared state for every waiter."""
|
|
_ = mock_auth_default, mock_bq_client
|
|
import threading
|
|
|
|
plugin = bigquery_agent_analytics_plugin.BigQueryAgentAnalyticsPlugin(
|
|
PROJECT_ID, DATASET_ID, table_id=TABLE_ID
|
|
)
|
|
|
|
setup_calls = []
|
|
release = threading.Event()
|
|
entered = threading.Event()
|
|
|
|
async def failing_setup(**kwargs):
|
|
setup_calls.append(threading.get_ident())
|
|
entered.set()
|
|
await asyncio.get_running_loop().run_in_executor(None, release.wait)
|
|
raise RuntimeError("setup boom")
|
|
|
|
errors: list[BaseException] = []
|
|
barrier = threading.Barrier(2)
|
|
|
|
def run_in_fresh_loop():
|
|
try:
|
|
barrier.wait(timeout=5)
|
|
asyncio.run(plugin._ensure_started())
|
|
except BaseException as e: # noqa: BLE001
|
|
errors.append(e)
|
|
|
|
# Patch from this thread only to prevent race conditions on plugin's dictionary
|
|
with mock.patch.object(plugin, "_lazy_setup", side_effect=failing_setup):
|
|
threads = [
|
|
platform_thread.create_thread(run_in_fresh_loop) for _ in range(2)
|
|
]
|
|
for t in threads:
|
|
t.start()
|
|
entered.wait(timeout=5)
|
|
release.set()
|
|
for t in threads:
|
|
t.join(timeout=10)
|
|
assert not t.is_alive(), "thread failed to terminate"
|
|
|
|
assert not errors # _ensure_started never raises to callers
|
|
assert len(setup_calls) == 1, f"setup ran {len(setup_calls)} times"
|
|
assert plugin._started is False
|
|
assert plugin._startup_error is not None
|
|
assert plugin._setup_future is None # cleared for the next retry window
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_namedtuple_attribute_does_not_drop_row(
|
|
self,
|
|
mock_write_client,
|
|
invocation_context,
|
|
callback_context,
|
|
mock_auth_default,
|
|
mock_bq_client,
|
|
mock_to_arrow_schema,
|
|
dummy_arrow_schema,
|
|
mock_asyncio_to_thread,
|
|
):
|
|
"""A namedtuple in attributes serializes as a mapping, not a TypeError.
|
|
|
|
Reconstructing tuple subclasses positionally raised in the final pass
|
|
and the safe callback dropped the entire row;
|
|
then required the mapping shape so field-name redaction
|
|
can run.
|
|
"""
|
|
_ = mock_auth_default, mock_bq_client
|
|
import collections
|
|
|
|
Point = collections.namedtuple("Point", ["x", "y"])
|
|
async with managed_plugin(
|
|
PROJECT_ID, DATASET_ID, table_id=TABLE_ID
|
|
) as plugin:
|
|
await plugin._ensure_started()
|
|
mock_write_client.append_rows.reset_mock()
|
|
bigquery_agent_analytics_plugin.TraceManager.push_span(invocation_context)
|
|
await plugin._log_event(
|
|
"STATE_DELTA",
|
|
callback_context,
|
|
event_data=bigquery_agent_analytics_plugin.EventData(
|
|
extra_attributes={"point": Point(1, 2)},
|
|
),
|
|
)
|
|
await plugin.flush()
|
|
log_entry = await _get_captured_event_dict_async(
|
|
mock_write_client, dummy_arrow_schema
|
|
)
|
|
attrs = json.loads(log_entry["attributes"])
|
|
# Namedtuples serialize as a MAPPING so field-name redaction can run;
|
|
# the
|
|
# row is still emitted either way.
|
|
assert attrs["point"] == {"x": 1, "y": 2}
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_setup_blocked_before_loop_state_does_not_leak(
|
|
self, mock_auth_default, mock_bq_client
|
|
):
|
|
"""A shutdown() that completes while setup is blocked
|
|
|
|
creating the shared client must abort the resumed setup, publish
|
|
nothing, and release every resource the attempt created.
|
|
"""
|
|
_ = mock_auth_default, mock_bq_client
|
|
plugin = bigquery_agent_analytics_plugin.BigQueryAgentAnalyticsPlugin(
|
|
PROJECT_ID, DATASET_ID, table_id=TABLE_ID
|
|
)
|
|
plugin._credentials = mock.MagicMock(quota_project_id=None)
|
|
|
|
entered = threading.Event()
|
|
release = threading.Event()
|
|
|
|
def gated_client(*args, **kwargs):
|
|
del args, kwargs
|
|
entered.set()
|
|
release.wait(10)
|
|
return mock.MagicMock()
|
|
|
|
with mock.patch(
|
|
"google.adk.plugins.bigquery_agent_analytics_plugin.bigquery.Client",
|
|
side_effect=gated_client,
|
|
):
|
|
owner = asyncio.create_task(plugin._ensure_started())
|
|
while not entered.is_set():
|
|
await asyncio.sleep(0.01)
|
|
# Shutdown completes fully while setup is blocked in the executor.
|
|
await plugin.shutdown()
|
|
release.set()
|
|
outcome = await owner # aborts internally; never raises
|
|
|
|
assert outcome == "aborted"
|
|
assert plugin._started is False
|
|
assert plugin.client is None
|
|
assert plugin._executor is None
|
|
assert plugin.parser is None
|
|
assert plugin.offloader is None
|
|
assert plugin._loop_state_by_loop == {}
|
|
# A direct start (no row) records no phantom loss; the
|
|
# structured outcome lets the row owner count instead.
|
|
assert plugin.get_drop_stats().get("shutdown_race", 0) == 0
|
|
# The abort is not a service failure: no poisoned backoff window.
|
|
assert plugin._startup_error is None
|
|
|
|
def test_concurrent_shutdown_folds_counters_once(
|
|
self, mock_auth_default, mock_bq_client
|
|
):
|
|
"""Two threads racing into shutdown() must not both be
|
|
|
|
admitted — the same processors' drop counters were folded twice.
|
|
"""
|
|
_ = mock_auth_default, mock_bq_client
|
|
plugin = bigquery_agent_analytics_plugin.BigQueryAgentAnalyticsPlugin(
|
|
PROJECT_ID, DATASET_ID, table_id=TABLE_ID
|
|
)
|
|
|
|
def make_state():
|
|
state = mock.MagicMock()
|
|
state.write_client = None
|
|
state.batch_processor = mock.MagicMock(
|
|
spec=bigquery_agent_analytics_plugin.BatchProcessor
|
|
)
|
|
state.batch_processor.shutdown = mock.AsyncMock()
|
|
state.batch_processor.get_drop_stats = mock.MagicMock(
|
|
return_value={"queue_full": 1}
|
|
)
|
|
return state
|
|
|
|
for _ in range(2):
|
|
# Closed fakes: shutdown claims and folds them without scheduling
|
|
# coroutines on them (a non-closed MagicMock loop leaked unawaited
|
|
# AsyncMock coroutines).
|
|
fake_loop = mock.MagicMock(spec=asyncio.AbstractEventLoop)
|
|
fake_loop.is_closed.return_value = True
|
|
plugin._loop_state_by_loop[fake_loop] = make_state()
|
|
|
|
barrier = threading.Barrier(2)
|
|
errors = []
|
|
|
|
def run_shutdown():
|
|
try:
|
|
barrier.wait(timeout=10)
|
|
asyncio.run(plugin.shutdown(timeout=0.1))
|
|
except Exception as e: # pylint: disable=broad-except
|
|
errors.append(e)
|
|
|
|
threads = [
|
|
platform_thread.create_thread(target=run_shutdown) for _ in range(2)
|
|
]
|
|
for t in threads:
|
|
t.start()
|
|
for t in threads:
|
|
t.join(timeout=30)
|
|
assert not t.is_alive()
|
|
|
|
assert not errors
|
|
# Two states, one queue_full each: exactly one shutdown owner folds
|
|
# them, so anything above 2 means double-folding.
|
|
assert plugin.get_drop_stats().get("queue_full", 0) == 2
|
|
|
|
def test_drop_counters_are_thread_safe(
|
|
self, mock_auth_default, mock_bq_client
|
|
):
|
|
"""Concurrent _count_local_drop() increments from
|
|
|
|
multiple threads must not lose updates.
|
|
"""
|
|
_ = mock_auth_default, mock_bq_client
|
|
plugin = bigquery_agent_analytics_plugin.BigQueryAgentAnalyticsPlugin(
|
|
PROJECT_ID, DATASET_ID, table_id=TABLE_ID
|
|
)
|
|
increments = 5_000
|
|
n_threads = 4
|
|
old_interval = sys.getswitchinterval()
|
|
sys.setswitchinterval(1e-5)
|
|
try:
|
|
|
|
def worker():
|
|
for _ in range(increments):
|
|
plugin._count_local_drop("stress")
|
|
|
|
threads = [
|
|
platform_thread.create_thread(target=worker) for _ in range(n_threads)
|
|
]
|
|
for t in threads:
|
|
t.start()
|
|
for t in threads:
|
|
t.join(timeout=60)
|
|
assert not t.is_alive()
|
|
finally:
|
|
sys.setswitchinterval(old_interval)
|
|
|
|
assert plugin.get_drop_stats()["stress"] == increments * n_threads
|
|
|
|
def test_unlimited_mode_scans_entire_emitted_value(self):
|
|
"""In unlimited mode the ENTIRE emitted value is
|
|
|
|
classified — a credential document just past the inspection window
|
|
fails closed; escape-free giant quoted prose passes whole.
|
|
"""
|
|
truncate = bigquery_agent_analytics_plugin._recursive_smart_truncate
|
|
ceiling = bigquery_agent_analytics_plugin._MAX_JSON_INSPECT_CHARS
|
|
|
|
raw = (
|
|
'"'
|
|
+ "a" * (ceiling + 10)
|
|
+ '\\u007b\\"access\\u005ftoken\\":'
|
|
+ '\\"R15-UNLIMITED-SECRET\\"\\u007d"'
|
|
)
|
|
out, truncated = truncate({"blob": raw}, -1)
|
|
assert "R15-UNLIMITED-SECRET" not in json.dumps(out)
|
|
assert out["blob"] == "[UNPARSEABLE_JSON_BLOB]"
|
|
assert truncated is True
|
|
|
|
prose = '"' + "hello world " * ((ceiling // 12) + 10) + '"'
|
|
out, truncated = truncate({"s": prose}, -1)
|
|
assert out["s"] == prose
|
|
assert truncated is False
|
|
|
|
def test_normalizer_redacts_and_bounds(self):
|
|
"""The JSON-native normalizer applies
|
|
|
|
sensitive-key/temp: redaction and the configured length bound, while
|
|
preserving sentinels and bracketed prose.
|
|
"""
|
|
normalize = bigquery_agent_analytics_plugin._normalize_json_native
|
|
|
|
out, _ = normalize(
|
|
{"prompt": [{"role": {"access_token": "R15-NATIVE-SECRET"}}]},
|
|
10000,
|
|
)
|
|
assert "R15-NATIVE-SECRET" not in json.dumps(out)
|
|
assert out["prompt"][0]["role"]["access_token"] == "[REDACTED]"
|
|
|
|
out, replaced = normalize("R15-ROLE-" + "x" * 1_000_000, 10)
|
|
assert out == "R15-ROLE-x...[TRUNCATED]"
|
|
assert replaced is True
|
|
|
|
for preserved in ("[FORMATTER_FAILED]", "[bracketed] prose"):
|
|
out, replaced = normalize(preserved, 10000)
|
|
assert out == preserved
|
|
assert replaced is False
|
|
|
|
def test_normalizer_preserves_post_normalization_key_collisions(self):
|
|
"""Normalized keys never silently overwrite an earlier value."""
|
|
normalize = bigquery_agent_analytics_plugin._normalize_json_native
|
|
|
|
unsupported_first, replaced = normalize(
|
|
{object(): "unsupported", "[UNSUPPORTED_KEY_1]": "genuine"},
|
|
10000,
|
|
)
|
|
assert unsupported_first == {
|
|
"[UNSUPPORTED_KEY_1]": "unsupported",
|
|
"[KEY_COLLISION_2][UNSUPPORTED_KEY_1]": "genuine",
|
|
}
|
|
assert replaced is True
|
|
|
|
genuine_first, replaced = normalize(
|
|
{"[UNSUPPORTED_KEY_1]": "genuine", object(): "unsupported"},
|
|
10000,
|
|
)
|
|
assert genuine_first == {
|
|
"[UNSUPPORTED_KEY_1]": "genuine",
|
|
"[KEY_COLLISION_2][UNSUPPORTED_KEY_1]": "unsupported",
|
|
}
|
|
assert replaced is True
|
|
|
|
marker_reserved, replaced = normalize(
|
|
{
|
|
object(): "unsupported",
|
|
"[KEY_COLLISION_2][UNSUPPORTED_KEY_1]": "reserved",
|
|
"[UNSUPPORTED_KEY_1]": "genuine",
|
|
},
|
|
10000,
|
|
)
|
|
assert marker_reserved == {
|
|
"[UNSUPPORTED_KEY_1]": "unsupported",
|
|
"[KEY_COLLISION_2][UNSUPPORTED_KEY_1]": "reserved",
|
|
"[KEY_COLLISION_3][UNSUPPORTED_KEY_1]": "genuine",
|
|
}
|
|
assert replaced is True
|
|
|
|
budget_reserved, replaced = normalize(
|
|
{
|
|
"[SANITIZE_BUDGET_EXCEEDED]": "reserved",
|
|
"[KEY_COLLISION_1][SANITIZE_BUDGET_EXCEEDED]": "marker",
|
|
"omitted": "value",
|
|
},
|
|
10000,
|
|
budget=[3],
|
|
)
|
|
assert budget_reserved == {
|
|
"[SANITIZE_BUDGET_EXCEEDED]": "reserved",
|
|
"[KEY_COLLISION_1][SANITIZE_BUDGET_EXCEEDED]": "marker",
|
|
"[KEY_COLLISION_2][SANITIZE_BUDGET_EXCEEDED]": (
|
|
"[SANITIZE_BUDGET_EXCEEDED]"
|
|
),
|
|
}
|
|
assert replaced is True
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_native_secret_mapping_via_model_field_redacted(
|
|
self,
|
|
mock_write_client,
|
|
invocation_context,
|
|
callback_context,
|
|
mock_auth_default,
|
|
mock_bq_client,
|
|
mock_to_arrow_schema,
|
|
dummy_arrow_schema,
|
|
mock_asyncio_to_thread,
|
|
):
|
|
"""At the row boundary: a nested model property handing
|
|
|
|
the parser a raw credential mapping is redacted in the written row.
|
|
"""
|
|
_ = mock_auth_default, mock_bq_client
|
|
|
|
class EvilContent(types.Content):
|
|
|
|
def __getattribute__(self, name):
|
|
if name == "role":
|
|
return {"access_token": "R15-NATIVE-SECRET"}
|
|
return super().__getattribute__(name)
|
|
|
|
hostile = llm_request_lib.LlmRequest(contents=[EvilContent(parts=[])])
|
|
assert type(hostile) is llm_request_lib.LlmRequest
|
|
|
|
config = bigquery_agent_analytics_plugin.BigQueryLoggerConfig(
|
|
content_formatter=lambda content, event_type: hostile
|
|
)
|
|
async with managed_plugin(
|
|
PROJECT_ID, DATASET_ID, table_id=TABLE_ID, config=config
|
|
) as plugin:
|
|
await plugin._ensure_started()
|
|
mock_write_client.append_rows.reset_mock()
|
|
bigquery_agent_analytics_plugin.TraceManager.push_span(invocation_context)
|
|
await plugin._log_event(
|
|
"LLM_REQUEST",
|
|
callback_context,
|
|
event_data=bigquery_agent_analytics_plugin.EventData(),
|
|
)
|
|
await asyncio.sleep(0.01)
|
|
log_entry = await _get_captured_event_dict_async(
|
|
mock_write_client, dummy_arrow_schema
|
|
)
|
|
assert "R15-NATIVE-SECRET" not in json.dumps(log_entry, default=str)
|
|
assert "[REDACTED]" in json.dumps(log_entry, default=str)
|
|
|
|
def test_overlimit_prose_prefixed_encoded_string_fails_closed(self):
|
|
"""An over-limit quoted value whose EMITTED prefix
|
|
|
|
hides an escaped container after prose fails closed; escape-free
|
|
over-limit quoted prose still raw-truncates.
|
|
"""
|
|
truncate = bigquery_agent_analytics_plugin._recursive_smart_truncate
|
|
|
|
raw = (
|
|
'"note \\u007b\\"access\\u005ftoken\\":'
|
|
'\\"R14-OVERLIMIT-SECRET\\"\\u007d'
|
|
+ "x" * 10050
|
|
+ '"'
|
|
)
|
|
out, truncated = truncate({"blob": raw}, 10000)
|
|
assert "R14-OVERLIMIT-SECRET" not in json.dumps(out)
|
|
assert out["blob"] == "[UNPARSEABLE_JSON_BLOB]"
|
|
assert truncated is True
|
|
|
|
prose = '"' + "hello world " * 2000 + '"'
|
|
out, truncated = truncate({"s": prose}, 1000)
|
|
assert out["s"].endswith("...[TRUNCATED]")
|
|
assert truncated is True
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_late_detonating_nested_model_normalized(
|
|
self,
|
|
mock_write_client,
|
|
invocation_context,
|
|
callback_context,
|
|
mock_auth_default,
|
|
mock_bq_client,
|
|
mock_to_arrow_schema,
|
|
dummy_arrow_schema,
|
|
mock_asyncio_to_thread,
|
|
caplog,
|
|
):
|
|
"""A nested model that parses cleanly but plants an
|
|
|
|
object whose __repr__ raises must be normalized inside the parse
|
|
boundary — the row survives Arrow preparation and the payload never
|
|
reaches the logs.
|
|
"""
|
|
_ = mock_auth_default, mock_bq_client
|
|
|
|
class LateBomb:
|
|
|
|
def __repr__(self):
|
|
raise RuntimeError("R14-LATE-SERIALIZE-SECRET")
|
|
|
|
class EvilContent(types.Content):
|
|
|
|
def __getattribute__(self, name):
|
|
if name == "role":
|
|
return LateBomb()
|
|
return super().__getattribute__(name)
|
|
|
|
hostile = llm_request_lib.LlmRequest(contents=[EvilContent(parts=[])])
|
|
assert type(hostile) is llm_request_lib.LlmRequest
|
|
|
|
config = bigquery_agent_analytics_plugin.BigQueryLoggerConfig(
|
|
content_formatter=lambda content, event_type: hostile
|
|
)
|
|
async with managed_plugin(
|
|
PROJECT_ID, DATASET_ID, table_id=TABLE_ID, config=config
|
|
) as plugin:
|
|
await plugin._ensure_started()
|
|
mock_write_client.append_rows.reset_mock()
|
|
bigquery_agent_analytics_plugin.TraceManager.push_span(invocation_context)
|
|
with caplog.at_level(logging.WARNING):
|
|
await plugin._log_event(
|
|
"LLM_REQUEST",
|
|
callback_context,
|
|
event_data=bigquery_agent_analytics_plugin.EventData(),
|
|
)
|
|
await asyncio.sleep(0.01)
|
|
# The row survives Arrow preparation (exercised by the capture
|
|
# helper) with the hostile object replaced by a sentinel.
|
|
log_entry = await _get_captured_event_dict_async(
|
|
mock_write_client, dummy_arrow_schema
|
|
)
|
|
dumped = json.dumps(log_entry, default=str)
|
|
assert "R14-LATE-SERIALIZE-SECRET" not in dumped
|
|
assert "R14-LATE-SERIALIZE-SECRET" not in caplog.text
|
|
assert "[UNSUPPORTED_OBJECT]" in dumped
|
|
assert plugin.get_drop_stats().get("arrow_prep_failed", 0) == 0
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_remote_scheduling_failure_keeps_teardown_incomplete(
|
|
self, mock_auth_default, mock_bq_client
|
|
):
|
|
"""An exception from _schedule_remote_drain() itself
|
|
|
|
counts the state as retained, so shutdown raises instead of
|
|
reporting success over live state.
|
|
"""
|
|
_ = mock_auth_default, mock_bq_client
|
|
plugin = bigquery_agent_analytics_plugin.BigQueryAgentAnalyticsPlugin(
|
|
PROJECT_ID, DATASET_ID, table_id=TABLE_ID
|
|
)
|
|
remote_loop = asyncio.new_event_loop()
|
|
thread = platform_thread.create_thread(target=remote_loop.run_forever)
|
|
thread.daemon = True
|
|
thread.start()
|
|
try:
|
|
state = mock.MagicMock()
|
|
state.write_client = None
|
|
state.batch_processor = mock.MagicMock(
|
|
spec=bigquery_agent_analytics_plugin.BatchProcessor
|
|
)
|
|
state.batch_processor.get_drop_stats = mock.MagicMock(return_value={})
|
|
plugin._loop_state_by_loop[remote_loop] = state
|
|
|
|
with mock.patch.object(
|
|
plugin,
|
|
"_schedule_remote_drain",
|
|
side_effect=RuntimeError("loop closed during scheduling"),
|
|
):
|
|
with pytest.raises(
|
|
bigquery_agent_analytics_plugin._ShutdownIncompleteError
|
|
):
|
|
await plugin.shutdown(timeout=2)
|
|
assert remote_loop in plugin._loop_state_by_loop
|
|
finally:
|
|
remote_loop.call_soon_threadsafe(remote_loop.stop)
|
|
thread.join(timeout=5)
|
|
remote_loop.close()
|
|
|
|
def test_prose_inside_encoded_string_fails_closed(self):
|
|
"""A single valid encoded string whose DECODED content
|
|
|
|
hides a container after prose fails closed; ordinary quoted prose
|
|
(including inner quotes) passes through.
|
|
"""
|
|
truncate = bigquery_agent_analytics_plugin._recursive_smart_truncate
|
|
|
|
raw = '"note \\u007b\\"access\\u005ftoken\\":\\"R13-SECRET\\"\\u007d"'
|
|
out, truncated = truncate({"blob": raw}, 10000)
|
|
assert "R13-SECRET" not in json.dumps(out)
|
|
assert out["blob"] == "[UNPARSEABLE_JSON_BLOB]"
|
|
assert truncated is True
|
|
|
|
for prose in (
|
|
'"just quoted prose"',
|
|
json.dumps('he said "hi"'),
|
|
):
|
|
out, truncated = truncate({"s": prose}, 10000)
|
|
assert out["s"] == prose
|
|
assert truncated is False
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_nested_hostile_model_subclass_fails_closed(
|
|
self,
|
|
mock_write_client,
|
|
invocation_context,
|
|
callback_context,
|
|
mock_auth_default,
|
|
mock_bq_client,
|
|
mock_to_arrow_schema,
|
|
dummy_arrow_schema,
|
|
mock_asyncio_to_thread,
|
|
caplog,
|
|
):
|
|
"""A hostile model subclass NESTED inside an
|
|
|
|
exact-typed formatter result fails closed at the parse boundary — the
|
|
row is written with a sentinel and the payload-bearing exception
|
|
never reaches the logs.
|
|
"""
|
|
_ = mock_auth_default, mock_bq_client
|
|
|
|
class EvilPart(types.Part):
|
|
|
|
def __getattribute__(self, name):
|
|
if name == "file_data":
|
|
raise RuntimeError("R13-NESTED-SECRET")
|
|
return super().__getattribute__(name)
|
|
|
|
hostile = types.Content(parts=[EvilPart()])
|
|
assert type(hostile) is types.Content # passes the exact-type gate
|
|
|
|
config = bigquery_agent_analytics_plugin.BigQueryLoggerConfig(
|
|
content_formatter=lambda content, event_type: hostile
|
|
)
|
|
async with managed_plugin(
|
|
PROJECT_ID, DATASET_ID, table_id=TABLE_ID, config=config
|
|
) as plugin:
|
|
await plugin._ensure_started()
|
|
mock_write_client.append_rows.reset_mock()
|
|
bigquery_agent_analytics_plugin.TraceManager.push_span(invocation_context)
|
|
with caplog.at_level(logging.WARNING):
|
|
await plugin._log_event(
|
|
"STATE_DELTA",
|
|
callback_context,
|
|
event_data=bigquery_agent_analytics_plugin.EventData(),
|
|
)
|
|
await asyncio.sleep(0.01)
|
|
log_entry = await _get_captured_event_dict_async(
|
|
mock_write_client, dummy_arrow_schema
|
|
)
|
|
assert "R13-NESTED-SECRET" not in json.dumps(log_entry, default=str)
|
|
assert "R13-NESTED-SECRET" not in caplog.text
|
|
assert "[CONTENT_PARSE_FAILED]" in log_entry["content"]
|
|
assert log_entry["is_truncated"] is True
|
|
assert plugin.get_drop_stats().get("content_parse_failed", 0) == 1
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_failed_remote_drain_fails_coalesced_waiter_too(
|
|
self, mock_auth_default, mock_bq_client
|
|
):
|
|
"""A failed remote drain keeps teardown incomplete for
|
|
|
|
the coalesced waiter as well — neither caller reports success over
|
|
live state.
|
|
"""
|
|
_ = mock_auth_default, mock_bq_client
|
|
plugin = bigquery_agent_analytics_plugin.BigQueryAgentAnalyticsPlugin(
|
|
PROJECT_ID, DATASET_ID, table_id=TABLE_ID
|
|
)
|
|
remote_loop = asyncio.new_event_loop()
|
|
thread = platform_thread.create_thread(target=remote_loop.run_forever)
|
|
thread.daemon = True
|
|
thread.start()
|
|
try:
|
|
entered = threading.Event()
|
|
release = threading.Event()
|
|
|
|
async def failing_drain(timeout=None):
|
|
del timeout
|
|
entered.set()
|
|
while not release.is_set():
|
|
await asyncio.sleep(0.01)
|
|
raise RuntimeError("remote drain fails")
|
|
|
|
bp = mock.MagicMock(spec=bigquery_agent_analytics_plugin.BatchProcessor)
|
|
bp.shutdown = failing_drain
|
|
bp.get_drop_stats = mock.MagicMock(return_value={})
|
|
state = mock.MagicMock()
|
|
state.write_client = None
|
|
state.batch_processor = bp
|
|
plugin._loop_state_by_loop[remote_loop] = state
|
|
|
|
owner = asyncio.create_task(plugin.shutdown(timeout=5))
|
|
while not entered.is_set():
|
|
await asyncio.sleep(0.01)
|
|
waiter = asyncio.create_task(plugin.shutdown())
|
|
await asyncio.sleep(0.05)
|
|
release.set()
|
|
with pytest.raises(
|
|
bigquery_agent_analytics_plugin._ShutdownIncompleteError
|
|
):
|
|
await owner
|
|
# The retrying waiter hits the same persistent remote failure.
|
|
with pytest.raises(
|
|
bigquery_agent_analytics_plugin._ShutdownIncompleteError
|
|
):
|
|
await asyncio.wait_for(waiter, timeout=10)
|
|
assert remote_loop in plugin._loop_state_by_loop
|
|
finally:
|
|
remote_loop.call_soon_threadsafe(remote_loop.stop)
|
|
thread.join(timeout=5)
|
|
remote_loop.close()
|
|
|
|
def test_prose_then_encoded_document_redacted(self):
|
|
"""An encoded credential document after a stretch of
|
|
|
|
raw prose in the suffix is still decoded and redacted.
|
|
"""
|
|
truncate = bigquery_agent_analytics_plugin._recursive_smart_truncate
|
|
|
|
v = '"note" then "\\u007b\\"access_token\\":\\"R12-SECRET\\"\\u007d"'
|
|
out, truncated = truncate({"b": v}, 10000)
|
|
assert "R12-SECRET" not in json.dumps(out)
|
|
assert "[REDACTED]" in out["b"]
|
|
del truncated
|
|
|
|
# A chain of prose and documents is walked to the depth cap.
|
|
chain = (
|
|
'"note" one "plain" two'
|
|
' "\\u007b\\"refresh_token\\":\\"R12-CHAIN-SECRET\\"\\u007d"'
|
|
)
|
|
out, _ = truncate({"b": chain}, 10000)
|
|
assert "R12-CHAIN-SECRET" not in json.dumps(out)
|
|
|
|
# An escape hidden in a prose gap cannot be verified.
|
|
out, truncated = truncate({"s": '"note" \\then "x"'}, 10000)
|
|
assert out["s"] == "[UNPARSEABLE_JSON_BLOB]"
|
|
assert truncated is True
|
|
|
|
# Multi-quote prose still passes through.
|
|
prose = '"a" and then "b" happened'
|
|
out, truncated = truncate({"s": prose}, 10000)
|
|
assert out["s"] == prose
|
|
assert truncated is False
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_native_subclass_formatter_result_fails_closed(
|
|
self,
|
|
mock_write_client,
|
|
invocation_context,
|
|
callback_context,
|
|
mock_auth_default,
|
|
mock_bq_client,
|
|
mock_to_arrow_schema,
|
|
dummy_arrow_schema,
|
|
mock_asyncio_to_thread,
|
|
caplog,
|
|
):
|
|
"""A SUBCLASS of a parser-native model shape from the
|
|
|
|
formatter fails closed at the boundary instead of reaching parser
|
|
attribute accesses outside it.
|
|
"""
|
|
_ = mock_auth_default, mock_bq_client
|
|
|
|
class SubRequest(llm_request_lib.LlmRequest):
|
|
pass
|
|
|
|
config = bigquery_agent_analytics_plugin.BigQueryLoggerConfig(
|
|
content_formatter=lambda content, event_type: SubRequest()
|
|
)
|
|
async with managed_plugin(
|
|
PROJECT_ID, DATASET_ID, table_id=TABLE_ID, config=config
|
|
) as plugin:
|
|
await plugin._ensure_started()
|
|
mock_write_client.append_rows.reset_mock()
|
|
bigquery_agent_analytics_plugin.TraceManager.push_span(invocation_context)
|
|
with caplog.at_level(logging.WARNING):
|
|
await plugin._log_event(
|
|
"STATE_DELTA",
|
|
callback_context,
|
|
event_data=bigquery_agent_analytics_plugin.EventData(),
|
|
)
|
|
await asyncio.sleep(0.01)
|
|
log_entry = await _get_captured_event_dict_async(
|
|
mock_write_client, dummy_arrow_schema
|
|
)
|
|
assert (
|
|
bigquery_agent_analytics_plugin._FORMATTER_FAILED_SENTINEL
|
|
in log_entry["content"]
|
|
)
|
|
assert "SubRequest" not in caplog.text
|
|
assert plugin.get_drop_stats().get("formatter_failed", 0) == 1
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_persistent_teardown_failure_raises_to_all_callers(
|
|
self, mock_auth_default, mock_bq_client
|
|
):
|
|
"""A persistently failing teardown must not report
|
|
|
|
success to the owner or to retrying waiters.
|
|
"""
|
|
_ = mock_auth_default, mock_bq_client
|
|
plugin = bigquery_agent_analytics_plugin.BigQueryAgentAnalyticsPlugin(
|
|
PROJECT_ID, DATASET_ID, table_id=TABLE_ID
|
|
)
|
|
entered = asyncio.Event()
|
|
release = asyncio.Event()
|
|
calls = []
|
|
|
|
async def always_failing_drain(timeout=None):
|
|
del timeout
|
|
calls.append(1)
|
|
if len(calls) == 1:
|
|
entered.set()
|
|
await release.wait()
|
|
raise RuntimeError("drain always fails")
|
|
|
|
state = mock.MagicMock()
|
|
state.write_client = None
|
|
state.batch_processor = mock.MagicMock(
|
|
spec=bigquery_agent_analytics_plugin.BatchProcessor
|
|
)
|
|
state.batch_processor.shutdown = mock.AsyncMock(
|
|
side_effect=always_failing_drain
|
|
)
|
|
state.batch_processor.get_drop_stats = mock.MagicMock(return_value={})
|
|
plugin._loop_state_by_loop[asyncio.get_running_loop()] = state
|
|
|
|
owner = asyncio.create_task(plugin.shutdown(timeout=5))
|
|
await entered.wait()
|
|
waiter = asyncio.create_task(plugin.shutdown())
|
|
await asyncio.sleep(0.05)
|
|
release.set()
|
|
with pytest.raises(RuntimeError, match="drain always fails"):
|
|
await owner
|
|
# The retrying waiter becomes the owner, fails the same way, and
|
|
# surfaces the failure instead of returning success over live state.
|
|
with pytest.raises(RuntimeError, match="drain always fails"):
|
|
await asyncio.wait_for(waiter, timeout=5)
|
|
assert len(calls) == 2
|
|
assert plugin._loop_state_by_loop != {}
|
|
|
|
def test_unicode_escaped_trailing_document_redacted(self):
|
|
"""A trailing quoted JSON document whose decoded
|
|
|
|
content hides a container behind Unicode escapes is decoded and
|
|
redacted; quoted prose and prose suffixes stay untouched.
|
|
"""
|
|
truncate = bigquery_agent_analytics_plugin._recursive_smart_truncate
|
|
|
|
v = '"note" "\\u007b\\"access_token\\":\\"R11-SECRET\\"\\u007d"'
|
|
out, truncated = truncate({"b": v}, 10000)
|
|
assert "R11-SECRET" not in json.dumps(out)
|
|
assert "[REDACTED]" in out["b"]
|
|
del truncated
|
|
|
|
# A stray leading escape in the suffix cannot be classified.
|
|
out, truncated = truncate({"s": '"note" \\x'}, 10000)
|
|
assert out["s"] == "[UNPARSEABLE_JSON_BLOB]"
|
|
assert truncated is True
|
|
|
|
for prose in (
|
|
'"hello" she said',
|
|
'"a" and then "b" happened',
|
|
'"note" "just more prose"',
|
|
):
|
|
out, truncated = truncate({"s": prose}, 10000)
|
|
assert out["s"] == prose
|
|
assert truncated is False
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_waiter_retries_after_failed_owner_teardown(
|
|
self, mock_auth_default, mock_bq_client
|
|
):
|
|
"""An ordinary teardown exception must not report
|
|
|
|
successful completion to coalesced waiters; they retry ownership.
|
|
"""
|
|
_ = mock_auth_default, mock_bq_client
|
|
plugin = bigquery_agent_analytics_plugin.BigQueryAgentAnalyticsPlugin(
|
|
PROJECT_ID, DATASET_ID, table_id=TABLE_ID
|
|
)
|
|
entered = asyncio.Event()
|
|
release = asyncio.Event()
|
|
calls = []
|
|
|
|
async def failing_first_drain(timeout=None):
|
|
del timeout
|
|
calls.append(1)
|
|
if len(calls) == 1:
|
|
entered.set()
|
|
await release.wait()
|
|
raise RuntimeError("first drain fails")
|
|
|
|
state = mock.MagicMock()
|
|
state.write_client = None
|
|
state.batch_processor = mock.MagicMock(
|
|
spec=bigquery_agent_analytics_plugin.BatchProcessor
|
|
)
|
|
state.batch_processor.shutdown = mock.AsyncMock(
|
|
side_effect=failing_first_drain
|
|
)
|
|
state.batch_processor.get_drop_stats = mock.MagicMock(return_value={})
|
|
plugin._loop_state_by_loop[asyncio.get_running_loop()] = state
|
|
|
|
owner = asyncio.create_task(plugin.shutdown(timeout=5))
|
|
await entered.wait()
|
|
waiter = asyncio.create_task(plugin.shutdown())
|
|
await asyncio.sleep(0.05)
|
|
release.set()
|
|
# The OWNER must not report success over live state —
|
|
# the teardown error propagates to its caller.
|
|
with pytest.raises(RuntimeError, match="first drain fails"):
|
|
await owner
|
|
# The waiter must not have accepted the failed teardown as success:
|
|
# it retries ownership, the second drain succeeds, state is claimed.
|
|
await asyncio.wait_for(waiter, timeout=5)
|
|
assert len(calls) == 2
|
|
assert plugin._loop_state_by_loop == {}
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.filterwarnings("error::RuntimeWarning")
|
|
async def test_rejecting_task_factory_does_not_leak_coroutine(
|
|
self, mock_auth_default, mock_bq_client
|
|
):
|
|
"""If the remote loop's task factory rejects task
|
|
|
|
creation, the drain coroutine is closed instead of leaking.
|
|
"""
|
|
_ = mock_auth_default, mock_bq_client
|
|
plugin = bigquery_agent_analytics_plugin.BigQueryAgentAnalyticsPlugin(
|
|
PROJECT_ID, DATASET_ID, table_id=TABLE_ID
|
|
)
|
|
remote_loop = asyncio.new_event_loop()
|
|
|
|
def rejecting_factory(loop, coro, **kwargs):
|
|
del loop, coro, kwargs
|
|
raise RuntimeError("factory rejects")
|
|
|
|
remote_loop.set_task_factory(rejecting_factory)
|
|
thread = platform_thread.create_thread(target=remote_loop.run_forever)
|
|
thread.daemon = True
|
|
thread.start()
|
|
try:
|
|
state = mock.MagicMock()
|
|
state.write_client = None
|
|
bp = mock.MagicMock(spec=bigquery_agent_analytics_plugin.BatchProcessor)
|
|
|
|
async def drain(timeout=None):
|
|
del timeout
|
|
|
|
bp.shutdown = drain
|
|
bp.get_drop_stats = mock.MagicMock(return_value={})
|
|
state.batch_processor = bp
|
|
plugin._loop_state_by_loop[remote_loop] = state
|
|
|
|
# The failed drain keeps teardown incomplete.
|
|
with pytest.raises(
|
|
bigquery_agent_analytics_plugin._ShutdownIncompleteError
|
|
):
|
|
await plugin.shutdown(timeout=2)
|
|
# The failed drain retains the state; no never-awaited warning
|
|
# (filterwarnings turns it into a hard error).
|
|
assert remote_loop in plugin._loop_state_by_loop
|
|
finally:
|
|
remote_loop.call_soon_threadsafe(remote_loop.stop)
|
|
thread.join(timeout=5)
|
|
remote_loop.close()
|
|
|
|
def test_unterminated_quoted_container_fails_closed(self):
|
|
"""A quoted layer that visibly begins an encoded
|
|
|
|
container but is missing its final quote fails closed; unterminated
|
|
quoted prose passes through.
|
|
"""
|
|
truncate = bigquery_agent_analytics_plugin._recursive_smart_truncate
|
|
|
|
v = '"{\\"access_token\\":\\"R10-SECRET\\"}'
|
|
out, truncated = truncate({"cache": v}, 10000)
|
|
assert "R10-SECRET" not in json.dumps(out)
|
|
assert out["cache"] == "[UNPARSEABLE_JSON_BLOB]"
|
|
assert truncated is True
|
|
|
|
prose = '"unterminated prose without a container'
|
|
out, truncated = truncate({"s": prose}, 10000)
|
|
assert out["s"] == prose
|
|
assert truncated is False
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_identity_formatter_preserves_native_shapes(
|
|
self,
|
|
mock_write_client,
|
|
invocation_context,
|
|
callback_context,
|
|
mock_auth_default,
|
|
mock_bq_client,
|
|
mock_to_arrow_schema,
|
|
dummy_arrow_schema,
|
|
mock_asyncio_to_thread,
|
|
):
|
|
"""An identity formatter must not destroy parser-native
|
|
|
|
shapes (dict/list) that it returns untransformed.
|
|
"""
|
|
_ = mock_auth_default, mock_bq_client
|
|
config = bigquery_agent_analytics_plugin.BigQueryLoggerConfig(
|
|
content_formatter=lambda content, event_type: content
|
|
)
|
|
async with managed_plugin(
|
|
PROJECT_ID, DATASET_ID, table_id=TABLE_ID, config=config
|
|
) as plugin:
|
|
await plugin._ensure_started()
|
|
mock_write_client.append_rows.reset_mock()
|
|
bigquery_agent_analytics_plugin.TraceManager.push_span(invocation_context)
|
|
await plugin._log_event(
|
|
"STATE_DELTA",
|
|
callback_context,
|
|
raw_content={"response": "safe-dict-content"},
|
|
event_data=bigquery_agent_analytics_plugin.EventData(),
|
|
)
|
|
await asyncio.sleep(0.01)
|
|
log_entry = await _get_captured_event_dict_async(
|
|
mock_write_client, dummy_arrow_schema
|
|
)
|
|
assert "safe-dict-content" in json.dumps(log_entry, default=str)
|
|
assert (
|
|
bigquery_agent_analytics_plugin._FORMATTER_FAILED_SENTINEL
|
|
not in json.dumps(log_entry, default=str)
|
|
)
|
|
assert plugin.get_drop_stats().get("formatter_failed", 0) == 0
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_waiter_retries_after_cancelled_owner_shutdown(
|
|
self, mock_auth_default, mock_bq_client
|
|
):
|
|
"""A coalesced caller must not claim success when the
|
|
|
|
owning shutdown was cancelled mid-teardown; it retries ownership and
|
|
finishes the job.
|
|
"""
|
|
_ = mock_auth_default, mock_bq_client
|
|
plugin = bigquery_agent_analytics_plugin.BigQueryAgentAnalyticsPlugin(
|
|
PROJECT_ID, DATASET_ID, table_id=TABLE_ID
|
|
)
|
|
gate = asyncio.Event()
|
|
calls = []
|
|
|
|
async def gated_first_shutdown(timeout=None):
|
|
del timeout
|
|
calls.append(1)
|
|
if len(calls) == 1:
|
|
await gate.wait()
|
|
|
|
state = mock.MagicMock()
|
|
state.write_client = None
|
|
state.batch_processor = mock.MagicMock(
|
|
spec=bigquery_agent_analytics_plugin.BatchProcessor
|
|
)
|
|
state.batch_processor.shutdown = mock.AsyncMock(
|
|
side_effect=gated_first_shutdown
|
|
)
|
|
state.batch_processor.get_drop_stats = mock.MagicMock(return_value={})
|
|
plugin._loop_state_by_loop[asyncio.get_running_loop()] = state
|
|
|
|
owner = asyncio.create_task(plugin.shutdown(timeout=5))
|
|
await asyncio.sleep(0.05)
|
|
waiter = asyncio.create_task(plugin.shutdown())
|
|
await asyncio.sleep(0.05)
|
|
owner.cancel()
|
|
with pytest.raises(asyncio.CancelledError):
|
|
await owner
|
|
|
|
# The waiter retried ownership and completed the teardown.
|
|
await asyncio.wait_for(waiter, timeout=5)
|
|
assert plugin._loop_state_by_loop == {}
|
|
assert len(calls) == 2
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_slow_remote_drain_is_retained_without_close_errors(
|
|
self, mock_auth_default, mock_bq_client
|
|
):
|
|
"""A remote drain still running at the deadline is
|
|
|
|
retained and keeps running remotely; the caller never closes a
|
|
coroutine it no longer owns (no 'coroutine already executing').
|
|
"""
|
|
_ = mock_auth_default, mock_bq_client
|
|
plugin = bigquery_agent_analytics_plugin.BigQueryAgentAnalyticsPlugin(
|
|
PROJECT_ID, DATASET_ID, table_id=TABLE_ID
|
|
)
|
|
remote_loop = asyncio.new_event_loop()
|
|
thread = platform_thread.create_thread(target=remote_loop.run_forever)
|
|
thread.daemon = True
|
|
thread.start()
|
|
try:
|
|
release = threading.Event()
|
|
drain_finished = threading.Event()
|
|
|
|
async def slow_drain(timeout=None):
|
|
del timeout
|
|
while not release.is_set():
|
|
await asyncio.sleep(0.01)
|
|
drain_finished.set()
|
|
|
|
bp = mock.MagicMock(spec=bigquery_agent_analytics_plugin.BatchProcessor)
|
|
bp.shutdown = slow_drain
|
|
bp.get_drop_stats = mock.MagicMock(return_value={})
|
|
state = mock.MagicMock()
|
|
state.write_client = None
|
|
state.batch_processor = bp
|
|
plugin._loop_state_by_loop[remote_loop] = state
|
|
|
|
# The timed-out drain keeps teardown incomplete.
|
|
with pytest.raises(
|
|
bigquery_agent_analytics_plugin._ShutdownIncompleteError
|
|
):
|
|
await plugin.shutdown(timeout=0.2)
|
|
# Timed out: state retained, no ValueError from closing a running
|
|
# coroutine (shutdown would have logged/raised through its guard).
|
|
assert remote_loop in plugin._loop_state_by_loop
|
|
# The remote drain keeps running to completion on its own loop.
|
|
release.set()
|
|
assert drain_finished.wait(timeout=5)
|
|
finally:
|
|
remote_loop.call_soon_threadsafe(remote_loop.stop)
|
|
thread.join(timeout=5)
|
|
remote_loop.close()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_formatter_logs_never_carry_payload_derived_names(
|
|
self,
|
|
mock_write_client,
|
|
invocation_context,
|
|
callback_context,
|
|
mock_auth_default,
|
|
mock_bq_client,
|
|
mock_to_arrow_schema,
|
|
dummy_arrow_schema,
|
|
mock_asyncio_to_thread,
|
|
caplog,
|
|
):
|
|
"""Formatter log lines are constant — payload-derived
|
|
|
|
result/exception CLASS NAMES never reach the logs.
|
|
"""
|
|
_ = mock_auth_default, mock_bq_client
|
|
secret_result_cls = type("R10_RESULT_SECRET", (), {})
|
|
secret_error_cls = type("R10_ERROR_SECRET", (Exception,), {})
|
|
|
|
outcomes = iter([secret_result_cls(), None])
|
|
|
|
def formatter(content, event_type):
|
|
del content, event_type
|
|
value = next(outcomes)
|
|
if value is None:
|
|
raise secret_error_cls()
|
|
return value
|
|
|
|
config = bigquery_agent_analytics_plugin.BigQueryLoggerConfig(
|
|
content_formatter=formatter
|
|
)
|
|
async with managed_plugin(
|
|
PROJECT_ID, DATASET_ID, table_id=TABLE_ID, config=config
|
|
) as plugin:
|
|
await plugin._ensure_started()
|
|
bigquery_agent_analytics_plugin.TraceManager.push_span(invocation_context)
|
|
with caplog.at_level(logging.WARNING):
|
|
for _ in range(2):
|
|
await plugin._log_event(
|
|
"STATE_DELTA",
|
|
callback_context,
|
|
event_data=bigquery_agent_analytics_plugin.EventData(),
|
|
)
|
|
assert "R10_RESULT_SECRET" not in caplog.text
|
|
assert "R10_ERROR_SECRET" not in caplog.text
|
|
assert plugin.get_drop_stats().get("formatter_failed", 0) == 2
|
|
|
|
def test_unicode_ws_and_bom_quoted_layers_fail_closed(self):
|
|
"""BOM/NBSP/EM-SPACE prefixes inside quoted JSON layers
|
|
|
|
are normalized before every shape check, under- and over-limit.
|
|
"""
|
|
truncate = bigquery_agent_analytics_plugin._recursive_smart_truncate
|
|
|
|
for pad in ("\u00a0", "\u2003", "\ufeff"):
|
|
v = json.dumps(pad + json.dumps({"access_token": "R8-WS-SECRET"}))
|
|
out, _ = truncate({"b": v}, 10000)
|
|
assert "R8-WS-SECRET" not in json.dumps(out), repr(pad)
|
|
|
|
secret = "R8-WS-OVER-SECRET-" + "x" * 300
|
|
for pad in ("\u00a0", "\u2003", "\ufeff"):
|
|
v = json.dumps(
|
|
pad + json.dumps({"access_token": secret}), ensure_ascii=False
|
|
)
|
|
out, truncated = truncate({"b": v}, 120)
|
|
assert "R8-WS-OVER-SECRET" not in json.dumps(out), repr(pad)
|
|
assert truncated
|
|
|
|
def test_quoted_prefix_suffix_smuggling_fails_closed(self):
|
|
"""Credential JSON smuggled after a harmless quoted
|
|
|
|
prefix fails closed; container-free quoted prose passes through.
|
|
"""
|
|
truncate = bigquery_agent_analytics_plugin._recursive_smart_truncate
|
|
|
|
for v in (
|
|
'"note" {"access_token":"R8-SUFFIX-SECRET"}',
|
|
'"note" blah {"refresh_token":"R8-SUFFIX-SECRET-2"}',
|
|
):
|
|
out, truncated = truncate({"b": v}, 10000)
|
|
assert "R8-SUFFIX-SECRET" not in json.dumps(out)
|
|
assert truncated
|
|
|
|
prose = '"hello" she said, "twice"'
|
|
out, truncated = truncate({"s": prose}, 10000)
|
|
assert out["s"] == prose
|
|
assert truncated is False
|
|
|
|
def test_safe_scalar_subclass_str_not_published(self):
|
|
"""Subclasses of allowlisted scalar types cannot leak
|
|
|
|
values through an overridden __str__; base conversions are used.
|
|
"""
|
|
import enum
|
|
import pathlib
|
|
|
|
truncate = bigquery_agent_analytics_plugin._recursive_smart_truncate
|
|
|
|
class Credential(enum.Enum):
|
|
access_token = "R8-ENUM-SECRET"
|
|
|
|
def __str__(self):
|
|
return self.value
|
|
|
|
out, _ = truncate({"c": Credential.access_token}, 10000)
|
|
assert "R8-ENUM-SECRET" not in json.dumps(out)
|
|
assert out["c"] == "Credential.access_token"
|
|
|
|
class SneakyPath(pathlib.PurePosixPath):
|
|
|
|
def __str__(self):
|
|
return "R8-PATH-SECRET"
|
|
|
|
out, _ = truncate({"p": SneakyPath("/tmp/x")}, 10000)
|
|
assert "R8-PATH-SECRET" not in json.dumps(out)
|
|
assert out["p"] == "/tmp/x"
|
|
|
|
def test_safe_scalar_truncation_reports_flag(self):
|
|
"""An over-limit safe scalar reports truncation."""
|
|
import pathlib
|
|
|
|
truncate = bigquery_agent_analytics_plugin._recursive_smart_truncate
|
|
out, truncated = truncate(pathlib.PurePosixPath("x" * 40), 8)
|
|
assert "[TRUNCATED]" in out
|
|
assert truncated is True
|
|
|
|
def test_hostile_container_protocols_fail_closed(self):
|
|
"""Raising items()/iteration/field access fails closed
|
|
|
|
to a sentinel instead of escaping the sanitizer.
|
|
"""
|
|
import collections.abc
|
|
|
|
truncate = bigquery_agent_analytics_plugin._recursive_smart_truncate
|
|
|
|
class EvilMapping(collections.abc.Mapping):
|
|
|
|
def __getitem__(self, k):
|
|
raise KeyError(k)
|
|
|
|
def __len__(self):
|
|
return 1
|
|
|
|
def __iter__(self):
|
|
return iter(["a"])
|
|
|
|
def items(self):
|
|
raise RuntimeError("R8-MAPPING-SECRET")
|
|
|
|
class EvilList(list):
|
|
|
|
def __iter__(self):
|
|
raise RuntimeError("R8-LIST-SECRET")
|
|
|
|
out, truncated = truncate({"m": EvilMapping(), "l": EvilList([1])}, 10000)
|
|
assert out["m"] == "[UNSUPPORTED_OBJECT]"
|
|
assert out["l"] == "[UNSUPPORTED_OBJECT]"
|
|
assert truncated is True
|
|
assert "R8-MAPPING-SECRET" not in json.dumps(out)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_hostile_protocol_row_still_emitted_no_canary_in_logs(
|
|
self,
|
|
mock_write_client,
|
|
invocation_context,
|
|
callback_context,
|
|
mock_auth_default,
|
|
mock_bq_client,
|
|
mock_to_arrow_schema,
|
|
dummy_arrow_schema,
|
|
mock_asyncio_to_thread,
|
|
caplog,
|
|
):
|
|
"""At the real callback boundary: the row is emitted and
|
|
|
|
the payload-controlled exception message reaches neither the row nor
|
|
the application logs.
|
|
"""
|
|
_ = mock_auth_default, mock_bq_client
|
|
import collections.abc
|
|
|
|
class EvilMapping(collections.abc.Mapping):
|
|
|
|
def __getitem__(self, k):
|
|
raise KeyError(k)
|
|
|
|
def __len__(self):
|
|
return 1
|
|
|
|
def __iter__(self):
|
|
return iter(["a"])
|
|
|
|
def items(self):
|
|
raise RuntimeError("R8-CALLBACK-SECRET")
|
|
|
|
async with managed_plugin(
|
|
PROJECT_ID, DATASET_ID, table_id=TABLE_ID
|
|
) as plugin:
|
|
await plugin._ensure_started()
|
|
mock_write_client.append_rows.reset_mock()
|
|
bigquery_agent_analytics_plugin.TraceManager.push_span(invocation_context)
|
|
await plugin._log_event(
|
|
"STATE_DELTA",
|
|
callback_context,
|
|
event_data=bigquery_agent_analytics_plugin.EventData(
|
|
extra_attributes={"hostile": EvilMapping()},
|
|
),
|
|
)
|
|
await asyncio.sleep(0.01)
|
|
log_entry = await _get_captured_event_dict_async(
|
|
mock_write_client, dummy_arrow_schema
|
|
)
|
|
assert "R8-CALLBACK-SECRET" not in json.dumps(log_entry, default=str)
|
|
assert "R8-CALLBACK-SECRET" not in caplog.text
|
|
attrs = json.loads(log_entry["attributes"])
|
|
assert attrs["hostile"] == "[UNSUPPORTED_OBJECT]"
|
|
assert log_entry["is_truncated"] is True
|
|
|
|
def test_scalar_key_collisions_fail_closed(self):
|
|
"""Scalar keys are normalized to their JSON form and
|
|
|
|
collisions get an explicit marker instead of silently collapsing.
|
|
"""
|
|
truncate = bigquery_agent_analytics_plugin._recursive_smart_truncate
|
|
|
|
for pair in (
|
|
{1: "n", "1": "s"},
|
|
{True: "b", "true": "s"},
|
|
{None: "x", "null": "s"},
|
|
):
|
|
out, truncated = truncate(pair, 10000)
|
|
assert truncated is True
|
|
assert len(out) == 2
|
|
# Round-trip through JSON keeps both values.
|
|
assert len(json.loads(json.dumps(out))) == 2
|
|
|
|
# A pre-existing key in the marker namespace is never
|
|
# overwritten — markers are re-allocated until unique.
|
|
out, truncated = truncate(
|
|
{"[KEY_COLLISION_1]1": "reserved", "1": "string", 1: "numeric"},
|
|
10000,
|
|
)
|
|
assert truncated is True
|
|
assert out["[KEY_COLLISION_1]1"] == "reserved"
|
|
assert out["1"] == "string"
|
|
assert len(out) == 3
|
|
assert sorted(out.values()) == ["numeric", "reserved", "string"]
|
|
|
|
def test_object_attr_traversal_bounded_and_selfref_terminates(self):
|
|
"""__dict__ traversal charges the budget per entry and
|
|
|
|
self-references terminate immediately.
|
|
"""
|
|
truncate = bigquery_agent_analytics_plugin._recursive_smart_truncate
|
|
|
|
class Big:
|
|
pass
|
|
|
|
big = Big()
|
|
for i in range(200):
|
|
setattr(big, f"attr{i}", i)
|
|
out, truncated = truncate({"b": big}, 10000, None, 0, [50])
|
|
assert truncated is True
|
|
assert len(out["b"]) <= 51
|
|
|
|
class Node:
|
|
pass
|
|
|
|
node = Node()
|
|
node.self = node
|
|
node.access_token = "R8-SELF-SECRET"
|
|
out, _ = truncate({"n": node}, 10000)
|
|
assert out["n"]["self"] == "[CIRCULAR_REFERENCE]"
|
|
assert "R8-SELF-SECRET" not in json.dumps(out)
|
|
|
|
def test_unlimited_mode_inspection_ceiling(self):
|
|
"""Max_content_length=-1 still bounds json.loads
|
|
|
|
materialization; over-ceiling container blobs fail closed.
|
|
"""
|
|
truncate = bigquery_agent_analytics_plugin._recursive_smart_truncate
|
|
ceiling = bigquery_agent_analytics_plugin._MAX_JSON_INSPECT_CHARS
|
|
big = "[" + "1," * (ceiling // 2 + 10) + "1]"
|
|
out, truncated = truncate({"b": big}, -1)
|
|
assert out["b"] == "[UNPARSEABLE_JSON_BLOB]"
|
|
assert truncated is True
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_cancelled_shutdown_accounting_is_o1(self):
|
|
"""External cancellation accounts queued rows without
|
|
|
|
a per-item synchronous drain.
|
|
"""
|
|
|
|
class CountingQueue(asyncio.Queue):
|
|
|
|
def __init__(self, *args, **kwargs):
|
|
super().__init__(*args, **kwargs)
|
|
self.get_nowait_calls = 0
|
|
|
|
def get_nowait(self):
|
|
self.get_nowait_calls += 1
|
|
return super().get_nowait()
|
|
|
|
bp = bigquery_agent_analytics_plugin.BatchProcessor(
|
|
write_client=mock.MagicMock(),
|
|
arrow_schema=None,
|
|
write_stream="s",
|
|
batch_size=1,
|
|
flush_interval=0.05,
|
|
retry_config=bigquery_agent_analytics_plugin.RetryConfig(),
|
|
queue_max_size=100,
|
|
shutdown_timeout=5.0,
|
|
)
|
|
counting_queue = CountingQueue(maxsize=100)
|
|
bp._queue = counting_queue
|
|
|
|
write_entered = asyncio.Event()
|
|
write_release = asyncio.Event()
|
|
|
|
async def blocked_write(rows):
|
|
del rows
|
|
write_entered.set()
|
|
await write_release.wait()
|
|
|
|
with mock.patch.object(
|
|
bp, "_write_rows_with_retry", side_effect=blocked_write
|
|
):
|
|
await bp.start()
|
|
await bp.append({"r": 0})
|
|
await write_entered.wait()
|
|
for i in range(3):
|
|
await bp.append({"r": i + 1})
|
|
|
|
closer = asyncio.create_task(bp.shutdown(timeout=30))
|
|
await asyncio.sleep(0.05)
|
|
calls_before = counting_queue.get_nowait_calls
|
|
closer.cancel()
|
|
with pytest.raises(asyncio.CancelledError):
|
|
await closer
|
|
|
|
# O(1): no per-item dequeue happened during cancellation — the queue
|
|
# was swapped out instead.
|
|
assert counting_queue.get_nowait_calls == calls_before
|
|
assert bp._queue is not counting_queue
|
|
assert bp.get_drop_stats()["shutdown_cancelled"] == 3
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_aborted_setup_holds_rendezvous_and_allows_restart(
|
|
self, mock_auth_default, mock_bq_client
|
|
):
|
|
"""The setup rendezvous stays claimed until aborted
|
|
|
|
teardown completes, and a later restart fully succeeds.
|
|
"""
|
|
_ = mock_auth_default, mock_bq_client
|
|
plugin = bigquery_agent_analytics_plugin.BigQueryAgentAnalyticsPlugin(
|
|
PROJECT_ID, DATASET_ID, table_id=TABLE_ID
|
|
)
|
|
plugin._credentials = mock.MagicMock(quota_project_id=None)
|
|
|
|
entered = threading.Event()
|
|
release = threading.Event()
|
|
first_call = threading.Event()
|
|
|
|
def gated_client(*args, **kwargs):
|
|
del args, kwargs
|
|
if not first_call.is_set():
|
|
first_call.set()
|
|
entered.set()
|
|
release.wait(10)
|
|
return mock.MagicMock()
|
|
|
|
future_held_during_teardown = []
|
|
original_teardown = plugin._teardown_aborted_setup
|
|
|
|
async def spying_teardown():
|
|
future_held_during_teardown.append(plugin._setup_future is not None)
|
|
await original_teardown()
|
|
|
|
write_client = mock.MagicMock()
|
|
write_client.transport = mock.MagicMock()
|
|
write_client.transport.close = mock.AsyncMock()
|
|
|
|
with (
|
|
mock.patch(
|
|
"google.adk.plugins.bigquery_agent_analytics_plugin.bigquery.Client",
|
|
side_effect=gated_client,
|
|
),
|
|
mock.patch.object(plugin, "_teardown_aborted_setup", spying_teardown),
|
|
mock.patch.object(
|
|
bigquery_agent_analytics_plugin,
|
|
"BigQueryWriteAsyncClient",
|
|
return_value=write_client,
|
|
),
|
|
mock.patch.object(
|
|
bigquery_agent_analytics_plugin.BatchProcessor,
|
|
"start",
|
|
mock.AsyncMock(),
|
|
),
|
|
):
|
|
owner = asyncio.create_task(plugin._ensure_started())
|
|
while not entered.is_set():
|
|
await asyncio.sleep(0.01)
|
|
await plugin.shutdown()
|
|
release.set()
|
|
outcome = await owner
|
|
assert outcome == "aborted"
|
|
# The rendezvous was still claimed while teardown ran, so no new
|
|
# setup could interleave and have its resources destroyed.
|
|
assert future_held_during_teardown == [True]
|
|
assert plugin._setup_future is None
|
|
|
|
# A fresh start after the abort fully succeeds.
|
|
outcome2 = await plugin._ensure_started()
|
|
assert outcome2 == "ok"
|
|
assert plugin._started is True
|
|
assert plugin.client is not None
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.filterwarnings("error::RuntimeWarning")
|
|
@pytest.mark.filterwarnings("error::pytest.PytestUnraisableExceptionWarning")
|
|
async def test_host_timeout_effective_during_remote_drain(
|
|
self, mock_auth_default, mock_bq_client
|
|
):
|
|
"""A stuck remote loop must not block the event loop —
|
|
|
|
an outer host timeout fires instead of waiting out the full drain.
|
|
Warning-clean: the shutdown coroutine created for
|
|
the never-running loop is explicitly closed, not leaked.
|
|
"""
|
|
_ = mock_auth_default, mock_bq_client
|
|
plugin = bigquery_agent_analytics_plugin.BigQueryAgentAnalyticsPlugin(
|
|
PROJECT_ID, DATASET_ID, table_id=TABLE_ID
|
|
)
|
|
|
|
remote_loop = asyncio.new_event_loop() # never runs
|
|
try:
|
|
state = mock.MagicMock()
|
|
state.write_client = None
|
|
state.batch_processor = bigquery_agent_analytics_plugin.BatchProcessor(
|
|
write_client=mock.MagicMock(),
|
|
arrow_schema=None,
|
|
write_stream="s",
|
|
batch_size=1,
|
|
flush_interval=0.05,
|
|
retry_config=bigquery_agent_analytics_plugin.RetryConfig(),
|
|
queue_max_size=10,
|
|
shutdown_timeout=5.0,
|
|
)
|
|
plugin._loop_state_by_loop[remote_loop] = state
|
|
|
|
start = time.monotonic()
|
|
with pytest.raises(asyncio.TimeoutError):
|
|
await asyncio.wait_for(plugin.shutdown(timeout=5), timeout=0.1)
|
|
elapsed = time.monotonic() - start
|
|
# The old synchronous future.result(timeout=5) blocked the loop for
|
|
# the full remote timeout before the host timeout could fire.
|
|
assert elapsed < 2.0
|
|
# The undrained state is retained for a retried close.
|
|
assert remote_loop in plugin._loop_state_by_loop
|
|
finally:
|
|
remote_loop.close()
|
|
|
|
def test_drop_stats_stable_while_shutdown_folds(
|
|
self, mock_auth_default, mock_bq_client
|
|
):
|
|
"""Claim+fold is one atomic transition, so readers never
|
|
|
|
observe a state as both live and folded (or neither).
|
|
"""
|
|
_ = mock_auth_default, mock_bq_client
|
|
plugin = bigquery_agent_analytics_plugin.BigQueryAgentAnalyticsPlugin(
|
|
PROJECT_ID, DATASET_ID, table_id=TABLE_ID
|
|
)
|
|
|
|
async def scenario():
|
|
gate = asyncio.Event()
|
|
|
|
async def gated_processor_shutdown(timeout=None):
|
|
del timeout
|
|
await gate.wait()
|
|
|
|
state = mock.MagicMock()
|
|
state.write_client = None
|
|
state.batch_processor = mock.MagicMock(
|
|
spec=bigquery_agent_analytics_plugin.BatchProcessor
|
|
)
|
|
state.batch_processor.shutdown = mock.AsyncMock(
|
|
side_effect=gated_processor_shutdown
|
|
)
|
|
state.batch_processor.get_drop_stats = mock.MagicMock(
|
|
return_value={"queue_full": 2}
|
|
)
|
|
loop = asyncio.get_running_loop()
|
|
plugin._loop_state_by_loop[loop] = state
|
|
|
|
closer = asyncio.create_task(plugin.shutdown(timeout=5))
|
|
for _ in range(10):
|
|
await asyncio.sleep(0.005)
|
|
assert plugin.get_drop_stats().get("queue_full", 0) == 2
|
|
gate.set()
|
|
await closer
|
|
assert plugin.get_drop_stats().get("queue_full", 0) == 2
|
|
|
|
asyncio.run(scenario())
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_raced_event_counts_exactly_one_loss(
|
|
self, mock_auth_default, mock_bq_client, callback_context
|
|
):
|
|
"""One event racing shutdown records exactly one loss
|
|
|
|
(shutdown_race), not shutdown_race + setup_unavailable.
|
|
"""
|
|
_ = mock_auth_default, mock_bq_client
|
|
plugin = bigquery_agent_analytics_plugin.BigQueryAgentAnalyticsPlugin(
|
|
PROJECT_ID, DATASET_ID, table_id=TABLE_ID
|
|
)
|
|
with mock.patch.object(
|
|
plugin, "_ensure_started", mock.AsyncMock(return_value="aborted")
|
|
):
|
|
await plugin._log_event(
|
|
"STATE_DELTA",
|
|
callback_context,
|
|
event_data=bigquery_agent_analytics_plugin.EventData(),
|
|
)
|
|
stats = plugin.get_drop_stats()
|
|
assert stats.get("shutdown_race", 0) == 1
|
|
assert stats.get("setup_unavailable", 0) == 0
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_cancelled_setup_closes_eventual_client_and_executor(
|
|
self, mock_auth_default, mock_bq_client
|
|
):
|
|
"""Cancelling a setup blocked in the client constructor
|
|
|
|
closes the eventual client and terminates the executor.
|
|
"""
|
|
_ = mock_auth_default, mock_bq_client
|
|
plugin = bigquery_agent_analytics_plugin.BigQueryAgentAnalyticsPlugin(
|
|
PROJECT_ID, DATASET_ID, table_id=TABLE_ID
|
|
)
|
|
plugin._credentials = mock.MagicMock(quota_project_id=None)
|
|
|
|
entered = threading.Event()
|
|
release = threading.Event()
|
|
eventual_client = mock.MagicMock()
|
|
|
|
def gated_client(*args, **kwargs):
|
|
del args, kwargs
|
|
entered.set()
|
|
release.wait(10)
|
|
return eventual_client
|
|
|
|
with mock.patch(
|
|
"google.adk.plugins.bigquery_agent_analytics_plugin.bigquery.Client",
|
|
side_effect=gated_client,
|
|
):
|
|
owner = asyncio.create_task(plugin._ensure_started())
|
|
while not entered.is_set():
|
|
await asyncio.sleep(0.01)
|
|
executor = plugin._executor
|
|
owner.cancel()
|
|
with pytest.raises(asyncio.CancelledError):
|
|
await owner
|
|
release.set()
|
|
# The constructor thread finishes and the done-callback closes the
|
|
# orphaned client.
|
|
for _ in range(100):
|
|
if eventual_client.close.called:
|
|
break
|
|
await asyncio.sleep(0.02)
|
|
|
|
assert eventual_client.close.called
|
|
assert plugin.client is None
|
|
assert plugin._executor is None
|
|
assert executor is not None and executor._shutdown
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_formatter_result_shapes_fail_closed(
|
|
self,
|
|
mock_write_client,
|
|
invocation_context,
|
|
callback_context,
|
|
mock_auth_default,
|
|
mock_bq_client,
|
|
mock_to_arrow_schema,
|
|
dummy_arrow_schema,
|
|
mock_asyncio_to_thread,
|
|
caplog,
|
|
):
|
|
"""Non-native formatter RESULTS fail closed inside the
|
|
|
|
boundary — a secret-returning or raising __str__ never reaches the
|
|
parser's str() fallback, the row, or the logs.
|
|
"""
|
|
_ = mock_auth_default, mock_bq_client
|
|
|
|
class LeakyResult:
|
|
|
|
def __str__(self):
|
|
return "R9-FORMATTER-SECRET"
|
|
|
|
class RaisingResult:
|
|
|
|
def __str__(self):
|
|
raise RuntimeError("R9-FORMATTER-RAISE-SECRET")
|
|
|
|
results = iter([LeakyResult(), RaisingResult()])
|
|
|
|
config = bigquery_agent_analytics_plugin.BigQueryLoggerConfig(
|
|
content_formatter=lambda content, event_type: next(results)
|
|
)
|
|
async with managed_plugin(
|
|
PROJECT_ID, DATASET_ID, table_id=TABLE_ID, config=config
|
|
) as plugin:
|
|
await plugin._ensure_started()
|
|
bigquery_agent_analytics_plugin.TraceManager.push_span(invocation_context)
|
|
with caplog.at_level(logging.WARNING):
|
|
for _ in range(2):
|
|
mock_write_client.append_rows.reset_mock()
|
|
await plugin._log_event(
|
|
"STATE_DELTA",
|
|
callback_context,
|
|
event_data=bigquery_agent_analytics_plugin.EventData(),
|
|
)
|
|
await asyncio.sleep(0.01)
|
|
log_entry = await _get_captured_event_dict_async(
|
|
mock_write_client, dummy_arrow_schema
|
|
)
|
|
dumped = json.dumps(log_entry, default=str)
|
|
assert "R9-FORMATTER-SECRET" not in dumped
|
|
assert "R9-FORMATTER-RAISE-SECRET" not in dumped
|
|
assert (
|
|
bigquery_agent_analytics_plugin._FORMATTER_FAILED_SENTINEL
|
|
in log_entry["content"]
|
|
)
|
|
assert "R9-FORMATTER-SECRET" not in caplog.text
|
|
assert "R9-FORMATTER-RAISE-SECRET" not in caplog.text
|
|
assert plugin.get_drop_stats().get("formatter_failed", 0) == 2
|
|
|
|
def test_value_backed_enums_not_published(self):
|
|
"""StrEnum / (str, Enum) / bytes-backed members are
|
|
|
|
stringified through Enum.__str__ (member name), never their value.
|
|
"""
|
|
import enum
|
|
|
|
truncate = bigquery_agent_analytics_plugin._recursive_smart_truncate
|
|
|
|
class StrCred(str, enum.Enum):
|
|
access_token = "R9-STR-ENUM-SECRET"
|
|
|
|
class BytesCred(bytes, enum.Enum):
|
|
token = b"R9-BYTES-ENUM-SECRET"
|
|
|
|
payload = {"s": StrCred.access_token, "b": BytesCred.token}
|
|
if sys.version_info >= (3, 11):
|
|
|
|
class NativeStrCred(enum.StrEnum):
|
|
refresh_token = "R9-STRENUM-SECRET"
|
|
|
|
payload["n"] = NativeStrCred.refresh_token
|
|
|
|
out, _ = truncate(payload, 10000)
|
|
dumped = json.dumps(out, default=str)
|
|
for canary in (
|
|
"R9-STR-ENUM-SECRET",
|
|
"R9-BYTES-ENUM-SECRET",
|
|
"R9-STRENUM-SECRET",
|
|
):
|
|
assert canary not in dumped, canary
|
|
assert out["s"] == "StrCred.access_token"
|
|
|
|
def test_strip_bom_ws_is_linear(self):
|
|
"""An alternating whitespace/BOM prefix is stripped in
|
|
|
|
one linear scan (the fixed-point slicing loop was quadratic).
|
|
"""
|
|
strip = bigquery_agent_analytics_plugin._strip_bom_ws
|
|
prefix = " \ufeff" * 200_000
|
|
start = time.monotonic()
|
|
assert strip(prefix + "{}") == "{}"
|
|
elapsed = time.monotonic() - start
|
|
# Quadratic behavior took minutes at this size; linear is ~25ms.
|
|
assert elapsed < 2.0
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_failed_remote_drain_retains_state(
|
|
self, mock_auth_default, mock_bq_client, caplog
|
|
):
|
|
"""A remote drain that raises must NOT claim/fold its
|
|
|
|
state; it is retained for a retried close and the payload-controlled
|
|
message stays out of the logs.
|
|
"""
|
|
_ = mock_auth_default, mock_bq_client
|
|
plugin = bigquery_agent_analytics_plugin.BigQueryAgentAnalyticsPlugin(
|
|
PROJECT_ID, DATASET_ID, table_id=TABLE_ID
|
|
)
|
|
remote_loop = asyncio.new_event_loop()
|
|
thread = platform_thread.create_thread(target=remote_loop.run_forever)
|
|
thread.daemon = True
|
|
thread.start()
|
|
try:
|
|
state = mock.MagicMock()
|
|
state.write_client = None
|
|
bp = mock.MagicMock(spec=bigquery_agent_analytics_plugin.BatchProcessor)
|
|
|
|
async def failing_shutdown(timeout=None):
|
|
del timeout
|
|
raise RuntimeError("R9-DRAIN-SECRET")
|
|
|
|
bp.shutdown = failing_shutdown
|
|
bp.get_drop_stats = mock.MagicMock(return_value={"queue_full": 1})
|
|
state.batch_processor = bp
|
|
plugin._loop_state_by_loop[remote_loop] = state
|
|
|
|
with caplog.at_level(logging.WARNING):
|
|
# A failed remote drain keeps teardown incomplete
|
|
# and surfaces to the owner instead of reporting success.
|
|
with pytest.raises(
|
|
bigquery_agent_analytics_plugin._ShutdownIncompleteError
|
|
):
|
|
await plugin.shutdown(timeout=2)
|
|
|
|
# Retained, not silently claimed as a successful drain.
|
|
assert remote_loop in plugin._loop_state_by_loop
|
|
assert plugin.get_drop_stats().get("queue_full", 0) == 1
|
|
assert "R9-DRAIN-SECRET" not in caplog.text
|
|
assert "RuntimeError" in caplog.text
|
|
finally:
|
|
remote_loop.call_soon_threadsafe(remote_loop.stop)
|
|
thread.join(timeout=5)
|
|
remote_loop.close()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_concurrent_shutdown_caller_awaits_completion(
|
|
self, mock_auth_default, mock_bq_client
|
|
):
|
|
"""A concurrent shutdown() caller coalesces on the
|
|
|
|
owner's completion instead of returning while teardown is running.
|
|
"""
|
|
_ = mock_auth_default, mock_bq_client
|
|
plugin = bigquery_agent_analytics_plugin.BigQueryAgentAnalyticsPlugin(
|
|
PROJECT_ID, DATASET_ID, table_id=TABLE_ID
|
|
)
|
|
gate = asyncio.Event()
|
|
|
|
async def gated_shutdown(timeout=None):
|
|
del timeout
|
|
await gate.wait()
|
|
|
|
state = mock.MagicMock()
|
|
state.write_client = None
|
|
state.batch_processor = mock.MagicMock(
|
|
spec=bigquery_agent_analytics_plugin.BatchProcessor
|
|
)
|
|
state.batch_processor.shutdown = mock.AsyncMock(side_effect=gated_shutdown)
|
|
state.batch_processor.get_drop_stats = mock.MagicMock(return_value={})
|
|
plugin._loop_state_by_loop[asyncio.get_running_loop()] = state
|
|
|
|
first = asyncio.create_task(plugin.shutdown(timeout=5))
|
|
await asyncio.sleep(0.05)
|
|
second = asyncio.create_task(plugin.shutdown())
|
|
await asyncio.sleep(0.05)
|
|
assert not second.done(), "second caller returned mid-teardown"
|
|
gate.set()
|
|
await first
|
|
await asyncio.wait_for(second, timeout=5)
|
|
assert plugin._loop_state_by_loop == {}
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_shutdown_counts_rows_on_closed_loop(
|
|
self, mock_auth_default, mock_bq_client
|
|
):
|
|
"""Queued rows owned by an already-closed loop are
|
|
|
|
counted as stale_loop when shutdown() claims the state.
|
|
"""
|
|
_ = mock_auth_default, mock_bq_client
|
|
plugin = bigquery_agent_analytics_plugin.BigQueryAgentAnalyticsPlugin(
|
|
PROJECT_ID, DATASET_ID, table_id=TABLE_ID
|
|
)
|
|
closed_loop = asyncio.new_event_loop()
|
|
closed_loop.close()
|
|
|
|
bp = bigquery_agent_analytics_plugin.BatchProcessor(
|
|
write_client=mock.MagicMock(),
|
|
arrow_schema=None,
|
|
write_stream="s",
|
|
batch_size=10,
|
|
flush_interval=0.05,
|
|
retry_config=bigquery_agent_analytics_plugin.RetryConfig(),
|
|
queue_max_size=10,
|
|
shutdown_timeout=1.0,
|
|
)
|
|
bp._queue.put_nowait({"r": 1})
|
|
state = mock.MagicMock()
|
|
state.write_client = None
|
|
state.batch_processor = bp
|
|
plugin._loop_state_by_loop[closed_loop] = state
|
|
|
|
await plugin.shutdown(timeout=1)
|
|
assert closed_loop not in plugin._loop_state_by_loop
|
|
assert plugin.get_drop_stats().get("stale_loop", 0) == 1
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_completed_constructor_close_dispatched_off_loop(
|
|
self, mock_auth_default, mock_bq_client
|
|
):
|
|
"""When the constructor future is already done at
|
|
|
|
cancellation time, the orphan client's close still runs off-loop and
|
|
does not extend the cancellation window.
|
|
"""
|
|
_ = mock_auth_default, mock_bq_client
|
|
plugin = bigquery_agent_analytics_plugin.BigQueryAgentAnalyticsPlugin(
|
|
PROJECT_ID, DATASET_ID, table_id=TABLE_ID
|
|
)
|
|
plugin._credentials = mock.MagicMock(quota_project_id=None)
|
|
|
|
loop_thread_id = threading.get_ident()
|
|
close_started = threading.Event()
|
|
close_finished = threading.Event()
|
|
close_thread_ids = []
|
|
eventual = mock.MagicMock()
|
|
|
|
def slow_close():
|
|
close_thread_ids.append(threading.get_ident())
|
|
close_started.set()
|
|
time.sleep(0.2)
|
|
close_finished.set()
|
|
|
|
eventual.close = slow_close
|
|
|
|
def wrap_and_cancel(cf, **kwargs):
|
|
del kwargs
|
|
# Deterministic completed-before-cancel interleaving: wait for the
|
|
# constructor to finish, then deliver the cancellation.
|
|
cf.result(timeout=5)
|
|
raise asyncio.CancelledError()
|
|
|
|
with (
|
|
mock.patch(
|
|
"google.adk.plugins.bigquery_agent_analytics_plugin.bigquery.Client",
|
|
return_value=eventual,
|
|
),
|
|
mock.patch.object(
|
|
bigquery_agent_analytics_plugin.asyncio,
|
|
"wrap_future",
|
|
side_effect=wrap_and_cancel,
|
|
),
|
|
):
|
|
start = time.monotonic()
|
|
with pytest.raises(asyncio.CancelledError):
|
|
await plugin._ensure_started()
|
|
elapsed = time.monotonic() - start
|
|
|
|
assert close_started.wait(timeout=5)
|
|
assert close_finished.wait(timeout=5)
|
|
# The 200ms close did not run inline on the event-loop thread.
|
|
assert elapsed < 0.15
|
|
assert close_thread_ids and close_thread_ids[0] != loop_thread_id
|
|
|
|
def test_stale_cleanup_accounts_in_o1(
|
|
self, mock_auth_default, mock_bq_client
|
|
):
|
|
"""Stale-loop cleanup accounts queued rows via qsize
|
|
|
|
minus sentinels, without a per-item synchronous drain.
|
|
"""
|
|
_ = mock_auth_default, mock_bq_client
|
|
plugin = bigquery_agent_analytics_plugin.BigQueryAgentAnalyticsPlugin(
|
|
PROJECT_ID, DATASET_ID, table_id=TABLE_ID
|
|
)
|
|
|
|
class CountingQueue(asyncio.Queue):
|
|
|
|
def __init__(self, *args, **kwargs):
|
|
super().__init__(*args, **kwargs)
|
|
self.get_nowait_calls = 0
|
|
|
|
def get_nowait(self):
|
|
self.get_nowait_calls += 1
|
|
return super().get_nowait()
|
|
|
|
closed_loop = asyncio.new_event_loop()
|
|
closed_loop.close()
|
|
bp = bigquery_agent_analytics_plugin.BatchProcessor(
|
|
write_client=mock.MagicMock(),
|
|
arrow_schema=None,
|
|
write_stream="s",
|
|
batch_size=10,
|
|
flush_interval=0.05,
|
|
retry_config=bigquery_agent_analytics_plugin.RetryConfig(),
|
|
queue_max_size=2000,
|
|
shutdown_timeout=1.0,
|
|
)
|
|
counting = CountingQueue(maxsize=2000)
|
|
for i in range(1000):
|
|
counting.put_nowait({"r": i})
|
|
bp._queue = counting
|
|
state = mock.MagicMock()
|
|
state.write_client = None
|
|
state.batch_processor = bp
|
|
plugin._loop_state_by_loop[closed_loop] = state
|
|
|
|
plugin._cleanup_stale_loop_states()
|
|
assert counting.get_nowait_calls == 0
|
|
assert plugin.get_drop_stats().get("stale_loop", 0) == 1000
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_shutdown_closes_shared_client(
|
|
self, mock_auth_default, mock_bq_client
|
|
):
|
|
"""Normal shutdown closes the shared BigQuery client
|
|
|
|
instead of just dropping the reference.
|
|
"""
|
|
_ = mock_auth_default, mock_bq_client
|
|
plugin = bigquery_agent_analytics_plugin.BigQueryAgentAnalyticsPlugin(
|
|
PROJECT_ID, DATASET_ID, table_id=TABLE_ID
|
|
)
|
|
client = mock.MagicMock()
|
|
plugin.client = client
|
|
await plugin.shutdown(timeout=1)
|
|
assert client.close.called
|
|
assert plugin.client is None
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_cancelled_processor_shutdown_is_retryable(self):
|
|
"""An externally cancelled BatchProcessor.shutdown()
|
|
|
|
(real processor, blocked writer) must not make later shutdown calls
|
|
re-raise the historical CancelledError; the retry completes and every
|
|
queued/in-flight row is accounted.
|
|
"""
|
|
bp = bigquery_agent_analytics_plugin.BatchProcessor(
|
|
write_client=mock.MagicMock(),
|
|
arrow_schema=None,
|
|
write_stream="s",
|
|
batch_size=1,
|
|
flush_interval=0.05,
|
|
retry_config=bigquery_agent_analytics_plugin.RetryConfig(),
|
|
queue_max_size=100,
|
|
shutdown_timeout=5.0,
|
|
)
|
|
write_entered = asyncio.Event()
|
|
write_release = asyncio.Event()
|
|
|
|
async def blocked_write(rows):
|
|
del rows
|
|
write_entered.set()
|
|
await write_release.wait()
|
|
|
|
with mock.patch.object(
|
|
bp, "_write_rows_with_retry", side_effect=blocked_write
|
|
):
|
|
await bp.start()
|
|
await bp.append({"r": 1})
|
|
await write_entered.wait() # worker is blocked mid-write (in-flight=1)
|
|
for i in range(3):
|
|
await bp.append({"r": i + 2}) # three rows stay queued
|
|
|
|
closer = asyncio.create_task(bp.shutdown(timeout=30))
|
|
await asyncio.sleep(0.05) # let shutdown reach its wait_for
|
|
closer.cancel()
|
|
with pytest.raises(asyncio.CancelledError):
|
|
await closer
|
|
|
|
# The retry completes instead of re-raising the historical
|
|
# cancellation, and nothing is left queued.
|
|
await bp.shutdown(timeout=1)
|
|
|
|
assert bp._batch_processor_task.cancelled()
|
|
assert bp._queue.empty()
|
|
stats = bp.get_drop_stats()
|
|
# 3 queued rows (shutdown_cancelled) + 1 in-flight row counted by the
|
|
# cancelled worker (shutdown_timeout).
|
|
assert stats["shutdown_cancelled"] == 3
|
|
assert stats["shutdown_timeout"] == 1
|
|
|
|
def test_overlimit_and_garbage_quoted_blobs_fail_closed(self):
|
|
"""Over-limit multi-layer quoted JSON and a valid quoted
|
|
|
|
credential layer with trailing garbage must not republish the secret.
|
|
"""
|
|
truncate = bigquery_agent_analytics_plugin._recursive_smart_truncate
|
|
|
|
secret = "ROUND7-TRIPLE-SECRET-" + "x" * 300
|
|
triple = json.dumps(json.dumps(json.dumps({"access_token": secret})))
|
|
out, truncated = truncate({"blob": triple}, 64)
|
|
assert "ROUND7-TRIPLE-SECRET" not in json.dumps(out)
|
|
assert truncated
|
|
|
|
trailing = (
|
|
json.dumps(json.dumps({"access_token": "ROUND7-TRAIL-SECRET"}))
|
|
+ " trailing"
|
|
)
|
|
out, truncated = truncate({"blob": trailing}, 10000)
|
|
assert "ROUND7-TRAIL-SECRET" not in json.dumps(out)
|
|
# Refined the policy: the leading string layer is
|
|
# redacted in place and the container-free suffix is preserved, so
|
|
# this is a redaction (changed), not a truncation.
|
|
assert "[REDACTED]" in out["blob"]
|
|
assert out["blob"].endswith(" trailing")
|
|
|
|
# Ordinary quoted prose — with or without a suffix — stays untouched.
|
|
for prose in ('"hello" she said', '"just a quote"'):
|
|
out, truncated = truncate({"s": prose}, 10000)
|
|
assert out["s"] == prose
|
|
assert truncated is False
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_shapes_redacted_at_row_boundary(
|
|
self,
|
|
mock_write_client,
|
|
invocation_context,
|
|
callback_context,
|
|
mock_auth_default,
|
|
mock_bq_client,
|
|
mock_to_arrow_schema,
|
|
dummy_arrow_schema,
|
|
mock_asyncio_to_thread,
|
|
caplog,
|
|
):
|
|
"""Shapes at the final row boundary: the
|
|
|
|
row is always emitted, no canary reaches the row or the logs,
|
|
unsupported keys fail closed, and discarded binary reports
|
|
is_truncated.
|
|
"""
|
|
_ = mock_auth_default, mock_bq_client
|
|
import collections
|
|
import types as types_module
|
|
|
|
Cred = collections.namedtuple("Cred", ["access_token"])
|
|
|
|
class Trap:
|
|
|
|
@property
|
|
def model_dump(self):
|
|
raise RuntimeError("ROUND7-PROPERTY-SECRET")
|
|
|
|
class SneakyStr(str):
|
|
|
|
def lstrip(self, *args):
|
|
return "plain"
|
|
|
|
def startswith(self, *args):
|
|
return False
|
|
|
|
async with managed_plugin(
|
|
PROJECT_ID, DATASET_ID, table_id=TABLE_ID
|
|
) as plugin:
|
|
await plugin._ensure_started()
|
|
mock_write_client.append_rows.reset_mock()
|
|
bigquery_agent_analytics_plugin.TraceManager.push_span(invocation_context)
|
|
await plugin._log_event(
|
|
"STATE_DELTA",
|
|
callback_context,
|
|
event_data=bigquery_agent_analytics_plugin.EventData(
|
|
extra_attributes={
|
|
"named": Cred("ROUND7-NAMED-SECRET"),
|
|
"ns": types_module.SimpleNamespace(
|
|
access_token="ROUND7-REPR-SECRET", note="ok"
|
|
),
|
|
"trap": Trap(),
|
|
"sneaky": SneakyStr('{"access_token":"ROUND7-STR-SUBCLASS"}'),
|
|
"bad_key": {(1, 2): "value"},
|
|
"binary": b"\xff\xfe",
|
|
},
|
|
),
|
|
)
|
|
await asyncio.sleep(0.01)
|
|
log_entry = await _get_captured_event_dict_async(
|
|
mock_write_client, dummy_arrow_schema
|
|
)
|
|
dumped_row = json.dumps(log_entry, default=str)
|
|
for canary in (
|
|
"ROUND7-NAMED-SECRET",
|
|
"ROUND7-REPR-SECRET",
|
|
"ROUND7-PROPERTY-SECRET",
|
|
"ROUND7-STR-SUBCLASS",
|
|
):
|
|
assert canary not in dumped_row, canary
|
|
assert canary not in caplog.text, canary
|
|
attrs = json.loads(log_entry["attributes"])
|
|
assert attrs["named"] == {"access_token": "[REDACTED]"}
|
|
assert attrs["ns"] == {"access_token": "[REDACTED]", "note": "ok"}
|
|
assert attrs["trap"] == "[UNSUPPORTED_OBJECT]"
|
|
assert attrs["bad_key"] == {"[UNSUPPORTED_KEY]": "value"}
|
|
assert attrs["binary"] == "[BINARY_DATA]"
|
|
assert log_entry["is_truncated"] is True
|
|
|
|
def test_setup_future_leaves_no_loop_references(
|
|
self, mock_auth_default, mock_bq_client
|
|
):
|
|
"""Repeated fresh-loop startups retain no per-loop setup structures.
|
|
|
|
The per-loop lock map kept strong references to every closed loop;
|
|
the cross-loop future replaces it.
|
|
"""
|
|
_ = mock_auth_default, mock_bq_client
|
|
plugin = bigquery_agent_analytics_plugin.BigQueryAgentAnalyticsPlugin(
|
|
PROJECT_ID, DATASET_ID, table_id=TABLE_ID
|
|
)
|
|
|
|
async def noop_setup(**kwargs):
|
|
return None
|
|
|
|
for _ in range(4):
|
|
plugin._started = False
|
|
with mock.patch.object(plugin, "_lazy_setup", side_effect=noop_setup):
|
|
asyncio.run(plugin._ensure_started())
|
|
assert plugin._setup_future is None
|
|
assert not hasattr(plugin, "_setup_locks")
|
|
|
|
def test_cleanup_survives_concurrent_insertion(
|
|
self, mock_auth_default, mock_bq_client
|
|
):
|
|
"""Cleanup snapshots keys, so insertion during is_closed() cannot raise
|
|
|
|
'dictionary changed size during iteration'.
|
|
"""
|
|
_ = mock_auth_default, mock_bq_client
|
|
plugin = bigquery_agent_analytics_plugin.BigQueryAgentAnalyticsPlugin(
|
|
PROJECT_ID, DATASET_ID, table_id=TABLE_ID
|
|
)
|
|
dead_loop = mock.MagicMock()
|
|
state = mock.MagicMock()
|
|
state.batch_processor.get_drop_stats.return_value = {"write_failed": 7}
|
|
|
|
def is_closed_and_mutate():
|
|
# Simulates another thread inserting mid-scan.
|
|
plugin._loop_state_by_loop[mock.MagicMock()] = mock.MagicMock()
|
|
return True
|
|
|
|
dead_loop.is_closed.side_effect = is_closed_and_mutate
|
|
plugin._loop_state_by_loop[dead_loop] = state
|
|
|
|
plugin._cleanup_stale_loop_states() # must not raise
|
|
assert plugin.get_drop_stats().get("write_failed") == 7
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_depth_capped_payload_flags_row_truncated(
|
|
self,
|
|
mock_write_client,
|
|
invocation_context,
|
|
callback_context,
|
|
mock_auth_default,
|
|
mock_bq_client,
|
|
mock_to_arrow_schema,
|
|
dummy_arrow_schema,
|
|
mock_asyncio_to_thread,
|
|
):
|
|
"""A real payload cut off by the depth cap marks the ROW as truncated
|
|
|
|
.
|
|
"""
|
|
_ = mock_auth_default, mock_bq_client
|
|
deep: dict = {"leaf": "payload"}
|
|
for _ in range(60):
|
|
deep = {"level": deep}
|
|
async with managed_plugin(
|
|
PROJECT_ID, DATASET_ID, table_id=TABLE_ID
|
|
) as plugin:
|
|
await plugin._ensure_started()
|
|
mock_write_client.append_rows.reset_mock()
|
|
bigquery_agent_analytics_plugin.TraceManager.push_span(invocation_context)
|
|
await plugin._log_event(
|
|
"STATE_DELTA",
|
|
callback_context,
|
|
event_data=bigquery_agent_analytics_plugin.EventData(
|
|
extra_attributes={"deep": deep},
|
|
),
|
|
)
|
|
await plugin.flush()
|
|
log_entry = await _get_captured_event_dict_async(
|
|
mock_write_client, dummy_arrow_schema
|
|
)
|
|
assert "[MAX_DEPTH_EXCEEDED]" in log_entry["attributes"]
|
|
assert log_entry["is_truncated"] is True
|
|
|
|
def test_zero_delay_retry_config_still_constructs(
|
|
self, mock_auth_default, mock_bq_client
|
|
):
|
|
"""Long-supported zero-delay retry configs must not be rejected
|
|
|
|
.
|
|
"""
|
|
_ = mock_auth_default, mock_bq_client
|
|
config = bigquery_agent_analytics_plugin.BigQueryLoggerConfig(
|
|
retry_config=bigquery_agent_analytics_plugin.RetryConfig(
|
|
max_retries=0, initial_delay=0, max_delay=0
|
|
)
|
|
)
|
|
plugin = bigquery_agent_analytics_plugin.BigQueryAgentAnalyticsPlugin(
|
|
PROJECT_ID, DATASET_ID, table_id=TABLE_ID, config=config
|
|
)
|
|
assert plugin.config.retry_config.max_retries == 0
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_owner_cancellation_does_not_poison_rendezvous(
|
|
self, mock_auth_default, mock_bq_client
|
|
):
|
|
"""A cancelled setup owner finalizes the shared future so later
|
|
|
|
startups are not stuck forever.
|
|
"""
|
|
_ = mock_auth_default, mock_bq_client
|
|
plugin = bigquery_agent_analytics_plugin.BigQueryAgentAnalyticsPlugin(
|
|
PROJECT_ID, DATASET_ID, table_id=TABLE_ID
|
|
)
|
|
entered = asyncio.Event()
|
|
|
|
async def hung_setup(**kwargs):
|
|
entered.set()
|
|
await asyncio.sleep(3600)
|
|
|
|
with mock.patch.object(plugin, "_lazy_setup", side_effect=hung_setup):
|
|
owner = asyncio.create_task(plugin._ensure_started())
|
|
await entered.wait()
|
|
owner.cancel()
|
|
with pytest.raises(asyncio.CancelledError):
|
|
await owner
|
|
|
|
assert plugin._setup_future is None # rendezvous cleared
|
|
|
|
# A later attempt is not stuck: it claims a fresh future and runs.
|
|
async def ok_setup(**kwargs):
|
|
return None
|
|
|
|
with mock.patch.object(plugin, "_lazy_setup", side_effect=ok_setup):
|
|
await asyncio.wait_for(plugin._ensure_started(), timeout=5)
|
|
assert plugin._started is True
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_waiter_cancellation_does_not_cancel_shared_future(
|
|
self, mock_auth_default, mock_bq_client
|
|
):
|
|
"""Cancelling one waiter must not cancel the owner's shared future
|
|
|
|
.
|
|
"""
|
|
_ = mock_auth_default, mock_bq_client
|
|
plugin = bigquery_agent_analytics_plugin.BigQueryAgentAnalyticsPlugin(
|
|
PROJECT_ID, DATASET_ID, table_id=TABLE_ID
|
|
)
|
|
entered = asyncio.Event()
|
|
release = asyncio.Event()
|
|
|
|
async def gated_setup(**kwargs):
|
|
entered.set()
|
|
await release.wait()
|
|
|
|
with mock.patch.object(plugin, "_lazy_setup", side_effect=gated_setup):
|
|
owner = asyncio.create_task(plugin._ensure_started())
|
|
await entered.wait()
|
|
waiter = asyncio.create_task(plugin._ensure_started())
|
|
await asyncio.sleep(0.05) # waiter reaches the shielded await
|
|
waiter.cancel()
|
|
with pytest.raises(asyncio.CancelledError):
|
|
await waiter
|
|
release.set()
|
|
await owner # owner publishes without InvalidStateError
|
|
|
|
assert plugin._started is True
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_shutdown_wins_over_in_flight_setup(
|
|
self, mock_auth_default, mock_bq_client
|
|
):
|
|
"""Setup completing after shutdown() must not resurrect _started
|
|
|
|
.
|
|
"""
|
|
_ = mock_auth_default, mock_bq_client
|
|
plugin = bigquery_agent_analytics_plugin.BigQueryAgentAnalyticsPlugin(
|
|
PROJECT_ID, DATASET_ID, table_id=TABLE_ID
|
|
)
|
|
entered = asyncio.Event()
|
|
release = asyncio.Event()
|
|
|
|
async def gated_setup(**kwargs):
|
|
entered.set()
|
|
await release.wait()
|
|
|
|
with mock.patch.object(plugin, "_lazy_setup", side_effect=gated_setup):
|
|
owner = asyncio.create_task(plugin._ensure_started())
|
|
await entered.wait()
|
|
await plugin.shutdown()
|
|
release.set()
|
|
outcome = await owner
|
|
|
|
assert plugin._started is False
|
|
# The abort is reported structurally, not counted here;
|
|
# only a row owner converts it into a shutdown_race loss.
|
|
assert outcome == "aborted"
|
|
assert plugin.get_drop_stats().get("shutdown_race", 0) == 0
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_close_invokes_full_shutdown(
|
|
self, mock_auth_default, mock_bq_client
|
|
):
|
|
"""plugin.close() (Runner/PluginManager ownership) performs the real
|
|
|
|
shutdown instead of the inherited no-op.
|
|
"""
|
|
_ = mock_auth_default, mock_bq_client
|
|
plugin = bigquery_agent_analytics_plugin.BigQueryAgentAnalyticsPlugin(
|
|
PROJECT_ID, DATASET_ID, table_id=TABLE_ID
|
|
)
|
|
plugin._started = True
|
|
await plugin.close()
|
|
assert plugin._started is False
|
|
assert plugin._is_shutting_down is False
|
|
# And it routes through shutdown() semantics: counters remain queryable.
|
|
assert isinstance(plugin.get_drop_stats(), dict)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_cancelled_close_releases_guard_and_allows_retry(
|
|
self, mock_auth_default, mock_bq_client
|
|
):
|
|
"""A close() cancelled mid-drain (PluginManager's close timeout) must
|
|
|
|
release _is_shutting_down, re-raise the cancellation, and leave the
|
|
retained loop state retryable by a second close.
|
|
"""
|
|
_ = mock_auth_default, mock_bq_client
|
|
plugin = bigquery_agent_analytics_plugin.BigQueryAgentAnalyticsPlugin(
|
|
PROJECT_ID, DATASET_ID, table_id=TABLE_ID
|
|
)
|
|
loop = asyncio.get_running_loop()
|
|
|
|
blocked = asyncio.Event()
|
|
release = asyncio.Event()
|
|
|
|
async def blocking_shutdown(timeout=None):
|
|
del timeout
|
|
blocked.set()
|
|
await release.wait()
|
|
|
|
state = mock.MagicMock()
|
|
state.write_client = None
|
|
state.batch_processor = mock.MagicMock(
|
|
spec=bigquery_agent_analytics_plugin.BatchProcessor
|
|
)
|
|
state.batch_processor.shutdown = mock.AsyncMock(
|
|
side_effect=blocking_shutdown
|
|
)
|
|
state.batch_processor.get_drop_stats = mock.MagicMock(return_value={})
|
|
plugin._loop_state_by_loop[loop] = state
|
|
|
|
closer = asyncio.create_task(plugin.close())
|
|
await blocked.wait()
|
|
closer.cancel()
|
|
with pytest.raises(asyncio.CancelledError):
|
|
await closer
|
|
|
|
# The guard is released and the undrained state is still retryable.
|
|
assert plugin._is_shutting_down is False
|
|
assert loop in plugin._loop_state_by_loop
|
|
|
|
# A second close now completes and removes the retained state.
|
|
state.batch_processor.shutdown = mock.AsyncMock()
|
|
await plugin.close()
|
|
assert plugin._is_shutting_down is False
|
|
assert loop not in plugin._loop_state_by_loop
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_loop_state_uses_single_lookup(
|
|
self, mock_auth_default, mock_bq_client
|
|
):
|
|
"""A concurrent removal cannot split an existence check from lookup."""
|
|
_ = mock_auth_default, mock_bq_client
|
|
plugin = bigquery_agent_analytics_plugin.BigQueryAgentAnalyticsPlugin(
|
|
PROJECT_ID, DATASET_ID, table_id=TABLE_ID
|
|
)
|
|
loop = asyncio.get_running_loop()
|
|
expected_state = mock.MagicMock()
|
|
|
|
class DeleteOnContainsDict(dict):
|
|
|
|
def __contains__(self, key):
|
|
present = super().__contains__(key)
|
|
if present:
|
|
del self[key]
|
|
return present
|
|
|
|
plugin._loop_state_by_loop = DeleteOnContainsDict({loop: expected_state})
|
|
|
|
assert await plugin._get_loop_state() is expected_state
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_writer_built_during_shutdown_is_not_published(
|
|
self, mock_auth_default, mock_bq_client
|
|
):
|
|
"""A shutdown() that completes while _get_loop_state() is mid-build
|
|
|
|
must not let the fresh writer be published afterwards; the new
|
|
processor and transport are torn down instead.
|
|
"""
|
|
_ = mock_auth_default, mock_bq_client
|
|
plugin = bigquery_agent_analytics_plugin.BigQueryAgentAnalyticsPlugin(
|
|
PROJECT_ID, DATASET_ID, table_id=TABLE_ID
|
|
)
|
|
plugin._credentials = mock.MagicMock(quota_project_id=None)
|
|
|
|
start_entered = asyncio.Event()
|
|
start_gate = asyncio.Event()
|
|
processor_shutdowns = []
|
|
|
|
async def gated_start(self):
|
|
del self
|
|
start_entered.set()
|
|
await start_gate.wait()
|
|
|
|
async def record_shutdown(self, timeout=None):
|
|
del self
|
|
processor_shutdowns.append(timeout)
|
|
|
|
transport = mock.MagicMock()
|
|
transport.close = mock.AsyncMock()
|
|
write_client = mock.MagicMock()
|
|
write_client.transport = transport
|
|
|
|
with (
|
|
mock.patch.object(
|
|
bigquery_agent_analytics_plugin.BatchProcessor,
|
|
"start",
|
|
gated_start,
|
|
),
|
|
mock.patch.object(
|
|
bigquery_agent_analytics_plugin.BatchProcessor,
|
|
"shutdown",
|
|
record_shutdown,
|
|
),
|
|
mock.patch.object(
|
|
bigquery_agent_analytics_plugin,
|
|
"BigQueryWriteAsyncClient",
|
|
return_value=write_client,
|
|
),
|
|
):
|
|
builder = asyncio.create_task(plugin._get_loop_state())
|
|
await start_entered.wait()
|
|
# shutdown() completes while the writer is still being built: its
|
|
# snapshot is empty, so only the publication guard can stop the leak.
|
|
await plugin.shutdown()
|
|
start_gate.set()
|
|
with pytest.raises(RuntimeError):
|
|
await builder
|
|
|
|
assert plugin._loop_state_by_loop == {}
|
|
assert processor_shutdowns, "fresh processor must be shut down"
|
|
transport.close.assert_awaited()
|
|
|
|
def test_sanitizer_covers_bytes_bom_str_and_mapping_converters(self):
|
|
"""Shapes: bytes/bytearray blobs, BOM-prefixed JSON,
|
|
|
|
__str__-returned credential JSON, and Mapping converter results.
|
|
"""
|
|
import collections
|
|
|
|
truncate = bigquery_agent_analytics_plugin._recursive_smart_truncate
|
|
|
|
class ToDictMapping:
|
|
|
|
def to_dict(self):
|
|
return collections.UserDict({"access_token": "SECRET-MAPPING"})
|
|
|
|
class StrLeaker:
|
|
|
|
def __str__(self):
|
|
return '{"access_token": "SECRET-STR"}'
|
|
|
|
payload = {
|
|
"bytes": b'{"access_token":"SECRET-BYTES"}',
|
|
"bytearray": bytearray(b'{"access_token":"SECRET-BA"}'),
|
|
"bom": '\ufeff{"access_token":"SECRET-BOM"}',
|
|
"converter": ToDictMapping(),
|
|
"strleak": StrLeaker(),
|
|
}
|
|
out, _ = truncate(payload, 10000)
|
|
dumped = json.dumps(out)
|
|
for marker in (
|
|
"SECRET-BYTES",
|
|
"SECRET-BA",
|
|
"SECRET-BOM",
|
|
"SECRET-MAPPING",
|
|
"SECRET-STR",
|
|
):
|
|
assert marker not in dumped, marker
|
|
|
|
def test_double_encoded_and_rootmodel_blobs_are_redacted(self):
|
|
"""Shapes: JSON-encoded string layers (double/triple
|
|
|
|
json.dumps) and scalar model_dump() results (RootModel[str]) re-enter
|
|
the redaction path instead of bypassing it.
|
|
"""
|
|
import pydantic
|
|
|
|
truncate = bigquery_agent_analytics_plugin._recursive_smart_truncate
|
|
|
|
double = json.dumps(json.dumps({"access_token": "DOUBLE-ENCODED-SECRET"}))
|
|
out, _ = truncate({"blob": double}, 10000)
|
|
assert "DOUBLE-ENCODED-SECRET" not in json.dumps(out)
|
|
|
|
triple = json.dumps(double)
|
|
out, _ = truncate({"blob": triple}, 10000)
|
|
assert "DOUBLE-ENCODED-SECRET" not in json.dumps(out)
|
|
|
|
root = pydantic.RootModel[str]('{"access_token":"ROOT-SECRET"}')
|
|
out, _ = truncate({"model": root}, 10000)
|
|
assert "ROOT-SECRET" not in json.dumps(out)
|
|
|
|
# Ordinary quoted prose (not a JSON document) is left untouched.
|
|
prose = '"hello" she said'
|
|
out, truncated = truncate({"s": prose}, 10000)
|
|
assert out["s"] == prose
|
|
assert truncated is False
|
|
|
|
def test_depth_truncated_json_blob_reports_truncation(self):
|
|
"""A JSON blob rewritten with [MAX_DEPTH_EXCEEDED]
|
|
|
|
discards payload and must therefore report truncated=True.
|
|
"""
|
|
truncate = bigquery_agent_analytics_plugin._recursive_smart_truncate
|
|
deep = "[" * 60 + "]" * 60
|
|
out, truncated = truncate({"blob": deep}, 10000)
|
|
assert "[MAX_DEPTH_EXCEEDED]" in out["blob"]
|
|
assert truncated is True
|
|
|
|
def test_sanitizer_stops_at_node_budget(self):
|
|
"""A very wide payload stops at the work budget, emits ONE remainder
|
|
|
|
sentinel, and the output stays bounded by the budget — iteration used
|
|
to continue over the full input, appending one sentinel per remaining
|
|
element.
|
|
"""
|
|
max_nodes = bigquery_agent_analytics_plugin._MAX_SANITIZE_NODES
|
|
truncate = bigquery_agent_analytics_plugin._recursive_smart_truncate
|
|
|
|
wide = list(range(max_nodes * 2))
|
|
out, truncated = truncate({"wide": wide}, 10000)
|
|
assert truncated
|
|
assert out["wide"][-1] == "[SANITIZE_BUDGET_EXCEEDED]"
|
|
assert out["wide"].count("[SANITIZE_BUDGET_EXCEEDED]") == 1
|
|
# Bounded output: budget entries plus the single remainder sentinel.
|
|
assert len(out["wide"]) <= max_nodes + 1
|
|
|
|
def test_sanitizer_budget_covers_directly_redacted_entries(self):
|
|
"""Directly redacted keys (temp:/sensitive) consume budget too — a
|
|
|
|
wide temp: mapping used to bypass the bound entirely and report
|
|
truncated=False.
|
|
"""
|
|
max_nodes = bigquery_agent_analytics_plugin._MAX_SANITIZE_NODES
|
|
truncate = bigquery_agent_analytics_plugin._recursive_smart_truncate
|
|
|
|
wide_temp = {f"temp:{i}": i for i in range(max_nodes * 2)}
|
|
out, truncated = truncate(wide_temp, 10000)
|
|
assert truncated
|
|
assert len(out) <= max_nodes + 1
|
|
assert "[SANITIZE_BUDGET_EXCEEDED]" in out
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_stale_loop_cleanup_counts_queued_rows(
|
|
self, mock_auth_default, mock_bq_client
|
|
):
|
|
"""Queued rows on a closed loop are counted under stale_loop
|
|
|
|
.
|
|
"""
|
|
_ = mock_auth_default, mock_bq_client
|
|
plugin = bigquery_agent_analytics_plugin.BigQueryAgentAnalyticsPlugin(
|
|
PROJECT_ID, DATASET_ID, table_id=TABLE_ID
|
|
)
|
|
dead_loop = mock.MagicMock()
|
|
dead_loop.is_closed.return_value = True
|
|
state = mock.MagicMock()
|
|
queue = asyncio.Queue()
|
|
queue.put_nowait({"row": 1})
|
|
state.batch_processor._queue = queue
|
|
state.batch_processor.get_drop_stats.return_value = {}
|
|
state.write_client = None
|
|
plugin._loop_state_by_loop[dead_loop] = state
|
|
|
|
plugin._cleanup_stale_loop_states()
|
|
assert plugin.get_drop_stats().get("stale_loop") == 1
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_restart_rebuilds_parser_and_offloader(
|
|
self, mock_auth_default, mock_bq_client
|
|
):
|
|
"""shutdown() clears parser/offloader so a restart cannot reuse the
|
|
|
|
terminated executor.
|
|
"""
|
|
_ = mock_auth_default, mock_bq_client
|
|
plugin = bigquery_agent_analytics_plugin.BigQueryAgentAnalyticsPlugin(
|
|
PROJECT_ID, DATASET_ID, table_id=TABLE_ID
|
|
)
|
|
plugin.parser = mock.MagicMock()
|
|
plugin.offloader = mock.MagicMock()
|
|
await plugin.shutdown()
|
|
assert plugin.parser is None
|
|
assert plugin.offloader is None
|
|
|
|
|
|
class TestLatestReviewLifecycleRegressions:
|
|
"""Regressions for lifecycle findings."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_later_before_model_short_circuit_does_not_leak_span(
|
|
self,
|
|
bq_plugin_inst,
|
|
mock_write_client,
|
|
callback_context,
|
|
invocation_context,
|
|
dummy_arrow_schema,
|
|
):
|
|
"""A synthesized response row belongs to the parent agent span."""
|
|
|
|
class ShortCircuitPlugin(bigquery_agent_analytics_plugin.BasePlugin):
|
|
|
|
def __init__(self):
|
|
super().__init__(name="short_circuit")
|
|
|
|
async def before_model_callback(self, **kwargs):
|
|
del kwargs
|
|
return llm_response_lib.LlmResponse(
|
|
content=types.Content(
|
|
role="model", parts=[types.Part(text="cached response")]
|
|
)
|
|
)
|
|
|
|
trace_manager = bigquery_agent_analytics_plugin.TraceManager
|
|
trace_manager.clear_stack()
|
|
try:
|
|
parent_span_id = trace_manager.push_span(callback_context, "agent")
|
|
manager = plugin_manager_lib.PluginManager(
|
|
[bq_plugin_inst, ShortCircuitPlugin()]
|
|
)
|
|
request = llm_request_lib.LlmRequest(
|
|
model="gemini-pro",
|
|
contents=[
|
|
types.Content(role="user", parts=[types.Part(text="prompt")])
|
|
],
|
|
)
|
|
|
|
short_response = await manager.run_before_model_callback(
|
|
callback_context=callback_context, llm_request=request
|
|
)
|
|
assert short_response is not None
|
|
leaked_span_id = trace_manager.get_current_span_id()
|
|
assert leaked_span_id != parent_span_id
|
|
|
|
# ADK intentionally skips run_after_model_callback on this path and
|
|
# emits the synthesized response as a non-partial event instead.
|
|
await bq_plugin_inst.flush()
|
|
mock_write_client.append_rows.reset_mock()
|
|
event = event_lib.Event(
|
|
author="agent",
|
|
content=short_response.content,
|
|
)
|
|
await manager.run_on_event_callback(
|
|
invocation_context=invocation_context, event=event
|
|
)
|
|
await bq_plugin_inst.flush()
|
|
|
|
rows = await _get_captured_rows_async(
|
|
mock_write_client, dummy_arrow_schema
|
|
)
|
|
response_row = next(
|
|
row for row in rows if row["event_type"] == "AGENT_RESPONSE"
|
|
)
|
|
assert response_row["span_id"] == parent_span_id
|
|
assert response_row["span_id"] != leaked_span_id
|
|
assert trace_manager.get_current_span_id() == parent_span_id
|
|
finally:
|
|
trace_manager.clear_stack()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_partial_event_preserves_live_llm_span(
|
|
self, bq_plugin_inst, callback_context, invocation_context
|
|
):
|
|
trace_manager = bigquery_agent_analytics_plugin.TraceManager
|
|
trace_manager.clear_stack()
|
|
try:
|
|
trace_manager.push_span(callback_context, "agent")
|
|
await bq_plugin_inst.before_model_callback(
|
|
callback_context=callback_context,
|
|
llm_request=llm_request_lib.LlmRequest(model="gemini-pro"),
|
|
)
|
|
llm_span_id = trace_manager.get_current_span_id()
|
|
|
|
await bq_plugin_inst.on_event_callback(
|
|
invocation_context=invocation_context,
|
|
event=event_lib.Event(
|
|
author="agent",
|
|
partial=True,
|
|
content=types.Content(
|
|
role="model", parts=[types.Part(text="stream chunk")]
|
|
),
|
|
),
|
|
)
|
|
assert trace_manager.get_current_span_id() == llm_span_id
|
|
finally:
|
|
trace_manager.clear_stack()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_external_cancel_during_worker_ack_is_not_swallowed(self):
|
|
processor = bigquery_agent_analytics_plugin.BatchProcessor(
|
|
write_client=mock.MagicMock(),
|
|
arrow_schema=None,
|
|
write_stream="stream",
|
|
batch_size=1,
|
|
flush_interval=1.0,
|
|
retry_config=bigquery_agent_analytics_plugin.RetryConfig(),
|
|
queue_max_size=10,
|
|
shutdown_timeout=1.0,
|
|
)
|
|
first_cancel_seen = asyncio.Event()
|
|
never = asyncio.Event()
|
|
|
|
async def slow_cancel_ack():
|
|
try:
|
|
await never.wait()
|
|
except asyncio.CancelledError:
|
|
first_cancel_seen.set()
|
|
await never.wait()
|
|
|
|
processor._batch_processor_task = asyncio.create_task(slow_cancel_ack())
|
|
processor._queue.put_nowait({"row": 1})
|
|
|
|
with mock.patch.object(
|
|
bigquery_agent_analytics_plugin.asyncio,
|
|
"wait_for",
|
|
new=mock.AsyncMock(side_effect=asyncio.TimeoutError),
|
|
):
|
|
owner = asyncio.create_task(processor.shutdown(timeout=0.01))
|
|
await first_cancel_seen.wait()
|
|
owner.cancel()
|
|
with pytest.raises(asyncio.CancelledError):
|
|
await owner
|
|
|
|
# A later close owns the retained terminal task/queue and accounts it.
|
|
await processor.shutdown(timeout=1.0)
|
|
assert processor._batch_processor_task.cancelled()
|
|
assert processor._queue.empty()
|
|
assert processor.get_drop_stats()["shutdown_timeout"] == 1
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_dead_loop_state_is_replaced_once_and_rows_are_accounted(
|
|
self, mock_auth_default, mock_bq_client
|
|
):
|
|
_ = mock_auth_default, mock_bq_client
|
|
plugin = bigquery_agent_analytics_plugin.BigQueryAgentAnalyticsPlugin(
|
|
PROJECT_ID, DATASET_ID, table_id=TABLE_ID
|
|
)
|
|
plugin._credentials = mock.MagicMock(quota_project_id=None)
|
|
plugin._write_stream_name = DEFAULT_STREAM_NAME
|
|
|
|
old_transport = mock.MagicMock()
|
|
close_entered = asyncio.Event()
|
|
close_release = asyncio.Event()
|
|
|
|
async def gated_old_close():
|
|
close_entered.set()
|
|
await close_release.wait()
|
|
|
|
old_transport.close = mock.AsyncMock(side_effect=gated_old_close)
|
|
old_client = mock.MagicMock(transport=old_transport)
|
|
old_processor = bigquery_agent_analytics_plugin.BatchProcessor(
|
|
write_client=old_client,
|
|
arrow_schema=None,
|
|
write_stream=DEFAULT_STREAM_NAME,
|
|
batch_size=1,
|
|
flush_interval=0.01,
|
|
retry_config=bigquery_agent_analytics_plugin.RetryConfig(),
|
|
queue_max_size=10,
|
|
shutdown_timeout=1.0,
|
|
)
|
|
old_processor._shutdown = True
|
|
old_processor._dropped["queue_full"] = 2
|
|
old_processor._queue.put_nowait({"old": 1})
|
|
old_processor._queue.put_nowait({"old": 2})
|
|
old_processor._batch_processor_task = asyncio.create_task(asyncio.sleep(0))
|
|
await old_processor._batch_processor_task
|
|
|
|
loop = asyncio.get_running_loop()
|
|
old_state = bigquery_agent_analytics_plugin._LoopState(
|
|
old_client, old_processor
|
|
)
|
|
plugin._loop_state_by_loop[loop] = old_state
|
|
|
|
new_transport = mock.MagicMock()
|
|
new_transport.close = mock.AsyncMock()
|
|
new_client = mock.MagicMock(transport=new_transport)
|
|
with mock.patch.object(
|
|
bigquery_agent_analytics_plugin,
|
|
"BigQueryWriteAsyncClient",
|
|
return_value=new_client,
|
|
):
|
|
builder = asyncio.create_task(plugin._get_loop_state())
|
|
await close_entered.wait()
|
|
|
|
# Replacement is already published while the sole owner closes the old
|
|
# transport, so a concurrent caller cannot build a second processor.
|
|
concurrent_state = await plugin._get_loop_state()
|
|
close_release.set()
|
|
replacement = await builder
|
|
|
|
assert replacement is concurrent_state
|
|
assert replacement is plugin._loop_state_by_loop[loop]
|
|
assert replacement is not old_state
|
|
old_transport.close.assert_awaited_once()
|
|
stats = plugin.get_drop_stats()
|
|
assert stats["queue_full"] == 2
|
|
assert stats["shutdown_timeout"] == 2
|
|
|
|
write_rows = mock.AsyncMock()
|
|
with mock.patch.object(
|
|
replacement.batch_processor,
|
|
"_write_rows_with_retry",
|
|
new=write_rows,
|
|
):
|
|
row = {"new": 1}
|
|
await replacement.batch_processor.append(row)
|
|
await asyncio.wait_for(replacement.batch_processor.flush(), timeout=1)
|
|
write_rows.assert_awaited_once_with([row])
|
|
|
|
await plugin.shutdown(timeout=1)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_normal_timeout_retrieves_worker_cancel_and_drains_queue(self):
|
|
"""The 3.10-compatible acknowledgement path handles worker cancel."""
|
|
processor = bigquery_agent_analytics_plugin.BatchProcessor(
|
|
write_client=mock.MagicMock(),
|
|
arrow_schema=None,
|
|
write_stream="stream",
|
|
batch_size=1,
|
|
flush_interval=1.0,
|
|
retry_config=bigquery_agent_analytics_plugin.RetryConfig(),
|
|
queue_max_size=10,
|
|
shutdown_timeout=1.0,
|
|
)
|
|
never = asyncio.Event()
|
|
processor._batch_processor_task = asyncio.create_task(never.wait())
|
|
processor._queue.put_nowait({"row": 1})
|
|
|
|
await processor.shutdown(timeout=0.001)
|
|
|
|
assert processor._batch_processor_task.cancelled()
|
|
assert processor._queue.empty()
|
|
assert processor.get_drop_stats()["shutdown_timeout"] == 1
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_error_columns_and_tracebacks_redact_embedded_credentials(
|
|
self,
|
|
bq_plugin_inst,
|
|
mock_write_client,
|
|
callback_context,
|
|
dummy_arrow_schema,
|
|
):
|
|
secrets = (
|
|
"AUTH-SECRET",
|
|
"QUERY-SECRET",
|
|
"JSON-SECRET",
|
|
"SIGNATURE-SECRET",
|
|
"ESCAPED-SECRET",
|
|
)
|
|
message = (
|
|
"safe prefix Authorization: Bearer AUTH-SECRET; "
|
|
"access-token=QUERY-SECRET"
|
|
)
|
|
traceback_text = (
|
|
'Traceback safe prefix {"access_token":"JSON-SECRET"}; '
|
|
'next {"access\\u005ftoken":"ESCAPED-SECRET"}; '
|
|
"x-goog-signature=SIGNATURE-SECRET"
|
|
)
|
|
|
|
await bq_plugin_inst._log_event(
|
|
"AGENT_ERROR",
|
|
callback_context,
|
|
raw_content={"error_traceback": traceback_text},
|
|
event_data=bigquery_agent_analytics_plugin.EventData(
|
|
status="ERROR", error_message=message
|
|
),
|
|
)
|
|
await bq_plugin_inst.flush()
|
|
row = await _get_captured_event_dict_async(
|
|
mock_write_client, dummy_arrow_schema
|
|
)
|
|
stored = json.dumps(row, default=str)
|
|
assert all(secret not in stored for secret in secrets)
|
|
assert row["error_message"].startswith("safe prefix Authorization:")
|
|
assert bigquery_agent_analytics_plugin._sanitize_sensitive_text(
|
|
"Authorization: Bearer AUTH-SECRET", -1
|
|
) == ("Authorization: [REDACTED]", True)
|
|
for escaped in (
|
|
r"access\u005ftoken=ESCAPED-SECRET",
|
|
r'Traceback { "access\u005ftoken":"ESCAPED-SECRET"}',
|
|
):
|
|
assert bigquery_agent_analytics_plugin._sanitize_sensitive_text(
|
|
escaped, -1
|
|
) == ("[REDACTED_SENSITIVE_TEXT]", True)
|
|
assert bigquery_agent_analytics_plugin._sanitize_sensitive_text(
|
|
"temp:credential=TEMP-SECRET", -1
|
|
) == ("temp:credential=[REDACTED]", True)
|
|
assert "[REDACTED]" in stored
|
|
assert row["is_truncated"] is True
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_safe_error_message_is_preserved_exactly(
|
|
self,
|
|
bq_plugin_inst,
|
|
mock_write_client,
|
|
callback_context,
|
|
dummy_arrow_schema,
|
|
):
|
|
message = "[INFO] ordinary failure at worker 7"
|
|
await bq_plugin_inst._log_event(
|
|
"LLM_ERROR",
|
|
callback_context,
|
|
event_data=bigquery_agent_analytics_plugin.EventData(
|
|
status="ERROR", error_message=message
|
|
),
|
|
)
|
|
await bq_plugin_inst.flush()
|
|
row = await _get_captured_event_dict_async(
|
|
mock_write_client, dummy_arrow_schema
|
|
)
|
|
assert row["error_message"] == message
|
|
assert row["is_truncated"] is False
|
|
|
|
def test_sensitive_text_redacts_complete_values_and_encoded_constructs(self):
|
|
sanitize = bigquery_agent_analytics_plugin._sanitize_sensitive_text
|
|
redacted_cases = {
|
|
"access_token=[REDACTED]SECRET": "SECRET",
|
|
"access_token=[REDACTED]]SECRET": "SECRET",
|
|
"access_token=[REDACTED]/SECRET": "SECRET",
|
|
'Authorization: Digest username="u", response="DIGEST-SECRET"': (
|
|
"DIGEST-SECRET"
|
|
),
|
|
(
|
|
"Authorization: AWS4-HMAC-SHA256 "
|
|
"Credential=AWS-SECRET, SignedHeaders=host"
|
|
): "AWS-SECRET",
|
|
"Proxy-Authorization: Negotiate NEGOTIATE-SECRET": "NEGOTIATE-SECRET",
|
|
"Bearer\nBEARER-SECRET": "BEARER-SECRET",
|
|
"Basic\tdXNlcjpwYXNz": "dXNlcjpwYXNz",
|
|
"Basic dXNlcg==": "dXNlcg==",
|
|
"sig=SIG-SECRET": "SIG-SECRET",
|
|
"x-amz-signature=AMZ-SIGNATURE-SECRET": "AMZ-SIGNATURE-SECRET",
|
|
"x_amz_credential=AMZ-CREDENTIAL-SECRET": "AMZ-CREDENTIAL-SECRET",
|
|
"google-access-id=GOOGLE-ID-SECRET": "GOOGLE-ID-SECRET",
|
|
r"access\u005ftoken=UNICODE-SECRET": "UNICODE-SECRET",
|
|
r"access\x5ftoken=HEX-SECRET": "HEX-SECRET",
|
|
"access_token%3DPERCENT-SECRET": "PERCENT-SECRET",
|
|
"access%255Ftoken%253DDOUBLE-SECRET": "DOUBLE-SECRET",
|
|
}
|
|
for value, secret in redacted_cases.items():
|
|
sanitized, changed = sanitize(value, -1)
|
|
assert changed is True, value
|
|
assert secret not in sanitized, value
|
|
|
|
# A sentinel is idempotent only when it is the complete value.
|
|
assert sanitize("access_token=[REDACTED]", -1) == (
|
|
"access_token=[REDACTED]",
|
|
False,
|
|
)
|
|
|
|
structured, _ = bigquery_agent_analytics_plugin._recursive_smart_truncate(
|
|
{
|
|
"x-amz-signature": "STRUCTURED-AMZ-SECRET",
|
|
"google_access_id": "STRUCTURED-GOOGLE-SECRET",
|
|
"safe": True,
|
|
},
|
|
-1,
|
|
)
|
|
assert structured == {
|
|
"x-amz-signature": "[REDACTED]",
|
|
"google_access_id": "[REDACTED]",
|
|
"safe": True,
|
|
}
|
|
|
|
def test_sensitive_text_preserves_safe_slashes_and_encoded_prose_exactly(
|
|
self,
|
|
):
|
|
sanitize = bigquery_agent_analytics_plugin._sanitize_sensitive_text
|
|
safe = (
|
|
r"C:\Users\secret\project\file.json",
|
|
r"Invalid \escape at position 4",
|
|
r"can't decode \x5c in position 2",
|
|
"the bearer of bad news",
|
|
"a basic principle",
|
|
"a basic test",
|
|
"design=balanced",
|
|
"signal=strong",
|
|
"progress%3D100%25 complete",
|
|
"literal%2525value",
|
|
)
|
|
for value in safe:
|
|
assert sanitize(value, -1) == (value, False)
|
|
|
|
# A moderately wide safe input exercises the bounded stack scanner while
|
|
# pinning the useful property instead of a timing threshold.
|
|
wide = (r"C:\safe\secret\file%25.txt; " * 20_000).rstrip()
|
|
assert sanitize(wide, len(wide)) == (wide, False)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_live_shutting_down_writer_aborts_owner_and_waiter_once(
|
|
self, mock_auth_default, mock_bq_client, callback_context
|
|
):
|
|
_ = mock_auth_default, mock_bq_client
|
|
plugin = bigquery_agent_analytics_plugin.BigQueryAgentAnalyticsPlugin(
|
|
PROJECT_ID, DATASET_ID, table_id=TABLE_ID
|
|
)
|
|
processor = bigquery_agent_analytics_plugin.BatchProcessor(
|
|
write_client=mock.MagicMock(),
|
|
arrow_schema=None,
|
|
write_stream="stream",
|
|
batch_size=1,
|
|
flush_interval=1.0,
|
|
retry_config=bigquery_agent_analytics_plugin.RetryConfig(),
|
|
queue_max_size=10,
|
|
shutdown_timeout=1.0,
|
|
)
|
|
never = asyncio.Event()
|
|
processor._batch_processor_task = asyncio.create_task(never.wait())
|
|
processor._shutdown = True
|
|
loop = asyncio.get_running_loop()
|
|
plugin._loop_state_by_loop[loop] = (
|
|
bigquery_agent_analytics_plugin._LoopState(mock.MagicMock(), processor)
|
|
)
|
|
|
|
entered = asyncio.Event()
|
|
release = asyncio.Event()
|
|
|
|
async def attempt_setup(**kwargs):
|
|
del kwargs
|
|
entered.set()
|
|
await release.wait()
|
|
await plugin._get_loop_state()
|
|
|
|
try:
|
|
with mock.patch.object(plugin, "_lazy_setup", side_effect=attempt_setup):
|
|
owner = asyncio.create_task(plugin._ensure_started())
|
|
await entered.wait()
|
|
waiter = asyncio.create_task(plugin._ensure_started())
|
|
await asyncio.sleep(0)
|
|
release.set()
|
|
assert await owner == "aborted"
|
|
assert await waiter == "aborted"
|
|
|
|
assert plugin._startup_error is None
|
|
assert plugin._setup_failures == 0
|
|
assert plugin._setup_retry_at == 0
|
|
assert plugin._loop_state_by_loop[loop].batch_processor is processor
|
|
|
|
await plugin._log_event(
|
|
"STATE_DELTA",
|
|
callback_context,
|
|
event_data=bigquery_agent_analytics_plugin.EventData(),
|
|
)
|
|
assert plugin.get_drop_stats()["shutdown_race"] == 1
|
|
finally:
|
|
processor._batch_processor_task.cancel()
|
|
with contextlib.suppress(asyncio.CancelledError):
|
|
await processor._batch_processor_task
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_detached_transport_closed_when_replacement_build_fails(
|
|
self, mock_auth_default, mock_bq_client
|
|
):
|
|
_ = mock_auth_default, mock_bq_client
|
|
plugin = bigquery_agent_analytics_plugin.BigQueryAgentAnalyticsPlugin(
|
|
PROJECT_ID, DATASET_ID, table_id=TABLE_ID
|
|
)
|
|
plugin._credentials = mock.MagicMock(quota_project_id=None)
|
|
loop = asyncio.get_running_loop()
|
|
|
|
old_transport = mock.MagicMock(close=mock.AsyncMock())
|
|
old_processor = bigquery_agent_analytics_plugin.BatchProcessor(
|
|
write_client=mock.MagicMock(transport=old_transport),
|
|
arrow_schema=None,
|
|
write_stream="stream",
|
|
batch_size=1,
|
|
flush_interval=1.0,
|
|
retry_config=bigquery_agent_analytics_plugin.RetryConfig(),
|
|
queue_max_size=10,
|
|
shutdown_timeout=1.0,
|
|
)
|
|
old_processor._shutdown = True
|
|
old_processor._batch_processor_task = asyncio.create_task(asyncio.sleep(0))
|
|
await old_processor._batch_processor_task
|
|
plugin._loop_state_by_loop[loop] = (
|
|
bigquery_agent_analytics_plugin._LoopState(
|
|
mock.MagicMock(transport=old_transport), old_processor
|
|
)
|
|
)
|
|
|
|
fresh_transport = mock.MagicMock(close=mock.AsyncMock())
|
|
fresh_client = mock.MagicMock(transport=fresh_transport)
|
|
with (
|
|
mock.patch.object(
|
|
bigquery_agent_analytics_plugin,
|
|
"BigQueryWriteAsyncClient",
|
|
return_value=fresh_client,
|
|
),
|
|
mock.patch.object(
|
|
bigquery_agent_analytics_plugin.BatchProcessor,
|
|
"__init__",
|
|
side_effect=RuntimeError("construction failed"),
|
|
),
|
|
):
|
|
with pytest.raises(RuntimeError, match="construction failed"):
|
|
await plugin._get_loop_state()
|
|
|
|
old_transport.close.assert_awaited_once()
|
|
fresh_transport.close.assert_awaited_once()
|
|
assert loop not in plugin._loop_state_by_loop
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_invalidated_writer_transport_closes_when_shutdown_is_cancelled(
|
|
self, mock_auth_default, mock_bq_client
|
|
):
|
|
_ = mock_auth_default, mock_bq_client
|
|
plugin = bigquery_agent_analytics_plugin.BigQueryAgentAnalyticsPlugin(
|
|
PROJECT_ID, DATASET_ID, table_id=TABLE_ID
|
|
)
|
|
plugin._credentials = mock.MagicMock(quota_project_id=None)
|
|
|
|
start_entered = asyncio.Event()
|
|
start_release = asyncio.Event()
|
|
shutdown_entered = asyncio.Event()
|
|
shutdown_never = asyncio.Event()
|
|
|
|
async def gated_start(self):
|
|
del self
|
|
start_entered.set()
|
|
await start_release.wait()
|
|
|
|
async def blocked_shutdown(self, timeout=None):
|
|
del self, timeout
|
|
shutdown_entered.set()
|
|
await shutdown_never.wait()
|
|
|
|
transport = mock.MagicMock(close=mock.AsyncMock())
|
|
write_client = mock.MagicMock(transport=transport)
|
|
with (
|
|
mock.patch.object(
|
|
bigquery_agent_analytics_plugin.BatchProcessor,
|
|
"start",
|
|
gated_start,
|
|
),
|
|
mock.patch.object(
|
|
bigquery_agent_analytics_plugin.BatchProcessor,
|
|
"shutdown",
|
|
blocked_shutdown,
|
|
),
|
|
mock.patch.object(
|
|
bigquery_agent_analytics_plugin,
|
|
"BigQueryWriteAsyncClient",
|
|
return_value=write_client,
|
|
),
|
|
):
|
|
builder = asyncio.create_task(plugin._get_loop_state())
|
|
await start_entered.wait()
|
|
await plugin.shutdown()
|
|
start_release.set()
|
|
await shutdown_entered.wait()
|
|
builder.cancel()
|
|
with pytest.raises(asyncio.CancelledError):
|
|
await builder
|
|
|
|
transport.close.assert_awaited_once()
|
|
assert plugin._loop_state_by_loop == {}
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_raw_bracket_prose_preserved_inline_and_gcs(self):
|
|
inline = bigquery_agent_analytics_plugin.HybridContentParser(
|
|
offloader=None,
|
|
trace_id="t",
|
|
span_id="s",
|
|
max_length=-1,
|
|
)
|
|
prose = ("[INFO] ready", "[link](https://example.test)", "{not json}")
|
|
for value in prose:
|
|
payload, parts, truncated = await inline.parse(
|
|
types.Content(parts=[types.Part(text=value)])
|
|
)
|
|
assert payload == {"text_summary": value}
|
|
assert parts[0]["text"] == value
|
|
assert truncated is False
|
|
|
|
offloader = mock.AsyncMock()
|
|
offloader.upload_content.return_value = "gs://bucket/safe.txt"
|
|
offloaded = bigquery_agent_analytics_plugin.HybridContentParser(
|
|
offloader=offloader,
|
|
trace_id="t",
|
|
span_id="s",
|
|
max_length=-1,
|
|
)
|
|
large_prose = "[INFO] " + "safe prose " * 4000
|
|
await offloaded.parse(types.Content(parts=[types.Part(text=large_prose)]))
|
|
assert offloader.upload_content.call_args.args[0] == large_prose
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_bracket_prose_auth_and_signature_classification(self):
|
|
parser = bigquery_agent_analytics_plugin.HybridContentParser(
|
|
offloader=None,
|
|
trace_id="t",
|
|
span_id="s",
|
|
max_length=-1,
|
|
)
|
|
safe = (
|
|
r"[INFO] C:\Users\secret\project",
|
|
"[INFO] the bearer of bad news",
|
|
"[INFO] a basic principle",
|
|
"[INFO] a basic test",
|
|
"[INFO] design=balanced and progress%3D100%25",
|
|
)
|
|
for value in safe:
|
|
payload, parts, truncated = await parser.parse(
|
|
types.Content(parts=[types.Part(text=value)])
|
|
)
|
|
assert payload == {"text_summary": value}
|
|
assert parts[0]["text"] == value
|
|
assert truncated is False
|
|
|
|
unsafe = (
|
|
("[WARN] Bearer\tBRACKET-BEARER-SECRET", "BRACKET-BEARER-SECRET"),
|
|
("[WARN] Basic\ndXNlcjpwYXNz", "dXNlcjpwYXNz"),
|
|
("[WARN] sig=BRACKET-SIG-SECRET", "BRACKET-SIG-SECRET"),
|
|
(
|
|
"[WARN] x-amz-signature=BRACKET-AMZ-SECRET",
|
|
"BRACKET-AMZ-SECRET",
|
|
),
|
|
(
|
|
"[WARN] access%255Ftoken%253DBRACKET-ENCODED-SECRET",
|
|
"BRACKET-ENCODED-SECRET",
|
|
),
|
|
)
|
|
for value, secret in unsafe:
|
|
payload, parts, truncated = await parser.parse(
|
|
types.Content(parts=[types.Part(text=value)])
|
|
)
|
|
stored = json.dumps({"payload": payload, "parts": parts})
|
|
assert secret not in stored
|
|
assert "[UNPARSEABLE_JSON_BLOB]" in stored
|
|
assert truncated is True
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_malformed_bracket_credentials_still_fail_closed(self):
|
|
parser = bigquery_agent_analytics_plugin.HybridContentParser(
|
|
offloader=None,
|
|
trace_id="t",
|
|
span_id="s",
|
|
max_length=-1,
|
|
)
|
|
for value in (
|
|
'{"access_token":"MALFORMED-SECRET"',
|
|
'{"access\\u005ftoken":"ESCAPED-SECRET"',
|
|
):
|
|
payload, parts, truncated = await parser.parse(
|
|
types.Content(parts=[types.Part(text=value)])
|
|
)
|
|
stored = json.dumps({"payload": payload, "parts": parts})
|
|
assert "MALFORMED-SECRET" not in stored
|
|
assert "ESCAPED-SECRET" not in stored
|
|
assert "[UNPARSEABLE_JSON_BLOB]" in stored
|
|
assert truncated is True
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_external_uri_redacts_query_fragment_and_userinfo(self):
|
|
parser = bigquery_agent_analytics_plugin.HybridContentParser(
|
|
offloader=None,
|
|
trace_id="t",
|
|
span_id="s",
|
|
max_length=-1,
|
|
)
|
|
signed = (
|
|
"https://storage.example.test/safe/path?safe=kept"
|
|
"&X-Goog-Credential=URI-CREDENTIAL"
|
|
"&X-Goog-Signature=URI-SIGNATURE#access-token=FRAGMENT-SECRET"
|
|
)
|
|
_, parts, truncated = await parser.parse(
|
|
types.Content(
|
|
parts=[types.Part.from_uri(file_uri=signed, mime_type="text/plain")]
|
|
)
|
|
)
|
|
uri = parts[0]["uri"]
|
|
assert uri.startswith("https://storage.example.test/safe/path?")
|
|
assert "safe=kept" in uri
|
|
assert all(
|
|
secret not in uri
|
|
for secret in ("URI-CREDENTIAL", "URI-SIGNATURE", "FRAGMENT-SECRET")
|
|
)
|
|
assert truncated is True
|
|
|
|
userinfo = types.Part(
|
|
file_data=types.FileData(
|
|
file_uri="https://user:password@example.test/safe",
|
|
mime_type="text/plain",
|
|
)
|
|
)
|
|
_, parts, truncated = await parser.parse(types.Content(parts=[userinfo]))
|
|
assert parts[0]["uri"] == "[REDACTED_SENSITIVE_URI]"
|
|
assert truncated is True
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_external_uri_redacts_sensitive_path_segments_and_variants(
|
|
self,
|
|
):
|
|
parser = bigquery_agent_analytics_plugin.HybridContentParser(
|
|
offloader=None,
|
|
trace_id="t",
|
|
span_id="s",
|
|
max_length=-1,
|
|
)
|
|
uri = (
|
|
"https://example.test/public/access-token/PATH-SECRET/report"
|
|
"?x-amz-signature=QUERY-SIGNATURE-SECRET"
|
|
"&access%255Ftoken%253DDOUBLE-QUERY-SECRET"
|
|
)
|
|
_, parts, truncated = await parser.parse(
|
|
types.Content(
|
|
parts=[types.Part.from_uri(file_uri=uri, mime_type="text/plain")]
|
|
)
|
|
)
|
|
stored_uri = parts[0]["uri"]
|
|
for secret in (
|
|
"PATH-SECRET",
|
|
"QUERY-SIGNATURE-SECRET",
|
|
"DOUBLE-QUERY-SECRET",
|
|
):
|
|
assert secret not in stored_uri
|
|
assert "/public/%5BREDACTED%5D/%5BREDACTED%5D/report" in stored_uri
|
|
assert truncated is True
|
|
|
|
safe_uri = "https://example.test/design/signal/public/progress%25/report"
|
|
_, parts, truncated = await parser.parse(
|
|
types.Content(
|
|
parts=[
|
|
types.Part.from_uri(file_uri=safe_uri, mime_type="text/plain")
|
|
]
|
|
)
|
|
)
|
|
assert parts[0]["uri"] == safe_uri
|
|
assert truncated is False
|
|
|
|
missing = types.Part(
|
|
file_data=types.FileData(file_uri=None, mime_type="text/plain")
|
|
)
|
|
_, parts, truncated = await parser.parse(types.Content(parts=[missing]))
|
|
assert parts[0]["uri"] == "[REDACTED_SENSITIVE_URI]"
|
|
assert truncated is True
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_structured_non_text_parts_are_complete_and_private(self):
|
|
parser = bigquery_agent_analytics_plugin.HybridContentParser(
|
|
offloader=None,
|
|
trace_id="t",
|
|
span_id="s",
|
|
max_length=1000,
|
|
)
|
|
secret = "STRUCTURED-PART-SECRET"
|
|
content = types.Content(
|
|
parts=[
|
|
types.Part(
|
|
function_response=types.FunctionResponse(
|
|
name="lookup", response={"access_token": secret, "ok": True}
|
|
)
|
|
),
|
|
types.Part(
|
|
executable_code=types.ExecutableCode(
|
|
language=types.Language.PYTHON,
|
|
code=json.dumps({"private_key": secret}),
|
|
)
|
|
),
|
|
types.Part(
|
|
code_execution_result=types.CodeExecutionResult(
|
|
outcome=types.Outcome.OUTCOME_OK,
|
|
output=f"Authorization: Bearer {secret}",
|
|
)
|
|
),
|
|
]
|
|
)
|
|
|
|
payload, parts, truncated = await parser.parse(content)
|
|
stored = json.dumps({"payload": payload, "parts": parts})
|
|
assert secret not in stored
|
|
assert truncated is True
|
|
assert "Function response: lookup" in payload["text_summary"]
|
|
assert "Executable code" in payload["text_summary"]
|
|
assert "Code execution result" in payload["text_summary"]
|
|
assert "function_response" in json.loads(parts[0]["part_attributes"])
|
|
assert "executable_code" in json.loads(parts[1]["part_attributes"])
|
|
assert "code_execution_result" in json.loads(parts[2]["part_attributes"])
|
|
|
|
def test_structured_part_dictionary_keys_are_sanitized_without_collisions(
|
|
self,
|
|
):
|
|
parser = bigquery_agent_analytics_plugin.HybridContentParser(
|
|
offloader=None,
|
|
trace_id="t",
|
|
span_id="s",
|
|
max_length=1000,
|
|
)
|
|
value = mock.MagicMock()
|
|
value.model_dump.return_value = {
|
|
"access_token=[REDACTED]": "genuine-marker",
|
|
"access_token=KEY-ONE-SECRET": "first",
|
|
"access-token=KEY-TWO-SECRET": "second",
|
|
"[KEY_COLLISION_1]access_token=[REDACTED]": "genuine-collision",
|
|
"sig": "STRUCTURED-SIG-SECRET",
|
|
"x-amz-credential": "STRUCTURED-AMZ-SECRET",
|
|
}
|
|
|
|
serialized, content_lost = parser._serialize_part_model(value)
|
|
dumped = json.dumps(serialized)
|
|
assert "KEY-ONE-SECRET" not in dumped
|
|
assert "KEY-TWO-SECRET" not in dumped
|
|
assert "STRUCTURED-SIG-SECRET" not in dumped
|
|
assert "STRUCTURED-AMZ-SECRET" not in dumped
|
|
assert sorted(serialized.values()) == [
|
|
"[REDACTED]",
|
|
"[REDACTED]",
|
|
"first",
|
|
"genuine-collision",
|
|
"genuine-marker",
|
|
"second",
|
|
]
|
|
assert len(serialized) == 6
|
|
assert any(key.startswith("[KEY_COLLISION_") for key in serialized)
|
|
assert content_lost is True
|