diff --git a/core/src/main/java/com/google/adk/plugins/debuglogging/ContentTrace.java b/core/src/main/java/com/google/adk/plugins/debuglogging/ContentTrace.java
new file mode 100644
index 000000000..606844323
--- /dev/null
+++ b/core/src/main/java/com/google/adk/plugins/debuglogging/ContentTrace.java
@@ -0,0 +1,201 @@
+/*
+ * Copyright 2026 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.google.adk.plugins.debuglogging;
+
+import static com.google.common.collect.ImmutableList.toImmutableList;
+import static java.util.function.Predicate.not;
+
+import com.fasterxml.jackson.annotation.JsonIgnore;
+import com.fasterxml.jackson.annotation.JsonInclude;
+import com.fasterxml.jackson.annotation.JsonProperty;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import com.google.genai.types.Blob;
+import com.google.genai.types.CodeExecutionResult;
+import com.google.genai.types.Content;
+import com.google.genai.types.ExecutableCode;
+import com.google.genai.types.FileData;
+import com.google.genai.types.FunctionCall;
+import com.google.genai.types.FunctionResponse;
+import com.google.genai.types.Part;
+import java.util.Optional;
+import java.util.stream.Stream;
+
+/**
+ * One {@link Content} as it appears in the trace — port of adk-python's {@code _serialize_content}.
+ *
+ *
The factory requires a content; callers holding an {@code Optional} use {@link
+ * Optional#map} rather than passing null.
+ */
+@JsonInclude(JsonInclude.Include.NON_ABSENT)
+record ContentTrace(Optional role, ImmutableList parts) {
+
+ /** Always produced — an empty part list is still worth recording, as in the original. */
+ static ContentTrace from(Content content) {
+ return new ContentTrace(content.role(), traceParts(content));
+ }
+
+ private static ImmutableList traceParts(Content content) {
+ return content.parts().orElseGet(ImmutableList::of).stream()
+ .map(PartTrace::from)
+ .flatMap(Optional::stream)
+ .collect(toImmutableList());
+ }
+
+ /**
+ * One {@link Part} as it appears in the trace — the declared shape of the per-part dict that
+ * adk-python builds in {@code _serialize_content}.
+ *
+ * The record is the schema: the field names are the trace's keys, and a part kind
+ * that is not declared here cannot appear in the output.
+ */
+ @JsonInclude(JsonInclude.Include.NON_ABSENT)
+ record PartTrace(
+ Optional text,
+ @JsonProperty("function_call") Optional functionCall,
+ @JsonProperty("function_response") Optional functionResponse,
+ @JsonProperty("inline_data") Optional inlineData,
+ @JsonProperty("file_data") Optional fileData,
+ @JsonProperty("code_execution_result") Optional codeExecutionResult,
+ @JsonProperty("executable_code") Optional executableCode) {
+
+ /**
+ * The part's trace form, or empty when it carries nothing worth recording — adk-python's {@code
+ * if part_data:} guard.
+ */
+ static Optional from(Part part) {
+ PartTrace trace =
+ new PartTrace(
+ part.text().filter(not(String::isEmpty)),
+ part.functionCall().map(FunctionCallTrace::from),
+ part.functionResponse().map(FunctionResponseTrace::from),
+ part.inlineData().map(InlineDataTrace::from),
+ part.fileData().map(FileDataTrace::from),
+ part.codeExecutionResult().map(CodeExecutionResultTrace::from),
+ part.executableCode().map(ExecutableCodeTrace::from));
+ return trace.isEmpty() ? Optional.empty() : Optional.of(trace);
+ }
+
+ /** Whether every declared field is absent, so the part would serialize to {@code {}}. */
+ @JsonIgnore
+ boolean isEmpty() {
+ return Stream.of(
+ text,
+ functionCall,
+ functionResponse,
+ inlineData,
+ fileData,
+ codeExecutionResult,
+ executableCode)
+ .allMatch(Optional::isEmpty);
+ }
+
+ /**
+ * A tool call the model requested.
+ *
+ * {@code args} stays untyped because it genuinely is: a tool author chooses the argument
+ * shape, so there is no schema to declare. It goes through {@link SafeSerializer} for the same
+ * reason adk-python routes it through {@code _safe_serialize}.
+ */
+ @JsonInclude(JsonInclude.Include.NON_ABSENT)
+ record FunctionCallTrace(
+ Optional id,
+ Optional name,
+ @JsonInclude(JsonInclude.Include.NON_EMPTY) ImmutableMap args) {
+
+ static FunctionCallTrace from(FunctionCall call) {
+ return new FunctionCallTrace(
+ call.id(),
+ call.name(),
+ call.args().map(SafeSerializer::serializeMap).orElseGet(ImmutableMap::of));
+ }
+ }
+
+ /**
+ * What a tool returned.
+ *
+ * {@code response} is untyped for the same reason as {@link FunctionCallTrace#args()}, and
+ * is contained by {@link SafeSerializer}, which is not optional here: the stock mapper throws
+ * on a perfectly legal tool result.
+ */
+ @JsonInclude(JsonInclude.Include.NON_ABSENT)
+ record FunctionResponseTrace(
+ Optional id,
+ Optional name,
+ @JsonInclude(JsonInclude.Include.NON_EMPTY) ImmutableMap response) {
+
+ static FunctionResponseTrace from(FunctionResponse functionResponse) {
+ return new FunctionResponseTrace(
+ functionResponse.id(),
+ functionResponse.name(),
+ functionResponse
+ .response()
+ .map(SafeSerializer::serializeMap)
+ .orElseGet(ImmutableMap::of));
+ }
+ }
+
+ /**
+ * What an inline blob is — never what it contains.
+ *
+ * This record has no field for the bytes, which is the point. adk-python drops them by
+ * choosing not to copy them into a dict, commenting "Omit actual data to keep file size
+ * manageable"; here the omission is structural, so a later edit cannot reintroduce a base64
+ * wall by accident.
+ */
+ @JsonInclude(JsonInclude.Include.NON_ABSENT)
+ record InlineDataTrace(
+ @JsonProperty("mime_type") Optional mimeType,
+ @JsonProperty("display_name") Optional displayName,
+ @JsonProperty("_data_omitted") boolean dataOmitted) {
+
+ static InlineDataTrace from(Blob blob) {
+ return new InlineDataTrace(blob.mimeType(), blob.displayName(), true);
+ }
+ }
+
+ /** A file reference carried by a part. */
+ @JsonInclude(JsonInclude.Include.NON_ABSENT)
+ record FileDataTrace(
+ @JsonProperty("file_uri") Optional fileUri,
+ @JsonProperty("mime_type") Optional mimeType) {
+
+ static FileDataTrace from(FileData fileData) {
+ return new FileDataTrace(fileData.fileUri(), fileData.mimeType());
+ }
+ }
+
+ /** The outcome of an executed code block. */
+ @JsonInclude(JsonInclude.Include.NON_ABSENT)
+ record CodeExecutionResultTrace(Optional outcome, Optional output) {
+
+ static CodeExecutionResultTrace from(CodeExecutionResult result) {
+ return new CodeExecutionResultTrace(result.outcome().map(String::valueOf), result.output());
+ }
+ }
+
+ /** A code block the model asked to run. */
+ @JsonInclude(JsonInclude.Include.NON_ABSENT)
+ record ExecutableCodeTrace(Optional language, Optional code) {
+
+ static ExecutableCodeTrace from(ExecutableCode executableCode) {
+ return new ExecutableCodeTrace(
+ executableCode.language().map(String::valueOf), executableCode.code());
+ }
+ }
+ }
+}
diff --git a/core/src/main/java/com/google/adk/plugins/debuglogging/DebugEntry.java b/core/src/main/java/com/google/adk/plugins/debuglogging/DebugEntry.java
new file mode 100644
index 000000000..4c76cdd4c
--- /dev/null
+++ b/core/src/main/java/com/google/adk/plugins/debuglogging/DebugEntry.java
@@ -0,0 +1,87 @@
+/*
+ * Copyright 2026 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.google.adk.plugins.debuglogging;
+
+import com.fasterxml.jackson.annotation.JsonInclude;
+import com.fasterxml.jackson.annotation.JsonProperty;
+import com.fasterxml.jackson.annotation.JsonValue;
+import com.google.common.base.Preconditions;
+import java.util.Optional;
+
+/**
+ * One line of the trace — port of adk-python's {@code _DebugEntry}.
+ *
+ * Everything but {@code data} is the same for every entry; {@code data} is what {@link
+ * TracePayload} models. Note that {@code agentName} is an entry field rather than payload, which is
+ * why {@code agent_end} can carry {@link TracePayload.MarkerTrace} and still name an agent.
+ *
+ *
Component order is the serialized order, and matches upstream's field declaration.
+ */
+@JsonInclude(JsonInclude.Include.NON_ABSENT)
+record DebugEntry(
+ String timestamp,
+ @JsonProperty("entry_type") Type entryType,
+ @JsonProperty("invocation_id") String invocationId,
+ @JsonProperty("agent_name") Optional agentName,
+ TracePayload data) {
+
+ /**
+ * These components come from a caller's arguments rather than from an ADK object, so they are
+ * worth guarding: a null here would not fail until the YAML write, on a different thread, long
+ * after the hook that caused it returned.
+ */
+ DebugEntry {
+ Preconditions.checkNotNull(timestamp);
+ Preconditions.checkNotNull(entryType);
+ Preconditions.checkNotNull(invocationId);
+ Preconditions.checkNotNull(agentName);
+ Preconditions.checkNotNull(data);
+ }
+
+ /**
+ * The kinds of entry, with the wire names upstream writes as bare strings.
+ *
+ * An enum rather than a {@code String}: a typo in one of these would produce a
+ * plausible-looking file that silently does not match adk-python's.
+ */
+ enum Type {
+ INVOCATION_START("invocation_start"),
+ USER_MESSAGE("user_message"),
+ AGENT_START("agent_start"),
+ AGENT_END("agent_end"),
+ LLM_REQUEST("llm_request"),
+ LLM_RESPONSE("llm_response"),
+ LLM_ERROR("llm_error"),
+ TOOL_CALL("tool_call"),
+ TOOL_RESPONSE("tool_response"),
+ TOOL_ERROR("tool_error"),
+ EVENT("event"),
+ SESSION_STATE_SNAPSHOT("session_state_snapshot"),
+ INVOCATION_END("invocation_end");
+
+ private final String wireName;
+
+ Type(String wireName) {
+ this.wireName = wireName;
+ }
+
+ @JsonValue
+ String wireName() {
+ return wireName;
+ }
+ }
+}
diff --git a/core/src/main/java/com/google/adk/plugins/debuglogging/DebugLoggingPlugin.java b/core/src/main/java/com/google/adk/plugins/debuglogging/DebugLoggingPlugin.java
new file mode 100644
index 000000000..d5b36e49c
--- /dev/null
+++ b/core/src/main/java/com/google/adk/plugins/debuglogging/DebugLoggingPlugin.java
@@ -0,0 +1,280 @@
+/*
+ * Copyright 2026 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.google.adk.plugins.debuglogging;
+
+import static com.google.common.base.Preconditions.checkNotNull;
+
+import com.google.adk.agents.BaseAgent;
+import com.google.adk.agents.CallbackContext;
+import com.google.adk.agents.InvocationContext;
+import com.google.adk.events.Event;
+import com.google.adk.models.LlmRequest;
+import com.google.adk.models.LlmResponse;
+import com.google.adk.plugins.BasePlugin;
+import com.google.adk.plugins.debuglogging.DebugEntry.Type;
+import com.google.adk.plugins.debuglogging.TracePayload.BranchTrace;
+import com.google.adk.plugins.debuglogging.TracePayload.LlmErrorTrace;
+import com.google.adk.plugins.debuglogging.TracePayload.MarkerTrace;
+import com.google.adk.plugins.debuglogging.TracePayload.SessionStateTrace;
+import com.google.adk.plugins.debuglogging.TracePayload.ToolCallTrace;
+import com.google.adk.plugins.debuglogging.TracePayload.ToolErrorTrace;
+import com.google.adk.plugins.debuglogging.TracePayload.ToolResponseTrace;
+import com.google.adk.plugins.debuglogging.TracePayload.UserMessageTrace;
+import com.google.adk.tools.BaseTool;
+import com.google.adk.tools.ToolContext;
+import com.google.genai.types.Content;
+import io.reactivex.rxjava3.core.Completable;
+import io.reactivex.rxjava3.core.Maybe;
+import io.reactivex.rxjava3.schedulers.Schedulers;
+import java.nio.file.Path;
+import java.time.Clock;
+import java.util.Map;
+
+/**
+ * Captures a complete record of an invocation to a YAML file for debugging.
+ *
+ *
Each invocation becomes one YAML document appended to the output file, holding the user
+ * message, every LLM request and response, every tool call and result, every event yielded by the
+ * runner, and — optionally — the session state as the invocation ended.
+ *
+ *
Port of adk-python's {@code DebugLoggingPlugin}. Refer to:
+ * https://github.com/google/adk-python/blob/main/src/google/adk/plugins/debug_logging_plugin.py
+ *
+ *
Example:
+ *
+ *
{@code
+ * Runner runner =
+ * new InMemoryRunner(
+ * agent, APP_NAME, ImmutableList.of(new DebugLoggingPlugin(Path.of("adk_debug.yaml"))));
+ * }
+ *
+ * Every hook is observe-only. None returns a value that changes the run: the plugin
+ * cannot rewrite a request, substitute a tool result, or swallow an error. Failures inside it are
+ * logged, never thrown.
+ *
+ *
Traces contain whatever the model and the tools said, so treat the output file as sensitive:
+ * it is meant to be read, and pasted into a bug report, by someone entitled to see the
+ * conversation. Two things are deliberately never written — the bytes of inline data, and the
+ * contents of requested auth configs, of which only a count is recorded.
+ */
+public class DebugLoggingPlugin extends BasePlugin {
+
+ private static final String DEFAULT_NAME = "debug_logging_plugin";
+ private static final String DEFAULT_OUTPUT_PATH = "adk_debug.yaml";
+
+ private final boolean includeSessionState;
+ private final boolean includeSystemInstruction;
+ private final DebugTraceRecorder recorder = new DebugTraceRecorder(Clock.systemDefaultZone());
+ private final DebugYamlWriter writer;
+
+ /** Writes {@code adk_debug.yaml} in the working directory, recording everything. */
+ public DebugLoggingPlugin() {
+ this(DEFAULT_NAME, Path.of(DEFAULT_OUTPUT_PATH), true, true);
+ }
+
+ /** As above, at a path of your choosing. */
+ public DebugLoggingPlugin(Path outputPath) {
+ this(DEFAULT_NAME, outputPath, true, true);
+ }
+
+ /**
+ * @param name plugin instance identifier
+ * @param outputPath file the YAML documents are appended to; missing parent directories are
+ * created
+ * @param includeSessionState whether to append a session state snapshot when an invocation ends
+ * @param includeSystemInstruction whether to record system instructions in full, rather than
+ * noting only that one was present
+ * @throws NullPointerException if {@code outputPath} is null
+ */
+ public DebugLoggingPlugin(
+ String name, Path outputPath, boolean includeSessionState, boolean includeSystemInstruction) {
+ super(name);
+ this.writer = new DebugYamlWriter(checkNotNull(outputPath, "outputPath cannot be null"));
+ this.includeSessionState = includeSessionState;
+ this.includeSystemInstruction = includeSystemInstruction;
+ }
+
+ @Override
+ public Maybe beforeRunCallback(InvocationContext invocationContext) {
+ startInvocation(invocationContext);
+ return Maybe.empty();
+ }
+
+ /**
+ * Records the message that started the invocation.
+ *
+ * This hook runs before {@link #beforeRunCallback}, so it opens the invocation rather
+ * than assuming one is already open — see {@link DebugTraceRecorder#start}.
+ */
+ @Override
+ public Maybe onUserMessageCallback(
+ InvocationContext invocationContext, Content userMessage) {
+ openInvocation(invocationContext);
+ recorder.record(
+ invocationContext.invocationId(), Type.USER_MESSAGE, UserMessageTrace.from(userMessage));
+ return Maybe.empty();
+ }
+
+ @Override
+ public Maybe beforeAgentCallback(BaseAgent agent, CallbackContext callbackContext) {
+ recordBranch(callbackContext, Type.AGENT_START);
+ return Maybe.empty();
+ }
+
+ @Override
+ public Maybe afterAgentCallback(BaseAgent agent, CallbackContext callbackContext) {
+ recorder.record(
+ callbackContext.invocationId(),
+ Type.AGENT_END,
+ callbackContext.agentName(),
+ MarkerTrace.INSTANCE);
+ return Maybe.empty();
+ }
+
+ @Override
+ public Maybe beforeModelCallback(
+ CallbackContext callbackContext, LlmRequest.Builder llmRequest) {
+ recorder.record(
+ callbackContext.invocationId(),
+ Type.LLM_REQUEST,
+ callbackContext.agentName(),
+ LlmRequestTrace.from(llmRequest.build(), includeSystemInstruction));
+ return Maybe.empty();
+ }
+
+ @Override
+ public Maybe afterModelCallback(
+ CallbackContext callbackContext, LlmResponse llmResponse) {
+ recorder.record(
+ callbackContext.invocationId(),
+ Type.LLM_RESPONSE,
+ callbackContext.agentName(),
+ LlmResponseTrace.from(llmResponse));
+ return Maybe.empty();
+ }
+
+ @Override
+ public Maybe onModelErrorCallback(
+ CallbackContext callbackContext, LlmRequest.Builder llmRequest, Throwable error) {
+ recorder.record(
+ callbackContext.invocationId(),
+ Type.LLM_ERROR,
+ callbackContext.agentName(),
+ LlmErrorTrace.from(error, llmRequest.build()));
+ return Maybe.empty();
+ }
+
+ @Override
+ public Maybe> beforeToolCallback(
+ BaseTool tool, Map toolArgs, ToolContext toolContext) {
+ recorder.record(
+ toolContext.invocationId(),
+ Type.TOOL_CALL,
+ toolContext.agentName(),
+ ToolCallTrace.of(tool.name(), toolContext.functionCallId().orElse(null), toolArgs));
+ return Maybe.empty();
+ }
+
+ @Override
+ public Maybe> afterToolCallback(
+ BaseTool tool,
+ Map toolArgs,
+ ToolContext toolContext,
+ Map result) {
+ recorder.record(
+ toolContext.invocationId(),
+ Type.TOOL_RESPONSE,
+ toolContext.agentName(),
+ ToolResponseTrace.of(tool.name(), toolContext.functionCallId().orElse(null), result));
+ return Maybe.empty();
+ }
+
+ @Override
+ public Maybe> onToolErrorCallback(
+ BaseTool tool, Map toolArgs, ToolContext toolContext, Throwable error) {
+ recorder.record(
+ toolContext.invocationId(),
+ Type.TOOL_ERROR,
+ toolContext.agentName(),
+ ToolErrorTrace.of(tool.name(), toolContext.functionCallId().orElse(null), toolArgs, error));
+ return Maybe.empty();
+ }
+
+ @Override
+ public Maybe onEventCallback(InvocationContext invocationContext, Event event) {
+ recorder.record(
+ invocationContext.invocationId(), Type.EVENT, event.author(), EventTrace.from(event));
+ return Maybe.empty();
+ }
+
+ /**
+ * Writes the invocation out.
+ *
+ * {@code afterRunCallback} returns a {@link Completable}, so the file write runs on {@link
+ * Schedulers#io()} rather than on whichever thread finished the run.
+ */
+ @Override
+ public Completable afterRunCallback(InvocationContext invocationContext) {
+ return Completable.fromAction(() -> flush(invocationContext)).subscribeOn(Schedulers.io());
+ }
+
+ /** Opens the invocation if this is the first hook to reach it; harmless if it is not. */
+ private void openInvocation(InvocationContext context) {
+ recorder.start(InvocationDebugState.of(context, recorder.now()));
+ }
+
+ private void startInvocation(InvocationContext context) {
+ openInvocation(context);
+ recorder.record(
+ context.invocationId(),
+ Type.INVOCATION_START,
+ context.agent().name(),
+ new BranchTrace(context.branch()));
+ }
+
+ private void recordBranch(CallbackContext callbackContext, Type type) {
+ recorder.record(
+ callbackContext.invocationId(),
+ type,
+ callbackContext.agentName(),
+ new BranchTrace(callbackContext.branch()));
+ }
+
+ private void flush(InvocationContext context) {
+ recorder.forWrite(context.invocationId()).ifPresent(state -> closeAndWrite(context, state));
+ }
+
+ /**
+ * The closing entries go in before the write, and the state is dropped afterwards whatever
+ * happens — upstream's {@code finally} in {@code after_run_callback}. Without it a failed write
+ * would leak one invocation's entries for the lifetime of the plugin.
+ */
+ private void closeAndWrite(InvocationContext context, InvocationDebugState state) {
+ try {
+ if (includeSessionState) {
+ recorder.record(
+ context.invocationId(),
+ Type.SESSION_STATE_SNAPSHOT,
+ SessionStateTrace.from(context.session()));
+ }
+ recorder.record(context.invocationId(), Type.INVOCATION_END, MarkerTrace.INSTANCE);
+ writer.append(state);
+ } finally {
+ recorder.finish(context.invocationId());
+ }
+ }
+}
diff --git a/core/src/main/java/com/google/adk/plugins/debuglogging/DebugTraceRecorder.java b/core/src/main/java/com/google/adk/plugins/debuglogging/DebugTraceRecorder.java
new file mode 100644
index 000000000..230b3665b
--- /dev/null
+++ b/core/src/main/java/com/google/adk/plugins/debuglogging/DebugTraceRecorder.java
@@ -0,0 +1,139 @@
+/*
+ * Copyright 2026 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.google.adk.plugins.debuglogging;
+
+import com.google.common.base.Preconditions;
+import java.time.Clock;
+import java.time.LocalDateTime;
+import java.time.format.DateTimeFormatter;
+import java.util.Optional;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.ConcurrentMap;
+import org.jspecify.annotations.Nullable;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * The live invocations, and the timestamped entries filed into them — upstream's {@code
+ * _invocation_states} dict together with {@code _add_entry}.
+ *
+ *
The two belong to one another: an entry is only ever created in order to be filed, and it is
+ * filed against a state that is looked up in the same breath. It is also the only class that reads
+ * the clock — the header and the first entry are stamped from the same source, so they cannot
+ * disagree about when the run began.
+ *
+ *
It also answers "what if there is no state for this id?". Upstream warns and carries on, with
+ * two different messages depending on whether an entry or a write asked; both are preserved, so a
+ * trace of adk-java's logs reads the same as adk-python's. Neither path throws: a missing state is
+ * logged and the run continues.
+ *
+ *
The map is concurrent because ADK runs invocations in parallel and every hook may arrive on a
+ * different thread.
+ */
+final class DebugTraceRecorder {
+
+ private static final Logger logger = LoggerFactory.getLogger(DebugTraceRecorder.class);
+
+ private static final String NO_STATE_FOR_ENTRY =
+ "No debug state for invocation {}, skipping entry";
+ private static final String NO_STATE_FOR_WRITE =
+ "No debug state for invocation {}, skipping write";
+
+ /**
+ * Upstream writes {@code datetime.now().isoformat()}, so the timestamp is local time with no
+ * offset. The pattern is stated rather than left to {@link LocalDateTime#toString()}, which drops
+ * zero seconds and prints a varying number of fractional digits — a trace reads better when every
+ * line is the same width and sorts lexicographically.
+ */
+ private static final DateTimeFormatter TIMESTAMP =
+ DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss.SSS");
+
+ private final ConcurrentMap states = new ConcurrentHashMap<>();
+ private final Clock clock;
+
+ DebugTraceRecorder(Clock clock) {
+ this.clock = Preconditions.checkNotNull(clock);
+ }
+
+ /**
+ * Registers {@code state} unless this invocation already has one.
+ *
+ * "Unless" is load-bearing. {@code Runner.runAsync} invokes {@code onUserMessageCallback}
+ * before {@code beforeRunCallback}, so either hook may be the first to reach an
+ * invocation. Both call this, the second finds the state already open, and the user message — the
+ * first thing anyone reading a debug trace looks for — is recorded rather than filed against an
+ * invocation that does not exist yet.
+ *
+ *
The state is still created once, still keyed by invocation id, and still holds the same
+ * header. This is the port's one behavioral difference from adk-python, which opens the state in
+ * the later hook only.
+ */
+ void start(InvocationDebugState state) {
+ states.putIfAbsent(state.invocationId(), state);
+ }
+
+ /** For the entry types upstream records without an agent name. */
+ void record(String invocationId, DebugEntry.Type type, TracePayload data) {
+ record(invocationId, type, null, data);
+ }
+
+ /**
+ * Nullable so the overload above can omit the agent name, and a plain {@code String} rather than
+ * an {@code Optional} because adk-java's {@code illegal-optional-check} profile bans {@code
+ * Optional} as a parameter.
+ */
+ void record(
+ String invocationId, DebugEntry.Type type, @Nullable String agentName, TracePayload data) {
+ DebugEntry entry =
+ new DebugEntry(now(), type, invocationId, Optional.ofNullable(agentName), data);
+ forEntry(invocationId).ifPresent(state -> state.add(entry));
+ }
+
+ /** The state an entry belongs to, or empty — with upstream's warning — if it is gone. */
+ Optional forEntry(String invocationId) {
+ return lookup(invocationId, NO_STATE_FOR_ENTRY);
+ }
+
+ /**
+ * The state to write out, left in place so that the closing entries can still be filed under it.
+ *
+ * Upstream removes the state in a {@code finally} after writing, not before,
+ * precisely because {@code session_state_snapshot} and {@code invocation_end} are recorded in
+ * between. {@link #finish} is that {@code finally}.
+ */
+ Optional forWrite(String invocationId) {
+ return lookup(invocationId, NO_STATE_FOR_WRITE);
+ }
+
+ /** Drops the invocation, whether or not the write succeeded. */
+ void finish(String invocationId) {
+ states.remove(invocationId);
+ }
+
+ /** The same clock the entries use, for an invocation's {@code start_time}. */
+ String now() {
+ return LocalDateTime.now(clock).format(TIMESTAMP);
+ }
+
+ private Optional lookup(String invocationId, String warning) {
+ Optional state = Optional.ofNullable(states.get(invocationId));
+ if (state.isEmpty()) {
+ logger.warn(warning, invocationId);
+ }
+ return state;
+ }
+}
diff --git a/core/src/main/java/com/google/adk/plugins/debuglogging/DebugYamlWriter.java b/core/src/main/java/com/google/adk/plugins/debuglogging/DebugYamlWriter.java
new file mode 100644
index 000000000..61c9530d2
--- /dev/null
+++ b/core/src/main/java/com/google/adk/plugins/debuglogging/DebugYamlWriter.java
@@ -0,0 +1,135 @@
+/*
+ * Copyright 2026 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.google.adk.plugins.debuglogging;
+
+import com.fasterxml.jackson.annotation.JsonInclude;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.dataformat.yaml.YAMLFactory;
+import com.fasterxml.jackson.dataformat.yaml.YAMLGenerator;
+import com.fasterxml.jackson.datatype.jdk8.Jdk8Module;
+import com.google.common.base.Preconditions;
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.StandardOpenOption;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Appends one invocation to the trace file as a YAML document — the write block in adk-python's
+ * {@code after_run_callback}.
+ *
+ * Two differences from the original, both forced by the JVM:
+ *
+ *
+ * The append is {@code synchronized}. The write runs on {@code Schedulers.io()}, so
+ * two invocations finishing together would otherwise interleave their documents into a file
+ * that no longer parses. One document is written whole or not at all. The lock is this
+ * instance's, so it orders the appends of one plugin; two plugins pointed at the same path
+ * are not coordinated.
+ * Missing parent directories are created , so a path pointing into a directory that
+ * does not exist yet still produces a trace.
+ *
+ *
+ * The document separator comes from Jackson, which writes {@code ---} at the start of every
+ * document by default — so, unlike upstream, this class does not write one itself. Writing both
+ * would produce two separators per invocation and a file that reads as if documents were missing.
+ *
+ *
Nothing here throws: a failed write is logged and the run continues.
+ */
+final class DebugYamlWriter {
+
+ private static final Logger logger = LoggerFactory.getLogger(DebugYamlWriter.class);
+
+ private static final String WROTE_TRACE = "Wrote debug data for invocation {} to {}";
+ private static final String FAILED_TO_WRITE = "Failed to write debug data for invocation {}";
+
+ private final Path outputPath;
+ private final ObjectMapper mapper = configure(new ObjectMapper(yamlFactory()));
+
+ /**
+ * Applies the trace settings to {@code mapper} and returns it, for JSON or YAML alike — the tests
+ * assert on the JSON form, this class writes the YAML one, and both must agree about which keys
+ * exist.
+ *
+ *
Deliberately not {@code JsonBaseModel.getMapper()}: that instance is shared across
+ * all of ADK and is configured with {@code Include.ALWAYS}, so changing it to suit a debug trace
+ * would change everyone's serialization. The trace records here are ours, so they get their own
+ * mapper.
+ *
+ *
{@link Jdk8Module} makes {@code Optional} fields serialize as their contents, and {@code
+ * NON_ABSENT} omits the empty ones — together reproducing pydantic's {@code exclude_none=True}
+ * without a stripping pass.
+ */
+ static T configure(T mapper) {
+ mapper.registerModule(new Jdk8Module());
+ mapper.setSerializationInclusion(JsonInclude.Include.NON_ABSENT);
+ return mapper;
+ }
+
+ /**
+ * Jackson quotes every scalar by default; PyYAML quotes only what it must, so a mapper left at
+ * its defaults writes a file that is correct but harder to read. Three features fix that without
+ * changing what the file means :
+ *
+ *
+ * {@code MINIMIZE_QUOTES} drops the quotes around ordinary text.
+ * {@code ALWAYS_QUOTE_NUMBERS_AS_STRINGS} is not optional alongside it. {@code
+ * MINIMIZE_QUOTES} alone still quotes a string reading {@code true} or {@code null}, but
+ * writes the string {@code "42"} bare — so a tool that returned a numeric string would be
+ * read back from the trace as a number, misreporting what the tool actually said. A test
+ * pins all four cases.
+ * {@code LITERAL_BLOCK_STYLE} renders multi-line text as a {@code |-} block instead of one
+ * line of {@code \n} escapes. Model output is the bulk of these traces and is usually
+ * multi-line.
+ *
+ */
+ private static YAMLFactory yamlFactory() {
+ return new YAMLFactory()
+ .enable(YAMLGenerator.Feature.MINIMIZE_QUOTES)
+ .enable(YAMLGenerator.Feature.ALWAYS_QUOTE_NUMBERS_AS_STRINGS)
+ .enable(YAMLGenerator.Feature.LITERAL_BLOCK_STYLE);
+ }
+
+ DebugYamlWriter(Path outputPath) {
+ this.outputPath = Preconditions.checkNotNull(outputPath);
+ }
+
+ /** Appends {@code state} as one document, or logs why it could not be. */
+ synchronized void append(InvocationDebugState state) {
+ try {
+ write(mapper.writeValueAsString(state));
+ logger.debug(WROTE_TRACE, state.invocationId(), outputPath);
+ } catch (IOException | RuntimeException e) {
+ logger.error(FAILED_TO_WRITE, state.invocationId(), e);
+ }
+ }
+
+ private void write(String document) throws IOException {
+ Path parent = outputPath.getParent();
+ if (parent != null) {
+ Files.createDirectories(parent);
+ }
+ Files.writeString(
+ outputPath,
+ document,
+ StandardCharsets.UTF_8,
+ StandardOpenOption.CREATE,
+ StandardOpenOption.APPEND);
+ }
+}
diff --git a/core/src/main/java/com/google/adk/plugins/debuglogging/EventTrace.java b/core/src/main/java/com/google/adk/plugins/debuglogging/EventTrace.java
new file mode 100644
index 000000000..a182aa659
--- /dev/null
+++ b/core/src/main/java/com/google/adk/plugins/debuglogging/EventTrace.java
@@ -0,0 +1,123 @@
+/*
+ * Copyright 2026 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.google.adk.plugins.debuglogging;
+
+import static java.util.function.Predicate.not;
+
+import com.fasterxml.jackson.annotation.JsonIgnore;
+import com.fasterxml.jackson.annotation.JsonInclude;
+import com.fasterxml.jackson.annotation.JsonProperty;
+import com.google.adk.events.Event;
+import com.google.adk.events.EventActions;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import java.util.Map;
+import java.util.Optional;
+
+/**
+ * One {@link Event} as it appears in the trace — port of adk-python's {@code on_event_callback}.
+ *
+ * Grounding metadata is recorded as a bare {@code has_grounding_metadata: true} rather than
+ * copied, as upstream does — the payload is large and adds nothing to a readable trace.
+ */
+@JsonInclude(JsonInclude.Include.NON_ABSENT)
+record EventTrace(
+ @JsonProperty("event_id") String eventId,
+ String author,
+ Optional content,
+ @JsonProperty("is_final_response") boolean isFinalResponse,
+ Optional partial,
+ @JsonProperty("turn_complete") Optional turnComplete,
+ Optional branch,
+ Optional actions,
+ @JsonProperty("has_grounding_metadata") Optional hasGroundingMetadata,
+ @JsonProperty("usage_metadata") Optional usageMetadata,
+ @JsonProperty("error_code") Optional errorCode,
+ @JsonProperty("error_message") Optional errorMessage,
+ @JsonProperty("long_running_tool_ids") @JsonInclude(JsonInclude.Include.NON_EMPTY)
+ ImmutableList longRunningToolIds)
+ implements TracePayload {
+
+ static EventTrace from(Event event) {
+ return new EventTrace(
+ event.id(),
+ event.author(),
+ event.content().map(ContentTrace::from),
+ event.finalResponse(),
+ event.partial(),
+ event.turnComplete(),
+ event.branch(),
+ EventActionsTrace.from(event.actions()),
+ event.groundingMetadata().map(unused -> Boolean.TRUE),
+ event.usageMetadata().map(UsageTrace::fromEvent),
+ event.errorCode().map(String::valueOf),
+ event.errorMessage(),
+ event.longRunningToolIds().map(ImmutableList::copyOf).orElseGet(ImmutableList::of));
+ }
+
+ /**
+ * What an event's {@link EventActions} contributed, as it appears in the trace.
+ *
+ * Two details are load-bearing and both come from upstream's {@code on_event_callback}:
+ *
+ *
+ * Requested auth configs are recorded as a count, never their content — the map is
+ * free-form and auth-related, and a debug file gets pasted into bug reports.
+ * The artifact delta keeps its filename → version mapping, which is the only reason it is
+ * worth recording at all.
+ *
+ *
+ * {@link EventActions} hands out its live maps, so every one of them is copied rather
+ * than referenced — a trace must not change after it was taken.
+ */
+ @JsonInclude(JsonInclude.Include.NON_ABSENT)
+ record EventActionsTrace(
+ @JsonProperty("state_delta") @JsonInclude(JsonInclude.Include.NON_EMPTY)
+ ImmutableMap stateDelta,
+ @JsonProperty("artifact_delta") @JsonInclude(JsonInclude.Include.NON_EMPTY)
+ ImmutableMap artifactDelta,
+ @JsonProperty("transfer_to_agent") Optional transferToAgent,
+ Optional escalate,
+ @JsonProperty("requested_auth_configs") Optional requestedAuthConfigs) {
+
+ /** Empty when the event requested nothing, matching upstream's {@code if actions_data:}. */
+ static Optional from(EventActions actions) {
+ EventActionsTrace trace =
+ new EventActionsTrace(
+ SafeSerializer.serializeMap(actions.stateDelta()),
+ ImmutableMap.copyOf(actions.artifactDelta()),
+ actions.transferToAgent(),
+ actions.escalate(),
+ countOf(actions.requestedAuthConfigs()));
+ return trace.isEmpty() ? Optional.empty() : Optional.of(trace);
+ }
+
+ /** How many were requested — never which, since the map is free-form and auth-related. */
+ private static Optional countOf(Map requested) {
+ return Optional.of(requested).filter(not(Map::isEmpty)).map(Map::size);
+ }
+
+ @JsonIgnore
+ boolean isEmpty() {
+ return stateDelta.isEmpty()
+ && artifactDelta.isEmpty()
+ && transferToAgent.isEmpty()
+ && escalate.isEmpty()
+ && requestedAuthConfigs.isEmpty();
+ }
+ }
+}
diff --git a/core/src/main/java/com/google/adk/plugins/debuglogging/InvocationDebugState.java b/core/src/main/java/com/google/adk/plugins/debuglogging/InvocationDebugState.java
new file mode 100644
index 000000000..42e761a86
--- /dev/null
+++ b/core/src/main/java/com/google/adk/plugins/debuglogging/InvocationDebugState.java
@@ -0,0 +1,115 @@
+/*
+ * Copyright 2026 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.google.adk.plugins.debuglogging;
+
+import com.fasterxml.jackson.annotation.JsonInclude;
+import com.fasterxml.jackson.annotation.JsonProperty;
+import com.fasterxml.jackson.annotation.JsonPropertyOrder;
+import com.google.adk.agents.InvocationContext;
+import com.google.adk.sessions.Session;
+import com.google.common.base.Preconditions;
+import com.google.common.collect.ImmutableList;
+import java.util.Optional;
+import java.util.concurrent.ConcurrentLinkedQueue;
+import org.jspecify.annotations.Nullable;
+
+/**
+ * One invocation's header and the entries recorded under it — port of adk-python's {@code
+ * _InvocationDebugState}. This is the YAML document that gets written when the invocation ends.
+ *
+ * Mutable, deliberately: entries accumulate for the length of a run. Two things follow from
+ * that, both required rather than stylistic:
+ *
+ *
+ * The queue is concurrent . ADK's hooks fire from whichever Rx thread the run is on,
+ * and a parallel agent has several of them appending at once; an {@code ArrayList} would drop
+ * or corrupt entries.
+ * {@link #entries()} returns an {@link ImmutableList} snapshot , never the live queue.
+ * The writer must see a document that cannot change underneath it.
+ *
+ *
+ * The app name and user id are optional even though upstream types them as required. {@link
+ * Session} validates only its id, so a hand-built session can carry neither — and a debug plugin
+ * that threw because the session it was observing was underpopulated would break the run
+ * for the sake of a trace. Absent means the key is simply not written.
+ */
+@JsonInclude(JsonInclude.Include.NON_ABSENT)
+@JsonPropertyOrder({"invocation_id", "session_id", "app_name", "user_id", "start_time", "entries"})
+final class InvocationDebugState {
+
+ private final String invocationId;
+ private final String sessionId;
+ private final Optional appName;
+ private final Optional userId;
+ private final String startTime;
+ private final ConcurrentLinkedQueue entries = new ConcurrentLinkedQueue<>();
+
+ private InvocationDebugState(
+ String invocationId,
+ String sessionId,
+ @Nullable String appName,
+ @Nullable String userId,
+ String startTime) {
+ this.invocationId = Preconditions.checkNotNull(invocationId);
+ this.sessionId = Preconditions.checkNotNull(sessionId);
+ this.appName = Optional.ofNullable(appName);
+ this.userId = Optional.ofNullable(userId);
+ this.startTime = Preconditions.checkNotNull(startTime);
+ }
+
+ /** The header adk-python fills in {@code before_run_callback}. */
+ static InvocationDebugState of(InvocationContext context, String startTime) {
+ Session session = context.session();
+ return new InvocationDebugState(
+ context.invocationId(), session.id(), session.appName(), context.userId(), startTime);
+ }
+
+ void add(DebugEntry entry) {
+ entries.add(entry);
+ }
+
+ @JsonProperty("invocation_id")
+ String invocationId() {
+ return invocationId;
+ }
+
+ @JsonProperty("session_id")
+ String sessionId() {
+ return sessionId;
+ }
+
+ @JsonProperty("app_name")
+ Optional appName() {
+ return appName;
+ }
+
+ @JsonProperty("user_id")
+ Optional userId() {
+ return userId;
+ }
+
+ @JsonProperty("start_time")
+ String startTime() {
+ return startTime;
+ }
+
+ /** A snapshot, in the order the hooks fired. */
+ @JsonProperty("entries")
+ ImmutableList entries() {
+ return ImmutableList.copyOf(entries);
+ }
+}
diff --git a/core/src/main/java/com/google/adk/plugins/debuglogging/LlmRequestTrace.java b/core/src/main/java/com/google/adk/plugins/debuglogging/LlmRequestTrace.java
new file mode 100644
index 000000000..a187bfe8f
--- /dev/null
+++ b/core/src/main/java/com/google/adk/plugins/debuglogging/LlmRequestTrace.java
@@ -0,0 +1,126 @@
+/*
+ * Copyright 2026 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.google.adk.plugins.debuglogging;
+
+import static com.google.common.collect.ImmutableList.toImmutableList;
+
+import com.fasterxml.jackson.annotation.JsonIgnore;
+import com.fasterxml.jackson.annotation.JsonInclude;
+import com.fasterxml.jackson.annotation.JsonProperty;
+import com.google.adk.models.LlmRequest;
+import com.google.common.collect.ImmutableList;
+import com.google.genai.types.GenerateContentConfig;
+import java.util.Optional;
+import java.util.stream.Stream;
+
+/**
+ * One {@link LlmRequest} as it appears in the trace — port of adk-python's {@code
+ * before_model_callback}.
+ *
+ * Only tool names are recorded, as upstream does: the declarations are large, repeat on
+ * every turn, and say nothing about the turn being traced.
+ */
+@JsonInclude(JsonInclude.Include.NON_ABSENT)
+record LlmRequestTrace(
+ Optional model,
+ @JsonProperty("content_count") int contentCount,
+ ImmutableList contents,
+ @JsonInclude(JsonInclude.Include.NON_EMPTY) ImmutableList tools,
+ Optional config)
+ implements TracePayload {
+
+ static LlmRequestTrace from(LlmRequest request, boolean includeSystemInstruction) {
+ return new LlmRequestTrace(
+ request.model(),
+ request.contents().size(),
+ request.contents().stream().map(ContentTrace::from).collect(toImmutableList()),
+ toolNames(request),
+ request.config().flatMap(config -> configTrace(config, includeSystemInstruction)));
+ }
+
+ private static ImmutableList toolNames(LlmRequest request) {
+ return ImmutableList.copyOf(request.tools().keySet());
+ }
+
+ private static Optional configTrace(
+ GenerateContentConfig config, boolean includeSystemInstruction) {
+ return GenerateContentConfigTrace.from(config, includeSystemInstruction);
+ }
+
+ /**
+ * The generation settings, as they appear in the trace.
+ *
+ * {@link GenerateContentConfig#systemInstruction()} is {@code Optional}, never a
+ * string, so the off-switch emits {@code has_system_instruction} and never upstream's {@code
+ * system_instruction_length}.
+ */
+ @JsonInclude(JsonInclude.Include.NON_ABSENT)
+ record GenerateContentConfigTrace(
+ @JsonProperty("system_instruction") Optional systemInstruction,
+ @JsonProperty("has_system_instruction") Optional hasSystemInstruction,
+ Optional temperature,
+ @JsonProperty("top_p") Optional topP,
+ @JsonProperty("top_k") Optional topK,
+ @JsonProperty("max_output_tokens") Optional maxOutputTokens,
+ @JsonProperty("response_mime_type") Optional responseMimeType,
+ @JsonProperty("has_response_schema") Optional hasResponseSchema) {
+
+ /** Empty when nothing was configured, matching upstream's {@code if config_data:}. */
+ static Optional from(
+ GenerateContentConfig config, boolean includeSystemInstruction) {
+ GenerateContentConfigTrace trace =
+ new GenerateContentConfigTrace(
+ includedInstruction(config, includeSystemInstruction),
+ summarizedInstruction(config, includeSystemInstruction),
+ config.temperature(),
+ config.topP(),
+ config.topK(),
+ config.maxOutputTokens(),
+ config.responseMimeType(),
+ config.responseSchema().map(unused -> Boolean.TRUE));
+ return trace.isEmpty() ? Optional.empty() : Optional.of(trace);
+ }
+
+ private static Optional includedInstruction(
+ GenerateContentConfig config, boolean includeSystemInstruction) {
+ return includeSystemInstruction
+ ? config.systemInstruction().map(ContentTrace::from)
+ : Optional.empty();
+ }
+
+ private static Optional summarizedInstruction(
+ GenerateContentConfig config, boolean includeSystemInstruction) {
+ return includeSystemInstruction
+ ? Optional.empty()
+ : config.systemInstruction().map(unused -> Boolean.TRUE);
+ }
+
+ @JsonIgnore
+ boolean isEmpty() {
+ return Stream.of(
+ systemInstruction,
+ hasSystemInstruction,
+ temperature,
+ topP,
+ topK,
+ maxOutputTokens,
+ responseMimeType,
+ hasResponseSchema)
+ .allMatch(Optional::isEmpty);
+ }
+ }
+}
diff --git a/core/src/main/java/com/google/adk/plugins/debuglogging/LlmResponseTrace.java b/core/src/main/java/com/google/adk/plugins/debuglogging/LlmResponseTrace.java
new file mode 100644
index 000000000..293a2b1a3
--- /dev/null
+++ b/core/src/main/java/com/google/adk/plugins/debuglogging/LlmResponseTrace.java
@@ -0,0 +1,56 @@
+/*
+ * Copyright 2026 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.google.adk.plugins.debuglogging;
+
+import com.fasterxml.jackson.annotation.JsonInclude;
+import com.fasterxml.jackson.annotation.JsonProperty;
+import com.google.adk.models.LlmResponse;
+import java.util.Optional;
+
+/**
+ * One {@link LlmResponse} as it appears in the trace — port of adk-python's {@code
+ * after_model_callback}.
+ *
+ * Grounding metadata is reduced to a boolean, as upstream does. Token counts use {@link
+ * UsageTrace#fromResponse}, which carries the cached-content count an event's copy omits.
+ */
+@JsonInclude(JsonInclude.Include.NON_ABSENT)
+record LlmResponseTrace(
+ Optional content,
+ Optional partial,
+ @JsonProperty("turn_complete") Optional turnComplete,
+ @JsonProperty("error_code") Optional errorCode,
+ @JsonProperty("error_message") Optional errorMessage,
+ @JsonProperty("usage_metadata") Optional usageMetadata,
+ @JsonProperty("has_grounding_metadata") Optional hasGroundingMetadata,
+ @JsonProperty("finish_reason") Optional finishReason,
+ @JsonProperty("model_version") Optional modelVersion)
+ implements TracePayload {
+
+ static LlmResponseTrace from(LlmResponse response) {
+ return new LlmResponseTrace(
+ response.content().map(ContentTrace::from),
+ response.partial(),
+ response.turnComplete(),
+ response.errorCode().map(String::valueOf),
+ response.errorMessage(),
+ response.usageMetadata().map(UsageTrace::fromResponse),
+ response.groundingMetadata().map(unused -> Boolean.TRUE),
+ response.finishReason().map(String::valueOf),
+ response.modelVersion());
+ }
+}
diff --git a/core/src/main/java/com/google/adk/plugins/debuglogging/SafeSerializer.java b/core/src/main/java/com/google/adk/plugins/debuglogging/SafeSerializer.java
new file mode 100644
index 000000000..8c8c16566
--- /dev/null
+++ b/core/src/main/java/com/google/adk/plugins/debuglogging/SafeSerializer.java
@@ -0,0 +1,157 @@
+/*
+ * Copyright 2026 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.google.adk.plugins.debuglogging;
+
+import static com.google.common.collect.ImmutableList.toImmutableList;
+import static java.util.Collections.newSetFromMap;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.IdentityHashMap;
+import java.util.Map;
+import java.util.Set;
+
+/**
+ * Converts an arbitrary value into something a YAML writer can render.
+ *
+ * Port of adk-python's {@code DebugLoggingPlugin._safe_serialize}. Tool arguments and tool
+ * results are declared {@code Map}, so the values inside them are whatever a tool
+ * author chose to return — this class is the guarantee that a debug plugin never throws while
+ * trying to describe them.
+ *
+ * Deliberate differences from the original:
+ *
+ *
+ * A {@code null} or empty input yields an empty map rather than a null, so nothing downstream
+ * has to null-check. Fields that should disappear when empty say so with
+ * {@code @JsonInclude(NON_EMPTY)} — which is also how pydantic's {@code exclude_none=True} is
+ * reproduced, without a stripping pass.
+ * A {@code null} inside a list or map becomes {@link #NULL_MARKER}, because Guava's
+ * immutable collections reject null elements. The position is preserved, which is what
+ * matters when reading a trace.
+ * Cycles are detected , so a self-referential tool result cannot overflow the stack.
+ * adk-java's own {@code JsonFormatter} keeps an identity set for the same reason; it is
+ * package-private to its own package, so the guard is reimplemented here rather than shared.
+ *
+ */
+final class SafeSerializer {
+
+ private static final String BYTES_MARKER = "";
+ private static final String NULL_MARKER = "";
+ private static final String CYCLE_MARKER = "";
+ private static final String UNSERIALIZABLE_MARKER = "";
+
+ private SafeSerializer() {}
+
+ /**
+ * The YAML-safe form of a map whose outer shape ADK or genai already fixes as {@code Map} — tool arguments, tool results, a state delta, session state.
+ *
+ * The only entry point. Callers that must omit the key when the map is empty declare
+ * {@code @JsonInclude(NON_EMPTY)} on the field and let Jackson decide.
+ *
+ *
Only the map's values stay {@code Object}, and only because the declaring interface
+ * says they are.
+ */
+ static ImmutableMap serializeMap(Map values) {
+ if (values.isEmpty()) {
+ return ImmutableMap.of();
+ }
+ Set ancestors = newSetFromMap(new IdentityHashMap<>());
+ ancestors.add(values);
+ return mapEntries(values, ancestors);
+ }
+
+ private static Object serializeNonNull(Object value, Set ancestors) {
+ if (isScalar(value)) {
+ return value;
+ }
+ if (value instanceof byte[] bytes) {
+ return BYTES_MARKER.formatted(bytes.length);
+ }
+ if (!isContainer(value)) {
+ return describe(value);
+ }
+ return serializeGuarded(value, ancestors);
+ }
+
+ /**
+ * Descends into a container only if it is not already an ancestor of itself.
+ *
+ * Membership is removed on the way out, so a value legitimately appearing twice in a
+ * tree is serialized twice — only a genuine cycle is cut.
+ */
+ private static Object serializeGuarded(Object container, Set ancestors) {
+ if (!ancestors.add(container)) {
+ return CYCLE_MARKER;
+ }
+ try {
+ return serializeContainer(container, ancestors);
+ } finally {
+ ancestors.remove(container);
+ }
+ }
+
+ private static Object serializeContainer(Object container, Set ancestors) {
+ if (container instanceof Map, ?> map) {
+ return mapEntries(map, ancestors);
+ }
+ if (container instanceof Collection> collection) {
+ return serializeCollection(collection, ancestors);
+ }
+ return serializeCollection(Arrays.asList((Object[]) container), ancestors);
+ }
+
+ private static ImmutableList serializeCollection(
+ Collection> values, Set ancestors) {
+ return values.stream()
+ .map(element -> serializeElement(element, ancestors))
+ .collect(toImmutableList());
+ }
+
+ private static ImmutableMap mapEntries(Map, ?> values, Set ancestors) {
+ ImmutableMap.Builder entries = ImmutableMap.builder();
+ for (Map.Entry, ?> entry : values.entrySet()) {
+ entries.put(String.valueOf(entry.getKey()), serializeElement(entry.getValue(), ancestors));
+ }
+ return entries.buildKeepingLast();
+ }
+
+ /** A nested value, where absence has to be represented rather than omitted. */
+ private static Object serializeElement(Object value, Set ancestors) {
+ return value == null ? NULL_MARKER : serializeNonNull(value, ancestors);
+ }
+
+ private static boolean isScalar(Object value) {
+ return value instanceof String || value instanceof Number || value instanceof Boolean;
+ }
+
+ private static boolean isContainer(Object value) {
+ return value instanceof Map || value instanceof Collection || value instanceof Object[];
+ }
+
+ /** Last resort, mirroring the original's {@code str(obj)} with its exception guard. */
+ private static Object describe(Object value) {
+ try {
+ return String.valueOf(value);
+ } catch (RuntimeException e) {
+ return UNSERIALIZABLE_MARKER;
+ }
+ }
+}
diff --git a/core/src/main/java/com/google/adk/plugins/debuglogging/TracePayload.java b/core/src/main/java/com/google/adk/plugins/debuglogging/TracePayload.java
new file mode 100644
index 000000000..435e3ab37
--- /dev/null
+++ b/core/src/main/java/com/google/adk/plugins/debuglogging/TracePayload.java
@@ -0,0 +1,197 @@
+/*
+ * Copyright 2026 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.google.adk.plugins.debuglogging;
+
+import com.fasterxml.jackson.annotation.JsonInclude;
+import com.fasterxml.jackson.annotation.JsonProperty;
+import com.fasterxml.jackson.annotation.JsonValue;
+import com.google.adk.models.LlmRequest;
+import com.google.adk.sessions.Session;
+import com.google.common.collect.ImmutableMap;
+import com.google.genai.types.Content;
+import java.util.Map;
+import java.util.Optional;
+import org.jspecify.annotations.Nullable;
+
+/**
+ * The {@code data} of one debug entry.
+ *
+ * adk-python passes {@code **kwargs} into a free-form {@code dict[str, Any]} in {@code
+ * _add_entry}. Every entry type here has a known shape instead, so the union is sealed: a new type
+ * cannot be added without declaring what it carries.
+ *
+ *
{@link BranchTrace} serves both {@code invocation_start} and {@code agent_start}, and {@link
+ * MarkerTrace} serves {@code agent_end} and {@code invocation_end}, which upstream records with no
+ * payload at all.
+ */
+sealed interface TracePayload
+ permits EventTrace,
+ LlmRequestTrace,
+ LlmResponseTrace,
+ TracePayload.BranchTrace,
+ TracePayload.LlmErrorTrace,
+ TracePayload.MarkerTrace,
+ TracePayload.SessionStateTrace,
+ TracePayload.ToolCallTrace,
+ TracePayload.ToolErrorTrace,
+ TracePayload.ToolResponseTrace,
+ TracePayload.UserMessageTrace {
+
+ /**
+ * Which agent branch an entry belongs to — the payload of {@code invocation_start} and {@code
+ * agent_start}.
+ *
+ *
A root invocation has no branch, and upstream's {@code exclude_none=True} then leaves the
+ * key out; {@code NON_ABSENT} does the same here, so the entry records {@code data: {}}.
+ */
+ @JsonInclude(JsonInclude.Include.NON_ABSENT)
+ record BranchTrace(Optional branch) implements TracePayload {}
+
+ /**
+ * The payload of an entry that has none — {@code agent_end} and {@code invocation_end}.
+ *
+ * This is upstream's shape, not an omission: it records the latter as literally {@code
+ * self._add_entry(invocation_id, "invocation_end")}. The entry still carries its timestamp, type,
+ * invocation id and (for {@code agent_end}) agent name — for these two types the timestamp
+ * is the information.
+ *
+ *
A singleton, because every instance is identical.
+ */
+ record MarkerTrace() implements TracePayload {
+
+ static final MarkerTrace INSTANCE = new MarkerTrace();
+
+ /**
+ * Renders as {@code data: {}}, matching upstream's default-empty dict.
+ *
+ *
Jackson has no properties to discover on a component-less record, so the empty mapping is
+ * stated rather than inferred — without this the writer would reject the entry.
+ */
+ @JsonValue
+ ImmutableMap fields() {
+ return ImmutableMap.of();
+ }
+ }
+
+ /** The message that opened the invocation — {@code user_message}. */
+ record UserMessageTrace(ContentTrace content) implements TracePayload {
+
+ static UserMessageTrace from(Content userMessage) {
+ return new UserMessageTrace(ContentTrace.from(userMessage));
+ }
+ }
+
+ /**
+ * A model call that threw — {@code llm_error}.
+ *
+ * Upstream's {@code str(error)} is always a string; Java's {@link Throwable#getMessage()} may
+ * be null, which is why the message is optional rather than the literal {@code "null"}.
+ */
+ @JsonInclude(JsonInclude.Include.NON_ABSENT)
+ record LlmErrorTrace(
+ @JsonProperty("error_type") String errorType,
+ @JsonProperty("error_message") Optional errorMessage,
+ Optional model)
+ implements TracePayload {
+
+ static LlmErrorTrace from(Throwable error, LlmRequest request) {
+ return new LlmErrorTrace(
+ error.getClass().getSimpleName(),
+ Optional.ofNullable(error.getMessage()),
+ request.model());
+ }
+ }
+
+ /**
+ * A tool about to run — {@code tool_call}.
+ *
+ * {@code args} is a plain map with no {@code NON_EMPTY}: upstream always passes a dict here,
+ * so a tool called with no arguments records {@code args: {}} rather than dropping the key.
+ */
+ @JsonInclude(JsonInclude.Include.NON_ABSENT)
+ record ToolCallTrace(
+ @JsonProperty("tool_name") String toolName,
+ @JsonProperty("function_call_id") Optional functionCallId,
+ ImmutableMap args)
+ implements TracePayload {
+
+ static ToolCallTrace of(
+ String toolName, @Nullable String functionCallId, Map args) {
+ return new ToolCallTrace(
+ toolName, Optional.ofNullable(functionCallId), SafeSerializer.serializeMap(args));
+ }
+ }
+
+ /** What a tool returned — {@code tool_response}. */
+ @JsonInclude(JsonInclude.Include.NON_ABSENT)
+ record ToolResponseTrace(
+ @JsonProperty("tool_name") String toolName,
+ @JsonProperty("function_call_id") Optional functionCallId,
+ ImmutableMap result)
+ implements TracePayload {
+
+ static ToolResponseTrace of(
+ String toolName, @Nullable String functionCallId, Map result) {
+ return new ToolResponseTrace(
+ toolName, Optional.ofNullable(functionCallId), SafeSerializer.serializeMap(result));
+ }
+ }
+
+ /**
+ * A tool that threw — {@code tool_error}.
+ *
+ * Not folded into {@link ToolCallTrace} with two optional fields: the arguments are recorded
+ * again here on purpose, so a failure is readable without hunting for the matching call entry.
+ */
+ @JsonInclude(JsonInclude.Include.NON_ABSENT)
+ record ToolErrorTrace(
+ @JsonProperty("tool_name") String toolName,
+ @JsonProperty("function_call_id") Optional functionCallId,
+ ImmutableMap args,
+ @JsonProperty("error_type") String errorType,
+ @JsonProperty("error_message") Optional errorMessage)
+ implements TracePayload {
+
+ static ToolErrorTrace of(
+ String toolName,
+ @Nullable String functionCallId,
+ Map args,
+ Throwable error) {
+ return new ToolErrorTrace(
+ toolName,
+ Optional.ofNullable(functionCallId),
+ SafeSerializer.serializeMap(args),
+ error.getClass().getSimpleName(),
+ Optional.ofNullable(error.getMessage()));
+ }
+ }
+
+ /**
+ * Session state as the invocation ended — {@code session_state_snapshot}.
+ *
+ * Written only when {@code includeSessionState} is on, matching upstream's guard.
+ */
+ record SessionStateTrace(
+ ImmutableMap state, @JsonProperty("event_count") int eventCount)
+ implements TracePayload {
+
+ static SessionStateTrace from(Session session) {
+ return new SessionStateTrace(
+ SafeSerializer.serializeMap(session.state()), session.events().size());
+ }
+ }
+}
diff --git a/core/src/main/java/com/google/adk/plugins/debuglogging/UsageTrace.java b/core/src/main/java/com/google/adk/plugins/debuglogging/UsageTrace.java
new file mode 100644
index 000000000..1db238666
--- /dev/null
+++ b/core/src/main/java/com/google/adk/plugins/debuglogging/UsageTrace.java
@@ -0,0 +1,57 @@
+/*
+ * Copyright 2026 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.google.adk.plugins.debuglogging;
+
+import com.fasterxml.jackson.annotation.JsonInclude;
+import com.fasterxml.jackson.annotation.JsonProperty;
+import com.google.genai.types.GenerateContentResponseUsageMetadata;
+import java.util.Optional;
+
+/**
+ * Token counts, as they appear in the trace.
+ *
+ * adk-python records different subsets in the two places: three counts for an event in
+ * {@code on_event_callback}, and four for a response in {@code after_model_callback}, adding {@code
+ * cached_content_token_count}. One record with two factories keeps that difference exact without
+ * duplicating the shape — {@link #fromEvent} leaves the cached count absent, and {@code NON_ABSENT}
+ * drops it.
+ */
+@JsonInclude(JsonInclude.Include.NON_ABSENT)
+record UsageTrace(
+ @JsonProperty("prompt_token_count") Optional promptTokenCount,
+ @JsonProperty("candidates_token_count") Optional candidatesTokenCount,
+ @JsonProperty("total_token_count") Optional totalTokenCount,
+ @JsonProperty("cached_content_token_count") Optional cachedContentTokenCount) {
+
+ /** The three counts an event carries. */
+ static UsageTrace fromEvent(GenerateContentResponseUsageMetadata usage) {
+ return new UsageTrace(
+ usage.promptTokenCount(),
+ usage.candidatesTokenCount(),
+ usage.totalTokenCount(),
+ Optional.empty());
+ }
+
+ /** The four counts an LLM response carries. */
+ static UsageTrace fromResponse(GenerateContentResponseUsageMetadata usage) {
+ return new UsageTrace(
+ usage.promptTokenCount(),
+ usage.candidatesTokenCount(),
+ usage.totalTokenCount(),
+ usage.cachedContentTokenCount());
+ }
+}
diff --git a/core/src/test/java/com/google/adk/plugins/debuglogging/ContentTraceTest.java b/core/src/test/java/com/google/adk/plugins/debuglogging/ContentTraceTest.java
new file mode 100644
index 000000000..62ca210a7
--- /dev/null
+++ b/core/src/test/java/com/google/adk/plugins/debuglogging/ContentTraceTest.java
@@ -0,0 +1,195 @@
+/*
+ * Copyright 2026 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.google.adk.plugins.debuglogging;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import com.google.genai.types.Blob;
+import com.google.genai.types.CodeExecutionResult;
+import com.google.genai.types.Content;
+import com.google.genai.types.ExecutableCode;
+import com.google.genai.types.FileData;
+import com.google.genai.types.FunctionCall;
+import com.google.genai.types.FunctionResponse;
+import com.google.genai.types.Language;
+import com.google.genai.types.Outcome;
+import com.google.genai.types.Part;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+/**
+ * Asserts on the serialized trace rather than on an intermediate map, because the record
+ * schema plus {@link DebugYamlWriter#configure} is what actually decides the document a reviewer
+ * reads.
+ */
+@RunWith(JUnit4.class)
+public class ContentTraceTest {
+
+ private static final String USER_ROLE = "user";
+ private static final String IMAGE_MIME_TYPE = "image/png";
+ private static final byte[] IMAGE_BYTES = {1, 2, 3, 4, 5};
+
+ private final ObjectMapper mapper = DebugYamlWriter.configure(new ObjectMapper());
+
+ private String serialize(Content content) throws Exception {
+ return mapper.writeValueAsString(ContentTrace.from(content));
+ }
+
+ private static Content contentOf(Part... parts) {
+ return Content.builder().role(USER_ROLE).parts(ImmutableList.copyOf(parts)).build();
+ }
+
+ @Test
+ public void serialize_textPart_usesTheExpectedKeys() throws Exception {
+ String json = serialize(contentOf(Part.builder().text("hello").build()));
+
+ assertThat(json).isEqualTo("{\"role\":\"user\",\"parts\":[{\"text\":\"hello\"}]}");
+ }
+
+ /** The headline guarantee: an image is described, never inlined. */
+ @Test
+ public void serialize_inlineData_neverEmitsTheBytes() throws Exception {
+ Blob blob =
+ Blob.builder()
+ .mimeType(IMAGE_MIME_TYPE)
+ .displayName("screenshot.png")
+ .data(IMAGE_BYTES)
+ .build();
+
+ String json = serialize(contentOf(Part.builder().inlineData(blob).build()));
+
+ assertThat(json).contains("\"mime_type\":\"image/png\"");
+ assertThat(json).contains("\"display_name\":\"screenshot.png\"");
+ assertThat(json).contains("\"_data_omitted\":true");
+ assertThat(json).doesNotContain("\"data\"");
+ assertThat(json).doesNotContain("AQIDBAU=");
+ }
+
+ /** adk-python's {@code if part.text:} is false for "", where a present Optional is not. */
+ @Test
+ public void serialize_emptyText_isDroppedLikeThePythonTruthinessCheck() throws Exception {
+ String json = serialize(contentOf(Part.builder().text("").build()));
+
+ assertThat(json).isEqualTo("{\"role\":\"user\",\"parts\":[]}");
+ }
+
+ @Test
+ public void serialize_partCarryingNothing_isDropped() throws Exception {
+ String json = serialize(contentOf(Part.builder().build()));
+
+ assertThat(json).isEqualTo("{\"role\":\"user\",\"parts\":[]}");
+ }
+
+ @Test
+ public void serialize_absentRole_omitsTheKeyRatherThanWritingNull() throws Exception {
+ Content content = Content.builder().parts(ImmutableList.of()).build();
+
+ assertThat(serialize(content)).isEqualTo("{\"parts\":[]}");
+ }
+
+ @Test
+ public void serialize_functionCall_carriesIdNameAndArgs() throws Exception {
+ FunctionCall call =
+ FunctionCall.builder()
+ .id("call-1")
+ .name("lookup_order")
+ .args(ImmutableMap.of("orderId", 7))
+ .build();
+
+ String json = serialize(contentOf(Part.builder().functionCall(call).build()));
+
+ assertThat(json)
+ .contains(
+ "\"function_call\":{\"id\":\"call-1\",\"name\":\"lookup_order\","
+ + "\"args\":{\"orderId\":7}}");
+ }
+
+ /**
+ * The other half of a tool exchange, and the most common part kind in a real trace after text.
+ * Its {@code response} goes through {@link SafeSerializer} for the same reason a call's arguments
+ * do — a tool author chooses the shape.
+ */
+ @Test
+ public void serialize_functionResponse_carriesIdNameAndResponse() throws Exception {
+ FunctionResponse response =
+ FunctionResponse.builder()
+ .id("call-1")
+ .name("lookup_order")
+ .response(ImmutableMap.of("status", "shipped"))
+ .build();
+
+ String json = serialize(contentOf(Part.builder().functionResponse(response).build()));
+
+ assertThat(json)
+ .contains(
+ "\"function_response\":{\"id\":\"call-1\",\"name\":\"lookup_order\","
+ + "\"response\":{\"status\":\"shipped\"}}");
+ }
+
+ @Test
+ public void serialize_fileData_carriesTheUriAndMimeType() throws Exception {
+ FileData fileData =
+ FileData.builder().fileUri("gs://bucket/report.pdf").mimeType("application/pdf").build();
+
+ assertThat(serialize(contentOf(Part.builder().fileData(fileData).build())))
+ .contains(
+ "\"file_data\":{\"file_uri\":\"gs://bucket/report.pdf\","
+ + "\"mime_type\":\"application/pdf\"}");
+ }
+
+ @Test
+ public void serialize_codeExecutionResult_carriesOutcomeAndOutput() throws Exception {
+ CodeExecutionResult result =
+ CodeExecutionResult.builder().outcome(Outcome.Known.OUTCOME_OK).output("42").build();
+
+ assertThat(serialize(contentOf(Part.builder().codeExecutionResult(result).build())))
+ .contains("\"code_execution_result\":{\"outcome\":\"OUTCOME_OK\",\"output\":\"42\"}");
+ }
+
+ @Test
+ public void serialize_executableCode_carriesLanguageAndCode() throws Exception {
+ ExecutableCode code =
+ ExecutableCode.builder().language(Language.Known.PYTHON).code("print(6 * 7)").build();
+
+ assertThat(serialize(contentOf(Part.builder().executableCode(code).build())))
+ .contains("\"executable_code\":{\"language\":\"PYTHON\",\"code\":\"print(6 * 7)\"}");
+ }
+
+ @Test
+ public void from_keepsOnlyThePartsThatSurvive() {
+ ContentTrace trace =
+ ContentTrace.from(
+ contentOf(
+ Part.builder().text("kept").build(),
+ Part.builder().text("").build(),
+ Part.builder().build()));
+
+ assertThat(trace.parts()).hasSize(1);
+ assertThat(trace.parts().get(0).text()).hasValue("kept");
+ }
+
+ @Test
+ public void from_returnsAnImmutableList() {
+ ContentTrace trace = ContentTrace.from(contentOf(Part.builder().text("hi").build()));
+
+ assertThat(trace.parts()).isInstanceOf(ImmutableList.class);
+ }
+}
diff --git a/core/src/test/java/com/google/adk/plugins/debuglogging/DebugLoggingPluginTest.java b/core/src/test/java/com/google/adk/plugins/debuglogging/DebugLoggingPluginTest.java
new file mode 100644
index 000000000..73a21c86c
--- /dev/null
+++ b/core/src/test/java/com/google/adk/plugins/debuglogging/DebugLoggingPluginTest.java
@@ -0,0 +1,303 @@
+/*
+ * Copyright 2026 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.google.adk.plugins.debuglogging;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.mockito.Mockito.when;
+
+import com.fasterxml.jackson.databind.MappingIterator;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.dataformat.yaml.YAMLFactory;
+import com.google.adk.agents.BaseAgent;
+import com.google.adk.agents.CallbackContext;
+import com.google.adk.agents.InvocationContext;
+import com.google.adk.events.Event;
+import com.google.adk.events.EventActions;
+import com.google.adk.models.LlmRequest;
+import com.google.adk.models.LlmResponse;
+import com.google.adk.sessions.Session;
+import com.google.adk.tools.BaseTool;
+import com.google.adk.tools.ToolContext;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import com.google.genai.types.Content;
+import com.google.genai.types.Part;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.rules.TemporaryFolder;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+import org.mockito.Mock;
+import org.mockito.junit.MockitoJUnit;
+import org.mockito.junit.MockitoRule;
+
+/**
+ * Covers the wiring: that each hook records the entry adk-python records, that none of them changes
+ * the run, and that a hook arriving without an invocation is dropped rather than thrown.
+ *
+ * Contexts are mocked in the style of {@code LoggingPluginTest}, this package's own precedent
+ * for a plugin test. Assertions read the written YAML back, so they cover the whole path from hook
+ * to file rather than the plugin's internal bookkeeping.
+ */
+@RunWith(JUnit4.class)
+public class DebugLoggingPluginTest {
+
+ private static final String INVOCATION_ID = "invocation_id";
+ private static final String AGENT_NAME = "agent_name";
+
+ @Rule public MockitoRule mockitoRule = MockitoJUnit.rule();
+ @Rule public TemporaryFolder tempFolder = new TemporaryFolder();
+
+ @Mock private InvocationContext invocationContext;
+ @Mock private BaseAgent agent;
+ @Mock private CallbackContext callbackContext;
+ @Mock private BaseTool tool;
+ @Mock private ToolContext toolContext;
+
+ private final Session session =
+ Session.builder("session_id").appName("app_name").userId("user_id").build();
+ private final Content userMessage =
+ Content.builder()
+ .role("user")
+ .parts(ImmutableList.of(Part.builder().text("hello").build()))
+ .build();
+ private final LlmRequest.Builder llmRequest =
+ LlmRequest.builder().model("gemini-2.0-flash").contents(ImmutableList.of());
+ private final LlmResponse llmResponse = LlmResponse.builder().build();
+ private final Event event =
+ Event.builder()
+ .id("event_id")
+ .author(AGENT_NAME)
+ .actions(EventActions.builder().build())
+ .build();
+ private final ImmutableMap toolArgs = ImmutableMap.of("query", "socks");
+ private final ImmutableMap toolResult = ImmutableMap.of("status", "ok");
+ private final Throwable error = new IllegalStateException("boom");
+
+ private Path tracePath;
+
+ @Before
+ public void setUp() throws Exception {
+ tracePath = tempFolder.newFolder("traces").toPath().resolve("adk_debug.yaml");
+
+ when(invocationContext.invocationId()).thenReturn(INVOCATION_ID);
+ when(invocationContext.session()).thenReturn(session);
+ when(invocationContext.agent()).thenReturn(agent);
+ when(invocationContext.userId()).thenReturn("user_id");
+ when(invocationContext.branch()).thenReturn(Optional.empty());
+ when(agent.name()).thenReturn(AGENT_NAME);
+
+ when(callbackContext.invocationId()).thenReturn(INVOCATION_ID);
+ when(callbackContext.agentName()).thenReturn(AGENT_NAME);
+ when(callbackContext.branch()).thenReturn(Optional.empty());
+
+ when(toolContext.invocationId()).thenReturn(INVOCATION_ID);
+ when(toolContext.agentName()).thenReturn(AGENT_NAME);
+ when(toolContext.functionCallId()).thenReturn(Optional.of("call-1"));
+ when(tool.name()).thenReturn("lookup_order");
+ }
+
+ private DebugLoggingPlugin plugin() {
+ return new DebugLoggingPlugin("debug_logging_plugin", tracePath, true, true);
+ }
+
+ /**
+ * Drives every hook in the order a real run fires them, and closes the invocation.
+ *
+ * {@code onUserMessageCallback} goes first , which is not an arbitrary choice: {@code
+ * Runner.runAsync} invokes it before {@code beforeRunCallback}. A test that fired them the other
+ * way round would never exercise the case this plugin handles.
+ */
+ private void runEveryHook(DebugLoggingPlugin plugin) {
+ plugin.onUserMessageCallback(invocationContext, userMessage).blockingGet();
+ plugin.beforeRunCallback(invocationContext).blockingGet();
+ plugin.beforeAgentCallback(agent, callbackContext).blockingGet();
+ plugin.beforeModelCallback(callbackContext, llmRequest).blockingGet();
+ plugin.afterModelCallback(callbackContext, llmResponse).blockingGet();
+ plugin.onModelErrorCallback(callbackContext, llmRequest, error).blockingGet();
+ plugin.beforeToolCallback(tool, toolArgs, toolContext).blockingGet();
+ plugin.afterToolCallback(tool, toolArgs, toolContext, toolResult).blockingGet();
+ plugin.onToolErrorCallback(tool, toolArgs, toolContext, error).blockingGet();
+ plugin.onEventCallback(invocationContext, event).blockingGet();
+ plugin.afterAgentCallback(agent, callbackContext).blockingGet();
+ plugin.afterRunCallback(invocationContext).blockingAwait();
+ }
+
+ private ImmutableList> readDocuments() throws Exception {
+ ObjectMapper yaml = new ObjectMapper(new YAMLFactory());
+ try (MappingIterator> documents =
+ yaml.readerFor(Map.class).readValues(tracePath.toFile())) {
+ return ImmutableList.copyOf(documents.readAll());
+ }
+ }
+
+ private ImmutableList entryTypes() throws Exception {
+ List> entries = (List>) readDocuments().get(0).get("entries");
+ return entries.stream()
+ .map(entry -> (String) ((Map, ?>) entry).get("entry_type"))
+ .collect(ImmutableList.toImmutableList());
+ }
+
+ @Test
+ public void everyHook_recordsItsEntry_inTheOrderTheyFired() throws Exception {
+ runEveryHook(plugin());
+
+ assertThat(entryTypes())
+ .containsExactly(
+ "user_message",
+ "invocation_start",
+ "agent_start",
+ "llm_request",
+ "llm_response",
+ "llm_error",
+ "tool_call",
+ "tool_response",
+ "tool_error",
+ "event",
+ "agent_end",
+ "session_state_snapshot",
+ "invocation_end")
+ .inOrder();
+ }
+
+ /**
+ * The port's deliberate difference from adk-python in what gets recorded.
+ *
+ * The user message arrives before {@code beforeRunCallback} has opened the invocation, so the
+ * first hook to arrive opens it. Without that, the entry would be filed against an invocation
+ * that does not exist yet and dropped.
+ */
+ @Test
+ public void userMessage_arrivingBeforeTheRunStarts_isKeptRatherThanDropped() throws Exception {
+ DebugLoggingPlugin plugin = plugin();
+
+ plugin.onUserMessageCallback(invocationContext, userMessage).blockingGet();
+ plugin.beforeRunCallback(invocationContext).blockingGet();
+ plugin.afterRunCallback(invocationContext).blockingAwait();
+
+ assertThat(entryTypes()).containsAtLeast("user_message", "invocation_start").inOrder();
+ assertThat(entryData("user_message").toString()).contains("hello");
+ }
+
+ /** The whole safety argument for this plugin: it observes, and cannot alter, a run. */
+ @Test
+ public void everyHook_returnsEmpty_soNothingIsAltered() {
+ DebugLoggingPlugin plugin = plugin();
+ plugin.beforeRunCallback(invocationContext).blockingGet();
+
+ assertThat(plugin.onUserMessageCallback(invocationContext, userMessage).blockingGet()).isNull();
+ assertThat(plugin.beforeAgentCallback(agent, callbackContext).blockingGet()).isNull();
+ assertThat(plugin.afterAgentCallback(agent, callbackContext).blockingGet()).isNull();
+ assertThat(plugin.beforeModelCallback(callbackContext, llmRequest).blockingGet()).isNull();
+ assertThat(plugin.afterModelCallback(callbackContext, llmResponse).blockingGet()).isNull();
+ assertThat(plugin.onModelErrorCallback(callbackContext, llmRequest, error).blockingGet())
+ .isNull();
+ assertThat(plugin.beforeToolCallback(tool, toolArgs, toolContext).blockingGet()).isNull();
+ assertThat(plugin.afterToolCallback(tool, toolArgs, toolContext, toolResult).blockingGet())
+ .isNull();
+ assertThat(plugin.onToolErrorCallback(tool, toolArgs, toolContext, error).blockingGet())
+ .isNull();
+ assertThat(plugin.onEventCallback(invocationContext, event).blockingGet()).isNull();
+ }
+
+ @Test
+ public void toolEntries_carryTheToolNameCallIdAndArguments() throws Exception {
+ runEveryHook(plugin());
+
+ Map, ?> toolCall = entryData("tool_call");
+ assertThat(toolCall.get("tool_name")).isEqualTo("lookup_order");
+ assertThat(toolCall.get("function_call_id")).isEqualTo("call-1");
+ assertThat(toolCall.get("args")).isEqualTo(ImmutableMap.of("query", "socks"));
+ }
+
+ @Test
+ public void llmError_recordsTheExceptionTypeAndModel() throws Exception {
+ runEveryHook(plugin());
+
+ Map, ?> llmError = entryData("llm_error");
+ assertThat(llmError.get("error_type")).isEqualTo("IllegalStateException");
+ assertThat(llmError.get("error_message")).isEqualTo("boom");
+ assertThat(llmError.get("model")).isEqualTo("gemini-2.0-flash");
+ }
+
+ @Test
+ public void includeSessionState_whenOff_omitsTheSnapshot() throws Exception {
+ runEveryHook(new DebugLoggingPlugin("debug_logging_plugin", tracePath, false, true));
+
+ assertThat(entryTypes()).doesNotContain("session_state_snapshot");
+ assertThat(entryTypes()).contains("invocation_end");
+ }
+
+ @Test
+ public void includeSystemInstruction_whenOff_notesItsPresenceWithoutTheText() throws Exception {
+ llmRequest.config(
+ com.google.genai.types.GenerateContentConfig.builder()
+ .systemInstruction(
+ Content.builder()
+ .parts(ImmutableList.of(Part.builder().text("be terse").build()))
+ .build())
+ .build());
+
+ runEveryHook(new DebugLoggingPlugin("debug_logging_plugin", tracePath, true, false));
+
+ Map, ?> config = (Map, ?>) entryData("llm_request").get("config");
+ assertThat(config.get("has_system_instruction")).isEqualTo(true);
+ assertThat(Files.readString(tracePath)).doesNotContain("be terse");
+ }
+
+ /** A hook can outlive its invocation; losing the state must cost a log line, not the run. */
+ @Test
+ public void hooksWithoutAnInvocation_areDroppedRatherThanThrown() throws Exception {
+ DebugLoggingPlugin plugin = plugin();
+
+ plugin.onEventCallback(invocationContext, event).blockingGet();
+ plugin.afterRunCallback(invocationContext).blockingAwait();
+
+ assertThat(Files.exists(tracePath)).isFalse();
+ }
+
+ /** Upstream drops the state in a {@code finally}; a second write must find nothing left. */
+ @Test
+ public void afterRun_dropsTheInvocation_soASecondRunWritesNothingMore() throws Exception {
+ DebugLoggingPlugin plugin = plugin();
+ runEveryHook(plugin);
+
+ plugin.afterRunCallback(invocationContext).blockingAwait();
+
+ assertThat(readDocuments()).hasSize(1);
+ }
+
+ /**
+ * The payload of the first entry of that type — by type, never by index, so adding an entry ahead
+ * of it cannot silently repoint an assertion at a different entry.
+ */
+ private Map, ?> entryData(String entryType) throws Exception {
+ List> entries = (List>) readDocuments().get(0).get("entries");
+ return entries.stream()
+ .map(entry -> (Map, ?>) entry)
+ .filter(entry -> entryType.equals(entry.get("entry_type")))
+ .findFirst()
+ .map(entry -> (Map, ?>) entry.get("data"))
+ .orElseThrow();
+ }
+}
diff --git a/core/src/test/java/com/google/adk/plugins/debuglogging/DebugTraceRecorderTest.java b/core/src/test/java/com/google/adk/plugins/debuglogging/DebugTraceRecorderTest.java
new file mode 100644
index 000000000..5c61144bc
--- /dev/null
+++ b/core/src/test/java/com/google/adk/plugins/debuglogging/DebugTraceRecorderTest.java
@@ -0,0 +1,190 @@
+/*
+ * Copyright 2026 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.google.adk.plugins.debuglogging;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.google.adk.agents.InvocationContext;
+import com.google.adk.agents.LlmAgent;
+import com.google.adk.agents.RunConfig;
+import com.google.adk.plugins.debuglogging.DebugEntry.Type;
+import com.google.adk.plugins.debuglogging.TracePayload.BranchTrace;
+import com.google.adk.plugins.debuglogging.TracePayload.MarkerTrace;
+import com.google.adk.sessions.InMemorySessionService;
+import com.google.adk.sessions.Session;
+import com.google.common.collect.ImmutableList;
+import java.time.Clock;
+import java.time.Instant;
+import java.time.ZoneOffset;
+import java.util.Optional;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+/**
+ * Covers the state layer end to end — the live-invocation map, the per-invocation accumulator, and
+ * the entries filed into it — since none of the three means anything without the other two.
+ *
+ *
The clock is fixed, so the timestamp is asserted exactly rather than pattern-matched.
+ */
+@RunWith(JUnit4.class)
+public class DebugTraceRecorderTest {
+
+ private static final String INVOCATION_ID = "inv-1";
+ private static final String AGENT = "root_agent";
+ private static final Clock FIXED =
+ Clock.fixed(Instant.parse("2026-08-02T10:15:30.123Z"), ZoneOffset.UTC);
+
+ private final ObjectMapper mapper = DebugYamlWriter.configure(new ObjectMapper());
+ private final DebugTraceRecorder recorder = new DebugTraceRecorder(FIXED);
+
+ private static InvocationContext invocationContext() {
+ return InvocationContext.builder()
+ .invocationId(INVOCATION_ID)
+ .agent(LlmAgent.builder().name(AGENT).build())
+ .session(Session.builder("session-1").appName("shop").userId("user-1").build())
+ .sessionService(new InMemorySessionService())
+ .runConfig(RunConfig.builder().build())
+ .build();
+ }
+
+ private InvocationDebugState startedInvocation() {
+ InvocationDebugState state = InvocationDebugState.of(invocationContext(), recorder.now());
+ recorder.start(state);
+ return state;
+ }
+
+ @Test
+ public void header_carriesTheInvocationSessionAndStartTime() throws Exception {
+ String json = mapper.writeValueAsString(startedInvocation());
+
+ assertThat(json)
+ .startsWith(
+ "{\"invocation_id\":\"inv-1\",\"session_id\":\"session-1\",\"app_name\":\"shop\","
+ + "\"user_id\":\"user-1\",\"start_time\":\"2026-08-02T10:15:30.123\","
+ + "\"entries\":[");
+ }
+
+ /**
+ * {@link Session.Builder#build} validates only the id, so a hand-built session can carry neither
+ * an app name nor a user id. Null-checking them here would throw inside {@code beforeRunCallback}
+ * and break a run because the plugin was attached . Both are {@link java.util.Optional},
+ * and an absent one simply omits its key.
+ */
+ @Test
+ public void header_sessionWithoutAppNameOrUserId_omitsThoseKeys() throws Exception {
+ InvocationContext context =
+ InvocationContext.builder()
+ .invocationId(INVOCATION_ID)
+ .agent(LlmAgent.builder().name(AGENT).build())
+ .session(Session.builder("session-1").build())
+ .sessionService(new InMemorySessionService())
+ .runConfig(RunConfig.builder().build())
+ .build();
+
+ String json = mapper.writeValueAsString(InvocationDebugState.of(context, recorder.now()));
+
+ assertThat(json).contains("\"invocation_id\":\"inv-1\"");
+ assertThat(json).contains("\"session_id\":\"session-1\"");
+ assertThat(json).doesNotContain("app_name");
+ assertThat(json).doesNotContain("user_id");
+ }
+
+ @Test
+ public void record_writesEveryEntryFieldInUpstreamsOrder() throws Exception {
+ startedInvocation();
+
+ recorder.record(INVOCATION_ID, Type.AGENT_START, AGENT, new BranchTrace(Optional.of("root")));
+
+ assertThat(mapper.writeValueAsString(recorder.forEntry(INVOCATION_ID).orElseThrow()))
+ .contains(
+ "\"entries\":[{\"timestamp\":\"2026-08-02T10:15:30.123\","
+ + "\"entry_type\":\"agent_start\",\"invocation_id\":\"inv-1\","
+ + "\"agent_name\":\"root_agent\",\"data\":{\"branch\":\"root\"}}]");
+ }
+
+ @Test
+ public void record_withoutAnAgentName_omitsTheKey() throws Exception {
+ startedInvocation();
+
+ recorder.record(INVOCATION_ID, Type.INVOCATION_END, MarkerTrace.INSTANCE);
+
+ String json = mapper.writeValueAsString(recorder.forEntry(INVOCATION_ID).orElseThrow());
+ assertThat(json).contains("\"entry_type\":\"invocation_end\"");
+ assertThat(json).doesNotContain("agent_name");
+ }
+
+ /** {@code agent_end} and {@code invocation_end} carry no payload, and must still write one. */
+ @Test
+ public void record_markerEntry_writesAnEmptyDataMapping() throws Exception {
+ startedInvocation();
+
+ recorder.record(INVOCATION_ID, Type.AGENT_END, AGENT, MarkerTrace.INSTANCE);
+
+ assertThat(mapper.writeValueAsString(recorder.forEntry(INVOCATION_ID).orElseThrow()))
+ .contains("\"agent_name\":\"root_agent\",\"data\":{}}");
+ }
+
+ @Test
+ public void record_keepsEntriesInTheOrderTheHooksFired() {
+ InvocationDebugState state = startedInvocation();
+
+ recorder.record(INVOCATION_ID, Type.USER_MESSAGE, MarkerTrace.INSTANCE);
+ recorder.record(INVOCATION_ID, Type.AGENT_START, MarkerTrace.INSTANCE);
+ recorder.record(INVOCATION_ID, Type.INVOCATION_END, MarkerTrace.INSTANCE);
+
+ assertThat(state.entries().stream().map(DebugEntry::entryType))
+ .containsExactly(Type.USER_MESSAGE, Type.AGENT_START, Type.INVOCATION_END)
+ .inOrder();
+ }
+
+ /** A debug plugin that lost a state must log a gap, never break the run it is observing. */
+ @Test
+ public void record_forAnUnknownInvocation_isDroppedRatherThanThrown() {
+ InvocationDebugState state = startedInvocation();
+
+ recorder.record("some-other-invocation", Type.EVENT, MarkerTrace.INSTANCE);
+
+ assertThat(state.entries()).isEmpty();
+ }
+
+ @Test
+ public void entries_isASnapshotNotTheLiveQueue() {
+ InvocationDebugState state = startedInvocation();
+ recorder.record(INVOCATION_ID, Type.USER_MESSAGE, MarkerTrace.INSTANCE);
+
+ ImmutableList taken = state.entries();
+ recorder.record(INVOCATION_ID, Type.INVOCATION_END, MarkerTrace.INSTANCE);
+
+ assertThat(taken).hasSize(1);
+ assertThat(state.entries()).hasSize(2);
+ }
+
+ /** {@code forWrite} leaves the state in place so the closing entries can still be filed. */
+ @Test
+ public void forWrite_doesNotRemoveTheStateUntilFinishIsCalled() {
+ startedInvocation();
+
+ assertThat(recorder.forWrite(INVOCATION_ID)).isPresent();
+ assertThat(recorder.forWrite(INVOCATION_ID)).isPresent();
+
+ recorder.finish(INVOCATION_ID);
+
+ assertThat(recorder.forWrite(INVOCATION_ID)).isEmpty();
+ }
+}
diff --git a/core/src/test/java/com/google/adk/plugins/debuglogging/DebugYamlWriterTest.java b/core/src/test/java/com/google/adk/plugins/debuglogging/DebugYamlWriterTest.java
new file mode 100644
index 000000000..e9227d6e0
--- /dev/null
+++ b/core/src/test/java/com/google/adk/plugins/debuglogging/DebugYamlWriterTest.java
@@ -0,0 +1,264 @@
+/*
+ * Copyright 2026 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.google.adk.plugins.debuglogging;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.fasterxml.jackson.databind.MappingIterator;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.dataformat.yaml.YAMLFactory;
+import com.google.adk.agents.InvocationContext;
+import com.google.adk.agents.LlmAgent;
+import com.google.adk.agents.RunConfig;
+import com.google.adk.plugins.debuglogging.DebugEntry.Type;
+import com.google.adk.plugins.debuglogging.TracePayload.MarkerTrace;
+import com.google.adk.plugins.debuglogging.TracePayload.ToolResponseTrace;
+import com.google.adk.sessions.InMemorySessionService;
+import com.google.adk.sessions.Session;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import java.io.File;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.time.Clock;
+import java.time.Instant;
+import java.time.ZoneOffset;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.TimeUnit;
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.rules.TemporaryFolder;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+/**
+ * Covers the guarantee the writer exists to give: a file of appended invocations that still parses
+ * as YAML, and a failed write that costs the run nothing.
+ *
+ * Assertions re-parse the file rather than matching its text, because "the trace can be read
+ * back" is the actual promise — a separator bug or a stray document boundary is invisible to a
+ * substring check.
+ */
+@RunWith(JUnit4.class)
+public class DebugYamlWriterTest {
+
+ private static final Clock FIXED =
+ Clock.fixed(Instant.parse("2026-08-02T10:15:30.123Z"), ZoneOffset.UTC);
+
+ @Rule public TemporaryFolder tempFolder = new TemporaryFolder();
+
+ private Path tracePath;
+ private DebugYamlWriter writer;
+ private DebugTraceRecorder recorder;
+
+ @Before
+ public void setUp() throws Exception {
+ tracePath = tempFolder.newFolder("traces").toPath().resolve("adk_debug.yaml");
+ writer = new DebugYamlWriter(tracePath);
+ recorder = new DebugTraceRecorder(FIXED);
+ }
+
+ private static InvocationContext contextFor(String invocationId) {
+ return InvocationContext.builder()
+ .invocationId(invocationId)
+ .agent(LlmAgent.builder().name("root_agent").build())
+ .session(Session.builder("session-1").appName("shop").userId("user-1").build())
+ .sessionService(new InMemorySessionService())
+ .runConfig(RunConfig.builder().build())
+ .build();
+ }
+
+ /** One invocation, recorded and closed the way {@code afterRunCallback} will do it. */
+ private InvocationDebugState invocation(String invocationId) {
+ InvocationDebugState state = InvocationDebugState.of(contextFor(invocationId), recorder.now());
+ recorder.start(state);
+ recorder.record(invocationId, Type.USER_MESSAGE, MarkerTrace.INSTANCE);
+ recorder.record(invocationId, Type.INVOCATION_END, MarkerTrace.INSTANCE);
+ return state;
+ }
+
+ private ImmutableList> readDocuments() throws Exception {
+ ObjectMapper yaml = new ObjectMapper(new YAMLFactory());
+ try (MappingIterator> documents =
+ yaml.readerFor(Map.class).readValues(tracePath.toFile())) {
+ return ImmutableList.copyOf(documents.readAll());
+ }
+ }
+
+ @Test
+ public void append_twoInvocations_yieldsTwoDocumentsThatBothReparse() throws Exception {
+ writer.append(invocation("inv-1"));
+ writer.append(invocation("inv-2"));
+
+ assertThat(readDocuments().stream().map(document -> document.get("invocation_id")))
+ .containsExactly("inv-1", "inv-2")
+ .inOrder();
+ }
+
+ /**
+ * Jackson writes the {@code ---} itself. Upstream writes its own because PyYAML does not, and
+ * porting that line literally would emit two separators and an empty document between them.
+ */
+ @Test
+ public void append_writesExactlyOneDocumentSeparatorPerInvocation() throws Exception {
+ writer.append(invocation("inv-1"));
+ writer.append(invocation("inv-2"));
+
+ List lines = Files.readAllLines(tracePath, StandardCharsets.UTF_8);
+ assertThat(lines.stream().filter(line -> line.startsWith("---")).count()).isEqualTo(2);
+ }
+
+ @Test
+ public void append_keepsTheEntriesAndTheirTypes() throws Exception {
+ writer.append(invocation("inv-1"));
+
+ Object entries = readDocuments().get(0).get("entries");
+ assertThat(entries).isInstanceOf(List.class);
+ assertThat(((List>) entries).stream().map(entry -> ((Map, ?>) entry).get("entry_type")))
+ .containsExactly("user_message", "invocation_end")
+ .inOrder();
+ }
+
+ /**
+ * The risk {@code MINIMIZE_QUOTES} introduces, pinned: a tool that returns the string
+ * {@code "42"} must not read back as the number 42, or a trace would misreport what a tool said.
+ */
+ @Test
+ public void append_stringsThatLookLikeScalars_stayQuotedAndReparseAsStrings() throws Exception {
+ InvocationDebugState state = invocation("inv-1");
+ recorder.record(
+ "inv-1",
+ Type.TOOL_RESPONSE,
+ ToolResponseTrace.of(
+ "lookup_order",
+ "call-7",
+ ImmutableMap.of("code", "42", "flag", "true", "missing", "null", "note", "in stock")));
+
+ writer.append(state);
+
+ Map, ?> result = (Map, ?>) entryData(readDocuments().get(0), 2).get("result");
+ assertThat(result.get("code")).isEqualTo("42");
+ assertThat(result.get("flag")).isEqualTo("true");
+ assertThat(result.get("missing")).isEqualTo("null");
+ assertThat(result.get("note")).isEqualTo("in stock");
+ }
+
+ /**
+ * {@code LITERAL_BLOCK_STYLE} is the reason model output stays readable in a trace: a multi-line
+ * answer must render as a {@code |-} block, not one line of {@code \n} escapes. The block form is
+ * asserted in the file text because re-parsing alone cannot tell the two renderings apart.
+ */
+ @Test
+ public void append_multiLineText_usesALiteralBlockRatherThanEscapes() throws Exception {
+ InvocationDebugState state = invocation("inv-1");
+ recorder.record(
+ "inv-1",
+ Type.TOOL_RESPONSE,
+ ToolResponseTrace.of(
+ "lookup_order", "call-7", ImmutableMap.of("summary", "line one\nline two")));
+
+ writer.append(state);
+
+ String file = Files.readString(tracePath, StandardCharsets.UTF_8);
+ assertThat(file).contains("summary: |-");
+ assertThat(file).doesNotContain("line one\\nline two");
+
+ Map, ?> result = (Map, ?>) entryData(readDocuments().get(0), 2).get("result");
+ assertThat(result.get("summary")).isEqualTo("line one\nline two");
+ }
+
+ private static Map, ?> entryData(Map document, int index) {
+ List> entries = (List>) document.get("entries");
+ return (Map, ?>) ((Map, ?>) entries.get(index)).get("data");
+ }
+
+ /**
+ * The reason {@code append} is {@code synchronized}: the write runs on {@code Schedulers.io()},
+ * so invocations that finish together arrive on different threads. Without the lock their
+ * documents interleave and the file stops parsing — which this asserts by re-reading it, not by
+ * inspecting the text.
+ *
+ * A latch releases every thread at once so the appends genuinely contend, rather than being
+ * serialized by the pool starting them one at a time.
+ */
+ @Test
+ public void append_fromManyThreadsAtOnce_yieldsOneIntactDocumentEach() throws Exception {
+ int writers = 8;
+ CountDownLatch start = new CountDownLatch(1);
+ CountDownLatch done = new CountDownLatch(writers);
+ ExecutorService pool = Executors.newFixedThreadPool(writers);
+ try {
+ for (int i = 0; i < writers; i++) {
+ pool.execute(appendTask("inv-" + i, start, done));
+ }
+ start.countDown();
+ assertThat(done.await(30, TimeUnit.SECONDS)).isTrue();
+ } finally {
+ pool.shutdownNow();
+ }
+
+ ImmutableList> documents = readDocuments();
+ assertThat(documents).hasSize(writers);
+ assertThat(documents.stream().map(document -> document.get("invocation_id")))
+ .containsExactly("inv-0", "inv-1", "inv-2", "inv-3", "inv-4", "inv-5", "inv-6", "inv-7");
+ }
+
+ /** One writer thread: wait for the starting gun, append, and report completion. */
+ private Runnable appendTask(String invocationId, CountDownLatch start, CountDownLatch done) {
+ InvocationDebugState state = invocation(invocationId);
+ return () -> awaitThenAppend(state, start, done);
+ }
+
+ private void awaitThenAppend(
+ InvocationDebugState state, CountDownLatch start, CountDownLatch done) {
+ try {
+ start.await();
+ writer.append(state);
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ } finally {
+ done.countDown();
+ }
+ }
+
+ @Test
+ public void append_createsTheParentDirectoryRatherThanLosingTheTrace() throws Exception {
+ Path nested = tempFolder.getRoot().toPath().resolve("a/b/c/adk_debug.yaml");
+
+ new DebugYamlWriter(nested).append(invocation("inv-1"));
+
+ assertThat(Files.exists(nested)).isTrue();
+ }
+
+ /** A full disk, a read-only path, a bad configuration: the run must not notice. */
+ @Test
+ public void append_whenThePathCannotBeWritten_logsInsteadOfThrowing() throws Exception {
+ File blocker = tempFolder.newFile("not-a-directory");
+ DebugYamlWriter blocked =
+ new DebugYamlWriter(blocker.toPath().resolve("nested/adk_debug.yaml"));
+
+ blocked.append(invocation("inv-1"));
+
+ assertThat(blocker.length()).isEqualTo(0);
+ }
+}
diff --git a/core/src/test/java/com/google/adk/plugins/debuglogging/EventTraceTest.java b/core/src/test/java/com/google/adk/plugins/debuglogging/EventTraceTest.java
new file mode 100644
index 000000000..3d6b3f587
--- /dev/null
+++ b/core/src/test/java/com/google/adk/plugins/debuglogging/EventTraceTest.java
@@ -0,0 +1,232 @@
+/*
+ * Copyright 2026 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.google.adk.plugins.debuglogging;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.google.adk.events.Event;
+import com.google.adk.events.EventActions;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.ImmutableSet;
+import com.google.genai.types.Content;
+import com.google.genai.types.FinishReason;
+import com.google.genai.types.GenerateContentResponseUsageMetadata;
+import com.google.genai.types.GroundingMetadata;
+import com.google.genai.types.Part;
+import java.util.HashMap;
+import java.util.Map;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+/** Covers the event envelope, and the two details in its actions block that carry real risk. */
+@RunWith(JUnit4.class)
+public class EventTraceTest {
+
+ private static final String AUTHOR = "assistant";
+
+ private final ObjectMapper mapper = DebugYamlWriter.configure(new ObjectMapper());
+
+ private String serialize(Event event) throws Exception {
+ return mapper.writeValueAsString(EventTrace.from(event));
+ }
+
+ private static Event.Builder eventBuilder() {
+ return Event.builder().id("event-1").author(AUTHOR);
+ }
+
+ @Test
+ public void serialize_plainEvent_carriesIdAuthorAndFinality() throws Exception {
+ String json = serialize(eventBuilder().build());
+
+ assertThat(json).contains("\"event_id\":\"event-1\"");
+ assertThat(json).contains("\"author\":\"assistant\"");
+ assertThat(json).contains("\"is_final_response\":");
+ }
+
+ @Test
+ public void serialize_content_isDelegatedToContentTrace() throws Exception {
+ Event event =
+ eventBuilder()
+ .content(
+ Content.builder()
+ .role("model")
+ .parts(ImmutableList.of(Part.builder().text("hi").build()))
+ .build())
+ .build();
+
+ assertThat(serialize(event))
+ .contains("\"content\":{\"role\":\"model\",\"parts\":[{\"text\":\"hi\"}]}");
+ }
+
+ @Test
+ public void serialize_noActions_omitsTheBlockEntirely() throws Exception {
+ assertThat(serialize(eventBuilder().build())).doesNotContain("actions");
+ }
+
+ /** The map is free-form and auth-related; a trace gets pasted into bug reports. */
+ @Test
+ public void serialize_requestedAuthConfigs_recordsTheCountAndNeverTheContent() throws Exception {
+ Map> authConfigs = new HashMap<>();
+ authConfigs.put("my-oauth", ImmutableMap.of("client_secret", "s3cret"));
+ EventActions actions = EventActions.builder().requestedAuthConfigs(authConfigs).build();
+
+ String json = serialize(eventBuilder().actions(actions).build());
+
+ assertThat(json).contains("\"requested_auth_configs\":1");
+ assertThat(json).doesNotContain("s3cret");
+ assertThat(json).doesNotContain("my-oauth");
+ }
+
+ @Test
+ public void serialize_artifactDelta_keepsTheFilenameToVersionMapping() throws Exception {
+ Map artifactDelta = new HashMap<>();
+ artifactDelta.put("report.pdf", 3);
+ EventActions actions = EventActions.builder().artifactDelta(artifactDelta).build();
+
+ assertThat(serialize(eventBuilder().actions(actions).build()))
+ .contains("\"artifact_delta\":{\"report.pdf\":3}");
+ }
+
+ @Test
+ public void serialize_stateDelta_goesThroughSafeSerializer() throws Exception {
+ Map stateDelta = new HashMap<>();
+ stateDelta.put("counter", 4);
+ EventActions actions = EventActions.builder().stateDelta(stateDelta).build();
+
+ assertThat(serialize(eventBuilder().actions(actions).build()))
+ .contains("\"state_delta\":{\"counter\":4}");
+ }
+
+ @Test
+ public void serialize_transferToAgent_isRecorded() throws Exception {
+ EventActions actions = EventActions.builder().transferToAgent("billing_agent").build();
+
+ assertThat(serialize(eventBuilder().actions(actions).build()))
+ .contains("\"transfer_to_agent\":\"billing_agent\"");
+ }
+
+ /** The trace is a snapshot: mutating the source afterwards must not change it. */
+ @Test
+ public void from_copiesTheLiveActionMapsRatherThanReferencingThem() {
+ Map artifactDelta = new HashMap<>();
+ artifactDelta.put("first.txt", 1);
+ EventActions actions = EventActions.builder().artifactDelta(artifactDelta).build();
+
+ EventTrace trace = EventTrace.from(eventBuilder().actions(actions).build());
+ actions.artifactDelta().put("sneaked-in.txt", 9);
+
+ assertThat(trace.actions().orElseThrow().artifactDelta()).containsExactly("first.txt", 1);
+ }
+
+ @Test
+ public void serialize_emptyLongRunningToolIds_isDroppedNotEmittedAsAnEmptyList()
+ throws Exception {
+ assertThat(serialize(eventBuilder().build())).doesNotContain("long_running_tool_ids");
+ }
+
+ @Test
+ public void serialize_longRunningToolIds_whenPresent_areRecorded() throws Exception {
+ Event event = eventBuilder().longRunningToolIds(ImmutableSet.of("call-7")).build();
+
+ assertThat(serialize(event)).contains("\"long_running_tool_ids\":[\"call-7\"]");
+ }
+
+ /** The streaming flags: both are tri-state, so the absent case must not read as {@code false}. */
+ @Test
+ public void serialize_streamingFlags_areRecorded() throws Exception {
+ Event event = eventBuilder().partial(true).turnComplete(false).build();
+
+ String json = serialize(event);
+
+ assertThat(json).contains("\"partial\":true");
+ assertThat(json).contains("\"turn_complete\":false");
+ }
+
+ @Test
+ public void serialize_absentStreamingFlags_omitTheKeysRatherThanWritingFalse() throws Exception {
+ String json = serialize(eventBuilder().build());
+
+ assertThat(json).doesNotContain("partial");
+ assertThat(json).doesNotContain("turn_complete");
+ }
+
+ /** The event's own branch, not {@link TracePayload.BranchTrace}'s — a separate code path. */
+ @Test
+ public void serialize_branch_isRecorded() throws Exception {
+ Event event = eventBuilder().branch("root.billing_agent").build();
+
+ assertThat(serialize(event)).contains("\"branch\":\"root.billing_agent\"");
+ }
+
+ @Test
+ public void serialize_errorCodeAndMessage_areRecorded() throws Exception {
+ Event event =
+ eventBuilder()
+ .errorCode(new FinishReason(FinishReason.Known.SAFETY))
+ .errorMessage("blocked by a safety filter")
+ .build();
+
+ String json = serialize(event);
+
+ assertThat(json).contains("\"error_code\":\"SAFETY\"");
+ assertThat(json).contains("\"error_message\":\"blocked by a safety filter\"");
+ }
+
+ /** Same reduction the response trace makes: the payload is large and adds nothing to a trace. */
+ @Test
+ public void serialize_groundingMetadata_isReducedToABoolean() throws Exception {
+ Event event = eventBuilder().groundingMetadata(GroundingMetadata.builder().build()).build();
+
+ String json = serialize(event);
+
+ assertThat(json).contains("\"has_grounding_metadata\":true");
+ assertThat(json).doesNotContain("groundingChunks");
+ }
+
+ /** An event carries the three-count subset; the cached count belongs to the response only. */
+ @Test
+ public void serialize_usageMetadata_carriesThreeCountsAndOmitsTheCachedOne() throws Exception {
+ Event event =
+ eventBuilder()
+ .usageMetadata(
+ GenerateContentResponseUsageMetadata.builder()
+ .promptTokenCount(120)
+ .candidatesTokenCount(45)
+ .totalTokenCount(165)
+ .cachedContentTokenCount(80)
+ .build())
+ .build();
+
+ String json = serialize(event);
+
+ assertThat(json)
+ .contains(
+ "\"usage_metadata\":{\"prompt_token_count\":120,\"candidates_token_count\":45,"
+ + "\"total_token_count\":165}");
+ assertThat(json).doesNotContain("cached_content_token_count");
+ }
+
+ @Test
+ public void serialize_escalate_isRecorded() throws Exception {
+ EventActions actions = EventActions.builder().escalate(true).build();
+
+ assertThat(serialize(eventBuilder().actions(actions).build())).contains("\"escalate\":true");
+ }
+}
diff --git a/core/src/test/java/com/google/adk/plugins/debuglogging/LlmExchangeTraceTest.java b/core/src/test/java/com/google/adk/plugins/debuglogging/LlmExchangeTraceTest.java
new file mode 100644
index 000000000..1ab9b0716
--- /dev/null
+++ b/core/src/test/java/com/google/adk/plugins/debuglogging/LlmExchangeTraceTest.java
@@ -0,0 +1,271 @@
+/*
+ * Copyright 2026 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.google.adk.plugins.debuglogging;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.google.adk.models.LlmRequest;
+import com.google.adk.models.LlmResponse;
+import com.google.adk.tools.BaseTool;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import com.google.genai.types.Content;
+import com.google.genai.types.FinishReason;
+import com.google.genai.types.GenerateContentConfig;
+import com.google.genai.types.GenerateContentResponseUsageMetadata;
+import com.google.genai.types.GroundingMetadata;
+import com.google.genai.types.Part;
+import com.google.genai.types.Schema;
+import com.google.genai.types.Type;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+/** Covers both halves of the model exchange, and the system-instruction off-switch. */
+@RunWith(JUnit4.class)
+public class LlmExchangeTraceTest {
+
+ private static final String MODEL = "gemini-2.0-flash";
+ private static final String SECRET_INSTRUCTION = "You are a helpful assistant with a secret.";
+ private static final String TOOL_DESCRIPTION = "Looks an order up by its id.";
+
+ private final ObjectMapper mapper = DebugYamlWriter.configure(new ObjectMapper());
+
+ private String serializeRequest(LlmRequest request, boolean includeSystemInstruction)
+ throws Exception {
+ return mapper.writeValueAsString(LlmRequestTrace.from(request, includeSystemInstruction));
+ }
+
+ private static Content userContent(String text) {
+ return Content.builder()
+ .role("user")
+ .parts(ImmutableList.of(Part.builder().text(text).build()))
+ .build();
+ }
+
+ private static LlmRequest requestWith(GenerateContentConfig config) {
+ return LlmRequest.builder()
+ .model(MODEL)
+ .contents(ImmutableList.of(userContent("hello")))
+ .config(config)
+ .build();
+ }
+
+ @Test
+ public void request_carriesModelAndContentCount() throws Exception {
+ LlmRequest request =
+ LlmRequest.builder()
+ .model(MODEL)
+ .contents(ImmutableList.of(userContent("a"), userContent("b")))
+ .build();
+
+ String json = serializeRequest(request, true);
+
+ assertThat(json).contains("\"model\":\"gemini-2.0-flash\"");
+ assertThat(json).contains("\"content_count\":2");
+ }
+
+ @Test
+ public void request_includeSystemInstruction_recordsItAsStructuredContent() throws Exception {
+ GenerateContentConfig config =
+ GenerateContentConfig.builder().systemInstruction(userContent(SECRET_INSTRUCTION)).build();
+
+ String json = serializeRequest(requestWith(config), true);
+
+ assertThat(json).contains("\"system_instruction\":");
+ assertThat(json).contains(SECRET_INSTRUCTION);
+ }
+
+ /** The off-switch exists so a trace can be shared without the prompt in it. */
+ @Test
+ public void request_excludeSystemInstruction_recordsOnlyThatOneWasPresent() throws Exception {
+ GenerateContentConfig config =
+ GenerateContentConfig.builder().systemInstruction(userContent(SECRET_INSTRUCTION)).build();
+
+ String json = serializeRequest(requestWith(config), false);
+
+ assertThat(json).contains("\"has_system_instruction\":true");
+ assertThat(json).doesNotContain(SECRET_INSTRUCTION);
+ assertThat(json).doesNotContain("\"system_instruction\":");
+ }
+
+ @Test
+ public void request_generationSettings_areRecorded() throws Exception {
+ GenerateContentConfig config =
+ GenerateContentConfig.builder().temperature(0.25f).maxOutputTokens(512).build();
+
+ String json = serializeRequest(requestWith(config), true);
+
+ assertThat(json).contains("\"temperature\":0.25");
+ assertThat(json).contains("\"max_output_tokens\":512");
+ }
+
+ @Test
+ public void request_emptyConfig_omitsTheBlockEntirely() throws Exception {
+ LlmRequest request =
+ LlmRequest.builder().model(MODEL).contents(ImmutableList.of(userContent("hi"))).build();
+
+ assertThat(serializeRequest(request, true)).doesNotContain("\"config\"");
+ }
+
+ @Test
+ public void request_responseMimeType_isRecorded() throws Exception {
+ GenerateContentConfig config =
+ GenerateContentConfig.builder().responseMimeType("application/json").build();
+
+ String json = serializeRequest(requestWith(config), true);
+
+ assertThat(json).contains("\"response_mime_type\":\"application/json\"");
+ }
+
+ /**
+ * A response schema can be arbitrarily large and says nothing about the turn being traced, so
+ * only its presence is recorded, as upstream does.
+ */
+ @Test
+ public void request_responseSchema_isReducedToABoolean() throws Exception {
+ GenerateContentConfig config =
+ GenerateContentConfig.builder()
+ .responseSchema(Schema.builder().type(Type.Known.OBJECT).build())
+ .build();
+
+ String json = serializeRequest(requestWith(config), true);
+
+ assertThat(json).contains("\"has_response_schema\":true");
+ assertThat(json).doesNotContain("\"response_schema\":");
+ }
+
+ /**
+ * Tool declarations repeat on every turn and dwarf everything else in the document, so upstream
+ * records names only. The record has no field for a declaration, so this is structural — but the
+ * key name and the {@code NON_EMPTY} inclusion still need pinning.
+ */
+ @Test
+ public void request_tools_recordTheirNamesOnly() throws Exception {
+ LlmRequest request =
+ LlmRequest.builder()
+ .model(MODEL)
+ .contents(ImmutableList.of(userContent("hi")))
+ .tools(ImmutableMap.of("lookup_order", new NamedTool("lookup_order")))
+ .build();
+
+ String json = serializeRequest(request, true);
+
+ assertThat(json).contains("\"tools\":[\"lookup_order\"]");
+ assertThat(json).doesNotContain(TOOL_DESCRIPTION);
+ }
+
+ @Test
+ public void request_withNoTools_omitsTheKey() throws Exception {
+ LlmRequest request =
+ LlmRequest.builder().model(MODEL).contents(ImmutableList.of(userContent("hi"))).build();
+
+ assertThat(serializeRequest(request, true)).doesNotContain("\"tools\"");
+ }
+
+ @Test
+ public void response_carriesContentAndFinishReason() throws Exception {
+ LlmResponse response =
+ LlmResponse.builder()
+ .content(userContent("answer"))
+ .finishReason(new FinishReason(FinishReason.Known.STOP))
+ .build();
+
+ String json = mapper.writeValueAsString(LlmResponseTrace.from(response));
+
+ assertThat(json).contains("\"parts\":[{\"text\":\"answer\"}]");
+ assertThat(json).contains("\"finish_reason\":\"STOP\"");
+ }
+
+ @Test
+ public void response_error_isRecorded() throws Exception {
+ LlmResponse response = LlmResponse.builder().errorMessage("quota exceeded").build();
+
+ String json = mapper.writeValueAsString(LlmResponseTrace.from(response));
+
+ assertThat(json).contains("\"error_message\":\"quota exceeded\"");
+ }
+
+ @Test
+ public void response_emptyResponse_omitsEveryAbsentField() throws Exception {
+ String json = mapper.writeValueAsString(LlmResponseTrace.from(LlmResponse.builder().build()));
+
+ assertThat(json).isEqualTo("{}");
+ }
+
+ /**
+ * A response's token counts carry one more field than an event's. {@link UsageTraceTest} pins the
+ * difference at the record; this pins that the response side asks for the four-count factory.
+ */
+ @Test
+ public void response_usageMetadata_carriesTheCachedContentCount() throws Exception {
+ LlmResponse response =
+ LlmResponse.builder()
+ .usageMetadata(
+ GenerateContentResponseUsageMetadata.builder()
+ .promptTokenCount(120)
+ .totalTokenCount(165)
+ .cachedContentTokenCount(80)
+ .build())
+ .build();
+
+ assertThat(mapper.writeValueAsString(LlmResponseTrace.from(response)))
+ .contains(
+ "\"usage_metadata\":{\"prompt_token_count\":120,\"total_token_count\":165,"
+ + "\"cached_content_token_count\":80}");
+ }
+
+ /** Grounding payloads are large and add nothing readable, so only their presence is kept. */
+ @Test
+ public void response_groundingMetadata_isReducedToABoolean() throws Exception {
+ LlmResponse response =
+ LlmResponse.builder().groundingMetadata(GroundingMetadata.builder().build()).build();
+
+ assertThat(mapper.writeValueAsString(LlmResponseTrace.from(response)))
+ .isEqualTo("{\"has_grounding_metadata\":true}");
+ }
+
+ /** The streaming flags and the resolved model version, which a partial response is read for. */
+ @Test
+ public void response_streamingFlagsAndModelVersion_areRecorded() throws Exception {
+ LlmResponse response =
+ LlmResponse.builder()
+ .partial(true)
+ .turnComplete(false)
+ .errorCode(new FinishReason(FinishReason.Known.SAFETY))
+ .modelVersion("gemini-2.0-flash-001")
+ .build();
+
+ String json = mapper.writeValueAsString(LlmResponseTrace.from(response));
+
+ assertThat(json).contains("\"partial\":true");
+ assertThat(json).contains("\"turn_complete\":false");
+ assertThat(json).contains("\"error_code\":\"SAFETY\"");
+ assertThat(json).contains("\"model_version\":\"gemini-2.0-flash-001\"");
+ }
+
+ /**
+ * The trace only ever reads {@link BaseTool#name()}, so a named stub is enough — and it keeps
+ * this test free of a mocking framework the rest of the file does not use.
+ */
+ private static final class NamedTool extends BaseTool {
+ NamedTool(String name) {
+ super(name, TOOL_DESCRIPTION);
+ }
+ }
+}
diff --git a/core/src/test/java/com/google/adk/plugins/debuglogging/SafeSerializerTest.java b/core/src/test/java/com/google/adk/plugins/debuglogging/SafeSerializerTest.java
new file mode 100644
index 000000000..cc63aa822
--- /dev/null
+++ b/core/src/test/java/com/google/adk/plugins/debuglogging/SafeSerializerTest.java
@@ -0,0 +1,148 @@
+/*
+ * Copyright 2026 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.google.adk.plugins.debuglogging;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+/**
+ * Pins the guarantee {@link SafeSerializer} exists to make: a debug plugin describing an arbitrary
+ * tool result never throws, and never puts a null into an immutable collection.
+ *
+ * Everything is exercised through {@code serializeMap}, the class's only entry point, so the
+ * tests cover what production actually calls rather than a convenience overload.
+ */
+@RunWith(JUnit4.class)
+public class SafeSerializerTest {
+
+ private static final String KEY = "v";
+
+ /** Serializes {@code raw} as a map value and hands back what the trace would show for it. */
+ private static Object valueOf(Object raw) {
+ Map holder = new HashMap<>();
+ holder.put(KEY, raw);
+ return SafeSerializer.serializeMap(holder).get(KEY);
+ }
+
+ @Test
+ public void serializeMap_empty_isEmptyNotNull() {
+ assertThat(SafeSerializer.serializeMap(new HashMap<>())).isEmpty();
+ }
+
+ @Test
+ public void serializeMap_nullValue_becomesTheNullMarker() {
+ assertThat(valueOf(null)).isEqualTo("");
+ }
+
+ @Test
+ public void serializeMap_primitives_passThrough() {
+ assertThat(valueOf("text")).isEqualTo("text");
+ assertThat(valueOf(42)).isEqualTo(42);
+ assertThat(valueOf(1.5)).isEqualTo(1.5);
+ assertThat(valueOf(true)).isEqualTo(true);
+ }
+
+ @Test
+ public void serializeMap_bytes_recordLengthNotContent() {
+ assertThat(valueOf(new byte[] {1, 2, 3, 4, 5})).isEqualTo("");
+ }
+
+ @Test
+ public void serializeMap_nestedStructures_areRecursivelyImmutable() {
+ Map nested = new HashMap<>();
+ nested.put("inner", Arrays.asList("a", "b"));
+
+ Object serialized = valueOf(nested);
+
+ assertThat(serialized).isInstanceOf(ImmutableMap.class);
+ assertThat(serialized).isEqualTo(ImmutableMap.of("inner", ImmutableList.of("a", "b")));
+ }
+
+ @Test
+ public void serializeMap_nullInsideList_becomesMarkerAndKeepsPosition() {
+ assertThat(valueOf(Arrays.asList("a", null, "c")))
+ .isEqualTo(ImmutableList.of("a", "", "c"));
+ }
+
+ @Test
+ public void serializeMap_array_isTreatedAsList() {
+ assertThat(valueOf(new Object[] {"a", null})).isEqualTo(ImmutableList.of("a", ""));
+ }
+
+ @Test
+ public void serializeMap_arbitraryObject_fallsBackToItsDescription() {
+ assertThat(valueOf(Optional.of("x"))).isEqualTo("Optional[x]");
+ }
+
+ @Test
+ public void serializeMap_objectWhoseToStringThrows_yieldsMarkerInsteadOfPropagating() {
+ assertThat(valueOf(new ThrowingToString())).isEqualTo("");
+ }
+
+ /**
+ * Without the identity guard this input recurses until the stack overflows. adk-java's own {@code
+ * JsonFormatter} keeps an identity set for the same reason.
+ */
+ @Test
+ public void serializeMap_selfReferentialMap_reportsACycleInsteadOfOverflowing() {
+ Map cyclic = new HashMap<>();
+ cyclic.put("name", "root");
+ cyclic.put("self", cyclic);
+
+ assertThat(SafeSerializer.serializeMap(cyclic))
+ .isEqualTo(ImmutableMap.of("name", "root", "self", ""));
+ }
+
+ @Test
+ public void serializeMap_cycleThroughAList_isAlsoCut() {
+ List cyclic = new ArrayList<>();
+ cyclic.add("first");
+ cyclic.add(cyclic);
+
+ assertThat(valueOf(cyclic)).isEqualTo(ImmutableList.of("first", ""));
+ }
+
+ /** A value appearing twice in a tree is not a cycle — the guard must not over-trigger. */
+ @Test
+ public void serializeMap_sameValueTwiceInATree_isSerializedTwice() {
+ Map shared = new HashMap<>();
+ shared.put("k", "v");
+
+ ImmutableMap expected = ImmutableMap.of("k", "v");
+ assertThat(valueOf(Arrays.asList(shared, shared)))
+ .isEqualTo(ImmutableList.of(expected, expected));
+ }
+
+ /** A tool result value that misbehaves exactly where the original's {@code str(obj)} would. */
+ private static final class ThrowingToString {
+ @Override
+ public String toString() {
+ throw new IllegalStateException("boom");
+ }
+ }
+}
diff --git a/core/src/test/java/com/google/adk/plugins/debuglogging/TracePayloadTest.java b/core/src/test/java/com/google/adk/plugins/debuglogging/TracePayloadTest.java
new file mode 100644
index 000000000..4f0c4999d
--- /dev/null
+++ b/core/src/test/java/com/google/adk/plugins/debuglogging/TracePayloadTest.java
@@ -0,0 +1,178 @@
+/*
+ * Copyright 2026 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.google.adk.plugins.debuglogging;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.google.adk.events.Event;
+import com.google.adk.models.LlmRequest;
+import com.google.adk.plugins.debuglogging.TracePayload.BranchTrace;
+import com.google.adk.plugins.debuglogging.TracePayload.LlmErrorTrace;
+import com.google.adk.plugins.debuglogging.TracePayload.MarkerTrace;
+import com.google.adk.plugins.debuglogging.TracePayload.SessionStateTrace;
+import com.google.adk.plugins.debuglogging.TracePayload.ToolCallTrace;
+import com.google.adk.plugins.debuglogging.TracePayload.ToolErrorTrace;
+import com.google.adk.plugins.debuglogging.TracePayload.ToolResponseTrace;
+import com.google.adk.plugins.debuglogging.TracePayload.UserMessageTrace;
+import com.google.adk.sessions.Session;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import com.google.genai.types.Content;
+import com.google.genai.types.Part;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Optional;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+/**
+ * Pins the eight payload shapes the state layer adds, on their serialized form rather than on their
+ * components — the wire keys are what a reader of a trace, or a script comparing adk-java's output
+ * with adk-python's, actually sees.
+ */
+@RunWith(JUnit4.class)
+public class TracePayloadTest {
+
+ private static final String TOOL = "lookup_order";
+ private static final String CALL_ID = "call-7";
+
+ private final ObjectMapper mapper = DebugYamlWriter.configure(new ObjectMapper());
+
+ private String serialize(TracePayload payload) throws Exception {
+ return mapper.writeValueAsString(payload);
+ }
+
+ @Test
+ public void userMessage_delegatesToContentTrace() throws Exception {
+ Content message =
+ Content.builder()
+ .role("user")
+ .parts(ImmutableList.of(Part.builder().text("where is my order?").build()))
+ .build();
+
+ assertThat(serialize(UserMessageTrace.from(message)))
+ .isEqualTo(
+ "{\"content\":{\"role\":\"user\",\"parts\":[{\"text\":\"where is my order?\"}]}}");
+ }
+
+ @Test
+ public void branch_whenSet_isRecorded() throws Exception {
+ assertThat(serialize(new BranchTrace(Optional.of("root.researcher"))))
+ .isEqualTo("{\"branch\":\"root.researcher\"}");
+ }
+
+ /** A root invocation has no branch, and upstream's {@code exclude_none} drops the key. */
+ @Test
+ public void branch_whenAbsent_leavesAnEmptyPayload() throws Exception {
+ assertThat(serialize(new BranchTrace(Optional.empty()))).isEqualTo("{}");
+ }
+
+ /** The two marker types must still write {@code data: {}} rather than fail as an empty bean. */
+ @Test
+ public void marker_serializesAsAnEmptyMapping() throws Exception {
+ assertThat(serialize(MarkerTrace.INSTANCE)).isEqualTo("{}");
+ }
+
+ @Test
+ public void llmError_recordsTypeMessageAndModel() throws Exception {
+ LlmRequest request = LlmRequest.builder().model("gemini-2.0-flash").build();
+
+ assertThat(serialize(LlmErrorTrace.from(new IllegalStateException("quota exhausted"), request)))
+ .isEqualTo(
+ "{\"error_type\":\"IllegalStateException\",\"error_message\":\"quota exhausted\","
+ + "\"model\":\"gemini-2.0-flash\"}");
+ }
+
+ /** Python's {@code str(error)} is always a string; a Java throwable may carry no message. */
+ @Test
+ public void llmError_withoutAMessage_omitsTheKeyRatherThanWritingNull() throws Exception {
+ LlmRequest request = LlmRequest.builder().model("gemini-2.0-flash").build();
+
+ assertThat(serialize(LlmErrorTrace.from(new IllegalStateException(), request)))
+ .doesNotContain("error_message");
+ }
+
+ @Test
+ public void toolCall_recordsNameCallIdAndArgs() throws Exception {
+ ImmutableMap args = ImmutableMap.of("orderId", 42);
+
+ assertThat(serialize(ToolCallTrace.of(TOOL, CALL_ID, args)))
+ .isEqualTo(
+ "{\"tool_name\":\"lookup_order\",\"function_call_id\":\"call-7\","
+ + "\"args\":{\"orderId\":42}}");
+ }
+
+ /**
+ * Upstream always passes a dict here, so a no-argument call records {@code args: {}}. This is the
+ * one place the port deliberately does not use {@code NON_EMPTY}.
+ */
+ @Test
+ public void toolCall_withNoArguments_stillEmitsTheArgsKey() throws Exception {
+ assertThat(serialize(ToolCallTrace.of(TOOL, CALL_ID, new HashMap<>()))).contains("\"args\":{}");
+ }
+
+ @Test
+ public void toolCall_argumentsGoThroughSafeSerializer() throws Exception {
+ Map args = new HashMap<>();
+ args.put("upload", new byte[] {1, 2, 3});
+
+ assertThat(serialize(ToolCallTrace.of(TOOL, CALL_ID, args)))
+ .contains("\"upload\":\"\"");
+ }
+
+ @Test
+ public void toolResponse_recordsTheResult() throws Exception {
+ ImmutableMap result = ImmutableMap.of("status", "shipped");
+
+ assertThat(serialize(ToolResponseTrace.of(TOOL, CALL_ID, result)))
+ .isEqualTo(
+ "{\"tool_name\":\"lookup_order\",\"function_call_id\":\"call-7\","
+ + "\"result\":{\"status\":\"shipped\"}}");
+ }
+
+ /** The arguments repeat here on purpose: a failure should be readable without its call entry. */
+ @Test
+ public void toolError_carriesTheArgumentsAlongsideTheError() throws Exception {
+ ImmutableMap args = ImmutableMap.of("orderId", 42);
+
+ String json =
+ serialize(
+ ToolErrorTrace.of(TOOL, CALL_ID, args, new IllegalArgumentException("no such id")));
+
+ assertThat(json).contains("\"args\":{\"orderId\":42}");
+ assertThat(json).contains("\"error_type\":\"IllegalArgumentException\"");
+ assertThat(json).contains("\"error_message\":\"no such id\"");
+ }
+
+ @Test
+ public void sessionState_recordsTheStateAndTheEventCount() throws Exception {
+ Map state = new HashMap<>();
+ state.put("cart_size", 2);
+ Session session =
+ Session.builder("session-1")
+ .appName("shop")
+ .userId("user-1")
+ .state(state)
+ .events(ImmutableList.of(Event.builder().id("e1").author("user").build()))
+ .build();
+
+ assertThat(serialize(SessionStateTrace.from(session)))
+ .isEqualTo("{\"state\":{\"cart_size\":2},\"event_count\":1}");
+ }
+}
diff --git a/core/src/test/java/com/google/adk/plugins/debuglogging/UsageTraceTest.java b/core/src/test/java/com/google/adk/plugins/debuglogging/UsageTraceTest.java
new file mode 100644
index 000000000..a632e3150
--- /dev/null
+++ b/core/src/test/java/com/google/adk/plugins/debuglogging/UsageTraceTest.java
@@ -0,0 +1,76 @@
+/*
+ * Copyright 2026 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.google.adk.plugins.debuglogging;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.google.genai.types.GenerateContentResponseUsageMetadata;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+/**
+ * Pins the only reason this record has two factories instead of one.
+ *
+ * adk-python records different subsets of the same token counts in the two places it
+ * reports them: three for an event in {@code on_event_callback}, and four for an LLM response in
+ * {@code after_model_callback}, adding {@code cached_content_token_count}. {@link
+ * UsageTrace#fromEvent} reproduces that by leaving the cached count absent.
+ *
+ *
Both factories are given the same fully-populated metadata, so the only thing that can
+ * make the two expectations differ is the factory itself. Without this, collapsing the pair into
+ * one method would silently diverge from adk-python and leave every other test green.
+ */
+@RunWith(JUnit4.class)
+public class UsageTraceTest {
+
+ private static final GenerateContentResponseUsageMetadata USAGE =
+ GenerateContentResponseUsageMetadata.builder()
+ .promptTokenCount(120)
+ .candidatesTokenCount(45)
+ .totalTokenCount(165)
+ .cachedContentTokenCount(80)
+ .build();
+
+ private final ObjectMapper mapper = DebugYamlWriter.configure(new ObjectMapper());
+
+ @Test
+ public void fromEvent_recordsThreeCountsAndOmitsTheCachedOne() throws Exception {
+ assertThat(mapper.writeValueAsString(UsageTrace.fromEvent(USAGE)))
+ .isEqualTo(
+ "{\"prompt_token_count\":120,\"candidates_token_count\":45,"
+ + "\"total_token_count\":165}");
+ }
+
+ @Test
+ public void fromResponse_addsTheCachedContentCount() throws Exception {
+ assertThat(mapper.writeValueAsString(UsageTrace.fromResponse(USAGE)))
+ .isEqualTo(
+ "{\"prompt_token_count\":120,\"candidates_token_count\":45,"
+ + "\"total_token_count\":165,\"cached_content_token_count\":80}");
+ }
+
+ /** Absent counts are omitted rather than written as nulls, in both shapes. */
+ @Test
+ public void fromResponse_withNoCountsAtAll_writesAnEmptyMapping() throws Exception {
+ GenerateContentResponseUsageMetadata empty =
+ GenerateContentResponseUsageMetadata.builder().build();
+
+ assertThat(mapper.writeValueAsString(UsageTrace.fromResponse(empty))).isEqualTo("{}");
+ }
+}