Skip to content

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

Merged
copybara-service[bot] merged 1 commit into
google:mainfrom
svetanis:feature/debug-logging-plugin
Oct 28, 2026
Merged

copybara-service[bot] merged 1 commit into
google:mainfrom
svetanis:feature/debug-logging-plugin

Conversation

@svetanis

@svetanis svetanis commented Aug 15, 2026 •

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. One new class, com.google.adk.plugins.DebugLoggingPlugin, next to
LoggingPlugin, 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(
            DebugLoggingPlugin.builder().outputPath(Path.of("adk_debug.yaml")).build()));

The PR adds two public types, DebugLoggingPlugin and its Builder. Everything else is private.

All twelve hooks are observe-only — eleven return Maybe.empty(), and afterRunCallback returns
a Completable that only writes the file. A failed write is 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 key for the bytes at all; grounding metadata as
has_grounding_metadata: true; requested auth configs as a count. Each matches upstream.

Differences 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. Timestamps are UTC instants, every single-line string value is quoted, and an entry with
no data omits the data key.

On the write path:

  • The write is inline and ordered. afterRunCallback returns Completable.fromAction, as
    LoggingPlugin does, so the plugin does not move the end of the run onto another thread;
    the runner joins it onto the event stream with concatWith, so a caller that drains runAsync has
    the file on disk when it returns.
  • Each document is appended in one write, under a lock, so two invocations finishing together,
    even in two plugin instances, each append one intact document.

Ported from adk-python/src/google/adk/plugins/debug_logging_plugin.py.

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.DebugLoggingPluginTest'
Tests run: 51, Failures: 0, Errors: 0, Skipped: 0
BUILD SUCCESS

Fifty-one tests in one class, all new. Most drive the hooks and assert on the re-parsed YAML,
because "the trace can be read back" is the actual promise.

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-7415ed7f-896e-43d3-9665-e97ff105a461"
session_id: "28eb6052-9b13-4f6e-befb-6d36930874a3"
app_name: "f15-debug-logging"
user_id: "demo-user"
start_time: "2026-10-26T05:11:12.764650100Z"
entries:
- timestamp: "2026-10-26T05:11:12.768772900Z"
  entry_type: "user_message"
  invocation_id: "e-7415ed7f-896e-43d3-9665-e97ff105a461"
  data:
    content:
      role: "user"
      parts:
      - text: "Where is order 42? Use the lookupOrder tool, then tell me in one sentence."
# … 2 entries omitted
- timestamp: "2026-10-26T05:11:12.822693500Z"
  entry_type: "llm_request"
  invocation_id: "e-7415ed7f-896e-43d3-9665-e97ff105a461"
  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"
    config:
      system_instruction:
        role: "user"
        parts:
        - text: "You are an agent. Your internal name is \"debug-logging-demo-agent\"\
            . The description about you is \"Answers one question, calling a tool\
            \ when it has one\"."
# … 2 entries omitted
- timestamp: "2026-10-26T05:11:14.387575900Z"
  entry_type: "tool_call"
  invocation_id: "e-7415ed7f-896e-43d3-9665-e97ff105a461"
  agent_name: "debug-logging-demo-agent"
  data:
    tool_name: "lookupOrder"
    function_call_id: "adk-e1ee0abf-b2a7-4038-8868-8c4c2e15ac66"
    args:
      orderId: "42"
- timestamp: "2026-10-26T05:11:14.392331800Z"
  entry_type: "tool_response"
  invocation_id: "e-7415ed7f-896e-43d3-9665-e97ff105a461"
  agent_name: "debug-logging-demo-agent"
  data:
    tool_name: "lookupOrder"
    function_call_id: "adk-e1ee0abf-b2a7-4038-8868-8c4c2e15ac66"
    result:
      orderId: "42"
      status: "shipped"
      carrier: "DHL"
# … 7 entries omitted

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
@svetanis
svetanis force-pushed the feature/debug-logging-plugin branch from aa77a43 to 3c3633e Compare October 26, 2026 05:39
@svetanis

Copy link
Copy Markdown
Contributor Author

@damianmomotgoogle ,

Thanks for the review. All 13 comments are addressed in a single commit rebased on main:

  1. One class: now a single com.google.adk.plugins.DebugLoggingPlugin with DebugEntry and InvocationDebugState as private records. The subpackage is gone.
  2. Typed payloads: entry data is built by dataOf in a LinkedHashMap, in order and without nulls, and stored as an ImmutableMap; one YAML mapper writes it. An entry with no data omits data. Covered by entriesWithoutData_omitTheDataKey.
  3. Two booleans: replaced with DebugLoggingPlugin.builder(); the no-argument constructor is kept. Covered by includeSessionState_whenOff_omitsTheSnapshot and includeSystemInstruction_whenOff_notesItsPresenceWithoutTheText.
  4. File permissions: the file is created rw------- where POSIX is supported, before anything is written. Covered by restrictsOutputFileToOwner.
  5. MINIMIZE_QUOTES: dropped, and ALWAYS_QUOTE_NUMBERS_AS_STRINGS with it; LITERAL_BLOCK_STYLE stays. Covered by scalarLikeStrings_areQuotedAndReadBackAsStrings.
  6. subscribeOn: removed. Covered by runAsync_withThePluginRegistered_completesOnTheCallersThread.
  7. <null>: a private safeSerialize keeps null as null and writes <circular reference> for a value that contains itself; SafeSerializer is gone. Covered by toolResult_nullValue_isWrittenAsNull and selfReferencingToolValues_areWrittenAsCircularReferences.
  8. Timestamps: UTC, from invocationContext.now(). Covered by timestamps_areUtcInstantsFromTheInvocationsInstantSource.
  9. Nine test classes: now one, DebugLoggingPluginTest (51 tests): the cases you listed, the two you asked for (items 4 and 5), and a twin of each test in the Kotlin class.
  10. Javadoc: trimmed, with no upstream helper names and no em dashes.
  11. Session.events(): replaced with immutableEvents(); the count is covered by sessionStateSnapshot_carriesTheStateAndEventCount.
  12. escalate: false: now recorded only when true. Covered by escalate_whenFalse_isOmittedWithItsEmptyActionsBlock.
  13. Empty args: args: {} and response: {} are kept. Covered by functionCallAndResponse_withEmptyMaps_keepArgsAndResponse.

Also aligned with Kotlin: tool names in declaration order, tool confirmations as a count, Unknown for an anonymous error class, and the plugin name in log lines.

@damianmomotgoogle damianmomotgoogle added ready to pull and removed waiting on reporter Waiting for reaction by reporter. Failing that, maintainers will eventually closed it as stale. labels Oct 27, 2026
@copybara-service
copybara-service Bot merged commit 921d59a into google:main Oct 28, 2026
8 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[FEATURE] Port DebugLoggingPlugin from adk-python

3 participants