From f915d0ac15055336d91fc10c4c0765533a4507e5 Mon Sep 17 00:00:00 2001 From: Andy Aylward Date: Wed, 2 Sep 2026 23:46:53 -0400 Subject: [PATCH] logging: render formattedMessage on stdout; tool calls log key-value pairs The shared JsonEncoder keeps the raw template and arguments for machine readers and adds the rendered line for eyes. ToolCallLog writes tool/ms/outcome as key-value pairs under a tool_call message, the shape the stats pipeline reads. --- domains/games/apis/mcpserver/README.md | 5 ++-- .../games/mcpserver/tools/ToolCallLog.java | 25 +++++++++++++---- .../mcpserver/McpToolCallLoggingTest.java | 28 +++++++++++++------ .../mcpserver/tools/ToolCallLogTest.java | 26 +++++++++++++---- .../platform/logging/LogbackConfigTest.java | 11 ++++---- domains/platform/resources/logback.xml | 9 ++++-- 6 files changed, 74 insertions(+), 30 deletions(-) diff --git a/domains/games/apis/mcpserver/README.md b/domains/games/apis/mcpserver/README.md index c7689ac1..1e706f75 100644 --- a/domains/games/apis/mcpserver/README.md +++ b/domains/games/apis/mcpserver/README.md @@ -247,8 +247,9 @@ Environment variables: ## Logging -Every tool call logs one line — name, duration, outcome — on the -`com.muchq.games.mcpserver.tools.ToolCallLog` logger: INFO for a success, WARN +Every tool call logs one line on the `com.muchq.games.mcpserver.tools.ToolCallLog` +logger: message `tool_call`, with `tool`, `ms`, and `outcome` as key-value pairs +(the `kvpList` of the JSON line, the shape the stats pipeline reads): INFO for a success, WARN when the tool answered `isError: true`, ERROR with the stack when an exception escaped the tool (the framework's JSON-RPC error mapping still applies). Arguments are not logged. A call the framework rejects before the tool method diff --git a/domains/games/apis/mcpserver/src/main/java/com/muchq/games/mcpserver/tools/ToolCallLog.java b/domains/games/apis/mcpserver/src/main/java/com/muchq/games/mcpserver/tools/ToolCallLog.java index 3b584a86..781a2b04 100644 --- a/domains/games/apis/mcpserver/src/main/java/com/muchq/games/mcpserver/tools/ToolCallLog.java +++ b/domains/games/apis/mcpserver/src/main/java/com/muchq/games/mcpserver/tools/ToolCallLog.java @@ -4,11 +4,14 @@ import java.util.function.Supplier; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import org.slf4j.spi.LoggingEventBuilder; /** - * One log line per tool call: name, duration, outcome. Every {@code @Tool} method wraps its body in - * {@link #logged}, which is the only server-side trace a call leaves — micronaut-mcp's dispatch - * (ToolRegistry) is final with private handlers, so there is no framework seam to hang this on. + * One log line per tool call, message {@code tool_call} with key-value pairs {@code tool}, {@code + * ms}, and {@code outcome}, so the line aggregates without parsing its text. Every {@code @Tool} + * method wraps its body in {@link #logged}, which is the only server-side trace a call leaves — + * micronaut-mcp's dispatch (ToolRegistry) is final with private handlers, so there is no framework + * seam to hang this on. * *

Outcomes map to levels: a successful result is INFO, a result the tool flagged {@code isError} * is WARN, and an exception that escapes the tool is ERROR with the stack — then rethrown, so the @@ -27,23 +30,33 @@ final class ToolCallLog { private ToolCallLog() {} + static final String MESSAGE = "tool_call"; + static CallToolResult logged(String tool, Supplier call) { long start = System.nanoTime(); CallToolResult result; try { result = call.get(); } catch (RuntimeException | Error e) { - LOG.error("tool={} ms={} outcome=threw", tool, elapsedMs(start), e); + line(LOG.atError().setCause(e), tool, elapsedMs(start), "threw"); throw e; } if (Boolean.TRUE.equals(result.isError())) { - LOG.warn("tool={} ms={} outcome=error", tool, elapsedMs(start)); + line(LOG.atWarn(), tool, elapsedMs(start), "error"); } else { - LOG.info("tool={} ms={} outcome=ok", tool, elapsedMs(start)); + line(LOG.atInfo(), tool, elapsedMs(start), "ok"); } return result; } + private static void line(LoggingEventBuilder builder, String tool, long ms, String outcome) { + builder + .addKeyValue("tool", tool) + .addKeyValue("ms", ms) + .addKeyValue("outcome", outcome) + .log(MESSAGE); + } + private static long elapsedMs(long startNanos) { return (System.nanoTime() - startNanos) / 1_000_000; } diff --git a/domains/games/apis/mcpserver/src/test/java/com/muchq/games/mcpserver/McpToolCallLoggingTest.java b/domains/games/apis/mcpserver/src/test/java/com/muchq/games/mcpserver/McpToolCallLoggingTest.java index 501ec26e..accb8beb 100644 --- a/domains/games/apis/mcpserver/src/test/java/com/muchq/games/mcpserver/McpToolCallLoggingTest.java +++ b/domains/games/apis/mcpserver/src/test/java/com/muchq/games/mcpserver/McpToolCallLoggingTest.java @@ -22,6 +22,7 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.slf4j.LoggerFactory; +import org.slf4j.event.KeyValuePair; /** * A tool call leaves a server-side line: name, duration, outcome. Asserted through the real server @@ -119,10 +120,19 @@ public void aSuccessfulToolCallLogsOneInfoLineNamingTheTool() throws Exception { assertThat(appender.list).hasSize(1); ILoggingEvent event = appender.list.get(0); assertThat(event.getLevel()).isEqualTo(Level.INFO); - assertThat(event.getFormattedMessage()) - .contains("tool=server_time") - .contains("ms=") - .contains("outcome=ok"); + assertThat(event.getMessage()).isEqualTo("tool_call"); + assertThat(kv(event, "tool")).isEqualTo("server_time"); + assertThat(kv(event, "outcome")).isEqualTo("ok"); + assertThat(kv(event, "ms")).isInstanceOf(Long.class); + } + + private static Object kv(ILoggingEvent event, String key) { + for (KeyValuePair pair : java.util.Objects.requireNonNull(event.getKeyValuePairs())) { + if (pair.key.equals(key)) { + return pair.value; + } + } + throw new AssertionError("no key-value pair " + key + " on " + event); } @Test @@ -137,10 +147,9 @@ public void aToolsOwnRejectionLogsAWarnLineNamingTheToolButNotItsArguments() thr assertThat(appender.list).hasSize(1); ILoggingEvent event = appender.list.get(0); assertThat(event.getLevel()).isEqualTo(Level.WARN); - assertThat(event.getFormattedMessage()) - .contains("tool=chess_com_games") - .contains("outcome=error"); - assertThat(event.getFormattedMessage()) + assertThat(kv(event, "tool")).isEqualTo("chess_com_games"); + assertThat(kv(event, "outcome")).isEqualTo("error"); + assertThat(event.getFormattedMessage() + event.getKeyValuePairs()) .as("arguments are caller data and stay out of the log — README's stated contract") .doesNotContain("sentinel-username-x9"); } @@ -175,7 +184,8 @@ public void everyAdvertisedToolLogsExactlyOneLineNamingItself() throws Exception assertThat(appender.list).as("%s must log exactly one line per call", name).hasSize(1); ILoggingEvent event = appender.list.get(0); - assertThat(event.getFormattedMessage()).contains("tool=" + name).contains("ms="); + assertThat(kv(event, "tool")).isEqualTo(name); + assertThat(kv(event, "ms")).isInstanceOf(Long.class); // server_time is the one tool that succeeds without the network; everything else is a // rejection or an unreachable-one_d4 report, both on the isError channel. Level expected = "server_time".equals(name) ? Level.INFO : Level.WARN; diff --git a/domains/games/apis/mcpserver/src/test/java/com/muchq/games/mcpserver/tools/ToolCallLogTest.java b/domains/games/apis/mcpserver/src/test/java/com/muchq/games/mcpserver/tools/ToolCallLogTest.java index 24fc9e07..4efb4820 100644 --- a/domains/games/apis/mcpserver/src/test/java/com/muchq/games/mcpserver/tools/ToolCallLogTest.java +++ b/domains/games/apis/mcpserver/src/test/java/com/muchq/games/mcpserver/tools/ToolCallLogTest.java @@ -8,10 +8,12 @@ import ch.qos.logback.classic.spi.ILoggingEvent; import ch.qos.logback.core.read.ListAppender; import io.modelcontextprotocol.spec.McpSchema.CallToolResult; +import java.util.Objects; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.slf4j.LoggerFactory; +import org.slf4j.event.KeyValuePair; public class ToolCallLogTest { @@ -31,6 +33,15 @@ public void detachAppender() { logger.detachAppender(appender); } + static Object kv(ILoggingEvent event, String key) { + for (KeyValuePair pair : Objects.requireNonNull(event.getKeyValuePairs())) { + if (pair.key.equals(key)) { + return pair.value; + } + } + throw new AssertionError("no key-value pair " + key + " on " + event); + } + @Test public void aSuccessfulCallIsLoggedAtInfoAndItsResultReturnedUntouched() { CallToolResult result = ToolResults.text("payload"); @@ -41,7 +52,10 @@ public void aSuccessfulCallIsLoggedAtInfoAndItsResultReturnedUntouched() { assertThat(appender.list).hasSize(1); ILoggingEvent event = appender.list.get(0); assertThat(event.getLevel()).isEqualTo(Level.INFO); - assertThat(event.getFormattedMessage()).contains("tool=server_time").contains("ms="); + assertThat(event.getMessage()).isEqualTo("tool_call"); + assertThat(kv(event, "tool")).isEqualTo("server_time"); + assertThat(kv(event, "outcome")).isEqualTo("ok"); + assertThat(kv(event, "ms")).isInstanceOf(Long.class); } @Test @@ -51,7 +65,8 @@ public void aResultTheToolFlaggedAsAnErrorIsLoggedAtWarn() { assertThat(appender.list).hasSize(1); ILoggingEvent event = appender.list.get(0); assertThat(event.getLevel()).isEqualTo(Level.WARN); - assertThat(event.getFormattedMessage()).contains("tool=chess_com_games"); + assertThat(kv(event, "tool")).isEqualTo("chess_com_games"); + assertThat(kv(event, "outcome")).isEqualTo("error"); } @Test @@ -70,7 +85,8 @@ public void anUncaughtExceptionIsLoggedAtErrorWithTheStackAndRethrown() { assertThat(appender.list).hasSize(1); ILoggingEvent event = appender.list.get(0); assertThat(event.getLevel()).isEqualTo(Level.ERROR); - assertThat(event.getFormattedMessage()).contains("tool=index_chess_games"); + assertThat(kv(event, "tool")).isEqualTo("index_chess_games"); + assertThat(kv(event, "outcome")).isEqualTo("threw"); assertThat(event.getThrowableProxy().getMessage()).isEqualTo("connection refused"); } @@ -91,8 +107,6 @@ public void theLoggedDurationCoversTheCall() { return ToolResults.text("x"); }); - String message = appender.list.get(0).getFormattedMessage(); - long ms = Long.parseLong(message.replaceAll(".*ms=(\\d+).*", "$1")); - assertThat(ms).isGreaterThanOrEqualTo(30); + assertThat((Long) kv(appender.list.get(0), "ms")).isGreaterThanOrEqualTo(30); } } diff --git a/domains/platform/libs/logging/src/test/java/com/muchq/platform/logging/LogbackConfigTest.java b/domains/platform/libs/logging/src/test/java/com/muchq/platform/logging/LogbackConfigTest.java index d0fb267e..87406de4 100644 --- a/domains/platform/libs/logging/src/test/java/com/muchq/platform/logging/LogbackConfigTest.java +++ b/domains/platform/libs/logging/src/test/java/com/muchq/platform/logging/LogbackConfigTest.java @@ -75,15 +75,16 @@ public void theSharedConfigEmitsOneParseableJsonObjectPerLineWithAnAbsoluteTimes Instant.parse("2026-01-01T00:00:00Z").toEpochMilli(), Instant.parse("2100-01-01T00:00:00Z").toEpochMilli()); - // The parameterized case is what services actually write, and - // JsonEncoder's contract there is NOT "message is the rendered text": - // the raw template rides in "message" and the values in "arguments". - // Anything reading these lines offline has to know that, so the - // semantic is pinned, not discovered. + // The parameterized case is what services actually write. "message" + // is the raw template and "arguments" the values — what machine + // readers key on — and "formattedMessage" is the rendered line, for + // anyone reading the stream by eye. JsonNode parameterized = lineContaining(captured.toString(UTF_8), "widget {} failed"); assertThat(parameterized.get("message").asText()).isEqualTo("widget {} failed after {} tries"); assertThat(parameterized.get("arguments").get(0).asText()).isEqualTo("w-7"); assertThat(parameterized.get("arguments").get(1).asText()).isEqualTo("3"); + assertThat(parameterized.get("formattedMessage").asText()) + .isEqualTo("widget w-7 failed after 3 tries"); // An ERROR with a throwable — the line Sentry and any alerting reads — // stays one parseable object with the exception structured inside it. diff --git a/domains/platform/resources/logback.xml b/domains/platform/resources/logback.xml index 962c9697..8a9a0722 100644 --- a/domains/platform/resources/logback.xml +++ b/domains/platform/resources/logback.xml @@ -3,8 +3,13 @@ - + pattern was not (#1456). "message" is the raw template and + "arguments" the values, for machine readers; "formattedMessage" + is the rendered line, for eyes on docker logs. LogbackConfigTest + pins the shape. --> + + true + INFO