diff --git a/client/transport/jsonrpc/src/main/java/org/a2aproject/sdk/client/transport/jsonrpc/JSONRPCTransport.java b/client/transport/jsonrpc/src/main/java/org/a2aproject/sdk/client/transport/jsonrpc/JSONRPCTransport.java
index d92956714..33a4779a8 100644
--- a/client/transport/jsonrpc/src/main/java/org/a2aproject/sdk/client/transport/jsonrpc/JSONRPCTransport.java
+++ b/client/transport/jsonrpc/src/main/java/org/a2aproject/sdk/client/transport/jsonrpc/JSONRPCTransport.java
@@ -66,6 +66,7 @@
import org.a2aproject.sdk.spec.TaskPushNotificationConfig;
import org.a2aproject.sdk.spec.TaskQueryParams;
import org.a2aproject.sdk.spec.util.Utils;
+import org.a2aproject.sdk.jsonrpc.common.logging.JsonPayloadLoggingHelper;
import org.jspecify.annotations.Nullable;
public class JSONRPCTransport implements ClientTransport {
@@ -94,12 +95,14 @@ public JSONRPCTransport(@Nullable A2AHttpClient httpClient, @Nullable AgentCard
@Override
public EventKind sendMessage(MessageSendParams request, @Nullable ClientCallContext context) throws A2AClientException {
checkNotNullParam("request", request);
+ JsonPayloadLoggingHelper.logTaskRequest(SEND_MESSAGE_METHOD, request);
PayloadAndHeaders payloadAndHeaders = applyInterceptors(SEND_MESSAGE_METHOD, ProtoUtils.ToProto.sendMessageRequest(request),
agentCard, context);
try {
String httpResponseBody = sendPostRequest(agentInterface.url(), payloadAndHeaders, SEND_MESSAGE_METHOD);
SendMessageResponse response = unmarshalResponse(httpResponseBody, SEND_MESSAGE_METHOD);
+ JsonPayloadLoggingHelper.logTaskResult(SEND_MESSAGE_METHOD, response.getResult());
return response.getResult();
} catch (A2AClientException e) {
throw e;
diff --git a/common/pom.xml b/common/pom.xml
index c1b3b4851..d443461c9 100644
--- a/common/pom.xml
+++ b/common/pom.xml
@@ -21,6 +21,14 @@
org.slf4j
slf4j-api
+
+ org.jspecify
+ jspecify
+
+
+ com.google.code.gson
+ gson
+
org.junit.jupiter
junit-jupiter-api
diff --git a/common/src/main/java/org/a2aproject/sdk/common/logging/A2AEventType.java b/common/src/main/java/org/a2aproject/sdk/common/logging/A2AEventType.java
new file mode 100644
index 000000000..09e2a8893
--- /dev/null
+++ b/common/src/main/java/org/a2aproject/sdk/common/logging/A2AEventType.java
@@ -0,0 +1,18 @@
+package org.a2aproject.sdk.common.logging;
+
+public enum A2AEventType {
+ TASK_REQUEST("task.request"),
+ TASK_STATE_CHANGE("task.state_change"),
+ TASK_ARTIFACT("task.artifact"),
+ TASK_RESULT("task.result"),
+ NEGOTIATION("negotiation"),
+ AUTHORIZATION("authorization"),
+ NOTIFICATION_SUBSCRIPTION("notification.subscription"),
+ NOTIFICATION_DELIVERY("notification.delivery"),
+ STREAMING_EVENT("streaming.event");
+
+ private final String value;
+ A2AEventType(String value) { this.value = value; }
+ public String value() { return value; }
+ @Override public String toString() { return value; }
+}
diff --git a/common/src/main/java/org/a2aproject/sdk/common/logging/A2APayloadLoggingConfig.java b/common/src/main/java/org/a2aproject/sdk/common/logging/A2APayloadLoggingConfig.java
new file mode 100644
index 000000000..9e30f8c1a
--- /dev/null
+++ b/common/src/main/java/org/a2aproject/sdk/common/logging/A2APayloadLoggingConfig.java
@@ -0,0 +1,69 @@
+package org.a2aproject.sdk.common.logging;
+
+import org.jspecify.annotations.Nullable;
+
+public final class A2APayloadLoggingConfig {
+ public static final int DEFAULT_MAX_SIZE = 4096;
+ public static final boolean DEFAULT_ENABLED = false;
+ public static final boolean DEFAULT_MASKING_ENABLED = true;
+
+ private final boolean enabled;
+ private final int maxSize;
+ private final boolean maskingEnabled;
+ @Nullable private final PayloadMaskingStrategy maskingStrategy;
+
+ private A2APayloadLoggingConfig(Builder b) {
+ this.enabled = b.enabled; this.maxSize = b.maxSize;
+ this.maskingEnabled = b.maskingEnabled; this.maskingStrategy = b.maskingStrategy;
+ }
+
+ public boolean isEnabled() { return enabled; }
+ public int getMaxSize() { return maxSize; }
+ public boolean isMaskingEnabled() { return maskingEnabled; }
+ @Nullable public PayloadMaskingStrategy getMaskingStrategy() { return maskingStrategy; }
+
+ public static Builder builder() { return new Builder(); }
+ public static A2APayloadLoggingConfig defaults() { return builder().build(); }
+
+ public static A2APayloadLoggingConfig fromSystemProperties() {
+ Builder b = builder();
+ String en = System.getProperty("a2a.logging.payload.enabled");
+ if (en != null) b.enabled(Boolean.parseBoolean(en));
+ String ms = System.getProperty("a2a.logging.payload.max-size");
+ if (ms != null) try { b.maxSize(Integer.parseInt(ms)); } catch (NumberFormatException ignored) {}
+ String me = System.getProperty("a2a.logging.payload.masking.enabled");
+ if (me != null) b.maskingEnabled(Boolean.parseBoolean(me));
+ String keys = System.getProperty("a2a.logging.payload.masking.sensitive-keys");
+ if (keys != null && !keys.isBlank()) {
+ String[] parts = keys.split(",");
+ java.util.Set set = new java.util.HashSet<>();
+ for (String p : parts) set.add(p.trim());
+ b.maskingStrategy(new DefaultPayloadMaskingStrategy(set));
+ }
+ String strat = System.getProperty("a2a.logging.payload.masking.strategy");
+ if (strat != null && !strat.isBlank()) try {
+ Class> cl = Class.forName(strat);
+ Object inst = cl.getDeclaredConstructor().newInstance();
+ if (inst instanceof PayloadMaskingStrategy) b.maskingStrategy((PayloadMaskingStrategy) inst);
+ } catch (Exception ignored) {}
+ return b.build();
+ }
+
+ public Builder toBuilder() {
+ Builder b = new Builder();
+ b.enabled = enabled; b.maxSize = maxSize; b.maskingEnabled = maskingEnabled; b.maskingStrategy = maskingStrategy;
+ return b;
+ }
+
+ public static final class Builder {
+ private boolean enabled = DEFAULT_ENABLED;
+ private int maxSize = DEFAULT_MAX_SIZE;
+ private boolean maskingEnabled = DEFAULT_MASKING_ENABLED;
+ @Nullable private PayloadMaskingStrategy maskingStrategy = null;
+ public Builder enabled(boolean v) { enabled = v; return this; }
+ public Builder maxSize(int v) { maxSize = v; return this; }
+ public Builder maskingEnabled(boolean v) { maskingEnabled = v; return this; }
+ public Builder maskingStrategy(PayloadMaskingStrategy v) { maskingStrategy = v; return this; }
+ public A2APayloadLoggingConfig build() { return new A2APayloadLoggingConfig(this); }
+ }
+}
diff --git a/common/src/main/java/org/a2aproject/sdk/common/logging/DefaultPayloadMaskingStrategy.java b/common/src/main/java/org/a2aproject/sdk/common/logging/DefaultPayloadMaskingStrategy.java
new file mode 100644
index 000000000..52547eccc
--- /dev/null
+++ b/common/src/main/java/org/a2aproject/sdk/common/logging/DefaultPayloadMaskingStrategy.java
@@ -0,0 +1,72 @@
+package org.a2aproject.sdk.common.logging;
+
+import java.util.HashSet;
+import java.util.Locale;
+import java.util.Set;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+import com.google.gson.JsonArray;
+import com.google.gson.JsonElement;
+import com.google.gson.JsonObject;
+import com.google.gson.JsonParser;
+import com.google.gson.JsonSyntaxException;
+
+public class DefaultPayloadMaskingStrategy implements PayloadMaskingStrategy {
+
+ private static final Set DEFAULT_SENSITIVE_KEYS = Set.of(
+ "token", "credentials", "password", "secret", "apikey", "api_key",
+ "privatekey", "private_key", "authorization", "authentication",
+ "accesstoken", "access_token", "refreshtoken", "refresh_token",
+ "clientsecret", "client_secret", "url");
+
+ private final Set sensitiveKeysLower;
+
+ public DefaultPayloadMaskingStrategy() { this(DEFAULT_SENSITIVE_KEYS); }
+
+ public DefaultPayloadMaskingStrategy(Set sensitiveKeys) {
+ Set lower = new HashSet<>();
+ for (String k : sensitiveKeys) lower.add(k.toLowerCase(Locale.ROOT));
+ this.sensitiveKeysLower = Set.copyOf(lower);
+ }
+
+ private boolean isSensitive(String key) {
+ String lower = key.toLowerCase(Locale.ROOT);
+ for (String s : sensitiveKeysLower) if (lower.contains(s)) return true;
+ return false;
+ }
+
+ @Override
+ public String mask(String payload) {
+ if (payload == null || payload.isEmpty()) return payload;
+ try {
+ JsonElement el = JsonParser.parseString(payload);
+ if (el.isJsonObject() || el.isJsonArray()) {
+ maskElement(el);
+ return el.toString();
+ }
+ } catch (JsonSyntaxException ignored) {}
+ String result = payload;
+ for (String key : sensitiveKeysLower) {
+ Pattern p = Pattern.compile("(\"" + Pattern.quote(key) + "\"\\s*:\\s*\")([^\"]+)(\")", Pattern.CASE_INSENSITIVE);
+ result = p.matcher(result).replaceAll("$1***$3");
+ }
+ return result;
+ }
+
+ private void maskElement(JsonElement el) {
+ if (el.isJsonObject()) {
+ JsonObject obj = el.getAsJsonObject();
+ for (String key : Set.copyOf(obj.keySet())) {
+ JsonElement val = obj.get(key);
+ if (isSensitive(key)) {
+ obj.addProperty(key, MASKED);
+ } else {
+ maskElement(val);
+ }
+ }
+ } else if (el.isJsonArray()) {
+ for (JsonElement item : el.getAsJsonArray()) maskElement(item);
+ }
+ }
+}
diff --git a/common/src/main/java/org/a2aproject/sdk/common/logging/NoOpPayloadMaskingStrategy.java b/common/src/main/java/org/a2aproject/sdk/common/logging/NoOpPayloadMaskingStrategy.java
new file mode 100644
index 000000000..645bbf018
--- /dev/null
+++ b/common/src/main/java/org/a2aproject/sdk/common/logging/NoOpPayloadMaskingStrategy.java
@@ -0,0 +1,5 @@
+package org.a2aproject.sdk.common.logging;
+
+public class NoOpPayloadMaskingStrategy implements PayloadMaskingStrategy {
+ @Override public String mask(String payload) { return payload; }
+}
diff --git a/common/src/main/java/org/a2aproject/sdk/common/logging/PayloadMaskingStrategy.java b/common/src/main/java/org/a2aproject/sdk/common/logging/PayloadMaskingStrategy.java
new file mode 100644
index 000000000..86054e53c
--- /dev/null
+++ b/common/src/main/java/org/a2aproject/sdk/common/logging/PayloadMaskingStrategy.java
@@ -0,0 +1,6 @@
+package org.a2aproject.sdk.common.logging;
+
+public interface PayloadMaskingStrategy {
+ String MASKED = "***";
+ String mask(String payload);
+}
diff --git a/common/src/main/java/org/a2aproject/sdk/common/logging/PayloadTruncator.java b/common/src/main/java/org/a2aproject/sdk/common/logging/PayloadTruncator.java
new file mode 100644
index 000000000..2c387a4a4
--- /dev/null
+++ b/common/src/main/java/org/a2aproject/sdk/common/logging/PayloadTruncator.java
@@ -0,0 +1,14 @@
+package org.a2aproject.sdk.common.logging;
+
+public final class PayloadTruncator {
+ public static final String TRUNCATED_SUFFIX = " [truncated]";
+ private static final int SUFFIX_LEN = TRUNCATED_SUFFIX.length();
+ private PayloadTruncator() {}
+ public static String truncate(String payload, int maxSize) {
+ if (payload == null) return null;
+ if (maxSize <= 0) return TRUNCATED_SUFFIX;
+ if (payload.length() <= maxSize) return payload;
+ int cut = Math.max(0, maxSize - SUFFIX_LEN);
+ return payload.substring(0, cut) + TRUNCATED_SUFFIX;
+ }
+}
diff --git a/common/src/main/java/org/a2aproject/sdk/common/logging/StructuredPayloadLogger.java b/common/src/main/java/org/a2aproject/sdk/common/logging/StructuredPayloadLogger.java
new file mode 100644
index 000000000..92ad11db0
--- /dev/null
+++ b/common/src/main/java/org/a2aproject/sdk/common/logging/StructuredPayloadLogger.java
@@ -0,0 +1,92 @@
+package org.a2aproject.sdk.common.logging;
+
+import java.util.concurrent.atomic.AtomicReference;
+
+import org.jspecify.annotations.Nullable;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+public final class StructuredPayloadLogger {
+ private static final Logger PAYLOAD_LOGGER = LoggerFactory.getLogger("org.a2aproject.sdk.payload");
+ private static final AtomicReference CONFIG = new AtomicReference<>(A2APayloadLoggingConfig.fromSystemProperties());
+ private static final AtomicReference STRATEGY = new AtomicReference<>(new DefaultPayloadMaskingStrategy());
+ private StructuredPayloadLogger() {}
+ public static void setConfig(A2APayloadLoggingConfig cfg) {
+ CONFIG.set(cfg);
+ if (cfg.getMaskingStrategy() != null) STRATEGY.set(cfg.getMaskingStrategy());
+ }
+ public static void setMaskingStrategy(PayloadMaskingStrategy s) { STRATEGY.set(s); }
+ public static void resetToSystemProperties() {
+ CONFIG.set(A2APayloadLoggingConfig.fromSystemProperties());
+ STRATEGY.set(new DefaultPayloadMaskingStrategy());
+ }
+ public static boolean isEnabled() {
+ A2APayloadLoggingConfig c = CONFIG.get();
+ return c.isEnabled() && PAYLOAD_LOGGER.isDebugEnabled();
+ }
+ public static void log(A2AEventType type, String payload, String... kvs) {
+ if (!isEnabled()) return;
+ A2APayloadLoggingConfig cfg = CONFIG.get();
+ String masked = cfg.isMaskingEnabled() ? STRATEGY.get().mask(payload) : payload;
+ int max = cfg.getMaxSize();
+ boolean truncated = masked != null && masked.length() > max;
+ String out = PayloadTruncator.truncate(masked, max);
+ int originalSize = masked != null ? masked.length() : 0;
+ String traceId = TraceCorrelation.currentTraceId();
+ String spanId = TraceCorrelation.currentSpanId();
+ try {
+ var builder = PAYLOAD_LOGGER.atDebug();
+ builder.addKeyValue("a2a.event.type", type.value());
+ builder.addKeyValue("a2a.payload", out);
+ builder.addKeyValue("a2a.payload.size", String.valueOf(originalSize));
+ builder.addKeyValue("a2a.payload.truncated", String.valueOf(truncated));
+ if (traceId != null) builder.addKeyValue("trace_id", traceId);
+ if (spanId != null) builder.addKeyValue("span_id", spanId);
+ for (int i = 0; i + 1 < kvs.length; i += 2) builder.addKeyValue(kvs[i], kvs[i+1]);
+ builder.log("a2a.payload event_type=" + type.value());
+ } catch (NoSuchMethodError | Exception e) {
+ StringBuilder sb = new StringBuilder();
+ sb.append("a2a.payload event_type=").append(type.value());
+ sb.append(" payload=").append(out);
+ sb.append(" a2a.payload.size=").append(originalSize);
+ sb.append(" a2a.payload.truncated=").append(truncated);
+ if (traceId != null) sb.append(" trace_id=").append(traceId);
+ if (spanId != null) sb.append(" span_id=").append(spanId);
+ for (int i = 0; i + 1 < kvs.length; i += 2) sb.append(" ").append(kvs[i]).append("=").append(kvs[i+1]);
+ PAYLOAD_LOGGER.debug(sb.toString());
+ }
+ }
+ public static void logTaskRequest(String op, String payload, @Nullable String taskId) {
+ if (taskId != null) log(A2AEventType.TASK_REQUEST, payload, "a2a.operation", op, "a2a.task_id", taskId);
+ else log(A2AEventType.TASK_REQUEST, payload, "a2a.operation", op);
+ }
+ public static void logTaskResult(String op, String payload, @Nullable String taskId) {
+ if (taskId != null) log(A2AEventType.TASK_RESULT, payload, "a2a.operation", op, "a2a.task_id", taskId);
+ else log(A2AEventType.TASK_RESULT, payload, "a2a.operation", op);
+ }
+ public static void logTaskStateChange(String payload, @Nullable String taskId, @Nullable String status) {
+ if (taskId != null && status != null) log(A2AEventType.TASK_STATE_CHANGE, payload, "a2a.task_id", taskId, "a2a.task.status", status);
+ else if (taskId != null) log(A2AEventType.TASK_STATE_CHANGE, payload, "a2a.task_id", taskId);
+ else log(A2AEventType.TASK_STATE_CHANGE, payload);
+ }
+ public static void logTaskArtifact(String payload, @Nullable String taskId, @Nullable String artifactId) {
+ if (taskId != null) log(A2AEventType.TASK_ARTIFACT, payload, "a2a.task_id", taskId, "a2a.artifact_id", artifactId != null ? artifactId : "");
+ else log(A2AEventType.TASK_ARTIFACT, payload);
+ }
+ public static void logAuthDecision(String op, boolean allowed, String reason) {
+ log(A2AEventType.AUTHORIZATION, reason, "a2a.operation", op, "a2a.auth.allowed", String.valueOf(allowed));
+ }
+ public static void logPushSubscription(String op, String payload) {
+ log(A2AEventType.NOTIFICATION_SUBSCRIPTION, payload, "a2a.operation", op);
+ }
+ public static void logPushDelivery(String url, boolean success, String payload) {
+ log(A2AEventType.NOTIFICATION_DELIVERY, payload, "a2a.push.url", url, "a2a.push.success", String.valueOf(success));
+ }
+ public static void logNegotiation(String op, String payload) {
+ log(A2AEventType.NEGOTIATION, payload, "a2a.operation", op);
+ }
+ public static void logStreamingEvent(String payload, @Nullable String taskId) {
+ if (taskId != null) log(A2AEventType.STREAMING_EVENT, payload, "a2a.task_id", taskId);
+ else log(A2AEventType.STREAMING_EVENT, payload);
+ }
+}
diff --git a/common/src/main/java/org/a2aproject/sdk/common/logging/TraceCorrelation.java b/common/src/main/java/org/a2aproject/sdk/common/logging/TraceCorrelation.java
new file mode 100644
index 000000000..fcb76b0fc
--- /dev/null
+++ b/common/src/main/java/org/a2aproject/sdk/common/logging/TraceCorrelation.java
@@ -0,0 +1,67 @@
+package org.a2aproject.sdk.common.logging;
+
+import java.lang.reflect.Method;
+
+import org.jspecify.annotations.Nullable;
+import org.slf4j.MDC;
+
+public final class TraceCorrelation {
+ private TraceCorrelation() {}
+ public static final String TRACE_ID_KEY = "trace_id";
+ public static final String SPAN_ID_KEY = "span_id";
+
+ @Nullable
+ public static String currentTraceId() {
+ String t = tryOtelTraceId();
+ if (t != null && !t.isEmpty()) return t;
+ t = MDC.get("trace_id"); if (t != null) return t;
+ t = MDC.get("traceId"); if (t != null) return t;
+ return MDC.get("X-B3-TraceId");
+ }
+
+ @Nullable
+ public static String currentSpanId() {
+ String s = tryOtelSpanId();
+ if (s != null && !s.isEmpty()) return s;
+ s = MDC.get("span_id"); if (s != null) return s;
+ s = MDC.get("spanId"); if (s != null) return s;
+ return MDC.get("X-B3-SpanId");
+ }
+
+ @Nullable
+ private static String tryOtelTraceId() {
+ try {
+ Class> spanClass = Class.forName("io.opentelemetry.api.trace.Span");
+ Object span = spanClass.getMethod("current").invoke(null);
+ Object ctx = span.getClass().getMethod("getSpanContext").invoke(span);
+ Boolean valid = (Boolean) ctx.getClass().getMethod("isValid").invoke(ctx);
+ if (valid == null || !valid) return null;
+ String id = (String) ctx.getClass().getMethod("getTraceId").invoke(ctx);
+ if (id != null && !id.equals("00000000000000000000000000000000")) return id;
+ } catch (Exception ignored) {}
+ return null;
+ }
+
+ @Nullable
+ private static String tryOtelSpanId() {
+ try {
+ Class> spanClass = Class.forName("io.opentelemetry.api.trace.Span");
+ Object span = spanClass.getMethod("current").invoke(null);
+ Object ctx = span.getClass().getMethod("getSpanContext").invoke(span);
+ Boolean valid = (Boolean) ctx.getClass().getMethod("isValid").invoke(ctx);
+ if (valid == null || !valid) return null;
+ String id = (String) ctx.getClass().getMethod("getSpanId").invoke(ctx);
+ if (id != null && !id.equals("0000000000000000")) return id;
+ } catch (Exception ignored) {}
+ return null;
+ }
+
+ public static void setMdc(@Nullable String traceId, @Nullable String spanId) {
+ if (traceId != null) MDC.put(TRACE_ID_KEY, traceId);
+ if (spanId != null) MDC.put(SPAN_ID_KEY, spanId);
+ }
+
+ public static void clearMdc() {
+ MDC.remove(TRACE_ID_KEY); MDC.remove(SPAN_ID_KEY);
+ }
+}
diff --git a/common/src/main/java/org/a2aproject/sdk/common/logging/package-info.java b/common/src/main/java/org/a2aproject/sdk/common/logging/package-info.java
new file mode 100644
index 000000000..b07db662b
--- /dev/null
+++ b/common/src/main/java/org/a2aproject/sdk/common/logging/package-info.java
@@ -0,0 +1,2 @@
+/** Structured payload logging for A2A. */
+package org.a2aproject.sdk.common.logging;
diff --git a/jsonrpc-common/pom.xml b/jsonrpc-common/pom.xml
index 33b9222db..9b9a93021 100644
--- a/jsonrpc-common/pom.xml
+++ b/jsonrpc-common/pom.xml
@@ -25,6 +25,14 @@
com.google.code.gson
gson
+
+ org.jspecify
+ jspecify
+
+
+ org.slf4j
+ slf4j-api
+
org.junit.jupiter
diff --git a/jsonrpc-common/src/main/java/org/a2aproject/sdk/jsonrpc/common/logging/JsonPayloadLoggingHelper.java b/jsonrpc-common/src/main/java/org/a2aproject/sdk/jsonrpc/common/logging/JsonPayloadLoggingHelper.java
new file mode 100644
index 000000000..75eecefaa
--- /dev/null
+++ b/jsonrpc-common/src/main/java/org/a2aproject/sdk/jsonrpc/common/logging/JsonPayloadLoggingHelper.java
@@ -0,0 +1,32 @@
+package org.a2aproject.sdk.jsonrpc.common.logging;
+
+import org.a2aproject.sdk.common.logging.A2AEventType;
+import org.a2aproject.sdk.common.logging.StructuredPayloadLogger;
+import org.a2aproject.sdk.server.util.JsonUtil;
+
+public final class JsonPayloadLoggingHelper {
+ private JsonPayloadLoggingHelper() {}
+ private static String toJsonSafe(Object obj) {
+ try { return JsonUtil.toJson(obj); } catch (Exception e) { return String.valueOf(obj); }
+ }
+ public static void logTaskRequest(String op, Object params) {
+ if (!StructuredPayloadLogger.isEnabled()) return;
+ StructuredPayloadLogger.log(A2AEventType.TASK_REQUEST, toJsonSafe(params), "a2a.operation", op);
+ }
+ public static void logTaskResult(String op, Object result) {
+ if (!StructuredPayloadLogger.isEnabled()) return;
+ StructuredPayloadLogger.log(A2AEventType.TASK_RESULT, toJsonSafe(result), "a2a.operation", op);
+ }
+ public static void logStreamingEvent(Object event) {
+ if (!StructuredPayloadLogger.isEnabled()) return;
+ StructuredPayloadLogger.log(A2AEventType.STREAMING_EVENT, toJsonSafe(event));
+ }
+ public static void logPushSubscription(String op, Object params) {
+ if (!StructuredPayloadLogger.isEnabled()) return;
+ StructuredPayloadLogger.log(A2AEventType.NOTIFICATION_SUBSCRIPTION, toJsonSafe(params), "a2a.operation", op);
+ }
+ public static void logNegotiation(String op, Object payload) {
+ if (!StructuredPayloadLogger.isEnabled()) return;
+ StructuredPayloadLogger.log(A2AEventType.NEGOTIATION, toJsonSafe(payload), "a2a.operation", op);
+ }
+}
diff --git a/jsonrpc-common/src/main/java/org/a2aproject/sdk/jsonrpc/common/logging/package-info.java b/jsonrpc-common/src/main/java/org/a2aproject/sdk/jsonrpc/common/logging/package-info.java
new file mode 100644
index 000000000..2beee450c
--- /dev/null
+++ b/jsonrpc-common/src/main/java/org/a2aproject/sdk/jsonrpc/common/logging/package-info.java
@@ -0,0 +1 @@
+package org.a2aproject.sdk.jsonrpc.common.logging;
diff --git a/server-common/src/main/java/org/a2aproject/sdk/server/logging/ServerPayloadLoggingHelper.java b/server-common/src/main/java/org/a2aproject/sdk/server/logging/ServerPayloadLoggingHelper.java
new file mode 100644
index 000000000..fe1f2b506
--- /dev/null
+++ b/server-common/src/main/java/org/a2aproject/sdk/server/logging/ServerPayloadLoggingHelper.java
@@ -0,0 +1,66 @@
+package org.a2aproject.sdk.server.logging;
+
+import org.a2aproject.sdk.common.logging.StructuredPayloadLogger;
+import org.a2aproject.sdk.server.util.JsonUtil;
+
+public final class ServerPayloadLoggingHelper {
+ private ServerPayloadLoggingHelper() {}
+ private static String toJsonSafe(Object obj) {
+ try { return JsonUtil.toJson(obj); } catch (Exception e) { return String.valueOf(obj); }
+ }
+ public static void logTaskRequest(String op, Object params) {
+ if (!StructuredPayloadLogger.isEnabled()) return;
+ StructuredPayloadLogger.logTaskRequest(op, toJsonSafe(params), extractTaskId(params));
+ }
+ public static void logTaskResult(String op, Object result) {
+ if (!StructuredPayloadLogger.isEnabled()) return;
+ StructuredPayloadLogger.logTaskResult(op, toJsonSafe(result), extractTaskId(result));
+ }
+ public static void logStateChange(Object event) {
+ if (!StructuredPayloadLogger.isEnabled()) return;
+ StructuredPayloadLogger.logTaskStateChange(toJsonSafe(event), extractTaskId(event), extractStatus(event));
+ }
+ public static void logArtifact(Object event) {
+ if (!StructuredPayloadLogger.isEnabled()) return;
+ StructuredPayloadLogger.logTaskArtifact(toJsonSafe(event), extractTaskId(event), null);
+ }
+ public static void logAuth(String op, boolean allowed, String reason) {
+ StructuredPayloadLogger.logAuthDecision(op, allowed, reason);
+ }
+ public static void logPushSub(String op, Object params) {
+ if (!StructuredPayloadLogger.isEnabled()) return;
+ StructuredPayloadLogger.logPushSubscription(op, toJsonSafe(params));
+ }
+ public static void logPushDelivery(String url, boolean success, Object event) {
+ StructuredPayloadLogger.logPushDelivery(url, success, toJsonSafe(event));
+ }
+ private static String extractTaskId(Object o) {
+ if (o == null) return null;
+ try {
+ String json = toJsonSafe(o);
+ com.google.gson.JsonElement el = com.google.gson.JsonParser.parseString(json);
+ if (el.isJsonObject()) {
+ com.google.gson.JsonObject jo = el.getAsJsonObject();
+ if (jo.has("taskId")) return jo.get("taskId").getAsString();
+ if (jo.has("id") && jo.get("id").isJsonPrimitive()) return jo.get("id").getAsString();
+ if (jo.has("task") && jo.get("task").isJsonObject()) {
+ com.google.gson.JsonObject t = jo.getAsJsonObject("task");
+ if (t.has("id")) return t.get("id").getAsString();
+ }
+ }
+ } catch (Exception ignored) {}
+ return null;
+ }
+ private static String extractStatus(Object o) {
+ if (o == null) return null;
+ try {
+ String json = toJsonSafe(o);
+ com.google.gson.JsonElement el = com.google.gson.JsonParser.parseString(json);
+ if (el.isJsonObject() && el.getAsJsonObject().has("status")) {
+ com.google.gson.JsonObject s = el.getAsJsonObject().getAsJsonObject("status");
+ if (s != null && s.has("state")) return s.get("state").getAsString();
+ }
+ } catch (Exception ignored) {}
+ return null;
+ }
+}
diff --git a/server-common/src/main/java/org/a2aproject/sdk/server/logging/package-info.java b/server-common/src/main/java/org/a2aproject/sdk/server/logging/package-info.java
new file mode 100644
index 000000000..406e2baf7
--- /dev/null
+++ b/server-common/src/main/java/org/a2aproject/sdk/server/logging/package-info.java
@@ -0,0 +1 @@
+package org.a2aproject.sdk.server.logging;
diff --git a/server-common/src/main/java/org/a2aproject/sdk/server/requesthandlers/DefaultRequestHandler.java b/server-common/src/main/java/org/a2aproject/sdk/server/requesthandlers/DefaultRequestHandler.java
index 3a669ae7a..dbe5f6a40 100644
--- a/server-common/src/main/java/org/a2aproject/sdk/server/requesthandlers/DefaultRequestHandler.java
+++ b/server-common/src/main/java/org/a2aproject/sdk/server/requesthandlers/DefaultRequestHandler.java
@@ -75,6 +75,7 @@
import org.a2aproject.sdk.spec.TaskState;
import org.a2aproject.sdk.spec.TaskStatusUpdateEvent;
import org.a2aproject.sdk.spec.UnsupportedOperationError;
+import org.a2aproject.sdk.server.logging.ServerPayloadLoggingHelper;
import org.a2aproject.sdk.util.Assert;
import org.jspecify.annotations.NonNull;
import org.jspecify.annotations.Nullable;
@@ -503,6 +504,7 @@ public DefaultRequestHandler build() {
@Override
public Task onGetTask(TaskQueryParams params, ServerCallContext context) throws A2AError {
+ ServerPayloadLoggingHelper.logTaskRequest("tasks/get", params);
LOGGER.debug("onGetTask {}", params.id());
enforceRead(context, params.id(), TaskOperation.GET_TASK);
Task task = taskStore.get(params.id());
@@ -542,37 +544,40 @@ private static Task limitTaskHistory(Task task, @Nullable Integer historyLength)
private void enforceRead(ServerCallContext context, String taskId, TaskOperation operation) throws A2AError {
if (authorizationProvider == null) {
if (authorizationRequired) {
+ ServerPayloadLoggingHelper.logAuth(operation.name(), false, "no provider - deny");
throw new TaskNotFoundError();
}
return;
}
- if (!authorizationProvider.checkRead(context, taskId, operation)) {
- throw new TaskNotFoundError();
- }
+ boolean allowed = authorizationProvider.checkRead(context, taskId, operation);
+ ServerPayloadLoggingHelper.logAuth(operation.name(), allowed, allowed ? "allow" : "deny read " + taskId);
+ if (!allowed) throw new TaskNotFoundError();
}
private void enforceWrite(ServerCallContext context, String taskId, TaskOperation operation) throws A2AError {
if (authorizationProvider == null) {
if (authorizationRequired) {
+ ServerPayloadLoggingHelper.logAuth(operation.name(), false, "no provider - deny");
throw new TaskNotFoundError();
}
return;
}
- if (!authorizationProvider.checkWrite(context, taskId, operation)) {
- throw new TaskNotFoundError();
- }
+ boolean allowed = authorizationProvider.checkWrite(context, taskId, operation);
+ ServerPayloadLoggingHelper.logAuth(operation.name(), allowed, allowed ? "allow" : "deny write " + taskId);
+ if (!allowed) throw new TaskNotFoundError();
}
private void enforceCreate(ServerCallContext context, TaskOperation operation) throws A2AError {
if (authorizationProvider == null) {
if (authorizationRequired) {
+ ServerPayloadLoggingHelper.logAuth(operation.name(), false, "no provider - deny create");
throw new TaskNotFoundError();
}
return;
}
- if (!authorizationProvider.checkCreate(context, operation)) {
- throw new TaskNotFoundError();
- }
+ boolean allowed = authorizationProvider.checkCreate(context, operation);
+ ServerPayloadLoggingHelper.logAuth(operation.name(), allowed, allowed ? "allow create" : "deny create");
+ if (!allowed) throw new TaskNotFoundError();
}
private void enforceListRead(ServerCallContext context) throws A2AError {
@@ -609,6 +614,7 @@ private void recordOwnershipIfNeeded(ServerCallContext context, @Nullable String
@Override
public ListTasksResult onListTasks(ListTasksParams params, ServerCallContext context) throws A2AError {
+ ServerPayloadLoggingHelper.logTaskRequest("tasks/list", params);
enforceListRead(context);
LOGGER.debug("onListTasks with contextId={}, status={}, pageSize={}, pageToken={}, statusTimestampAfter={}",
params.contextId(), params.status(), params.pageSize(), params.pageToken(), params.statusTimestampAfter());
@@ -634,6 +640,7 @@ public ListTasksResult onListTasks(ListTasksParams params, ServerCallContext con
}
ListTasksResult result = taskStore.list(params, context);
+ ServerPayloadLoggingHelper.logTaskResult("tasks/list", result);
// Post-filter per-task read authorization at the handler level.
// On the CDI path this is redundant (store already filters), but on the builder
// path the store may not have the provider, so the handler must be authoritative.
@@ -652,6 +659,7 @@ public ListTasksResult onListTasks(ListTasksParams params, ServerCallContext con
@Override
public Task onCancelTask(CancelTaskParams params, ServerCallContext context) throws A2AError {
+ ServerPayloadLoggingHelper.logTaskRequest("tasks/cancel", params);
enforceWrite(context, params.id(), TaskOperation.CANCEL_TASK);
// Serialize check-then-act per task so two concurrent cancels (or a cancel racing
// a concurrent completion) cannot both act on the pre-transition state.
@@ -751,6 +759,7 @@ private Task doCancelTask(CancelTaskParams params, ServerCallContext context) th
@Override
@SuppressWarnings("NullAway")
public EventKind onMessageSend(MessageSendParams rawParams, ServerCallContext context) throws A2AError {
+ ServerPayloadLoggingHelper.logTaskRequest("message/send", rawParams);
MessageSendParams params = normalizeBlankIds(rawParams);
LOGGER.debug("onMessageSend - task: {}; context {}", params.message().taskId(), params.message().contextId());
String msgTaskId = params.message().taskId();
@@ -1129,6 +1138,7 @@ public void onComplete() {
@Override
public TaskPushNotificationConfig onCreateTaskPushNotificationConfig(
TaskPushNotificationConfig params, ServerCallContext context) throws A2AError {
+ ServerPayloadLoggingHelper.logPushSub("tasks/pushNotificationConfig/create", params);
if (pushConfigStore == null || !pushNotificationsEnabled) {
throw new UnsupportedOperationError();
}
diff --git a/server-common/src/main/java/org/a2aproject/sdk/server/tasks/AgentEmitter.java b/server-common/src/main/java/org/a2aproject/sdk/server/tasks/AgentEmitter.java
index 2f5dcad8a..94574cdac 100644
--- a/server-common/src/main/java/org/a2aproject/sdk/server/tasks/AgentEmitter.java
+++ b/server-common/src/main/java/org/a2aproject/sdk/server/tasks/AgentEmitter.java
@@ -7,6 +7,7 @@
import java.util.concurrent.atomic.AtomicBoolean;
import org.a2aproject.sdk.server.agentexecution.RequestContext;
+import org.a2aproject.sdk.server.logging.ServerPayloadLoggingHelper;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.a2aproject.sdk.server.events.EventQueue;
@@ -139,6 +140,7 @@ public void updateStatus(TaskState taskState, @Nullable Message message) {
.contextId(contextId)
.status(new TaskStatus(taskState, message, null))
.build();
+ ServerPayloadLoggingHelper.logStateChange(event);
if (isFinal) {
enqueueTerminalEvent(event);
} else {
@@ -232,6 +234,7 @@ public void addArtifact(List> parts, @Nullable String artifactId, @Nulla
.append(append)
.lastChunk(lastChunk)
.build();
+ ServerPayloadLoggingHelper.logArtifact(event);
eventQueue.enqueueEvent(event);
}
diff --git a/server-common/src/main/java/org/a2aproject/sdk/server/tasks/BasePushNotificationSender.java b/server-common/src/main/java/org/a2aproject/sdk/server/tasks/BasePushNotificationSender.java
index 9dff9da60..bd4f6b52e 100644
--- a/server-common/src/main/java/org/a2aproject/sdk/server/tasks/BasePushNotificationSender.java
+++ b/server-common/src/main/java/org/a2aproject/sdk/server/tasks/BasePushNotificationSender.java
@@ -28,6 +28,7 @@
import org.a2aproject.sdk.spec.TaskArtifactUpdateEvent;
import org.a2aproject.sdk.spec.TaskPushNotificationConfig;
import org.a2aproject.sdk.spec.TaskStatusUpdateEvent;
+import org.a2aproject.sdk.server.logging.ServerPayloadLoggingHelper;
import org.jspecify.annotations.Nullable;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -242,6 +243,7 @@ private boolean dispatchNotification(StreamingEventKind event,
}
}
+ ServerPayloadLoggingHelper.logPushDelivery(url, true, event);
try {
postBuilder
.url(url)
@@ -250,6 +252,7 @@ private boolean dispatchNotification(StreamingEventKind event,
.post();
} catch (IOException | InterruptedException e) {
LOGGER.debug("Error pushing data to " + url + ": {}", e.getMessage(), e);
+ ServerPayloadLoggingHelper.logPushDelivery(url, false, event);
return false;
}
return true;
diff --git a/server-common/src/main/java/org/a2aproject/sdk/server/tasks/TaskManager.java b/server-common/src/main/java/org/a2aproject/sdk/server/tasks/TaskManager.java
index 775975727..e77a52608 100644
--- a/server-common/src/main/java/org/a2aproject/sdk/server/tasks/TaskManager.java
+++ b/server-common/src/main/java/org/a2aproject/sdk/server/tasks/TaskManager.java
@@ -22,6 +22,7 @@
import org.a2aproject.sdk.spec.TaskState;
import org.a2aproject.sdk.spec.TaskStatus;
import org.a2aproject.sdk.spec.TaskStatusUpdateEvent;
+import org.a2aproject.sdk.server.logging.ServerPayloadLoggingHelper;
import org.jspecify.annotations.Nullable;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -90,6 +91,7 @@ boolean saveTaskEvent(TaskStatusUpdateEvent event, boolean isReplicated) throws
boolean saveTaskEvent(TaskStatusUpdateEvent event, boolean isReplicated, @Nullable AtomicReference taskSnapshot)
throws A2AServerException {
+ ServerPayloadLoggingHelper.logStateChange(event);
checkIdsAndUpdateIfNecessary(event.taskId(), event.contextId());
Task task = ensureTask(event.taskId(), event.contextId());
@@ -130,6 +132,7 @@ boolean saveTaskEvent(TaskArtifactUpdateEvent event, boolean isReplicated) throw
boolean saveTaskEvent(TaskArtifactUpdateEvent event, boolean isReplicated, @Nullable AtomicReference taskSnapshot)
throws A2AServerException {
+ ServerPayloadLoggingHelper.logArtifact(event);
checkIdsAndUpdateIfNecessary(event.taskId(), event.contextId());
Task task = ensureTask(event.taskId(), event.contextId());
// taskId is guaranteed to be non-null after checkIdsAndUpdateIfNecessary
diff --git a/server-common/src/main/resources/META-INF/a2a-defaults.properties b/server-common/src/main/resources/META-INF/a2a-defaults.properties
index 5c2f7cdeb..d19c7a0a1 100644
--- a/server-common/src/main/resources/META-INF/a2a-defaults.properties
+++ b/server-common/src/main/resources/META-INF/a2a-defaults.properties
@@ -62,3 +62,8 @@ a2a.push-notification.url.allow-private-network-targets=false
# TTL in seconds for DNS resolution caching during URL validation.
# Avoids a blocking DNS lookup on every push notification. Set to 0 to disable.
a2a.push-notification.url.dns-cache-ttl-seconds=30
+
+# Structured payload logging (see issue #1221)
+a2a.logging.payload.enabled=false
+a2a.logging.payload.max-size=4096
+a2a.logging.payload.masking.enabled=true