3636os .environ .setdefault ("GRPC_ENABLE_FORK_SUPPORT" , "1" )
3737
3838import random
39+ import re
3940import time
4041from types import MappingProxyType
4142from 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