Files
adk-python/src/google/adk/plugins/bigquery_agent_analytics_plugin.py
T
George WealeandCopybara-Service 2367901ec5 chore: Upgrade to headers to 2026
Co-authored-by: George Weale <gweale@google.com>
PiperOrigin-RevId: 858763407
2026-01-20 14:50:09 -08:00

2101 lines
69 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 atexit
from concurrent.futures import ThreadPoolExecutor
import contextvars
import dataclasses
from dataclasses import dataclass
from dataclasses import field
from datetime import datetime
from datetime import timezone
import functools
import json
import logging
import mimetypes
import random
import time
from types import MappingProxyType
from typing import Any
from typing import Callable
from typing import Optional
from typing import TYPE_CHECKING
import uuid
import weakref
from google.api_core import client_options
from google.api_core.exceptions import InternalServerError
from google.api_core.exceptions import ServiceUnavailable
from google.api_core.exceptions import TooManyRequests
from google.api_core.gapic_v1 import client_info as gapic_client_info
import google.auth
from google.cloud import bigquery
from google.cloud import exceptions as cloud_exceptions
from google.cloud import storage
from google.cloud.bigquery import schema as bq_schema
from google.cloud.bigquery_storage_v1 import types as bq_storage_types
from google.cloud.bigquery_storage_v1.services.big_query_write.async_client import BigQueryWriteAsyncClient
from google.genai import types
import pyarrow as pa
from ..agents.callback_context import CallbackContext
from ..models.llm_request import LlmRequest
from ..models.llm_response import LlmResponse
from ..tools.base_tool import BaseTool
from ..tools.tool_context import ToolContext
from ..version import __version__
from .base_plugin import BasePlugin
if TYPE_CHECKING:
from ..agents.invocation_context import InvocationContext
logger: logging.Logger = logging.getLogger("google_adk." + __name__)
# gRPC Error Codes
_GRPC_DEADLINE_EXCEEDED = 4
_GRPC_INTERNAL = 13
_GRPC_UNAVAILABLE = 14
# --- Helper Formatters ---
def _format_content(
content: Optional[types.Content], *, max_len: int = 5000
) -> tuple[str, bool]:
"""Formats an Event content for logging.
Args:
content: The content to format.
max_len: Maximum length for text parts.
Returns:
A tuple of (formatted_string, is_truncated).
"""
if content is None or not content.parts:
return "None", False
parts = []
truncated = False
for p in content.parts:
if p.text:
if max_len != -1 and len(p.text) > max_len:
parts.append(f"text: '{p.text[:max_len]}...'")
truncated = True
else:
parts.append(f"text: '{p.text}'")
elif p.function_call:
parts.append(f"call: {p.function_call.name}")
elif p.function_response:
parts.append(f"resp: {p.function_response.name}")
else:
parts.append("other")
return " | ".join(parts), truncated
def _recursive_smart_truncate(obj: Any, max_len: int) -> tuple[Any, bool]:
"""Recursively truncates string values within a dict or list.
Args:
obj: The object to truncate.
max_len: Maximum length for string values.
Returns:
A tuple of (truncated_object, is_truncated).
"""
if isinstance(obj, str):
if max_len != -1 and len(obj) > max_len:
return obj[:max_len] + "...[TRUNCATED]", True
return obj, False
elif isinstance(obj, dict):
truncated_any = False
# Use dict comprehension for potentially slightly better performance,
# but explicit loop is fine for clarity given recursive nature.
new_dict = {}
for k, v in obj.items():
val, trunc = _recursive_smart_truncate(v, max_len)
if trunc:
truncated_any = True
new_dict[k] = val
return new_dict, truncated_any
elif isinstance(obj, (list, tuple)):
truncated_any = False
new_list = []
# Explicit loop to handle flag propagation
for i in obj:
val, trunc = _recursive_smart_truncate(i, max_len)
if trunc:
truncated_any = True
new_list.append(val)
return type(obj)(new_list), truncated_any
elif dataclasses.is_dataclass(obj) and not isinstance(obj, type):
# Convert dataclasses to dicts so they become valid JSON objects
return _recursive_smart_truncate(dataclasses.asdict(obj), max_len)
elif hasattr(obj, "model_dump") and callable(obj.model_dump):
# Pydantic v2
try:
return _recursive_smart_truncate(obj.model_dump(), max_len)
except Exception:
pass
elif hasattr(obj, "dict") and callable(obj.dict):
# Pydantic v1
try:
return _recursive_smart_truncate(obj.dict(), max_len)
except Exception:
pass
elif hasattr(obj, "to_dict") and callable(obj.to_dict):
# Common pattern for custom objects
try:
return _recursive_smart_truncate(obj.to_dict(), max_len)
except Exception:
pass
elif obj is None or isinstance(obj, (int, float, bool)):
# Basic types are safe
return obj, False
# Fallback for unknown types: Convert to string to ensure JSON validity
# We return string representation of the object, which is a valid JSON string value.
return str(obj), False
# --- PyArrow Helper Functions ---
def _pyarrow_datetime() -> pa.DataType:
return pa.timestamp("us", tz=None)
def _pyarrow_numeric() -> pa.DataType:
return pa.decimal128(38, 9)
def _pyarrow_bignumeric() -> pa.DataType:
return pa.decimal256(76, 38)
def _pyarrow_time() -> pa.DataType:
return pa.time64("us")
def _pyarrow_timestamp() -> pa.DataType:
return pa.timestamp("us", tz="UTC")
_BQ_TO_ARROW_SCALARS = MappingProxyType({
"BOOL": pa.bool_,
"BOOLEAN": pa.bool_,
"BYTES": pa.binary,
"DATE": pa.date32,
"DATETIME": _pyarrow_datetime,
"FLOAT": pa.float64,
"FLOAT64": pa.float64,
"GEOGRAPHY": pa.string,
"INT64": pa.int64,
"INTEGER": pa.int64,
"JSON": pa.string,
"NUMERIC": _pyarrow_numeric,
"BIGNUMERIC": _pyarrow_bignumeric,
"STRING": pa.string,
"TIME": _pyarrow_time,
"TIMESTAMP": _pyarrow_timestamp,
})
_BQ_FIELD_TYPE_TO_ARROW_FIELD_METADATA = {
"GEOGRAPHY": {
b"ARROW:extension:name": b"google:sqlType:geography",
b"ARROW:extension:metadata": b'{"encoding": "WKT"}',
},
"DATETIME": {b"ARROW:extension:name": b"google:sqlType:datetime"},
"JSON": {b"ARROW:extension:name": b"google:sqlType:json"},
}
_STRUCT_TYPES = ("RECORD", "STRUCT")
def _bq_to_arrow_scalars(bq_scalar: str) -> Optional[Callable[[], pa.DataType]]:
"""Maps BigQuery scalar types to PyArrow type constructors."""
return _BQ_TO_ARROW_SCALARS.get(bq_scalar)
def _bq_to_arrow_field(bq_field: bq_schema.SchemaField) -> Optional[pa.Field]:
"""Converts a BigQuery SchemaField to a PyArrow Field."""
arrow_type = _bq_to_arrow_data_type(bq_field)
if arrow_type:
metadata = _BQ_FIELD_TYPE_TO_ARROW_FIELD_METADATA.get(
bq_field.field_type.upper() if bq_field.field_type else ""
)
nullable = bq_field.mode.upper() != "REQUIRED"
return pa.field(
bq_field.name, arrow_type, nullable=nullable, metadata=metadata
)
logger.warning(
"Could not determine Arrow type for field '%s' with type '%s'.",
bq_field.name,
bq_field.field_type,
)
return None
def _bq_to_arrow_struct_data_type(
field: bq_schema.SchemaField,
) -> Optional[pa.StructType]:
"""Converts a BigQuery RECORD/STRUCT field to a PyArrow StructType."""
arrow_fields = []
for subfield in field.fields:
arrow_subfield = _bq_to_arrow_field(subfield)
if arrow_subfield:
arrow_fields.append(arrow_subfield)
else:
logger.warning(
"Failed to convert STRUCT/RECORD field '%s' due to subfield '%s'.",
field.name,
subfield.name,
)
return None
return pa.struct(arrow_fields)
def _bq_to_arrow_data_type(
field: bq_schema.SchemaField,
) -> Optional[pa.DataType]:
"""Converts a BigQuery field to a PyArrow DataType."""
if field.mode == "REPEATED":
inner = _bq_to_arrow_data_type(
bq_schema.SchemaField(field.name, field.field_type, fields=field.fields)
)
return pa.list_(inner) if inner else None
field_type_upper = field.field_type.upper() if field.field_type else ""
if field_type_upper in _STRUCT_TYPES:
return _bq_to_arrow_struct_data_type(field)
constructor = _bq_to_arrow_scalars(field_type_upper)
if constructor:
return constructor()
else:
logger.warning(
"Failed to convert BigQuery field '%s': unsupported type '%s'.",
field.name,
field.field_type,
)
return None
def to_arrow_schema(
bq_schema_list: list[bq_schema.SchemaField],
) -> Optional[pa.Schema]:
"""Converts a list of BigQuery SchemaFields to a PyArrow Schema.
Args:
bq_schema_list: list of bigquery.SchemaField objects.
Returns:
pa.Schema or None if conversion fails.
"""
arrow_fields = []
for bq_field in bq_schema_list:
af = _bq_to_arrow_field(bq_field)
if af:
arrow_fields.append(af)
else:
logger.error("Failed to convert schema due to field '%s'.", bq_field.name)
return None
return pa.schema(arrow_fields)
# ==============================================================================
# CONFIGURATION
# ==============================================================================
@dataclass
class RetryConfig:
"""Configuration for retrying failed BigQuery write operations.
Attributes:
max_retries: Maximum number of retry attempts.
initial_delay: Initial delay between retries in seconds.
multiplier: Multiplier for exponential backoff.
max_delay: Maximum delay between retries in seconds.
"""
max_retries: int = 3
initial_delay: float = 1.0
multiplier: float = 2.0
max_delay: float = 10.0
@dataclass
class BigQueryLoggerConfig:
"""Configuration for the BigQueryAgentAnalyticsPlugin.
Attributes:
enabled: Whether logging is enabled.
event_allowlist: list of event types to log. If None, all are allowed.
event_denylist: list of event types to ignore.
max_content_length: Max length for text content before truncation.
table_id: BigQuery table ID.
clustering_fields: Fields to cluster the table by.
log_multi_modal_content: Whether to log detailed content parts.
retry_config: Retry configuration for writes.
batch_size: Number of rows per batch.
batch_flush_interval: Max time to wait before flushing a batch.
shutdown_timeout: Max time to wait for shutdown.
queue_max_size: Max size of the in-memory queue.
content_formatter: Optional custom formatter for content.
"""
enabled: bool = True
# V1 Configuration Parity
event_allowlist: list[str] | None = None
event_denylist: list[str] | None = None
max_content_length: int = 500 * 1024 # Defaults to 500KB per text block
table_id: str = "agent_events_v2"
# V2 Configuration
clustering_fields: list[str] = field(
default_factory=lambda: ["event_type", "agent", "user_id"]
)
log_multi_modal_content: bool = True
retry_config: RetryConfig = field(default_factory=RetryConfig)
batch_size: int = 1
batch_flush_interval: float = 1.0
shutdown_timeout: float = 10.0
queue_max_size: int = 10000
content_formatter: Optional[Callable[[Any, str], Any]] = None
# If provided, large content (images, audio, video, large text) will be offloaded to this GCS bucket.
gcs_bucket_name: Optional[str] = None
# If provided, this connection ID will be used as the authorizer for ObjectRef columns.
# Format: "location.connection_id" (e.g. "us.my-connection")
connection_id: Optional[str] = None
# ==============================================================================
# HELPER: TRACE MANAGER (Async-Safe with ContextVars)
# ==============================================================================
_trace_id_ctx = contextvars.ContextVar("_bq_analytics_trace_id", default=None)
_root_agent_name_ctx = contextvars.ContextVar(
"_bq_analytics_root_agent_name", default=None
)
_span_stack_ctx = contextvars.ContextVar("_bq_analytics_span_stack", default=())
_span_times_ctx = contextvars.ContextVar(
"_bq_analytics_span_times", default=None
)
_span_first_token_times_ctx = contextvars.ContextVar(
"_bq_analytics_span_first_token_times", default=None
)
class TraceManager:
"""Manages OpenTelemetry-style trace and span context using contextvars."""
@staticmethod
def init_trace(callback_context: CallbackContext) -> None:
if _trace_id_ctx.get() is None:
_trace_id_ctx.set(callback_context.invocation_id)
# Extract root agent name from invocation context
try:
root_agent = callback_context._invocation_context.agent.root_agent
_root_agent_name_ctx.set(root_agent.name)
except (AttributeError, ValueError):
pass
_span_stack_ctx.set(())
_span_times_ctx.set({})
_span_first_token_times_ctx.set({})
@staticmethod
def get_trace_id(callback_context: CallbackContext) -> Optional[str]:
# Try contextvars first
if trace_id := _trace_id_ctx.get():
return trace_id
# Fallback to callback_context for existing tests/legacy flows
return callback_context.state.get("_bq_analytics_trace_id")
@staticmethod
def push_span(
callback_context: CallbackContext, span_id: Optional[str] = None
) -> str:
# Ensure trace is initialized
if _trace_id_ctx.get() is None:
TraceManager.init_trace(callback_context)
span_id = span_id or str(uuid.uuid4())
stack = _span_stack_ctx.get()
new_stack = stack + (span_id,)
_span_stack_ctx.set(new_stack)
times = dict(_span_times_ctx.get() or {})
times[span_id] = time.time()
_span_times_ctx.set(times)
return span_id
@staticmethod
def pop_span() -> tuple[Optional[str], Optional[int]]:
stack = list(_span_stack_ctx.get())
if not stack:
return None, None
span_id = stack.pop()
_span_stack_ctx.set(tuple(stack))
times_dict = dict(_span_times_ctx.get() or {})
start_time = times_dict.pop(span_id, None)
_span_times_ctx.set(times_dict)
ft_dict = dict(_span_first_token_times_ctx.get() or {})
ft_dict.pop(span_id, None)
_span_first_token_times_ctx.set(ft_dict)
duration_ms = int((time.time() - start_time) * 1000) if start_time else None
return span_id, duration_ms
@staticmethod
def get_current_span_and_parent() -> tuple[Optional[str], Optional[str]]:
stack = _span_stack_ctx.get()
if not stack:
return None, None
return stack[-1], (stack[-2] if len(stack) > 1 else None)
@staticmethod
def get_current_span_id() -> Optional[str]:
stack = _span_stack_ctx.get()
return stack[-1] if stack else None
@staticmethod
def get_root_agent_name() -> Optional[str]:
return _root_agent_name_ctx.get()
@staticmethod
def get_start_time(span_id: str) -> Optional[float]:
times = _span_times_ctx.get()
return times.get(span_id) if times else None
@staticmethod
def record_first_token(span_id: str) -> bool:
"""Records the current time as first token time if not already recorded.
Returns:
True if this was the first token (newly recorded), False otherwise.
"""
first_tokens = dict(_span_first_token_times_ctx.get() or {})
if span_id not in first_tokens:
first_tokens[span_id] = time.time()
_span_first_token_times_ctx.set(first_tokens)
return True
return False
@staticmethod
def get_first_token_time(span_id: str) -> Optional[float]:
first_tokens = _span_first_token_times_ctx.get()
return first_tokens.get(span_id) if first_tokens else None
# ==============================================================================
# HELPER: BATCH PROCESSOR
# ==============================================================================
class BatchProcessor:
"""Handles asynchronous batching and writing of events to BigQuery."""
def __init__(
self,
write_client: BigQueryWriteAsyncClient,
arrow_schema: pa.Schema,
write_stream: str,
batch_size: int,
flush_interval: float,
retry_config: RetryConfig,
queue_max_size: int,
shutdown_timeout: float,
):
"""Initializes the BatchProcessor.
Args:
write_client: BigQueryWriteAsyncClient for writing rows.
arrow_schema: PyArrow schema for serialization.
write_stream: BigQuery write stream name.
batch_size: Number of rows per batch.
flush_interval: Max time to wait before flushing a batch.
retry_config: Retry configuration.
queue_max_size: Max size of the in-memory queue.
shutdown_timeout: Max time to wait for shutdown.
"""
self.write_client = write_client
self.arrow_schema = arrow_schema
self.write_stream = write_stream
self.batch_size = batch_size
self.flush_interval = flush_interval
self.retry_config = retry_config
self.shutdown_timeout = shutdown_timeout
self._queue: asyncio.Queue[dict[str, Any]] = asyncio.Queue(
maxsize=queue_max_size
)
self._batch_processor_task: Optional[asyncio.Task] = None
self._shutdown = False
async def start(self):
"""Starts the batch writer worker task."""
if self._batch_processor_task is None:
self._batch_processor_task = asyncio.create_task(self._batch_writer())
async def append(self, row: dict[str, Any]) -> None:
"""Appends a row to the queue for batching.
Args:
row: Dictionary representing a single row.
"""
try:
self._queue.put_nowait(row)
except asyncio.QueueFull:
logger.warning("BigQuery log queue full, dropping event.")
def _prepare_arrow_batch(self, rows: list[dict[str, Any]]) -> pa.RecordBatch:
"""Prepares a PyArrow RecordBatch from a list of rows.
Args:
rows: list of row dictionaries.
Returns:
pa.RecordBatch for writing.
"""
data = {field.name: [] for field in self.arrow_schema}
for row in rows:
for field in self.arrow_schema:
value = row.get(field.name)
# JSON fields must be serialized to strings for the Arrow layer
field_metadata = self.arrow_schema.field(field.name).metadata
is_json = False
if field_metadata and b"ARROW:extension:name" in field_metadata:
if field_metadata[b"ARROW:extension:name"] == b"google:sqlType:json":
is_json = True
arrow_field_type = self.arrow_schema.field(field.name).type
is_struct = pa.types.is_struct(arrow_field_type)
is_list = pa.types.is_list(arrow_field_type)
if is_json:
if value is not None:
if isinstance(value, (dict, list)):
try:
value = json.dumps(value)
except (TypeError, ValueError):
value = str(value)
elif isinstance(value, (str, bytes)):
if isinstance(value, bytes):
try:
value = value.decode("utf-8")
except UnicodeDecodeError:
value = str(value)
# Check if it's already a valid JSON object or array to avoid double-encoding
is_already_json = False
if isinstance(value, str):
stripped = value.strip()
if stripped.startswith(("{", "[")) and stripped.endswith(
("}", "]")
):
try:
json.loads(value)
is_already_json = True
except (ValueError, TypeError):
pass
if not is_already_json:
try:
value = json.dumps(value)
except (TypeError, ValueError):
value = str(value)
# If is_already_json is True, we keep value as-is
else:
# For other types (int, float, bool), serialize to JSON equivalents
try:
value = json.dumps(value)
except (TypeError, ValueError):
value = str(value)
elif isinstance(value, (dict, list)) and not is_struct and not is_list:
if value is not None and not isinstance(value, (str, bytes)):
try:
value = json.dumps(value)
except (TypeError, ValueError):
value = str(value)
data[field.name].append(value)
return pa.RecordBatch.from_pydict(data, schema=self.arrow_schema)
async def _batch_writer(self) -> None:
"""Worker task that batches and writes rows to BigQuery."""
while not self._shutdown or not self._queue.empty():
batch = []
try:
if self._shutdown:
try:
first_item = self._queue.get_nowait()
except asyncio.QueueEmpty:
break
else:
first_item = await asyncio.wait_for(
self._queue.get(), timeout=self.flush_interval
)
batch.append(first_item)
while len(batch) < self.batch_size:
try:
item = self._queue.get_nowait()
batch.append(item)
except asyncio.QueueEmpty:
break
if batch:
try:
await self._write_rows_with_retry(batch)
finally:
# Mark tasks as done ONLY after processing (write attempt)
for _ in batch:
self._queue.task_done()
except asyncio.TimeoutError:
continue
except asyncio.CancelledError:
logger.info("Batch writer task cancelled.")
break
except Exception as e:
logger.error("Error in batch writer loop: %s", e, exc_info=True)
await asyncio.sleep(1)
async def _write_rows_with_retry(self, rows: list[dict[str, Any]]) -> None:
"""Writes a batch of rows to BigQuery with retry logic.
Args:
rows: list of row dictionaries to write.
"""
attempt = 0
delay = self.retry_config.initial_delay
try:
arrow_batch = self._prepare_arrow_batch(rows)
serialized_schema = self.arrow_schema.serialize().to_pybytes()
serialized_batch = arrow_batch.serialize().to_pybytes()
req = bq_storage_types.AppendRowsRequest(
write_stream=self.write_stream,
trace_id=f"google-adk-bq-logger/{__version__}",
)
req.arrow_rows.writer_schema.serialized_schema = serialized_schema
req.arrow_rows.rows.serialized_record_batch = serialized_batch
except Exception as e:
logger.error(
"Failed to prepare Arrow batch (Data Loss): %s", e, exc_info=True
)
return
while attempt <= self.retry_config.max_retries:
try:
async def requests_iter():
yield req
responses = await self.write_client.append_rows(requests_iter())
async for response in responses:
error = getattr(response, "error", None)
error_code = getattr(error, "code", None)
if error_code and error_code != 0:
error_message = getattr(error, "message", "Unknown error")
logger.warning(
"BigQuery Write API returned error code %s: %s",
error_code,
error_message,
)
if error_code in [
_GRPC_DEADLINE_EXCEEDED,
_GRPC_INTERNAL,
_GRPC_UNAVAILABLE,
]: # Deadline, Internal, Unavailable
raise ServiceUnavailable(error_message)
else:
if "schema mismatch" in error_message.lower():
logger.error(
"BigQuery Schema Mismatch: %s. This usually means the"
" table schema does not match the expected schema.",
error_message,
)
else:
logger.error("Non-retryable BigQuery error: %s", error_message)
row_errors = getattr(response, "row_errors", [])
if row_errors:
for row_error in row_errors:
logger.error("Row error details: %s", row_error)
logger.error("Row content causing error: %s", rows)
return
return
except (ServiceUnavailable, TooManyRequests, InternalServerError) as e:
attempt += 1
if attempt > self.retry_config.max_retries:
logger.error(
"BigQuery Batch Dropped after %s attempts. Last error: %s",
self.retry_config.max_retries + 1,
e,
)
return
sleep_time = min(
delay * (1 + random.random()), self.retry_config.max_delay
)
logger.warning(
"BigQuery write failed (Attempt %s), retrying in %.2fs..."
" Error: %s",
attempt,
sleep_time,
e,
)
await asyncio.sleep(sleep_time)
delay *= self.retry_config.multiplier
except Exception as e:
logger.error(
"Unexpected BigQuery Write API error (Dropping batch): %s",
e,
exc_info=True,
)
return
async def shutdown(self, timeout: float = 5.0) -> None:
"""Shuts down the BatchProcessor, draining the queue.
Args:
timeout: Maximum time to wait for the queue to drain.
"""
self._shutdown = True
logger.info("BatchProcessor shutting down, draining queue...")
if self._batch_processor_task:
try:
await asyncio.wait_for(self._batch_processor_task, timeout=timeout)
except asyncio.TimeoutError:
logger.warning("BatchProcessor shutdown timed out, cancelling worker.")
self._batch_processor_task.cancel()
except Exception as e:
logger.error("Error during BatchProcessor shutdown: %s", e)
async def close(self) -> None:
"""Closes the processor and flushes remaining items."""
if self._shutdown:
return
self._shutdown = True
# Wait for queue to be empty
try:
await asyncio.wait_for(self._queue.join(), timeout=self.shutdown_timeout)
except (asyncio.TimeoutError, asyncio.CancelledError):
logger.warning(
"Timeout waiting for BigQuery batch queue to empty on shutdown."
)
# Cancel the writer task if it's still running (it should exit on _shutdown + empty queue)
if self._batch_processor_task and not self._batch_processor_task.done():
self._batch_processor_task.cancel()
try:
await self._batch_processor_task
except asyncio.CancelledError:
pass
# ==============================================================================
# HELPER: CONTENT PARSER (Length Limits Only)
# ==============================================================================
class ContentParser:
"""Parses content for logging with length limits and structure normalization."""
def __init__(self, max_length: int) -> None:
"""Initializes the ContentParser.
Args:
max_length: Maximum length for text content.
"""
self.max_length = max_length
def _truncate(self, text: str) -> tuple[str, bool]:
if self.max_length != -1 and text and len(text) > self.max_length:
return text[: self.max_length] + "...[TRUNCATED]", True
return text, False
class GCSOffloader:
"""Offloads content to GCS."""
def __init__(
self,
project_id: str,
bucket_name: str,
executor: ThreadPoolExecutor,
storage_client: Optional[storage.Client] = None,
):
self.client = storage_client or storage.Client(project=project_id)
self.bucket = self.client.bucket(bucket_name)
self.executor = executor
async def upload_content(
self, data: bytes | str, content_type: str, path: str
) -> str:
"""Async wrapper around blocking GCS upload."""
loop = asyncio.get_running_loop()
return await loop.run_in_executor(
self.executor,
functools.partial(self._upload_sync, data, content_type, path),
)
def _upload_sync(
self, data: bytes | str, content_type: str, path: str
) -> str:
blob = self.bucket.blob(path)
blob.upload_from_string(data, content_type=content_type)
return f"gs://{self.bucket.name}/{path}"
class HybridContentParser:
"""Parses content and offloads large/binary parts to GCS."""
def __init__(
self,
offloader: Optional[GCSOffloader],
trace_id: str,
span_id: str,
max_length: int = 20000,
connection_id: Optional[str] = None,
):
self.offloader = offloader
self.trace_id = trace_id
self.span_id = span_id
self.max_length = max_length
self.connection_id = connection_id
self.inline_text_limit = 32 * 1024 # 32KB limit
def _truncate(self, text: str) -> tuple[str, bool]:
if self.max_length != -1 and len(text) > self.max_length:
return (
text[: self.max_length] + "...[TRUNCATED]",
True,
)
return text, False
async def _parse_content_object(
self, content: types.Content | types.Part
) -> tuple[str, list[dict[str, Any]], bool]:
"""Parses a Content or Part object into summary text and content parts."""
content_parts = []
is_truncated = False
summary_text = []
parts = content.parts if hasattr(content, "parts") else [content]
for idx, part in enumerate(parts):
part_data = {
"part_index": idx,
"mime_type": "text/plain",
"uri": None,
"text": None,
"part_attributes": "{}",
"storage_mode": "INLINE",
"object_ref": None,
}
# CASE A: It is already a URI (e.g. from user input)
if hasattr(part, "file_data") and part.file_data:
part_data["storage_mode"] = "EXTERNAL_URI"
part_data["uri"] = part.file_data.file_uri
part_data["mime_type"] = part.file_data.mime_type
# CASE B: It is Binary/Inline Data (Image/Blob)
elif hasattr(part, "inline_data") and part.inline_data:
if self.offloader:
ext = mimetypes.guess_extension(part.inline_data.mime_type) or ".bin"
path = f"{datetime.now().date()}/{self.trace_id}/{self.span_id}_p{idx}{ext}"
try:
uri = await self.offloader.upload_content(
part.inline_data.data, part.inline_data.mime_type, path
)
part_data["storage_mode"] = "GCS_REFERENCE"
part_data["uri"] = uri
object_ref = {
"uri": uri,
"version": None,
"authorizer": self.connection_id,
"details": json.dumps({
"gcs_metadata": {"content_type": part.inline_data.mime_type}
}),
}
part_data["object_ref"] = object_ref
part_data["mime_type"] = part.inline_data.mime_type
part_data["text"] = "[MEDIA OFFLOADED]"
except Exception as e:
logger.warning("Failed to offload content to GCS: %s", e)
part_data["text"] = "[UPLOAD FAILED]"
else:
part_data["text"] = "[BINARY DATA]"
# CASE C: Text
elif hasattr(part, "text") and part.text:
text_len = len(part.text.encode("utf-8"))
# If max_length is set and smaller than inline limit, use it as threshold
# to prefer offloading over truncation.
offload_threshold = self.inline_text_limit
if self.max_length != -1 and self.max_length < offload_threshold:
offload_threshold = self.max_length
if self.offloader and text_len > offload_threshold:
# Text is too big, treat as file
path = f"{datetime.now().date()}/{self.trace_id}/{self.span_id}_p{idx}.txt"
try:
uri = await self.offloader.upload_content(
part.text, "text/plain", path
)
part_data["storage_mode"] = "GCS_REFERENCE"
part_data["uri"] = uri
object_ref = {
"uri": uri,
"version": None,
"authorizer": self.connection_id,
"details": json.dumps(
{"gcs_metadata": {"content_type": "text/plain"}}
),
}
part_data["object_ref"] = object_ref
part_data["mime_type"] = "text/plain"
part_data["text"] = part.text[:200] + "... [OFFLOADED]"
except Exception as e:
logger.warning("Failed to offload text to GCS: %s", e)
clean_text, truncated = self._truncate(part.text)
if truncated:
is_truncated = True
part_data["text"] = clean_text
summary_text.append(clean_text)
else:
# Text is small or no offloader, keep inline
clean_text, truncated = self._truncate(part.text)
if truncated:
is_truncated = True
part_data["text"] = clean_text
summary_text.append(clean_text)
elif hasattr(part, "function_call") and part.function_call:
part_data["mime_type"] = "application/json"
part_data["text"] = f"Function: {part.function_call.name}"
part_data["part_attributes"] = json.dumps(
{"function_name": part.function_call.name}
)
content_parts.append(part_data)
summary_str, truncated = self._truncate(" | ".join(summary_text))
if truncated:
is_truncated = True
return summary_str, content_parts, is_truncated
async def parse(self, content: Any) -> tuple[Any, list[dict[str, Any]], bool]:
"""Parses content into JSON payload and content parts, potentially offloading to GCS."""
json_payload = {}
content_parts = []
is_truncated = False
def process_text(t: str) -> tuple[str, bool]:
return self._truncate(t)
if isinstance(content, LlmRequest):
# Handle Prompt
messages = []
contents = (
content.contents
if isinstance(content.contents, list)
else [content.contents]
)
for c in contents:
role = getattr(c, "role", "unknown")
summary, parts, trunc = await self._parse_content_object(c)
if trunc:
is_truncated = True
content_parts.extend(parts)
messages.append({"role": role, "content": summary})
if messages:
json_payload["prompt"] = messages
# Handle System Instruction
if content.config and getattr(content.config, "system_instruction", None):
si = content.config.system_instruction
if isinstance(si, str):
json_payload["system_prompt"] = si
else:
summary, parts, trunc = await self._parse_content_object(si)
if trunc:
is_truncated = True
content_parts.extend(parts)
json_payload["system_prompt"] = summary
elif isinstance(content, (types.Content, types.Part)):
summary, parts, trunc = await self._parse_content_object(content)
return {"text_summary": summary}, parts, trunc
elif isinstance(content, (dict, list)):
json_payload, is_truncated = _recursive_smart_truncate(
content, self.max_length
)
elif isinstance(content, str):
json_payload, is_truncated = process_text(content)
elif content is None:
json_payload = None
else:
json_payload, is_truncated = process_text(str(content))
return json_payload, content_parts, is_truncated
def _get_events_schema() -> list[bigquery.SchemaField]:
"""Returns the BigQuery schema for the events table."""
return [
bigquery.SchemaField(
"timestamp",
"TIMESTAMP",
mode="REQUIRED",
description=(
"The UTC timestamp when the event occurred. Used for ordering"
" events within a session."
),
),
bigquery.SchemaField(
"event_type",
"STRING",
mode="NULLABLE",
description=(
"The category of the event (e.g., 'LLM_REQUEST', 'TOOL_CALL',"
" 'AGENT_RESPONSE'). Helps in filtering specific types of"
" interactions."
),
),
bigquery.SchemaField(
"agent",
"STRING",
mode="NULLABLE",
description=(
"The name of the agent that generated this event. Useful for"
" multi-agent systems."
),
),
bigquery.SchemaField(
"session_id",
"STRING",
mode="NULLABLE",
description=(
"A unique identifier for the entire conversation session. Used"
" to group all events belonging to a single user interaction."
),
),
bigquery.SchemaField(
"invocation_id",
"STRING",
mode="NULLABLE",
description=(
"A unique identifier for a single turn or execution within a"
" session. Groups related events like LLM request and response."
),
),
bigquery.SchemaField(
"user_id",
"STRING",
mode="NULLABLE",
description=(
"The identifier of the end-user participating in the session,"
" if available."
),
),
bigquery.SchemaField(
"trace_id",
"STRING",
mode="NULLABLE",
description=(
"OpenTelemetry trace ID for distributed tracing across services."
),
),
bigquery.SchemaField(
"span_id",
"STRING",
mode="NULLABLE",
description="OpenTelemetry span ID for this specific operation.",
),
bigquery.SchemaField(
"parent_span_id",
"STRING",
mode="NULLABLE",
description=(
"OpenTelemetry parent span ID to reconstruct the operation"
" hierarchy."
),
),
bigquery.SchemaField(
"content",
"JSON",
mode="NULLABLE",
description=(
"The primary payload of the event, stored as a JSON string. The"
" structure depends on the event_type (e.g., prompt text for"
" LLM_REQUEST, tool output for TOOL_RESPONSE)."
),
),
bigquery.SchemaField(
"content_parts",
"RECORD",
mode="REPEATED",
fields=[
bigquery.SchemaField(
"mime_type",
"STRING",
mode="NULLABLE",
description=(
"The MIME type of the content part (e.g., 'text/plain',"
" 'image/png')."
),
),
bigquery.SchemaField(
"uri",
"STRING",
mode="NULLABLE",
description=(
"The URI of the content part if stored externally"
" (e.g., GCS bucket path)."
),
),
bigquery.SchemaField(
"object_ref",
"RECORD",
mode="NULLABLE",
fields=[
bigquery.SchemaField(
"uri",
"STRING",
mode="NULLABLE",
description="The URI of the object.",
),
bigquery.SchemaField(
"version",
"STRING",
mode="NULLABLE",
description="The version of the object.",
),
bigquery.SchemaField(
"authorizer",
"STRING",
mode="NULLABLE",
description="The authorizer for the object.",
),
bigquery.SchemaField(
"details",
"JSON",
mode="NULLABLE",
description="Additional details about the object.",
),
],
description=(
"The ObjectRef of the content part if stored externally."
),
),
bigquery.SchemaField(
"text",
"STRING",
mode="NULLABLE",
description="The raw text content if the part is text-based.",
),
bigquery.SchemaField(
"part_index",
"INTEGER",
mode="NULLABLE",
description=(
"The zero-based index of this part within the content."
),
),
bigquery.SchemaField(
"part_attributes",
"STRING",
mode="NULLABLE",
description=(
"Additional metadata for this content part as a JSON"
" object (serialized to string)."
),
),
bigquery.SchemaField(
"storage_mode",
"STRING",
mode="NULLABLE",
description=(
"Indicates how the content part is stored (e.g.,"
" 'INLINE', 'GCS_REFERENCE', 'EXTERNAL_URI')."
),
),
],
description=(
"For multi-modal events, contains a list of content parts"
" (text, images, etc.)."
),
),
bigquery.SchemaField(
"attributes",
"JSON",
mode="NULLABLE",
description=(
"A JSON object containing arbitrary key-value pairs for"
" additional event metadata. Includes enrichment fields like"
" 'root_agent_name' (turn orchestration), 'model' (request"
" model), 'model_version' (response version), and"
" 'usage_metadata' (detailed token counts)."
),
),
bigquery.SchemaField(
"latency_ms",
"JSON",
mode="NULLABLE",
description=(
"A JSON object containing latency measurements, such as"
" 'total_ms' and 'time_to_first_token_ms'."
),
),
bigquery.SchemaField(
"status",
"STRING",
mode="NULLABLE",
description="The outcome of the event, typically 'OK' or 'ERROR'.",
),
bigquery.SchemaField(
"error_message",
"STRING",
mode="NULLABLE",
description="Detailed error message if the status is 'ERROR'.",
),
bigquery.SchemaField(
"is_truncated",
"BOOLEAN",
mode="NULLABLE",
description=(
"Boolean flag indicating if the 'content' field was truncated"
" because it exceeded the maximum allowed size."
),
),
]
# ==============================================================================
# MAIN PLUGIN
# ==============================================================================
_GLOBAL_WRITE_CLIENT: Optional[BigQueryWriteAsyncClient] = None
_GLOBAL_CLIENT_LOCK = asyncio.Lock()
class BigQueryAgentAnalyticsPlugin(BasePlugin):
"""BigQuery Agent Analytics Plugin (v2.0 using Write API).
Logs agent events (LLM requests, tool calls, etc.) to BigQuery for analytics.
Uses the BigQuery Write API for efficient, asynchronous, and reliable logging.
"""
def __init__(
self,
project_id: str,
dataset_id: str,
*,
table_id: Optional[str] = None,
config: Optional[BigQueryLoggerConfig] = None,
location: str = "US",
) -> None:
"""Initializes the BigQueryAgentAnalyticsPlugin.
Args:
project_id: Google Cloud project ID.
dataset_id: BigQuery dataset ID.
table_id: BigQuery table ID (optional, overrides config).
config: BigQueryLoggerConfig (optional).
location: BigQuery location (default: "US").
"""
super().__init__(name="bigquery_agent_analytics")
self.project_id = project_id
self.dataset_id = dataset_id
self.config = config or BigQueryLoggerConfig()
self.table_id = table_id or self.config.table_id
self.location = location
self._started = False
self._is_shutting_down = False
self._setup_lock = None
self.client = None
self.write_client = None
self.write_stream = None
self.batch_processor = None
self._executor = None
self.offloader: Optional[GCSOffloader] = None
self.parser: Optional[HybridContentParser] = None
def _format_content_safely(
self, content: Optional[types.Content]
) -> tuple[str, bool]:
"""Formats content using config.content_formatter or default formatter.
Args:
content: The content to format.
Returns:
A tuple of (formatted_string, is_truncated).
"""
if content is None:
return "None", False
try:
# If a custom formatter is provided, we could try to use it here too,
# but it expects (content, event_type). For internal formatting,
# we stick to the default _format_content but respect max_len.
return _format_content(content, max_len=self.config.max_content_length)
except Exception as e:
logger.warning("Content formatter failed: %s", e)
return "[FORMATTING FAILED]", False
async def _lazy_setup(self, **kwargs) -> None:
"""Performs lazy initialization of BigQuery clients and resources."""
if self._started:
return
loop = asyncio.get_running_loop()
if not self.client:
if self._executor is None:
self._executor = ThreadPoolExecutor(max_workers=1)
self.client = await loop.run_in_executor(
self._executor,
lambda: bigquery.Client(
project=self.project_id, location=self.location
),
)
self.full_table_id = f"{self.project_id}.{self.dataset_id}.{self.table_id}"
self._schema = _get_events_schema()
await loop.run_in_executor(self._executor, self._ensure_schema_exists)
if not self.write_client:
global _GLOBAL_WRITE_CLIENT
async with _GLOBAL_CLIENT_LOCK:
if _GLOBAL_WRITE_CLIENT is None:
def get_credentials():
creds, project_id = google.auth.default(
scopes=["https://www.googleapis.com/auth/cloud-platform"]
)
return creds, project_id
creds, project_id = await loop.run_in_executor(
self._executor, get_credentials
)
quota_project_id = (
getattr(creds, "quota_project_id", None) or project_id
)
options = (
client_options.ClientOptions(quota_project_id=quota_project_id)
if quota_project_id
else None
)
client_info = gapic_client_info.ClientInfo(
user_agent=f"google-adk-bq-logger/{__version__}"
)
# Initialize the async client in the current event loop, not in the
# executor.
_GLOBAL_WRITE_CLIENT = BigQueryWriteAsyncClient(
credentials=creds,
client_info=client_info,
client_options=options,
)
self.write_client = _GLOBAL_WRITE_CLIENT
self.write_stream = f"projects/{self.project_id}/datasets/{self.dataset_id}/tables/{self.table_id}/_default"
if not self.batch_processor:
self.arrow_schema = to_arrow_schema(self._schema)
if not self.arrow_schema:
raise RuntimeError("Failed to convert BigQuery schema to Arrow schema.")
self.offloader = None
if self.config.gcs_bucket_name:
self.offloader = GCSOffloader(
self.project_id,
self.config.gcs_bucket_name,
self._executor,
storage_client=kwargs.get("storage_client"),
)
self.parser = HybridContentParser(
self.offloader,
"",
"",
max_length=self.config.max_content_length,
connection_id=self.config.connection_id,
)
self.batch_processor = BatchProcessor(
write_client=self.write_client,
arrow_schema=self.arrow_schema,
write_stream=self.write_stream,
batch_size=self.config.batch_size,
flush_interval=self.config.batch_flush_interval,
retry_config=self.config.retry_config,
queue_max_size=self.config.queue_max_size,
shutdown_timeout=self.config.shutdown_timeout,
)
await self.batch_processor.start()
# Register cleanup to ensure logs are flushed if user forgets to close
# Use weakref to avoid circular references that prevent garbage collection
atexit.register(self._atexit_cleanup, weakref.proxy(self.batch_processor))
@staticmethod
def _atexit_cleanup(batch_processor: "BatchProcessor") -> None:
"""Clean up batch processor on script exit."""
# Check if the batch_processor object is still alive
if batch_processor and not batch_processor._shutdown:
# Emergency Flush: Rescue any logs remaining in the queue
remaining_items = []
try:
while True:
remaining_items.append(batch_processor._queue.get_nowait())
except (asyncio.QueueEmpty, AttributeError):
pass
if remaining_items:
# We need a new loop and client to flush these
async def rescue_flush():
try:
# Create a short-lived client just for this flush
try:
# Note: This relies on google.auth.default() working in this context.
# pylint: disable=g-import-not-at-top
from google.cloud.bigquery_storage_v1.services.big_query_write.async_client import BigQueryWriteAsyncClient
# pylint: enable=g-import-not-at-top
client = BigQueryWriteAsyncClient()
except Exception as e:
logger.warning("Could not create rescue client: %s", e)
return
# Patch batch_processor.write_client temporarily
old_client = batch_processor.write_client
batch_processor.write_client = client
try:
# Force a write
await batch_processor._write_rows_with_retry(remaining_items)
logger.info("Rescued logs flushed successfully.")
except Exception as e:
logger.error("Failed to flush rescued logs: %s", e)
finally:
batch_processor.write_client = old_client
except Exception as e:
logger.error("Rescue flush failed: %s", e)
try:
loop = asyncio.new_event_loop()
loop.run_until_complete(rescue_flush())
loop.close()
except Exception as e:
logger.error("Failed to run rescue loop: %s", e)
def _ensure_schema_exists(self) -> None:
"""Ensures the BigQuery table exists with the correct schema."""
try:
self.client.get_table(self.full_table_id)
except cloud_exceptions.NotFound:
logger.info("Table %s not found, creating table.", self.full_table_id)
tbl = bigquery.Table(self.full_table_id, schema=self._schema)
tbl.time_partitioning = bigquery.TimePartitioning(
type_=bigquery.TimePartitioningType.DAY, field="timestamp"
)
tbl.clustering_fields = self.config.clustering_fields
try:
self.client.create_table(tbl)
except cloud_exceptions.Conflict:
pass
except Exception as e:
logger.error(
"Could not create table %s: %s",
self.full_table_id,
e,
exc_info=True,
)
except Exception as e:
logger.error(
"Error checking for table %s: %s",
self.full_table_id,
e,
exc_info=True,
)
async def shutdown(self, timeout: float | None = None) -> None:
"""Shuts down the plugin and releases resources.
Args:
timeout: Maximum time to wait for the queue to drain.
"""
if self._is_shutting_down:
return
self._is_shutting_down = True
t = timeout if timeout is not None else self.config.shutdown_timeout
loop = asyncio.get_running_loop()
try:
if self.batch_processor:
await self.batch_processor.shutdown(timeout=t)
if self.write_client and getattr(self.write_client, "transport", None):
# Only close the client if it's NOT the global one (unlikely with new logic,
# but good for safety if injected manually) or if we decide to handle global close differently.
# For now, we DO NOT close the global client to allow reuse.
if self.write_client is not _GLOBAL_WRITE_CLIENT:
await self.write_client.transport.close()
if self.client:
if self._executor:
executor = self._executor
await loop.run_in_executor(None, lambda: executor.shutdown(wait=True))
self._executor = None
self.write_client = None
self.client = None
self._is_shutting_down = False
except Exception as e:
logger.error("Error during shutdown: %s", e, exc_info=True)
self._is_shutting_down = False
self._started = False
def __getstate__(self):
"""Custom pickling to exclude non-picklable runtime objects."""
state = self.__dict__.copy()
state["_setup_lock"] = None
state["client"] = None
state["write_client"] = None
state["write_stream"] = None
state["batch_processor"] = None
state["_executor"] = None
state["offloader"] = None
state["parser"] = None
state["_started"] = False
state["_is_shutting_down"] = False
return state
def __setstate__(self, state):
"""Custom unpickling to restore state."""
self.__dict__.update(state)
async def __aenter__(self) -> BigQueryAgentAnalyticsPlugin:
await self._ensure_started()
return self
async def __aexit__(self, exc_type, exc_val, exc_tb) -> None:
await self.shutdown()
async def _ensure_started(self, **kwargs) -> None:
"""Ensures that the plugin is started and initialized."""
if not self._started:
# Kept original lock name as it was not explicitly changed.
if self._setup_lock is None:
self._setup_lock = asyncio.Lock()
async with self._setup_lock:
if not self._started:
try:
await self._lazy_setup(**kwargs)
self._started = True
except Exception as e:
logger.error("Failed to initialize BigQuery Plugin: %s", e)
async def _log_event(
self,
event_type: str,
callback_context: CallbackContext,
raw_content: Any = None,
is_truncated: bool = False,
**kwargs,
) -> None:
"""Logs an event to BigQuery.
Args:
event_type: The type of event (e.g., 'LLM_REQUEST').
callback_context: The callback context.
raw_content: The raw content to log.
is_truncated: Whether the content is already truncated.
**kwargs: Additional attributes to log.
"""
if not self.config.enabled or self._is_shutting_down:
return
if self.config.event_denylist and event_type in self.config.event_denylist:
return
if (
self.config.event_allowlist
and event_type not in self.config.event_allowlist
):
return
if not self._started:
await self._ensure_started()
if not self._started:
return
timestamp = datetime.now(timezone.utc)
if self.config.content_formatter:
try:
raw_content = self.config.content_formatter(raw_content, event_type)
except Exception as e:
logger.warning("Content formatter failed: %s", e)
trace_id = TraceManager.get_trace_id(callback_context)
current_span_id, current_parent_span_id = (
TraceManager.get_current_span_and_parent()
)
span_id = current_span_id
if "span_id_override" in kwargs:
val = kwargs.pop("span_id_override")
if val is not None:
span_id = val
parent_span_id = current_parent_span_id
if "parent_span_id_override" in kwargs:
val = kwargs.pop("parent_span_id_override")
if val is not None:
parent_span_id = val
# Use HybridContentParser if offloader is available, otherwise use default
# Re-initialize parser with current trace/span IDs for GCS pathing
self.parser = HybridContentParser(
self.offloader,
trace_id or "no_trace",
span_id or "no_span",
self.config.max_content_length,
connection_id=self.config.connection_id,
)
content_json, content_parts, parser_truncated = await self.parser.parse(
raw_content
)
is_truncated = is_truncated or parser_truncated
total_latency = kwargs.get("latency_ms")
tfft = kwargs.get("time_to_first_token_ms")
latency_json = {}
if total_latency is not None:
latency_json["total_ms"] = total_latency
if tfft is not None:
latency_json["time_to_first_token_ms"] = tfft
kwargs.pop("latency_ms", None)
kwargs.pop("time_to_first_token_ms", None)
# Check if content was truncated by the parser or explicitly passed
# (Already handled by parser_truncated above, but keeping for safety or if other logic added later)
status = kwargs.pop("status", "OK")
error_message = kwargs.pop("error_message", None)
# V2 Metadata Extensions
model = kwargs.pop("model", None)
model_version = kwargs.pop("model_version", None)
usage_metadata = kwargs.pop("usage_metadata", None)
# Add new fields to attributes instead of columns
kwargs["root_agent_name"] = TraceManager.get_root_agent_name()
if model:
kwargs["model"] = model
if model_version:
kwargs["model_version"] = model_version
if usage_metadata:
# Use smart truncate to handle Pydantic, Dataclasses, and other objects
usage_dict, _ = _recursive_smart_truncate(
usage_metadata, self.config.max_content_length
)
if isinstance(usage_dict, dict):
kwargs["usage_metadata"] = usage_dict
else:
# Fallback if it couldn't be converted to dict
kwargs["usage_metadata"] = usage_metadata
# Serialize remaining kwargs to JSON string for attributes
try:
attributes_json = json.dumps(kwargs)
except (TypeError, ValueError):
# Fallback for non-serializable objects
attributes_json = json.dumps(kwargs, default=str)
row = {
"timestamp": timestamp,
"event_type": event_type,
"agent": callback_context.agent_name,
"user_id": callback_context.user_id,
"session_id": callback_context.session.id,
"invocation_id": callback_context.invocation_id,
"trace_id": trace_id,
"span_id": span_id,
"parent_span_id": parent_span_id,
"content": content_json,
"content_parts": (
content_parts if self.config.log_multi_modal_content else []
),
"attributes": attributes_json,
"latency_ms": latency_json if latency_json else None,
"status": status,
"error_message": error_message,
"is_truncated": is_truncated,
}
if self.batch_processor:
await self.batch_processor.append(row)
# --- UPDATED CALLBACKS FOR V1 PARITY ---
async def on_user_message_callback(
self,
*,
invocation_context: InvocationContext,
user_message: types.Content,
**kwargs,
) -> None:
"""Parity with V1: Logs USER_MESSAGE_RECEIVED event.
Args:
invocation_context: The context of the current invocation.
user_message: The message content received from the user.
"""
await self._log_event(
"USER_MESSAGE_RECEIVED",
CallbackContext(invocation_context),
raw_content=user_message,
)
async def before_run_callback(
self, *, invocation_context: "InvocationContext", **kwargs
) -> None:
"""Callback before the agent run starts.
Args:
invocation_context: The context of the current invocation.
"""
await self._ensure_started()
await self._log_event(
"INVOCATION_STARTING", CallbackContext(invocation_context)
)
async def after_run_callback(
self, *, invocation_context: "InvocationContext", **kwargs
) -> None:
"""Callback after the agent run completes.
Args:
invocation_context: The context of the current invocation.
"""
await self._log_event(
"INVOCATION_COMPLETED", CallbackContext(invocation_context)
)
async def before_agent_callback(
self, *, agent: Any, callback_context: CallbackContext, **kwargs
) -> None:
"""Callback before an agent starts processing.
Args:
agent: The agent instance.
callback_context: The callback context.
"""
TraceManager.init_trace(callback_context)
TraceManager.push_span(callback_context)
await self._log_event(
"AGENT_STARTING",
callback_context,
raw_content=getattr(agent, "instruction", ""),
)
async def after_agent_callback(
self, *, agent: Any, callback_context: CallbackContext, **kwargs
) -> None:
"""Callback after an agent completes processing.
Args:
agent: The agent instance.
callback_context: The callback context.
"""
span_id, duration = TraceManager.pop_span()
# When popping, the current stack now points to parent.
# The event we are logging ("AGENT_COMPLETED") belongs to the span we just popped.
# So we must override span_id to be the popped span, and parent to be current top of stack.
parent_span_id, _ = TraceManager.get_current_span_and_parent()
await self._log_event(
"AGENT_COMPLETED",
callback_context,
latency_ms=duration,
span_id_override=span_id,
parent_span_id_override=parent_span_id,
)
async def before_model_callback(
self,
*,
callback_context: CallbackContext,
llm_request: LlmRequest,
**kwargs,
) -> None:
"""Callback before LLM call.
Logs the LLM request details including:
1. Prompt content
2. System instruction (if available)
The content is formatted as 'Prompt: {prompt} | System Prompt:
{system_prompt}'.
"""
# 5. Attributes (Config & Tools)
attributes = {}
if llm_request.config:
config_dict = {}
for field_name in [
"temperature",
"top_p",
"top_k",
"candidate_count",
"max_output_tokens",
"stop_sequences",
]:
if val := getattr(llm_request.config, field_name, None):
config_dict[field_name] = val
if config_dict:
attributes["llm_config"] = config_dict
if hasattr(llm_request, "tools_dict") and llm_request.tools_dict:
attributes["tools"] = list(llm_request.tools_dict.keys())
# Merge any additional kwargs into attributes
attributes.update(kwargs)
TraceManager.push_span(callback_context)
await self._log_event(
"LLM_REQUEST",
callback_context,
raw_content=llm_request,
model=llm_request.model,
**attributes,
)
async def after_model_callback(
self,
*,
callback_context: CallbackContext,
llm_response: "LlmResponse",
**kwargs,
) -> None:
"""Callback after LLM call.
Logs the LLM response details including:
1. Response content
2. Token usage (if available)
The content is formatted as 'Response: {content} | Usage: {usage}'.
Args:
callback_context: The callback context.
llm_response: The LLM response object.
"""
content_dict = {}
is_truncated = False
if llm_response.content:
part_str, part_truncated = self._format_content_safely(
llm_response.content
)
if part_str:
content_dict["response"] = part_str
if part_truncated:
is_truncated = True
if llm_response.usage_metadata:
usage = llm_response.usage_metadata
usage_dict = {}
if hasattr(usage, "prompt_token_count"):
usage_dict["prompt"] = usage.prompt_token_count
if hasattr(usage, "candidates_token_count"):
usage_dict["completion"] = usage.candidates_token_count
if hasattr(usage, "total_token_count"):
usage_dict["total"] = usage.total_token_count
if usage_dict:
content_dict["usage"] = usage_dict
if content_dict:
content_str = content_dict
else:
content_str = None
span_id = TraceManager.get_current_span_id()
_, parent_span_id = TraceManager.get_current_span_and_parent()
is_popped = False
duration = 0
tfft = None
if hasattr(llm_response, "partial") and llm_response.partial:
# Streaming chunk - do NOT pop span yet
if span_id:
TraceManager.record_first_token(span_id)
start_time = TraceManager.get_start_time(span_id)
first_token = TraceManager.get_first_token_time(span_id)
if start_time:
duration = int((time.time() - start_time) * 1000)
if start_time and first_token:
tfft = int((first_token - start_time) * 1000)
else:
# Final response - pop span
start_time = None
if span_id:
# Ensure we have first token time even if it wasn't streaming (or single chunk)
TraceManager.record_first_token(span_id)
start_time = TraceManager.get_start_time(span_id)
first_token = TraceManager.get_first_token_time(span_id)
if start_time and first_token:
tfft = int((first_token - start_time) * 1000)
# ACTUALLY pop the span
popped_span_id, duration = TraceManager.pop_span()
is_popped = True
# If we popped, the span_id from get_current_span_and_parent() above is correct for THIS event
# Wait, if we popped, get_current_span_and_parent() now returns parent.
# But we captured span_id BEFORE popping. So we should use THAT.
# If is_popped is True, we must override span_id in log_event to use the popped one.
# Otherwise log_event will fetch current stack (which is parent).
span_id = popped_span_id or span_id
extra_kwargs = {}
if tfft is not None:
extra_kwargs["time_to_first_token_ms"] = tfft
await self._log_event(
"LLM_RESPONSE",
callback_context,
raw_content=content_str,
is_truncated=is_truncated,
latency_ms=duration,
model_version=llm_response.model_version,
usage_metadata=llm_response.usage_metadata,
span_id_override=span_id if is_popped else None,
parent_span_id_override=parent_span_id
if is_popped
else None, # Use pre-pop state
**extra_kwargs,
**kwargs,
)
async def on_model_error_callback(
self, *, callback_context: CallbackContext, error: Exception, **kwargs
) -> None:
"""Callback on LLM error.
Args:
callback_context: The callback context.
error: The exception that occurred.
**kwargs: Additional arguments.
"""
span_id, duration = TraceManager.pop_span()
parent_span_id, _ = TraceManager.get_current_span_and_parent()
await self._log_event(
"LLM_ERROR",
callback_context,
error_message=str(error),
latency_ms=duration,
span_id_override=span_id,
parent_span_id_override=parent_span_id,
)
async def before_tool_callback(
self,
*,
tool: BaseTool,
tool_args: dict[str, Any],
tool_context: ToolContext,
**kwargs,
) -> None:
"""Callback before tool execution.
Args:
tool: The tool being executed.
tool_args: The arguments passed to the tool.
tool_context: The tool context.
"""
args_truncated, is_truncated = _recursive_smart_truncate(
tool_args, self.config.max_content_length
)
content_dict = {"tool": tool.name, "args": args_truncated}
TraceManager.push_span(tool_context)
await self._log_event(
"TOOL_STARTING",
tool_context,
raw_content=content_dict,
is_truncated=is_truncated,
)
async def after_tool_callback(
self,
*,
tool: BaseTool,
tool_args: dict[str, Any],
tool_context: ToolContext,
result: dict[str, Any],
**kwargs,
) -> None:
"""Callback after tool execution.
Args:
tool: The tool that was executed.
tool_args: The arguments passed to the tool.
tool_context: The tool context.
result: The response from the tool.
"""
resp_truncated, is_truncated = _recursive_smart_truncate(
result, self.config.max_content_length
)
content_dict = {"tool": tool.name, "result": resp_truncated}
span_id, duration = TraceManager.pop_span()
parent_span_id, _ = TraceManager.get_current_span_and_parent()
await self._log_event(
"TOOL_COMPLETED",
tool_context,
raw_content=content_dict,
is_truncated=is_truncated,
latency_ms=duration,
span_id_override=span_id,
parent_span_id_override=parent_span_id,
)
async def on_tool_error_callback(
self,
*,
tool: BaseTool,
tool_args: dict[str, Any],
tool_context: ToolContext,
error: Exception,
**kwargs,
) -> None:
"""Callback on tool error.
Args:
tool: The tool that failed.
tool_args: The arguments passed to the tool.
tool_context: The tool context.
error: The exception that occurred.
**kwargs: Additional arguments.
"""
args_truncated, is_truncated = _recursive_smart_truncate(
tool_args, self.config.max_content_length
)
content_dict = {"tool": tool.name, "args": args_truncated}
_, duration = TraceManager.pop_span()
await self._log_event(
"TOOL_ERROR",
tool_context,
raw_content=content_dict,
error_message=str(error),
is_truncated=is_truncated,
latency_ms=duration,
)