Skip to content

Commit 0b7f14c

Browse files
committed
feat(bigquery-analytics): otel correlation, custom_metadata allowlist, column projection
Implements three BQAA plugin observability controls (GoogleCloudPlatform/BigQuery-Agent-Analytics-SDK#312/google#320/google#321): - google#312 span-level Cloud Trace correlation: capture the ambient OTel span context at row-emission time (only when is_valid) into attributes.otel.*; span_id/parent_span_id stay the BQAA-internal execution tree. Corrects the stale "OpenTelemetry span ID" schema descriptions. Best-effort join key (an unsampled valid span is absent from Cloud Trace), not a foreign key; otel_parent_span_id deferred (not derivable from SpanContext alone). - google#320 custom_metadata allowlist: custom_metadata_allowlist config (exact keys + explicit "a2a:*"-style prefixes) captures event.custom_metadata into attributes.custom_metadata.* on every row emitted from the source Event (including AGENT_RESPONSE, which did not read custom_metadata before), through the existing safety pipeline (truncation, sensitive-key redaction, circular-ref handling, is_truncated). The built-in a2a:* path is unchanged. - google#321 physical column projection: payload_column_denylist (denylist-first, scoped to content/content_parts/attributes/latency_ms; identity/correlation columns are protected and raise ValueError). Applied schema-first so the BQ schema, Arrow schema, row dict, and views stay consistent; projection-aware views drop derived columns that reference a denied payload column. Also broadens the is_truncated column description to cover content or metadata payload truncation. Adds 25 unit tests; full plugin suite green (287 passed, 6 skipped).
1 parent 84bbb35 commit 0b7f14c

2 files changed

Lines changed: 506 additions & 8 deletions

File tree

src/google/adk/plugins/bigquery_agent_analytics_plugin.py

Lines changed: 210 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@
3636
os.environ.setdefault("GRPC_ENABLE_FORK_SUPPORT", "1")
3737

3838
import random
39+
import re
3940
import time
4041
from types import MappingProxyType
4142
from typing import Any
@@ -606,6 +607,19 @@ class BigQueryLoggerConfig:
606607
views like ``v_llm_request``. Set a distinct prefix per table when
607608
multiple plugin instances share one dataset to avoid view-name
608609
collisions.
610+
custom_metadata_allowlist: Keys to capture from ``event.custom_metadata``
611+
into ``attributes.custom_metadata.*`` (#320). Entries are exact keys, or
612+
explicit prefix patterns ending in ``*`` (e.g. ``"a2a:*"``). ``None`` /
613+
empty preserves today's behavior (only the built-in ``a2a:*`` path runs).
614+
Captured values pass the same safety pipeline (truncation, sensitive-key
615+
redaction, circular-reference handling) as all other logged content.
616+
payload_column_denylist: Payload columns to project OUT of the table at
617+
write time (#321). Only the projectable payload columns
618+
``content`` / ``content_parts`` / ``attributes`` / ``latency_ms`` may be
619+
listed; identity / correlation columns are protected and raise
620+
``ValueError`` if listed. Applied schema-first (table schema, Arrow
621+
schema, row dict, and views all stay consistent); views that reference a
622+
denied column drop the dependent derived columns.
609623
"""
610624

611625
enabled: bool = True
@@ -652,6 +666,18 @@ class BigQueryLoggerConfig:
652666
# ``v_staging_llm_request``).
653667
view_prefix: str = "v"
654668

669+
# --- #320: generic custom_metadata capture (allowlist) ---
670+
# Exact keys and/or explicit ``*``-suffixed prefix patterns to capture
671+
# from ``event.custom_metadata`` into ``attributes.custom_metadata.*``.
672+
# None/empty preserves today's behavior (only the built-in ``a2a:*`` path).
673+
custom_metadata_allowlist: list[str] | None = None
674+
675+
# --- #321: physical column projection (denylist-first) ---
676+
# Payload columns to omit from the table at write time. Only the
677+
# projectable payload columns are accepted; identity/correlation columns
678+
# are protected (see ``_PROJECTABLE_PAYLOAD_COLUMNS``).
679+
payload_column_denylist: list[str] | None = None
680+
655681

656682
# ==============================================================================
657683
# HELPER: TRACE MANAGER (Async-Safe with ContextVars)
@@ -1727,15 +1753,23 @@ def _get_events_schema() -> list[bigquery.SchemaField]:
17271753
"span_id",
17281754
"STRING",
17291755
mode="NULLABLE",
1730-
description="OpenTelemetry span ID for this specific operation.",
1756+
description=(
1757+
"BQAA-internal execution-tree span id for this operation. This is"
1758+
" the plugin's own correlation id used with parent_span_id to"
1759+
" reconstruct the agent/LLM/tool tree -- NOT the OpenTelemetry"
1760+
" span id, except on the root/invocation row where it may reuse"
1761+
" the ambient OTel span id. For span-level Cloud Trace"
1762+
" correlation use attributes.otel.span_id (best-effort)."
1763+
),
17311764
),
17321765
bigquery.SchemaField(
17331766
"parent_span_id",
17341767
"STRING",
17351768
mode="NULLABLE",
17361769
description=(
1737-
"OpenTelemetry parent span ID to reconstruct the operation"
1738-
" hierarchy."
1770+
"BQAA-internal parent execution-tree span id, used to reconstruct"
1771+
" the operation hierarchy. Points at another BQAA row, not an"
1772+
" OpenTelemetry parent span."
17391773
),
17401774
),
17411775
bigquery.SchemaField(
@@ -1852,7 +1886,9 @@ def _get_events_schema() -> list[bigquery.SchemaField]:
18521886
" additional event metadata. Includes enrichment fields like"
18531887
" 'root_agent_name' (turn orchestration), 'model' (request"
18541888
" model), 'model_version' (response version), and"
1855-
" 'usage_metadata' (detailed token counts)."
1889+
" 'usage_metadata' (detailed token counts). May also carry"
1890+
" 'otel' (best-effort ambient Cloud Trace span/trace ids) and"
1891+
" 'custom_metadata' (allowlisted event.custom_metadata keys)."
18561892
),
18571893
),
18581894
bigquery.SchemaField(
@@ -1881,13 +1917,76 @@ def _get_events_schema() -> list[bigquery.SchemaField]:
18811917
"BOOLEAN",
18821918
mode="NULLABLE",
18831919
description=(
1884-
"Boolean flag indicating if the 'content' field was truncated"
1885-
" because it exceeded the maximum allowed size."
1920+
"Boolean flag indicating if the content or metadata payload was"
1921+
" truncated because it exceeded the maximum allowed size. Set"
1922+
" when 'content', captured 'custom_metadata', or A2A metadata is"
1923+
" truncated; redaction of sensitive keys does not set this flag."
18861924
),
18871925
),
18881926
]
18891927

18901928

1929+
# Payload columns eligible for physical projection (#321). Every other
1930+
# schema column is an identity / correlation / view-critical column and is
1931+
# *protected* — it cannot be projected out, because the BQAA execution tree
1932+
# and the per-event views depend on it.
1933+
_PROJECTABLE_PAYLOAD_COLUMNS = frozenset(
1934+
{"content", "content_parts", "attributes", "latency_ms"}
1935+
)
1936+
1937+
1938+
def _validate_payload_column_denylist(
1939+
denylist: Optional[list[str]],
1940+
) -> frozenset[str]:
1941+
"""Validates ``payload_column_denylist`` and returns the denied set.
1942+
1943+
Only the projectable payload columns may be denied. Anything else —
1944+
an identity/correlation column or an unknown name — is a hard error,
1945+
so a typo or an attempt to drop a join key fails loudly at construction
1946+
rather than producing malformed rows or broken views.
1947+
"""
1948+
denied = frozenset(denylist or ())
1949+
invalid = denied - _PROJECTABLE_PAYLOAD_COLUMNS
1950+
if invalid:
1951+
raise ValueError(
1952+
"payload_column_denylist may only contain projectable payload"
1953+
f" columns {sorted(_PROJECTABLE_PAYLOAD_COLUMNS)}; got"
1954+
f" {sorted(invalid)}. Identity/correlation columns (timestamp,"
1955+
" event_type, session_id, invocation_id, trace_id, span_id,"
1956+
" parent_span_id, is_truncated, ...) are protected and cannot be"
1957+
" projected out."
1958+
)
1959+
return denied
1960+
1961+
1962+
def _project_schema(
1963+
schema: list[bigquery.SchemaField], denied: frozenset[str]
1964+
) -> list[bigquery.SchemaField]:
1965+
"""Returns *schema* with denied columns removed (schema-first projection)."""
1966+
if not denied:
1967+
return schema
1968+
return [f for f in schema if f.name not in denied]
1969+
1970+
1971+
def _parse_custom_metadata_allowlist(
1972+
allowlist: Optional[list[str]],
1973+
) -> tuple[frozenset[str], tuple[str, ...]]:
1974+
"""Splits the allowlist into exact keys and explicit prefix patterns (#320).
1975+
1976+
An entry ending in ``*`` is an explicit prefix pattern (the ``*`` is
1977+
stripped); every other entry matches exactly. This keeps a plain key
1978+
like ``"citation_metadata"`` from being treated as a prefix.
1979+
"""
1980+
exact: set[str] = set()
1981+
prefixes: list[str] = []
1982+
for entry in allowlist or ():
1983+
if entry.endswith("*"):
1984+
prefixes.append(entry[:-1])
1985+
else:
1986+
exact.add(entry)
1987+
return frozenset(exact), tuple(prefixes)
1988+
1989+
18911990
# ==============================================================================
18921991
# ANALYTICS VIEW DEFINITIONS
18931992
# ==============================================================================
@@ -2179,6 +2278,15 @@ def __init__(
21792278
if not self.config.view_prefix:
21802279
raise ValueError("view_prefix must be a non-empty string.")
21812280

2281+
# #320: pre-parse the custom_metadata allowlist into exact keys + prefixes.
2282+
self._custom_metadata_exact, self._custom_metadata_prefixes = (
2283+
_parse_custom_metadata_allowlist(self.config.custom_metadata_allowlist)
2284+
)
2285+
# #321: validate (fail-closed on protected/unknown columns) the projection.
2286+
self._denied_columns = _validate_payload_column_denylist(
2287+
self.config.payload_column_denylist
2288+
)
2289+
21822290
self.table_id = table_id or self.config.table_id
21832291
self.location = location
21842292

@@ -2403,7 +2511,9 @@ async def _lazy_setup(self, **kwargs) -> None:
24032511

24042512
self.full_table_id = f"{self.project_id}.{self.dataset_id}.{self.table_id}"
24052513
if not self._schema:
2406-
self._schema = _get_events_schema()
2514+
# #321: project out denied payload columns schema-first, so the table
2515+
# schema, Arrow schema, row dict, and views all stay consistent.
2516+
self._schema = _project_schema(_get_events_schema(), self._denied_columns)
24072517
await loop.run_in_executor(self._executor, self._ensure_schema_exists)
24082518

24092519
if not self.parser:
@@ -2634,6 +2744,27 @@ def _maybe_upgrade_schema(self, existing_table: bigquery.Table) -> None:
26342744
exc_info=True,
26352745
)
26362746

2747+
def _project_view_columns(self, extra_cols: list[str]) -> list[str]:
2748+
"""Drops derived view expressions that reference a denied column (#321).
2749+
2750+
Each entry is a ``"SQL_EXPR AS alias"`` string referencing payload
2751+
columns (``content`` / ``attributes`` / ``latency_ms``) as bare
2752+
identifiers. When such a column is projected out, its dependent view
2753+
columns must go too, otherwise the view SQL references a non-existent
2754+
column and view creation fails.
2755+
"""
2756+
if not self._denied_columns:
2757+
return list(extra_cols)
2758+
kept: list[str] = []
2759+
for expr in extra_cols:
2760+
if any(
2761+
re.search(rf"\b{re.escape(col)}\b", expr)
2762+
for col in self._denied_columns
2763+
):
2764+
continue
2765+
kept.append(expr)
2766+
return kept
2767+
26372768
def _create_analytics_views(self) -> None:
26382769
"""Creates per-event-type BigQuery views (idempotent).
26392770
@@ -2644,7 +2775,11 @@ def _create_analytics_views(self) -> None:
26442775
"""
26452776
for event_type, extra_cols in _EVENT_VIEW_DEFS.items():
26462777
view_name = self.config.view_prefix + "_" + event_type.lower()
2647-
columns = ",\n ".join(list(_VIEW_COMMON_COLUMNS) + extra_cols)
2778+
# #321: projection-aware views -- drop any derived column whose SQL
2779+
# references a denied payload column (content / attributes / latency_ms).
2780+
# Common columns are all protected, so they always remain.
2781+
projected_extra = self._project_view_columns(extra_cols)
2782+
columns = ",\n ".join(list(_VIEW_COMMON_COLUMNS) + projected_extra)
26482783
sql = _VIEW_SQL_TEMPLATE.format(
26492784
project=self.project_id,
26502785
dataset=self.dataset_id,
@@ -3143,8 +3278,62 @@ def _enrich_attributes(
31433278
if self.config.custom_tags:
31443279
attrs["custom_tags"] = self.config.custom_tags
31453280

3281+
# #312: best-effort span-level Cloud Trace correlation. Capture the
3282+
# ambient OTel span context at row-emission time, ONLY when it is valid.
3283+
# Stored under attributes.otel.* (staged); the typed span_id /
3284+
# parent_span_id columns stay the BQAA-internal execution tree. This is
3285+
# a best-effort join key, not a foreign key -- an unsampled valid span
3286+
# is absent from the Cloud Trace export.
3287+
otel_ctx = trace.get_current_span().get_span_context()
3288+
if otel_ctx.is_valid:
3289+
attrs["otel"] = {
3290+
"span_id": format(otel_ctx.span_id, "016x"),
3291+
"trace_id": format(otel_ctx.trace_id, "032x"),
3292+
}
3293+
31463294
return attrs
31473295

3296+
def _custom_metadata_allowed(self, key: Any) -> bool:
3297+
"""Returns whether *key* matches the #320 allowlist (exact or prefix)."""
3298+
if not isinstance(key, str):
3299+
return False
3300+
if key in self._custom_metadata_exact:
3301+
return True
3302+
return any(key.startswith(p) for p in self._custom_metadata_prefixes)
3303+
3304+
def _capture_custom_metadata(
3305+
self, event_data: EventData, attributes: dict[str, Any]
3306+
) -> bool:
3307+
"""Captures allowlisted ``custom_metadata`` into ``attributes`` (#320).
3308+
3309+
Reads ``event.custom_metadata`` from the row's source Event, keeps only
3310+
allowlisted keys, runs them through the shared safety pipeline
3311+
(truncation + sensitive-key redaction + circular-reference handling),
3312+
and writes the result under ``attributes['custom_metadata']``.
3313+
3314+
The built-in ``a2a:*`` handling in ``on_event_callback`` is unaffected;
3315+
this is purely additive under a separate namespace.
3316+
3317+
Returns:
3318+
True if any captured value was truncated (so the caller can flip
3319+
``is_truncated``).
3320+
"""
3321+
source = event_data.source_event
3322+
meta = getattr(source, "custom_metadata", None) if source else None
3323+
if not meta:
3324+
return False
3325+
captured = {
3326+
k: v for k, v in meta.items() if self._custom_metadata_allowed(k)
3327+
}
3328+
if not captured:
3329+
return False
3330+
safe, truncated = _recursive_smart_truncate(
3331+
captured, self.config.max_content_length
3332+
)
3333+
if isinstance(safe, dict) and safe:
3334+
attributes["custom_metadata"] = safe
3335+
return bool(truncated)
3336+
31483337
async def _log_event(
31493338
self,
31503339
event_type: str,
@@ -3207,6 +3396,14 @@ async def _log_event(
32073396
latency_json = self._extract_latency(event_data)
32083397
attributes = self._enrich_attributes(event_data, callback_context)
32093398

3399+
# #320: capture allowlisted custom_metadata into attributes.custom_metadata.
3400+
# Runs for every row emitted from a source Event (incl. AGENT_RESPONSE,
3401+
# which does not otherwise read custom_metadata), through the same safety
3402+
# pipeline. Truncation here also flips is_truncated.
3403+
if self._custom_metadata_exact or self._custom_metadata_prefixes:
3404+
meta_truncated = self._capture_custom_metadata(event_data, attributes)
3405+
is_truncated = is_truncated or meta_truncated
3406+
32103407
# Serialize attributes to JSON string
32113408
try:
32123409
attributes_json = json.dumps(attributes)
@@ -3236,6 +3433,11 @@ async def _log_event(
32363433
"is_truncated": is_truncated,
32373434
}
32383435

3436+
# #321: drop denied payload columns from the row so it matches the
3437+
# projected table / Arrow schema exactly (schema-first consistency).
3438+
if self._denied_columns:
3439+
row = {k: v for k, v in row.items() if k not in self._denied_columns}
3440+
32393441
state = await self._get_loop_state()
32403442
await state.batch_processor.append(row)
32413443

0 commit comments

Comments
 (0)