Skip to content

feat(plugins): port DebugLoggingPlugin from adk-python - #1432

Open
svetanis wants to merge 1 commit into
google:mainfrom
svetanis:feature/debug-logging-plugin
Open

svetanis wants to merge 1 commit into
google:mainfrom
svetanis:feature/debug-logging-plugin

Conversation

@svetanis

Copy link
Copy Markdown
Contributor

Please ensure you have read the contribution guide before creating a pull request.

Link to Issue or Description of Change

1. Link to an existing issue (if applicable):

2. Or, if no issue exists, describe the change:

Problem:

When an agent misbehaves, adk-java has no way to capture what happened in a form you can read
afterwards or attach to a bug report. The contents sent to the model, what came back, each tool call
and its result, the events the runner yielded — all of it is observable only while the run is
happening, and nothing produces a structured record. adk-python ships DebugLoggingPlugin for
exactly this; adk-java has no equivalent.

Solution:

A port of that plugin. New files in a new com.google.adk.plugins.debuglogging subpackage, no
existing file modified
, no existing API changed, and no new dependency —
jackson-dataformat-yaml is already a compile-scope dependency of core. Registration uses the
surface that is already there:

Runner runner =
    new InMemoryRunner(
        agent, "my-app", ImmutableList.of(new DebugLoggingPlugin(Path.of("adk_debug.yaml"))));

Twelve new files, the same count plugins/agentanalytics already ships:

New file(s) Concern
DebugLoggingPlugin.java the twelve hooks; each body records one entry and returns
DebugYamlWriter.java append one YAML document per invocation, serialized by a lock, and how a mapper is configured for traces
DebugEntry.java, InvocationDebugState.java, DebugTraceRecorder.java one entry, one invocation's accumulator, and the live-invocation map that timestamped entries are filed into
TracePayload.java (sealed, + 8 nested) the closed set of shapes an entry's data can take
ContentTrace.java (nesting PartTrace and its 6 part kinds), EventTrace.java, LlmRequestTrace.java, LlmResponseTrace.java, UsageTrace.java the curated shape of each ADK/genai type as it appears in a trace
SafeSerializer.java arbitrary tool values made YAML-safe without ever throwing

The PR adds exactly one public type, DebugLoggingPlugin. Everything else is package-private.

All twelve hooks are observe-only — eleven return Maybe.empty(), and afterRunCallback returns
a Completable that only writes the file. Failures inside are logged, not thrown.

The trace curates rather than dumps, which is why this is not a generic ObjectMapper over ADK's
types. Tool declarations are recorded as names only; inline data as mime_type with
_data_omitted: true and no component for the bytes at all; grounding metadata as
has_grounding_metadata: true; requested auth configs as a count. Each matches upstream.

One difference from adk-python. onUserMessageCallback fires before beforeRunCallback, and
upstream opens its per-invocation state only in the later hook, so its user_message entry is
dropped. Here either hook opens the invocation, so the user's message is recorded. A test and a demo
row pin it.

On the write path:

  • The write is off the calling thread but still ordered. afterRunCallback returns
    Completable, so the write runs on Schedulers.io(); Runner.runAgentWithUpdatedSession joins it
    onto the event stream with concatWith, so a caller that drains runAsync has the file on disk
    when it returns.
  • The writer is synchronized. Two invocations finishing together would otherwise interleave
    their documents into a file that no longer parses — a wide window on the JVM, with the write on
    Schedulers.io().

Ported from adk-python/src/google/adk/plugins/debug_logging_plugin.py (572 lines).

Testing Plan

Unit Tests:

  • I have added or updated unit tests for my change.
  • All unit tests pass locally.
mvn -o -pl core test -Dtest='com.google.adk.plugins.debuglogging.*Test'
Tests run: 97, Failures: 0, Errors: 0, Skipped: 0
BUILD SUCCESS

Ninety-seven tests, all new — these are new classes, so there is no before/after state to contrast.
They assert on serialized output, not on intermediate maps, and the writer and plugin tests
assert on the re-parsed YAML, because "the trace can be read back" is the actual promise:

Test class Pins
EventTraceTest (17) all thirteen envelope components on their wire keys; an absent streaming flag omitted rather than written false; the actions block only when non-empty; auth configs as a count; the live action maps copied
LlmExchangeTraceTest (15) request and response shape; tool names only; includeSystemInstruction=false records presence, not text; response schema and grounding metadata each reduced to a boolean
SafeSerializerTest (12) an arbitrary tool value never throws; nulls never reach an immutable collection; cycles are cut instead of overflowing the stack
ContentTraceTest (12) all six part kinds on their wire keys; blank text and empty parts dropped; inline data keeps its mime type, never its bytes
TracePayloadTest (12) the eight payload shapes on their wire keys; data: {} for markers; args: {} kept when a tool takes none
DebugLoggingPluginTest (9) all twelve hooks record, in the order a real run fires them; the Maybe hooks return empty; the user message survives arriving before the run opens; both config switches; the state is released after the write
DebugTraceRecorderTest (9) entry field order; an entry for an unknown invocation is dropped, not thrown; entries() is a snapshot, not the live queue; a session without app name or user id omits those keys
DebugYamlWriterTest (8) two invocations, two documents, both re-parse; one --- each; all four scalar-lookalikes survive as strings; multi-line text as a literal block; an unwritable path logs instead of throwing; eight threads appending at once still yield eight intact documents
UsageTraceTest (3) three token counts for an event, four for a response — the cached-content count present in one and absent in the other

Manual End-to-End (E2E) Tests:

One real invocation against gemini-2.5-flash through an InMemoryRunner, with one tool. Two model
calls, 15 entries, one document — excerpt:

---
invocation_id: e-3b988d1a-5abc-4345-b1c1-acd6c5c84516
session_id: 339cf111-94ac-4014-b737-e3c4250f81d1
app_name: f15-debug-logging
user_id: demo-user
start_time: 2026-08-14T13:55:53.934
entries:
- timestamp: 2026-08-14T13:55:53.941
  entry_type: user_message
  invocation_id: e-3b988d1a-5abc-4345-b1c1-acd6c5c84516
  data:
    content:
      role: user
      parts:
      - text: "Where is order 42? Use the lookupOrder tool, then tell me in one sentence."
- timestamp: 2026-08-14T13:55:54.000
  entry_type: llm_request
  invocation_id: e-3b988d1a-5abc-4345-b1c1-acd6c5c84516
  agent_name: debug-logging-demo-agent
  data:
    model: gemini-2.5-flash
    content_count: 1
    contents:
    - role: user
      parts:
      - text: "Where is order 42? Use the lookupOrder tool, then tell me in one sentence."
    tools:
    - lookupOrder
- timestamp: 2026-08-14T13:55:55.547
  entry_type: tool_call
  invocation_id: e-3b988d1a-5abc-4345-b1c1-acd6c5c84516
  agent_name: debug-logging-demo-agent
  data:
    tool_name: lookupOrder
    function_call_id: adk-5f689fbe-8bbb-465d-b516-6303c70c2adf
    args:
      orderId: "42"
- timestamp: 2026-08-14T13:55:55.554
  entry_type: tool_response
  invocation_id: e-3b988d1a-5abc-4345-b1c1-acd6c5c84516
  agent_name: debug-logging-demo-agent
  data:
    tool_name: lookupOrder
    function_call_id: adk-5f689fbe-8bbb-465d-b516-6303c70c2adf
    result:
      orderId: "42"
      status: shipped
      carrier: DHL

Eight further scenarios were run offline against scripted models, covering the entry sequence, both
tool-failure shapes, a model error, append across two invocations, two invocations racing on one
file, a blob in the conversation, and the configuration switches.

Checklist

  • I have read the CONTRIBUTING.md document.
  • My pull request contains a single commit.
  • I have performed a self-review of my own code.
  • I have commented my code, particularly in hard-to-understand areas.
  • I have added tests that prove my fix is effective or that my feature works.
  • New and existing unit tests pass locally with my changes.
  • I have manually tested my changes end-to-end.
  • Any dependent changes have been merged and published in downstream modules.

@hemasekhar-p hemasekhar-p self-assigned this Aug 17, 2026
@hemasekhar-p

Copy link
Copy Markdown
Contributor

Hi @svetanis, thank you for your contribution. We truly appreciate you taking the time to submit this PR. It is currently under review by our team and we will update you if any additional information is required. Thank you!

@svetanis
svetanis force-pushed the feature/debug-logging-plugin branch from f5c551e to 71ed59e Compare September 2, 2026 02:16
@svetanis
svetanis force-pushed the feature/debug-logging-plugin branch from 71ed59e to aa77a43 Compare October 18, 2026 04:46
* limitations under the License.
*/

package com.google.adk.plugins.debuglogging;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

HIGH: Please match the structure of the Kotlin port: one DebugLoggingPlugin class in the main plugins package (L17, L66-L71), with DebugEntry and InvocationDebugState as private nested classes (L490-L505) and the remaining logic in private helpers. In Java, that means placing com.google.adk.plugins.DebugLoggingPlugin next to LoggingPlugin and folding in the 11 package-private types, which reduces 1,673 production lines across 12 files to roughly Kotlin's 544.

* MarkerTrace} serves {@code agent_end} and {@code invocation_end}, which upstream records with no
* payload at all.
*/
sealed interface TracePayload

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

HIGH: Kotlin applies the same curation (tool names only, _data_omitted, has_grounding_metadata, and counts) without defining a separate type per payload: each entry's data is an ordered map built by a null-dropping helper, dataOf, plus small private serializers for content and actions (L423-L464). Please use the same approach with LinkedHashMap<String, Object> and one YAML mapper instead of this sealed interface, the *Trace records with Optional components and isEmpty() checks, MarkerTrace, and the DebugEntry.Type enum.

Comment on lines +103 to +104
public DebugLoggingPlugin(
String name, Path outputPath, boolean includeSessionState, boolean includeSystemInstruction) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

MEDIUM: Two adjacent booleans make calls like new DebugLoggingPlugin(name, path, true, false) error-prone. Kotlin provides Java callers with a builder, DebugLoggingPlugin.builder(), supporting outputPath, includeSessionState, includeSystemInstruction, and name, matching ADK Java's ContextFilterPlugin.builder(). Please replace this constructor and the Path one with a builder, while keeping the no-argument constructor for default settings.

Comment on lines +128 to +133
Files.writeString(
outputPath,
document,
StandardCharsets.UTF_8,
StandardOpenOption.CREATE,
StandardOpenOption.APPEND);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

HIGH: The trace contains full prompts, tool arguments, and session state, yet the file is created using the process umask (producing rw-r--r-- under umask 022, verified via InMemoryRunner). Kotlin creates the file and restricts it to the owner before the first write (L170-L175, L351-L363) and tests this behavior (restrictsOutputFileToOwner), and Python now opens the file with mode 0o600 (L152-L154, L622-L626). Please create the file with owner-only permissions, for example with PosixFilePermissions.asFileAttribute(...) where POSIX is supported, and include the corresponding test.

Comment on lines +104 to +105
.enable(YAMLGenerator.Feature.MINIMIZE_QUOTES)
.enable(YAMLGenerator.Feature.ALWAYS_QUOTE_NUMBERS_AS_STRINGS)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

MEDIUM: With MINIMIZE_QUOTES, tool strings such as 0x1F, 1_000, 2026-01-01, and .inf are written bare. PyYAML parses them back as 31, 1000, a date, and inf, while Jackson's own YAML reader fails on the entire document at .inf. Kotlin dumps with SnakeYAML, which quotes any string that would be parsed as another type (L75-L85). In Jackson, dropping MINIMIZE_QUOTES and ALWAYS_QUOTE_NUMBERS_AS_STRINGS quotes every string, while LITERAL_BLOCK_STYLE still renders multi-line text as |-.

* to file rather than the plugin's internal bookkeeping.
*/
@RunWith(JUnit4.class)
public class DebugLoggingPluginTest {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

MEDIUM: Kotlin tests this plugin in one test class that exercises the public hooks and inspects the generated file (DebugLoggingPluginTest.kt). Most of the nine test classes here target package-private types that will be removed once the plugin follows Kotlin. Please consolidate the behavioral test cases (hook order, user message before beforeRunCallback, both switches, missing state, unwritable path, concurrent appends, and scalar-like strings) into this class, and add tests for owner-only file permissions and strings like .inf and 0x1F.

Comment on lines +72 to +84
/**
* Registers {@code state} unless this invocation already has one.
*
* <p>"Unless" is load-bearing. {@code Runner.runAsync} invokes {@code onUserMessageCallback}
* <b>before</b> {@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.
*
* <p>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.
*/

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

MEDIUM: Several Javadocs here span multiple paragraphs of design rationale, reference adk-python's private helpers (_add_entry, _serialize_content), and use em-dashes. ADK keeps doc comments to about three sentences focused on why, leaving detailed design rationale for the PR description. See Kotlin's KDoc for the expected length (L42-L65, L91-L92). Please trim the comments accordingly when merging into one file.


static SessionStateTrace from(Session session) {
return new SessionStateTrace(
SafeSerializer.serializeMap(session.state()), session.events().size());

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LOW: Session.events() is deprecated; use session.immutableEvents().size() for the count.

SafeSerializer.serializeMap(actions.stateDelta()),
ImmutableMap.copyOf(actions.artifactDelta()),
actions.transferToAgent(),
actions.escalate(),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LOW: Recording escalate: false causes an otherwise empty actions block to be emitted. Both Python (L288-L289) and Kotlin (L429) record escalate only when it is true.

record FunctionCallTrace(
Optional<String> id,
Optional<String> name,
@JsonInclude(JsonInclude.Include.NON_EMPTY) ImmutableMap<String, Object> args) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LOW: NON_EMPTY drops an empty args or response map from function_call and function_response parts, while Python (L134-L145) and Kotlin (L441-L448) write args: {}. The tool_call entry here keeps args: {}, so the two disagree; omit the key only when the value is absent, as Kotlin's dataOf does.

@damianmomotgoogle damianmomotgoogle added waiting on reporter Waiting for reaction by reporter. Failing that, maintainers will eventually closed it as stale. and removed needs review labels Oct 22, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

waiting on reporter Waiting for reaction by reporter. Failing that, maintainers will eventually closed it as stale.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[FEATURE] Port DebugLoggingPlugin from adk-python

3 participants