diff --git a/src/google/adk/plugins/bigquery_agent_analytics_plugin.py b/src/google/adk/plugins/bigquery_agent_analytics_plugin.py index fddae0ad33..fab0a1ab4e 100644 --- a/src/google/adk/plugins/bigquery_agent_analytics_plugin.py +++ b/src/google/adk/plugins/bigquery_agent_analytics_plugin.py @@ -499,6 +499,17 @@ class BigQueryLoggerConfig: 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. + gcs_bucket_name: GCS bucket for offloading large content. + connection_id: BigQuery connection ID for ObjectRef columns. + log_session_metadata: Whether to log session metadata. + custom_tags: Static custom tags to attach to every event. + auto_schema_upgrade: Whether to auto-add new columns on + schema evolution. + create_views: Whether to auto-create per-event-type views. + view_prefix: Prefix for auto-created view names. Default + ``"v"`` produces views like ``v_llm_request``. Set a + distinct prefix per table when multiple plugin instances + share one dataset to avoid view-name collisions. """ enabled: bool = True @@ -538,6 +549,12 @@ class BigQueryLoggerConfig: # Automatically create per-event-type BigQuery views that unnest # JSON columns into typed, queryable columns. create_views: bool = True + # Prefix for auto-created per-event-type view names. + # Default "v" produces views like ``v_llm_request``. Set a distinct + # prefix per table when multiple plugin instances share one dataset + # to avoid view-name collisions (e.g. ``"v_staging"`` → + # ``v_staging_llm_request``). + view_prefix: str = "v" # ============================================================================== @@ -1878,6 +1895,9 @@ def __init__( else: logger.warning(f"Unknown configuration parameter: {key}") + if not self.config.view_prefix: + raise ValueError("view_prefix must be a non-empty string.") + self.table_id = table_id or self.config.table_id self.location = location @@ -2314,7 +2334,7 @@ def _create_analytics_views(self) -> None: Errors are logged but never raised. """ for event_type, extra_cols in _EVENT_VIEW_DEFS.items(): - view_name = "v_" + event_type.lower() + view_name = self.config.view_prefix + "_" + event_type.lower() columns = ",\n ".join(list(_VIEW_COMMON_COLUMNS) + extra_cols) sql = _VIEW_SQL_TEMPLATE.format( project=self.project_id, diff --git a/tests/unittests/plugins/test_bigquery_agent_analytics_plugin.py b/tests/unittests/plugins/test_bigquery_agent_analytics_plugin.py index a8f79b4091..48749efc06 100644 --- a/tests/unittests/plugins/test_bigquery_agent_analytics_plugin.py +++ b/tests/unittests/plugins/test_bigquery_agent_analytics_plugin.py @@ -5087,18 +5087,19 @@ def test_reset_logs_fork_warning(self): class TestAnalyticsViews: """Tests for auto-created per-event-type BigQuery views.""" - def _make_plugin(self, create_views=True): + 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, + table_id=table_id, config=config, ) plugin.client = mock.MagicMock() - plugin.full_table_id = f"{PROJECT_ID}.{DATASET_ID}.{TABLE_ID}" + plugin.full_table_id = f"{PROJECT_ID}.{DATASET_ID}.{table_id}" plugin._schema = bigquery_agent_analytics_plugin._get_events_schema() return plugin @@ -5184,6 +5185,7 @@ 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( @@ -5242,6 +5244,77 @@ async def test_create_analytics_views_raises_on_startup_failure( 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 (Issue #4645)