feat(plugins): improve BigQuery analytics observability and delivery - #12
feat(plugins): improve BigQuery analytics observability and delivery#12caohy1988 wants to merge 8 commits into
Conversation
|
CI follow-up after the first draft run:
|
caohy1988
left a comment
There was a problem hiding this comment.
Full review at 8de2adb4 — approve with one requested change
Review surface: the 5 plugin commits (+876 on the plugin + its test file). The other 8 branch commits are upstream sync noise — verified none touch the plugin. Local run with the worktree's own src: 472 passed, matching CI. mypy matrices green; the pre-commit failure is the documented unrelated uv workflow issue.
Per-feature verification
- event_id (google#6465 dedup key) — Solid. NULLABLE STRING column,
_SCHEMA_VERSION1→2, in_VIEW_COMMON_COLUMNS, stamped at row construction so transport retries preserve it;test_bigquery_retry_reuses_the_same_event_idproves the same ID survives an ack-lost retry. Existing tables pick it up because_maybe_upgrade_schemais diff-based, not label-gated. - Exactly-once opt-in (google#6465) — Traced every branch; the protocol is sound: frozen per-batch offset; in-band and raised
ALREADY_EXISTSconfirm (idempotent assignment, not+=);NOT_FOUND/OUT_OF_RANGEdesync → accounted underoffset_conflict→ finalize old stream → rotate with 30s backoff; therequest_sent/definitive_rejectionpair correctly separates "server explicitly rejected" (no desync, offset safely reused) from "ack lost" (poison + rotate); offsets reset to 0 on a new stream; finalize is idempotent and retried across shutdown/close via the pending set. Test coverage is genuinely good: bothALREADY_EXISTSforms, all four desync forms, ambiguous-exhaustion rotation, finalize retry. - finish_reason (google#5644) — Attributes + typed view column; partial chunks omit the key (tested);
.nameon the str-enum is fine.error_messagerouting is proven sanitized by test (Bearer MODEL-SECRET→[REDACTED],statusstays"OK", secret absent from the whole row). - NODE_OUTPUT/NODE_ERROR (google#6529) — Guards are right:
node_path+ non-partial +content is None+output is not NonepreventsAGENT_RESPONSEdouble-logging; themessage_as_outputcase is tested;STATE_DELTAcoexists correctly; views expose node identity; dict/list/str payloads round-trip with adefault=strfallback for exotic outputs. - Dead tracer cleanup (google#5889) — Removed, and the ~20 test patch sites correctly moved to
create=True.
Findings
Three inline (1×P2, 2×P3) plus one nit: squash 8de2adb4 into cb5b14cb when splitting for upstream — it only adds the asserts and swaps the exception type, so it isn't a standalone change.
Verdict: approve with finding 1 (the default-mode retry change) addressed or explicitly documented. The engineering is careful and the test coverage is real; 2–4 are upstream-review polish.
Live e2e verification (real Vertex Gemini + real BigQuery)Ran a fresh end-to-end suite against this branch (
The MAX_TOKENS scenario also surfaced a behavior issue (a plain, non-workflow agent produced a |
Full review — 2 blockers, 5 majors; keep as draft until addressedLine references are against BlockersB1 — exactly-once mode can silently confirm-and-drop batches (cross-batch cascade).
Fix shape: make "an attempt with unknown outcome was sent" sticky per batch; desync on exhaustion whenever it is set; honor ALREADY_EXISTS as confirmation only when this batch previously sent at that offset — otherwise treat it as desync. The existing test at tests:9300 always precedes ALREADY_EXISTS with a send, which masks the first-attempt hole; B1 needs its own cross-batch test. B2 — partial streaming rows carry MajorsM1 — NODE_ERROR has no partial guard → duplicate error rows. The NODE_OUTPUT branch checks M2 — LLM finish anomalies masquerade as node failures. M3 — M4 — stream finalize on shutdown has no timeout. M5 — committed docs missing. No user-facing prose documents (a) CreateWriteStream quota exposure — one stream per event loop, plus one per rotation, plus re-creation after failures — or (b) that exactly-once degrades to at-most-once during desync/rotation windows (batches dropped under Minors
Verified good
Suggested order: B1, B2, then M1-M3 (cheap row-semantics fixes), M4, then M5 + hygiene/attribution, then the test gaps. With those addressed this is a merge-quality change — the live run shows all four issues genuinely fixed under production conditions. |
8de2adb to
5fec54b
Compare
Fresh review — current head
|
Assign each analytics row a stable event_id before enqueue so Storage Write API retries preserve a queryable deduplication key. Existing tables receive the nullable column through additive schema upgrade. Refs google#6465
Project finish_reason into LLM response attributes and typed views, and route model response diagnostics through the existing sanitized error_message column without changing status semantics. The finish_reason projection follows the approach proposed by @roanny in google#5704. Refs google#5644
Emit NODE_OUTPUT and NODE_ERROR rows from workflow events that already reach the plugin, preserving node identity while avoiding message-as-output duplication. Fixes google#6529
Add an opt-in committed-stream writer with explicit per-batch offsets, retry-stable requests, stream rotation after ambiguous outcomes, bounded finalization, and loss accounting. The design follows the public discussion in google#6465 and @addenergyx's proposal in google#6466. This implementation was developed independently to avoid CLA-sensitive source derivation. Refs google#6465
5fec54b to
97c1a41
Compare
Re-review at head
|
| Scenario | Observed | Table result |
|---|---|---|
| Suppress 1 ack, then retry | Retry at same offset hit real in-band code 6 ("expected offset 1, received 0") → confirmed via sticky-ambiguity rule; next batch landed at offset 1 | Each row exactly once |
| Suppress ALL acks (exhaustion) | retry_exhausted=1 counted honestly, stream desynced, rotation created a replacement stream, next batch landed on it |
Each row exactly once |
Zero duplicates, zero silent loss, in both scenarios. The B1 state machine holds against the real Storage Write API.
Agent e2e (plain + SSE runs of STOP and MAX_TOKENS agents, a 3-FunctionNode workflow incl. a pydantic-returning node, and a failing node — run twice: default and exactly_once_delivery=True):
| Check | Result |
|---|---|
| B2: SSE finish_reason count | ✅ 2 LLM_RESPONSE rows per SSE turn, exactly 1 carries finish_reason (STOP and MAX_TOKENS, both modes) |
| NODE_ERROR conflation | ✅ MAX_TOKENS agents (plain + SSE) produced zero NODE_ERROR rows; only the genuine RuntimeError node produced one, exactly once |
| NODE_OUTPUT structured output | ✅ pydantic node serialized as structured JSON ({"patient":...,"score":99,"tags":["a","b"]}) |
| Span export (google#5889) | ✅ 0 plugin-scoped spans through a global in-memory SpanProcessor, both runs |
| event_id duplicates | ✅ 0 duplicate groups in both datasets |
| Unit suite | ✅ 485 passed, 6 skipped (env-gated), reproduced in a clean venv |
Prior findings — all verified fixed in code (not just claimed)
- B1 confirm-and-drop: sticky
had_ambiguous_sendper batch (plugin:2578, set 2726/2756, never reset); exhaustion desyncs iff sticky (2729-2732);_handle_already_exists(2510-2527) confirms only after a prior ambiguous send, else warns + desyncs + countsoffset_conflictwithout advancing the offset. Full path×state matrix traced — every terminal path advances the offset only via_confirm_committed_delivery. Cross-batch test coverage pins the cascade (tests:9430, 9470). - Rotation stall: finalize decoupled — old stream goes to
_pending_finalize_streams(2542), replacement created immediately (2544); finalize runs only at shutdown/close under the deadline budget (2795-2823). Pinned by tests:9548 (hung finalizer, bounded write) and 9506. - B2 partial double-count:
is_partialgate at 7064; finish_reason (7146-7156) and error_message (7157-7161) stamped only on final responses; real-StreamingResponseAggregatortest at tests:1922. Verified live (table above). - NODE_ERROR conflation:
_LLM_RESPONSE_ERROR_CODES= allFinishReason+BlockedReasonvalues (220-224), excluded at 6588; robust for str and enum forms (pydantic coerces to str; str-enum hashing matches); LiteLLM maps to canonical enums. Parametrized tests incl. BlockedReason. - NODE_OUTPUT guard: now
message_as_output is not Trueinstead ofcontent is None(6600-6603), and error/output are independentifs — content+output, error+output, and message-as-output cases all tested. - Shutdown finalize budget intact (2787-2823, pinned by tests:9658);
python -Oasserts replaced with an explicit desync-on-None check (2501-2507, tested); default mode reverted to byte-identical upstream behavior (empty response stream = success again, 2691; offset field never set —HasFieldasserted) plusevent_id. - M5 docs: config docstring (1720-1727) now documents one stream per event loop, CreateWriteStream quota exposure, the 30s rotation backoff, and every drop boundary — text checked against the implementation, accurate.
- Feature request: BigQueryLoggerConfig.export_internal_spans flag to disable plugin span export google/adk-python#5889 hygiene:
027847econtains only the tracer removal; the vacuous patch-a-nonexistent-attribute test was removed; the real-provider exporter regression test is retained. - Attribution/CLA: all 7 commit bodies non-empty; @roanny prose credit +
Refs #5644on64baec5; @addenergyx prose credit with explicit independent-implementation statements on8dcc72f/97c1a41; no Co-authored-by trailers (deliberate, CLA-safe);Fixes #6529/Fixes #5889refs correct.
New findings
MAJOR-1 — non-retryable in-band error after an ambiguous send does not desync (plugin:2667-2684). The fall-through non-retryable branch counts non_retryable and returns without consulting had_ambiguous_send — the only terminal path that skips the check. Scenario: batch A's first attempt times out but committed (sticky flag set); a later attempt gets an in-band code outside {4,5,6,11,13,14} (e.g. 8 RESOURCE_EXHAUSTED, 7 PERMISSION_DENIED) → A dropped as non_retryable, stream NOT desynced, offset still points at A's rows. If batch B then also has an ambiguous first send, B's retry sees ALREADY_EXISTS (caused by A) and wrongly confirms B — silent, uncounted loss, with offset drift if len(A) != len(B). One-line fix: desync in that branch when exactly_once_delivery and had_ambiguous_send, plus a test. This is the last hole in the ambiguity invariant; every other terminal path honors it.
MAJOR-2 (residual, pre-existing ADK-core semantics — document rather than fix here). The "exactly one finish_reason row per call" guarantee holds for the progressive-SSE default (verified live). With PROGRESSIVE_SSE_STREAMING disabled, the legacy aggregator emits up to 3 non-partial responses per call, each stamped (reproduced with a probe); LiteLLM mixed text+tool streams emit 2. Not a plugin regression — strictly better than pre-fix — but nothing tests or documents the non-progressive modes. Suggest a docstring note ("one row per final response; legacy/LiteLLM streams can emit multiple finals") and, ideally, a legacy-mode test.
Minors:
- 18 inert
mock.patch(..., "tracer", ..., create=True)sites remain in tests, patching a nonexistent attribute — dead scaffolding worth deleting (the surrounding tests are otherwise meaningful). _LLM_RESPONSE_ERROR_CODESis frozen at import: a future server-side finish reason produces a spurious NODE_ERROR; a custom node error code equal to a member (e.g."OTHER") silently loses its NODE_ERROR row._pending_finalize_streamsgrows unboundedly in a long-lived process under stream churn (drained only at shutdown); an opportunistic fire-and-forget finalize during rotation would cap it.create_streamhas no plugin-level timeout (rotation 2544, initial 4538) — a hung CreateWriteStream still stalls the single writer; bounded only by transport defaults.- Rotation backoff is a hardcoded 30.0s, no jitter, ignores
retry_config(2548) — documented, but inconsistent with the configurable policy. - No fork/child test pinning that a forked child creates a fresh committed stream (behavior is correct by construction via
_reset_runtime_state, 5522-5523, but unpinned). output_formulti-node attribution is not surfaced on NODE_OUTPUT rows (only path/run_id/parent_run_id).676bc5dis broken in isolation for string finish_reason (fixed one commit later) — bisect hazard only; commit timestamps are non-monotonic from the rewrite. Cosmetic.
Recommendation
Fix MAJOR-1 (one line + one test), add the MAJOR-2 docstring note, optionally sweep the inert tracer patches — then this is ready to undraft. Everything else on the list is quality-of-life and can trail. The live evidence at this head is comprehensive: all four issues are solved under production conditions, the exactly-once state machine survives real lost-ack and exhaustion-rotation scenarios without duplicating or silently losing a row, and default-mode behavior is upstream-identical plus a working dedup key.
caohy1988
left a comment
There was a problem hiding this comment.
Fresh review at 97c1a415 — approve
Rebased history verified: 7 clean per-issue commits, +379 plugin / +1249 tests vs merge-base. Local run with the worktree's own src: 491 passed (consistent with CI's 485 passed + 6 skipped).
Last round's findings — all fixed, each pinned by a test
- Empty-stream retry (P2) — now scoped to
exactly_once_delivery(:2700-2702); default mode keeps the old assume-success behavior, pinned bytest_default_mode_keeps_empty_response_as_success(exactly one call, zero drops). - Unbounded finalize (P3) —
_finalize_stream_before(deadline)implements the remaining-budget contract with clean timeout/cancel handling, pinned bytest_finalization_respects_remaining_close_budget(hanging finalize cancelled inside budget). - Production asserts (P3) — replaced by
_confirm_committed_delivery, which poisons-and-rotates on a missing offset instead of crashing; strictly safer, pinned bytest_missing_committed_offset_desynchronizes_without_assertion.
The additional hardening — verified on close tracing
had_ambiguous_send/_handle_already_exists: first-attemptALREADY_EXISTSdesyncs instead of falsely confirming a batch that never had an ambiguous send — the confirm-and-drop cascade is closed, pinned in both in-band and raised forms.- Rotation no longer awaits finalization before creating the replacement; the old stream is parked in
_pending_finalize_streamsand finalized at shutdown. Rows stay visible, writer never blocks. Pinned. finish_reason/error_messageextraction gated on non-partial (streaming double-count fixed);getattr(finish_reason, "name", str(...))covers plain-string reasons._LLM_RESPONSE_ERROR_CODES(FinishReason + BlockedReason values) keeps model-termination codes out ofNODE_ERROR— no more double classification, pinned.message_as_output is not Truedirectly encodes the framework's dedup semantics (verified atrunners.py:942-946— the runner clearsoutputon content-carrying copies before the plugin sees them).- Config docs now state the loss boundaries and CreateWriteStream quota exposure.
027847e5gives google#5889's dead-tracer removal its own commit; history splits cleanly per issue for upstream.
Remaining — nothing blocking
One informational inline note, plus a trivial nit: 027847e5's title slightly overstates (spans were already not created; the commit removes dead code) — irrelevant once split.
Verdict: approve. Every prior finding is fixed with a test behind it, and the branch is in clean per-issue split shape. The live BigQuery e2e run (SSE streaming + duplicate-offset probes) is the only remaining gate before un-drafting.
| ) | ||
| if ( | ||
| event.output is not None | ||
| and getattr(node_info, "message_as_output", None) is not True |
There was a problem hiding this comment.
[P3, informational] With the content is None guard dropped, task-mode agents (finish_task FC args as output, no message_as_output) can now emit both AGENT_RESPONSE and NODE_OUTPUT for related events — the runner only clears output on content-carrying copies when message_as_output is set (runners.py:942-946). The telemetry is distinct (args vs response text) and test_content_and_output_event_preserves_node_output pins this as deliberate, so no change needed — just be ready to articulate that story when upstream review asks.
Remove the unused plugin tracer so analytics stack bookkeeping cannot create plugin-owned OpenTelemetry spans beside the framework spans. Keep existing trace tests compatible with the intentionally absent module tracer. Fixes google#5889
Read optional finish-reason and diagnostic fields through guarded attribute access so response-like implementations remain compatible with the analytics callback.
Preserve ambiguous committed-stream outcomes across retries, reject first-attempt offset conflicts, and rotate without waiting on optional finalization. Record streaming termination metadata only on final responses, distinguish model termination from node failures, and preserve structured node outputs. Document stream quota and loss boundaries and add focused regression coverage. The committed-stream design follows the public discussions in google#6465 and @addenergyx's proposal in google#6466; this implementation was developed independently.
Desynchronize a committed stream when a terminal in-band rejection follows an ambiguous send so the next batch cannot be falsely confirmed at an occupied offset. Document terminal-response multiplicity outside progressive SSE, add cross-batch regression coverage, and remove inert tracer patches from the analytics tests.
97c1a41 to
d8fac0b
Compare
Final review at
|
caohy1988
left a comment
There was a problem hiding this comment.
Fresh review at d8fac0be — approve, no new findings
Delta vs 97c1a415: one 5-line logic change, a docstring, and a large test cleanup across 8 clean per-issue commits. Local run with the worktree's own src: 492 passed (consistent with CI's 486 + 6 skipped).
The new logic change — verified correct, hole closed
Desync when a terminal in-band non-retryable rejection follows an ambiguous send (:2683-2684). I re-walked the full ambiguity matrix; this was the last open branch:
- Ambiguous send → in-band
ALREADY_EXISTS→ confirm ✓ - Ambiguous send → in-band non-retryable → drop + desync ← this fix; previously the batch dropped but the stream stayed trusted, so the next batch reused the occupied offset and could be falsely confirmed at it
- Ambiguous send → raised exception / retry exhaustion → desync ✓
- No ambiguity → in-band
ALREADY_EXISTS/NOT_FOUND/OUT_OF_RANGE→ desync ✓ - No ambiguity → in-band non-retryable → drop, no desync — correct: the offset was never applied and is safely reused
The cross-batch regression (test_non_retryable_rejection_after_ambiguity_rotates_stream) has exactly the right shape: batch A times out then gets PERMISSION_DENIED; batch B must land on the replacement stream at offset 0 — asserting the full stream sequence [committed-1, committed-1, committed-2], _next_offset == 1, and a single rotation. Without the fix, batch B writes to the poisoned stream.
The cleanup — verified
- 18 inert tracer patches removed: correct — since
37abcef1removed the module tracer,mock.patch(...tracer, create=True)was scaffolding around nothing.test_push_pop_does_not_export_spans_through_real_providernow asserts zero exported spans against the real module state — a stronger guarantee than the patched version. - SSE/LiteLLM cardinality docstring: accurate — progressive SSE yields one terminal response per call; legacy aggregators and mixed LiteLLM streams can emit several, which is exactly why the non-partial gating from the previous round matters.
37abcef1title corrected — last round's nit is closed.- Task-mode AGENT_RESPONSE + NODE_OUTPUT co-emission left unchanged — already recorded as deliberate in the earlier inline comment, so upstream has the context.
Verdict: approve. The ambiguity matrix is fully closed with a regression test on every branch, and the commit map splits cleanly per issue. Remaining gates are the ones you know: the queued CI matrix going green and the live BigQuery e2e run (SSE streaming + duplicate-offset probes) before un-drafting.
Live e2e addendum — full real-agent suite re-run at final head
|
| Check | Result |
|---|---|
| Live schema upgrade v1→v2 | ✅ Pre-staged a populated v1 agent_events table (16 fields, adk_schema_version:1, one legacy row) — the plugin upgraded it in place: event_id added (17 fields), label bumped to 2, legacy row keeps NULL event_id, all 51 new rows populated |
| finish_reason single-stamping | ✅ Plain turns: 1 row / 1 stamped; SSE turns: 2 rows / exactly 1 stamped (STOP and MAX_TOKENS, both modes) |
| NODE_OUTPUT / NODE_ERROR | ✅ 3 outputs (incl. structured pydantic JSON), exactly 1 NODE_ERROR (RuntimeError), zero NODE_ERROR from MAX_TOKENS agents |
| Span export | ✅ 0 plugin-scoped spans through a real global SpanProcessor, both modes |
| Duplicates | Default mode caught a second wild at-least-once transport duplicate (byte-identical USER_MESSAGE_RECEIVED pair sharing one event_id, same timestamp/trace/content) — the documented _default behavior, now identifiable and dedupable. Exactly-once mode: zero duplicates, again |
| SAFETY finish reason | Not reproduced — BLOCK_LOW_AND_ABOVE across three harm categories on a crime-narration prompt still yielded a benign STOP completion from gemini-2.5-flash. SAFETY/MALFORMED remain covered by parametrized unit tests only; the enum-name projection path is identical for all members |
Two independent runs have now each caught a naturally occurring transport duplicate in default mode, and exactly-once mode has produced zero across every run — strong field evidence for both halves of the google#6465 fix at the exact commit under review.
Live BigQuery e2e evidence at
|
Summary
This mono draft PR updates only the BigQuery Agent Analytics plugin and its unit tests. It addresses four related upstream reports while keeping each concern in a separate commit so the work can be split for upstream review later.
Related upstream issues:
finish_reasonfrom LlmResponse to attributes in BigQueryAgentAnalyticsPlugin LLM_RESPONSE events google/adk-python#5644Issue coverage
google#6465 — retry duplicate visibility and opt-in exactly-once delivery
event_idUUID to the physical schema, emitted row, and generated views. The same ID survives retries because it is assigned before enqueue/write processing.BigQueryLoggerConfig.exactly_once_delivery = False, preserving default-stream behavior.ALREADY_EXISTSas confirmation only after the same batch previously had an ambiguous send at that offset. A first-attempt conflict poisons the local offset state instead of silently confirming the wrong batch.NOT_FOUND/OUT_OF_RANGEand exhausted ambiguous outcomes as desynchronization, accounts loss, and rotates before later writes.google#5644 — LLM response termination detail
finish_reasoninv_llm_responseand row attributes.finish_reasonanderror_messageonly on the final response, avoiding progressive-SSE double counting.error_messagecolumn without changing status semantics.google#6529 — FunctionNode terminal event visibility (stage 1)
NODE_OUTPUTfor final workflow-node events carrying output, including legal content+output and error+output events.message_as_outputduplication and preserves Pydantic results as structured JSON.NODE_ERRORonly for final node-execution failures.MAX_TOKENSandMODEL_ARMORinLLM_RESPONSEinstead of misclassifying them as node failures.google#5889 — tracing cleanup
Review findings addressed
finish_reason/error_messagedouble counting.NODE_ERRORrows.event.content is Noneandif/elifguards discarded valid node output.Live BigQuery validation
The review-fix head was validated against ADC project test-project-0728-467323 with temporary datasets that were deleted after the probes. Live committed-stream tests covered lost acknowledgements, retry exhaustion, rotation, and duplicate offsets with zero duplicate or silently lost rows; progressive SSE emitted exactly one finish-reason row per turn.
The final ambiguity branch added in this update is covered by a deterministic cross-batch regression. A final quick pass remains appropriate before undrafting.
Verification
pre-commit run --files src/google/adk/plugins/bigquery_agent_analytics_plugin.py tests/unittests/plugins/test_bigquery_agent_analytics_plugin.pyPYTHONPATH=src <review-venv>/bin/pytest -q tests/unittests/plugins/test_bigquery_agent_analytics_plugin.pymainat0c79d1a8; forkmainfast-forwarded to the same commit.Commit map
Attribution / CLA note
The committed-stream implementation was developed independently from the public design discussions in google#6465 and google#6466. It does not copy or cherry-pick PR google#6466 and intentionally has no
Co-authored-bytrailer while that contributor's CLA status is unresolved. Credit to @addenergyx for reporting the duplicate-delivery failure mode and advancing the public offset-aware design discussion.