Skip to content

Commit 3e61e57

Browse files
caohy1988claude
andcommitted
fix(bigquery): address PR google#1344 review — flush/close coordination, HITL pair keys, fail-closed redaction, atomic finalize gate, per-operation tool spans
Resolves all five review findings on the previous commit: 1. BatchProcessor.close() now waits (bounded by the same shutdownTimeout deadline) for an in-flight flush to release the flush lock before tearing down the writer and Arrow resources, instead of treating an empty queue as proof that no flush is active. 2. HITL_*_COMPLETED rows (both the event path and the user-message path) now carry pause_kind and function_call_id, so completions are joinable to their HITL_*_REQUEST / TOOL_PAUSED rows when multiple HITL requests share an invocation. 3. New JsonFormatter.redactTree replaces the smartTruncate-based attributes boundary: raw containers are walked natively with keys redacted before any Jackson conversion, and an unserializable leaf becomes "[UNSERIALIZABLE]" instead of routing the whole map through the textual fallback that exposed sibling secrets. JsonNode leaves are handled before the Iterable branch (ObjectNode iterates its values). 4. The invocation-finalization gate is now atomic: the isProcessed check runs inside computeIfAbsent's mapping function under the per-key lock, paired with finalize's mark-before-remove ordering; the residual tombstone-cache-expiry window is documented. 5. Tool spans carry an operation identity (ToolContext.functionCallId) and a parent captured at push time: ADK runs an event's function calls concurrently by default within one branch, so TOOL_STARTING rows are stamped from the pushed record, completions/errors pop by identity rather than stack position, and parents skip concurrent siblings. Tests: 160 focused BQAA tests pass (previous commit: 153), full core suite 1,616 passed / 0 failed. New coverage: close-waits-for-in-flight- flush, per-leaf fail-closed redaction (including nested containers and converted POJO leaves), HITL completion pair keys on both paths, and same-branch concurrent tools with out-of-order identity pops. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent e9b4073 commit 3e61e57

9 files changed

Lines changed: 474 additions & 48 deletions

File tree

core/src/main/java/com/google/adk/plugins/agentanalytics/BatchProcessor.java

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -325,6 +325,26 @@ public void close() {
325325
}
326326
}
327327
}
328+
// An in-flight flush may have drained the queue (making the loop above exit immediately) while
329+
// still blocked inside its bounded Storage Write append. Wait for it to release the flush lock
330+
// within the same deadline, so the writer and Arrow resources are not torn down underneath an
331+
// active flush.
332+
while (flushLock.get() && Instant.now().isBefore(drainDeadline)) {
333+
try {
334+
Thread.sleep(10);
335+
} catch (InterruptedException e) {
336+
Thread.currentThread().interrupt();
337+
break;
338+
}
339+
}
340+
if (flushLock.get()) {
341+
// The in-flight append has its own shutdownTimeout bound and accounts its batch as
342+
// append_error when it fails; resource teardown below may race it, but its catch blocks
343+
// handle and count the failure.
344+
logger.severe(
345+
"Closing while a flush is still in flight; its batch is accounted by the bounded"
346+
+ " append.");
347+
}
328348
int remaining = this.queue.size();
329349
if (remaining > 0) {
330350
droppedShutdownTimeout.addAndGet(remaining);

core/src/main/java/com/google/adk/plugins/agentanalytics/BigQueryAgentAnalyticsPlugin.java

Lines changed: 64 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -342,13 +342,12 @@ private Completable logEvent(
342342
row.put("latency_ms", convertToJsonNode(latencyMap));
343343
}
344344
// Redact the complete assembled attributes tree at the output boundary, regardless of which
345-
// producer populated it (state deltas, custom tags, labels, extra attributes). Individual
346-
// producers may also pre-truncate, but only this final pass guarantees sensitive keys never
347-
// reach BigQuery. Integer.MAX_VALUE disables length truncation: this is a redaction-only walk,
348-
// and redaction intentionally does not set is_truncated (parity with the Python plugin).
349-
row.put(
350-
"attributes",
351-
smartTruncate(getAttributes(data, invocationContext), Integer.MAX_VALUE).node());
345+
// producer populated it (state deltas, custom tags, labels, extra attributes). redactTree
346+
// walks raw containers and fails CLOSED on unserializable values (one bad custom tag or
347+
// session-state object must not route the whole map through a textual fallback that would
348+
// expose sibling secrets). Redaction intentionally does not set is_truncated (parity with the
349+
// Python plugin).
350+
row.put("attributes", JsonFormatter.redactTree(getAttributes(data, invocationContext)));
352351

353352
CompletableFuture<Void> parseFuture;
354353
if (content != null) {
@@ -414,6 +413,19 @@ private static String resolveAgentName(
414413
return eventData.flatMap(EventData::fallbackAgentName).orElse("unknown");
415414
}
416415

416+
/**
417+
* Pause/resume pair keys for a HITL completion row: {@code pause_kind} derived from the synthetic
418+
* call name and, when the response carries one, the {@code function_call_id} that joins the
419+
* completion to its HITL_*_REQUEST / TOOL_PAUSED rows.
420+
*/
421+
private static ImmutableMap<String, Object> hitlPairKeys(
422+
String hitlName, Optional<String> functionCallId) {
423+
ImmutableMap.Builder<String, Object> keys = ImmutableMap.builder();
424+
keys.put("pause_kind", HITL_PAUSE_KIND_MAP.getOrDefault(hitlName, "tool"));
425+
functionCallId.ifPresent(id -> keys.put("function_call_id", id));
426+
return keys.buildOrThrow();
427+
}
428+
417429
@CanIgnoreReturnValue
418430
private static EventData.Builder withFallbackAgent(
419431
EventData.Builder builder, @Nullable String author) {
@@ -569,15 +581,19 @@ public Maybe<Content> onUserMessageCallback(
569581
ImmutableMap.of("tool", responseName, "result", truncatedResult.node());
570582
if (HITL_EVENT_TYPES.containsKey(responseName)) {
571583
// HITL completions stay on the HITL_*_COMPLETED stream — they must not also emit
572-
// TOOL_COMPLETED.
584+
// TOOL_COMPLETED. The pair keys make the completion joinable to its HITL_*_REQUEST /
585+
// TOOL_PAUSED rows even when multiple HITL requests share an invocation.
573586
logCompletable =
574587
logCompletable.andThen(
575588
logEvent(
576589
HITL_EVENT_TYPES.get(responseName) + "_COMPLETED",
577590
invocationContext,
578591
contentMap,
579592
truncatedResult.isTruncated(),
580-
Optional.empty()));
593+
Optional.of(
594+
EventData.builder()
595+
.setExtraAttributes(hitlPairKeys(responseName, functionResponse.id()))
596+
.build())));
581597
} else {
582598
if (functionResponse.id().isEmpty()) {
583599
logger.fine(
@@ -683,21 +699,23 @@ public Maybe<Event> onEventCallback(InvocationContext invocationContext, Event e
683699
}
684700
if (part.functionResponse().isPresent()
685701
&& HITL_EVENT_TYPES.containsKey(part.functionResponse().get().name().orElse(""))) {
686-
String hitlEvent = HITL_EVENT_TYPES.get(part.functionResponse().get().name().get());
702+
FunctionResponse hitlResponse = part.functionResponse().get();
703+
String hitlEvent = HITL_EVENT_TYPES.get(hitlResponse.name().get());
687704
TruncationResult truncatedResult =
688-
smartTruncate(part.functionResponse().get().response(), config.maxContentLength());
705+
smartTruncate(hitlResponse.response(), config.maxContentLength());
689706
logCompletable =
690707
logCompletable.andThen(
691708
logEvent(
692709
hitlEvent + "_COMPLETED",
693710
invocationContext,
694711
ImmutableMap.of(
695-
"tool",
696-
part.functionResponse().get().name().get(),
697-
"response",
698-
truncatedResult.node()),
712+
"tool", hitlResponse.name().get(), "response", truncatedResult.node()),
699713
truncatedResult.isTruncated(),
700-
Optional.empty()));
714+
Optional.of(
715+
EventData.builder()
716+
.setExtraAttributes(
717+
hitlPairKeys(hitlResponse.name().get(), hitlResponse.id()))
718+
.build())));
701719
}
702720
}
703721
}
@@ -1040,10 +1058,24 @@ public Maybe<Map<String, Object>> beforeToolCallback(
10401058
}
10411059
ImmutableMap<String, Object> contentMap =
10421060
ImmutableMap.of("tool_origin", getToolOrigin(tool), "tool", tool.name(), "args", toolArgs);
1043-
state
1044-
.getTraceManager(toolContext.invocationContext().invocationId())
1045-
.pushSpan(toolContext.invocationContext(), "tool");
1046-
return logEvent("TOOL_STARTING", toolContext.invocationContext(), contentMap, Optional.empty())
1061+
// Push with the function-call identity: ADK executes an event's function calls concurrently by
1062+
// default within one branch, so tool spans are created, stamped, and popped by operation
1063+
// identity rather than stack position. Stamp the row directly from the pushed record so a
1064+
// sibling tool pushing in between cannot divert the row's span IDs.
1065+
TraceManager.SpanRecord toolSpan =
1066+
state
1067+
.getTraceManager(toolContext.invocationContext().invocationId())
1068+
.pushSpanRecord(
1069+
toolContext.invocationContext(), "tool", toolContext.functionCallId().orElse(null));
1070+
EventData.Builder startingData = EventData.builder().setSpanIdOverride(toolSpan.spanId());
1071+
if (toolSpan.parentSpanId() != null) {
1072+
startingData.setParentSpanIdOverride(toolSpan.parentSpanId());
1073+
}
1074+
return logEvent(
1075+
"TOOL_STARTING",
1076+
toolContext.invocationContext(),
1077+
contentMap,
1078+
Optional.of(startingData.build()))
10471079
.andThen(Maybe.empty());
10481080
}
10491081

@@ -1061,7 +1093,9 @@ public Maybe<Map<String, Object>> afterToolCallback(
10611093
.ensureInvocationSpan(toolContext.invocationContext());
10621094
TraceManager traceManager =
10631095
state.getTraceManager(toolContext.invocationContext().invocationId());
1064-
Optional<RecordData> popped = traceManager.popSpan(toolContext.invocationContext(), "tool");
1096+
Optional<RecordData> popped =
1097+
traceManager.popSpan(
1098+
toolContext.invocationContext(), "tool", toolContext.functionCallId().orElse(null));
10651099
TruncationResult truncationResult = smartTruncate(result, config.maxContentLength());
10661100
ImmutableMap<String, Object> contentMap =
10671101
ImmutableMap.of(
@@ -1072,15 +1106,15 @@ public Maybe<Map<String, Object>> afterToolCallback(
10721106
"tool_origin",
10731107
getToolOrigin(tool));
10741108

1075-
SpanIds spanIds = traceManager.getCurrentSpanAndParent(toolContext.invocationContext());
1076-
10771109
EventData.Builder eventDataBuilder = EventData.builder();
10781110
if (popped.isPresent()) {
10791111
eventDataBuilder.setLatency(popped.get().duration());
10801112
}
10811113
// Always record the internal execution-tree span so parent_span_id references a logged row.
1114+
// The parent comes from the popped record (captured at push time): under concurrent tool
1115+
// execution the branch's stack top may be a sibling tool, not this span's parent.
10821116
popped.ifPresent(p -> eventDataBuilder.setSpanIdOverride(p.spanId()));
1083-
spanIds.spanId().ifPresent(eventDataBuilder::setParentSpanIdOverride);
1117+
popped.flatMap(RecordData::parentSpanId).ifPresent(eventDataBuilder::setParentSpanIdOverride);
10841118

10851119
return logEvent(
10861120
"TOOL_COMPLETED",
@@ -1102,7 +1136,9 @@ public Maybe<Map<String, Object>> onToolErrorCallback(
11021136
.ensureInvocationSpan(toolContext.invocationContext());
11031137
TraceManager traceManager =
11041138
state.getTraceManager(toolContext.invocationContext().invocationId());
1105-
Optional<RecordData> popped = traceManager.popSpan(toolContext.invocationContext(), "tool");
1139+
Optional<RecordData> popped =
1140+
traceManager.popSpan(
1141+
toolContext.invocationContext(), "tool", toolContext.functionCallId().orElse(null));
11061142

11071143
TruncationResult truncationResult = smartTruncate(toolArgs, config.maxContentLength());
11081144
ImmutableMap<String, Object> contentMap =
@@ -1112,16 +1148,16 @@ public Maybe<Map<String, Object>> onToolErrorCallback(
11121148
.put("tool_origin", getToolOrigin(tool))
11131149
.buildOrThrow();
11141150

1115-
SpanIds spanIds = traceManager.getCurrentSpanAndParent(toolContext.invocationContext());
1116-
11171151
EventData.Builder eventDataBuilder =
11181152
EventData.builder().setStatus("ERROR").setErrorMessage(error.getMessage());
11191153
if (popped.isPresent()) {
11201154
eventDataBuilder.setLatency(popped.get().duration());
11211155
}
11221156
// Always record the internal execution-tree span so parent_span_id references a logged row.
1157+
// The parent comes from the popped record (captured at push time): under concurrent tool
1158+
// execution the branch's stack top may be a sibling tool, not this span's parent.
11231159
popped.ifPresent(p -> eventDataBuilder.setSpanIdOverride(p.spanId()));
1124-
spanIds.spanId().ifPresent(eventDataBuilder::setParentSpanIdOverride);
1160+
popped.flatMap(RecordData::parentSpanId).ifPresent(eventDataBuilder::setParentSpanIdOverride);
11251161

11261162
return logEvent(
11271163
"TOOL_ERROR",

core/src/main/java/com/google/adk/plugins/agentanalytics/JsonFormatter.java

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ final class JsonFormatter {
4040
static final String CYCLE_DETECTED_MESSAGE = "[cycle detected]";
4141
static final String MAX_DEPTH_MESSAGE = "[max depth exceeded]";
4242
static final String REDACTED_MESSAGE = "[REDACTED]";
43+
static final String UNSERIALIZABLE_MESSAGE = "[UNSERIALIZABLE]";
4344
// Guard against unbounded recursion on deeply nested (non-cyclic) payloads.
4445
static final int MAX_TRUNCATE_DEPTH = 200;
4546

@@ -86,6 +87,83 @@ static TruncationResult smartTruncate(Object obj, int maxLength) {
8687
}
8788
}
8889

90+
/**
91+
* Redacts sensitive keys across an attributes tree, failing closed on unserializable values.
92+
*
93+
* <p>Unlike {@link #smartTruncate}, which converts the whole object to JSON first (so one
94+
* unsupported value routes the ENTIRE tree through the textual {@code safeToString} fallback,
95+
* exposing sibling secrets as plain text), this walks raw Java containers natively: keys are
96+
* redacted before any Jackson conversion, and only leaf values are converted individually. A leaf
97+
* that cannot be converted becomes {@value #UNSERIALIZABLE_MESSAGE} without affecting its
98+
* siblings. No length truncation is applied.
99+
*/
100+
static JsonNode redactTree(Object obj) {
101+
return redactTreeInternal(obj, newSetFromMap(new IdentityHashMap<>()), 0);
102+
}
103+
104+
private static JsonNode redactTreeInternal(Object obj, Set<Object> visited, int depth) {
105+
if (obj == null) {
106+
return mapper.nullNode();
107+
}
108+
if (depth > MAX_TRUNCATE_DEPTH) {
109+
return mapper.valueToTree(MAX_DEPTH_MESSAGE);
110+
}
111+
// JsonNode must be handled before the Iterable branch: ObjectNode implements
112+
// Iterable<JsonNode> over its VALUES, so the generic Iterable walk would flatten a JSON
113+
// object into an array and lose its keys.
114+
if (obj instanceof JsonNode jsonNode) {
115+
return recursiveSmartTruncate(
116+
jsonNode, Integer.MAX_VALUE, newSetFromMap(new IdentityHashMap<>()), depth)
117+
.node();
118+
}
119+
if (obj instanceof Map<?, ?> map) {
120+
if (!visited.add(obj)) {
121+
return mapper.valueToTree(CYCLE_DETECTED_MESSAGE);
122+
}
123+
try {
124+
ObjectNode node = mapper.createObjectNode();
125+
for (Map.Entry<?, ?> entry : map.entrySet()) {
126+
String key = String.valueOf(entry.getKey());
127+
if (isSensitiveKey(key)) {
128+
node.set(key, mapper.valueToTree(REDACTED_MESSAGE));
129+
continue;
130+
}
131+
node.set(key, redactTreeInternal(entry.getValue(), visited, depth + 1));
132+
}
133+
return node;
134+
} finally {
135+
visited.remove(obj);
136+
}
137+
}
138+
if (obj instanceof Iterable<?> iterable) {
139+
if (!visited.add(obj)) {
140+
return mapper.valueToTree(CYCLE_DETECTED_MESSAGE);
141+
}
142+
try {
143+
ArrayNode node = mapper.createArrayNode();
144+
for (Object element : iterable) {
145+
node.add(redactTreeInternal(element, visited, depth + 1));
146+
}
147+
return node;
148+
} finally {
149+
visited.remove(obj);
150+
}
151+
}
152+
try {
153+
// A converted leaf may itself be a container (e.g. a POJO serialized to an object): run the
154+
// JSON-level redacting walk over it with truncation disabled.
155+
return recursiveSmartTruncate(
156+
mapper.valueToTree(obj),
157+
Integer.MAX_VALUE,
158+
newSetFromMap(new IdentityHashMap<>()),
159+
depth)
160+
.node();
161+
} catch (IllegalArgumentException e) {
162+
logger.fine("redactTree replacing unserializable value: " + e.getMessage());
163+
return mapper.valueToTree(UNSERIALIZABLE_MESSAGE);
164+
}
165+
}
166+
89167
static JsonNode convertToJsonNode(Object obj) {
90168
if (obj == null) {
91169
return mapper.nullNode();

core/src/main/java/com/google/adk/plugins/agentanalytics/PluginState.java

Lines changed: 44 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -223,15 +223,9 @@ BatchProcessor getBatchProcessor(String invocationId) {
223223
* </ul>
224224
*/
225225
void appendRow(String invocationId, Map<String, Object> row) {
226-
if (isProcessed(invocationId)) {
227-
droppedAfterFinalize.incrementAndGet();
228-
logger.warning(
229-
"Dropping late analytics row: invocation " + invocationId + " is already finalized.");
230-
return;
231-
}
232226
BatchProcessor processor;
233227
try {
234-
processor = getBatchProcessor(invocationId);
228+
processor = getOrCreateProcessorIfActive(invocationId);
235229
} catch (RuntimeException e) {
236230
droppedWriterCreateError.incrementAndGet();
237231
logger.log(
@@ -240,9 +234,52 @@ void appendRow(String invocationId, Map<String, Object> row) {
240234
e);
241235
return;
242236
}
237+
if (processor == null) {
238+
droppedAfterFinalize.incrementAndGet();
239+
logger.warning(
240+
"Dropping late analytics row: invocation " + invocationId + " is already finalized.");
241+
return;
242+
}
243243
processor.append(row);
244244
}
245245

246+
/**
247+
* Atomically returns the invocation's processor, creating it only while the invocation is still
248+
* active.
249+
*
250+
* <p>The {@code isProcessed} check runs INSIDE the {@code computeIfAbsent} mapping function, i.e.
251+
* under the map's per-key lock, closing the check-then-act race with {@code
252+
* ensureInvocationCompleted}: finalization marks the invocation processed BEFORE removing the
253+
* processor mapping, so a continuation that finds no mapping after removal is guaranteed to
254+
* observe {@code isProcessed == true} and installs nothing (a null mapping-function result adds
255+
* no entry). If a continuation instead wins the key lock first and creates the processor,
256+
* finalization subsequently removes and closes that same processor, and the late row is accounted
257+
* by {@link BatchProcessor#append}'s closed gate.
258+
*
259+
* <p>Residual limitation: the processed-invocations tombstone cache is bounded (size and TTL), so
260+
* a continuation arriving after its tombstone was evicted could still recreate a processor; such
261+
* processors are drained and closed at plugin-wide shutdown.
262+
*/
263+
private @Nullable BatchProcessor getOrCreateProcessorIfActive(String invocationId) {
264+
return batchProcessors.computeIfAbsent(
265+
invocationId,
266+
id -> {
267+
if (isProcessed(id)) {
268+
return null;
269+
}
270+
BatchProcessor p =
271+
new BatchProcessor(
272+
createWriter(),
273+
config.batchSize(),
274+
config.batchFlushInterval(),
275+
config.queueMaxSize(),
276+
executor,
277+
config.shutdownTimeout());
278+
p.start();
279+
return p;
280+
});
281+
}
282+
246283
protected @Nullable GcsOffloader getGcsOffloader(BigQueryLoggerConfig config) {
247284
if (config.gcsBucketName().isEmpty()) {
248285
return null;

0 commit comments

Comments
 (0)