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..0d099ecc7 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
@@ -34,6 +34,7 @@
import org.a2aproject.sdk.grpc.utils.JSONRPCUtils;
import org.a2aproject.sdk.grpc.utils.ProtoUtils;
import org.a2aproject.sdk.jsonrpc.common.json.JsonProcessingException;
+import org.a2aproject.sdk.jsonrpc.common.logging.JsonPayloadLoggingHelper;
import org.a2aproject.sdk.jsonrpc.common.wrappers.A2AResponse;
import org.a2aproject.sdk.jsonrpc.common.wrappers.CancelTaskResponse;
import org.a2aproject.sdk.jsonrpc.common.wrappers.DeleteTaskPushNotificationConfigResponse;
@@ -94,13 +95,16 @@ 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, request.message().taskId(), request.message().contextId());
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);
- return response.getResult();
+ EventKind result = response.getResult();
+ JsonPayloadLoggingHelper.logTaskResult(result, result != null ? extractTaskIdFromEventKind(result) : null);
+ return result;
} catch (A2AClientException e) {
throw e;
} catch (IOException | InterruptedException | JsonProcessingException e) {
@@ -113,11 +117,22 @@ public void sendMessageStreaming(MessageSendParams request, Consumer errorConsumer, @Nullable ClientCallContext context) throws A2AClientException {
checkNotNullParam("request", request);
checkNotNullParam("eventConsumer", eventConsumer);
+ JsonPayloadLoggingHelper.logTaskRequest(SEND_STREAMING_MESSAGE_METHOD, request, request.message().taskId(), request.message().contextId());
PayloadAndHeaders payloadAndHeaders = applyInterceptors(SEND_STREAMING_MESSAGE_METHOD,
ProtoUtils.ToProto.sendMessageRequest(request), agentCard, context);
final AtomicReference> ref = new AtomicReference<>();
- SSEEventListener sseEventListener = new SSEEventListener(eventConsumer, errorConsumer);
+ Consumer loggingEventConsumer = event -> {
+ String taskId = event instanceof Task t ? t.id() : event instanceof org.a2aproject.sdk.spec.Message m ? m.taskId()
+ : event instanceof org.a2aproject.sdk.spec.TaskStatusUpdateEvent tue ? tue.taskId()
+ : event instanceof org.a2aproject.sdk.spec.TaskArtifactUpdateEvent taue ? taue.taskId() : null;
+ JsonPayloadLoggingHelper.logStreamingEvent(event, taskId, event != null ? event.getClass().getSimpleName() : null);
+ eventConsumer.accept(event);
+ };
+ Consumer loggingErrorConsumer = error -> {
+ if (errorConsumer != null) errorConsumer.accept(error);
+ };
+ SSEEventListener sseEventListener = new SSEEventListener(loggingEventConsumer, loggingErrorConsumer);
try {
A2AHttpClient.PostBuilder builder = createPostBuilder(agentInterface.url(), payloadAndHeaders, SEND_STREAMING_MESSAGE_METHOD);
@@ -140,13 +155,16 @@ public void sendMessageStreaming(MessageSendParams request, Consumer e
checkNotNullParam("request", request);
checkNotNullParam("eventConsumer", eventConsumer);
checkNotNullParam("errorConsumer", errorConsumer);
+ JsonPayloadLoggingHelper.logPushSubscription(SUBSCRIBE_TO_TASK_METHOD, request, request.id(), null);
PayloadAndHeaders payloadAndHeaders = applyInterceptors(SUBSCRIBE_TO_TASK_METHOD, ProtoUtils.ToProto.subscribeToTaskRequest(request), agentCard, context);
AtomicReference> ref = new AtomicReference<>();
- SSEEventListener sseEventListener = new SSEEventListener(eventConsumer, errorConsumer);
+ Consumer loggingEventConsumer = event -> {
+ String taskId = event instanceof Task t ? t.id() : event instanceof org.a2aproject.sdk.spec.Message m ? m.taskId()
+ : event instanceof org.a2aproject.sdk.spec.TaskStatusUpdateEvent tue ? tue.taskId()
+ : event instanceof org.a2aproject.sdk.spec.TaskArtifactUpdateEvent taue ? taue.taskId() : null;
+ JsonPayloadLoggingHelper.logStreamingEvent(event, taskId, event != null ? event.getClass().getSimpleName() : null);
+ eventConsumer.accept(event);
+ };
+ Consumer loggingErrorConsumer = error -> errorConsumer.accept(error);
+ SSEEventListener sseEventListener = new SSEEventListener(loggingEventConsumer, loggingErrorConsumer);
try {
A2AHttpClient.PostBuilder builder = createPostBuilder(agentInterface.url(), payloadAndHeaders, SUBSCRIBE_TO_TASK_METHOD);
@@ -290,13 +334,16 @@ public void subscribeToTask(TaskIdParams request, Consumer e
@Override
public AgentCard getExtendedAgentCard(GetExtendedAgentCardParams params, @Nullable ClientCallContext context) throws A2AClientException {
try {
+ JsonPayloadLoggingHelper.logNegotiation(GET_EXTENDED_AGENT_CARD_METHOD, params);
PayloadAndHeaders payloadAndHeaders = applyInterceptors(GET_EXTENDED_AGENT_CARD_METHOD,
ProtoUtils.ToProto.extendedAgentCard(params), agentCard, context);
try {
String httpResponseBody = sendPostRequest(agentInterface.url(), payloadAndHeaders, GET_EXTENDED_AGENT_CARD_METHOD);
GetExtendedAgentCardResponse response = unmarshalResponse(httpResponseBody, GET_EXTENDED_AGENT_CARD_METHOD);
- return response.getResult();
+ AgentCard result = response.getResult();
+ JsonPayloadLoggingHelper.logNegotiation(GET_EXTENDED_AGENT_CARD_METHOD + "/result", result);
+ return result;
} catch (IOException | InterruptedException | JsonProcessingException e) {
throw new A2AClientException("Failed to get authenticated extended agent card: " + e, e);
}
@@ -378,4 +425,13 @@ private > T unmarshalResponse(String response, String m
private @Nullable Map getHttpHeaders(@Nullable ClientCallContext context) {
return context != null ? context.getHeaders() : null;
}
+
+ private @Nullable String extractTaskIdFromEventKind(EventKind eventKind) {
+ if (eventKind instanceof Task task) {
+ return task.id();
+ } else if (eventKind instanceof org.a2aproject.sdk.spec.Message msg) {
+ return msg.taskId();
+ }
+ return null;
+ }
}
\ No newline at end of file
diff --git a/common/pom.xml b/common/pom.xml
index c1b3b4851..0dfa96b50 100644
--- a/common/pom.xml
+++ b/common/pom.xml
@@ -21,11 +21,34 @@
org.slf4j
slf4j-api
+
+ org.jspecify
+ jspecify
+
+
+ com.google.code.gson
+ gson
+
org.junit.jupiter
junit-jupiter-api
test
+
+ org.junit.jupiter
+ junit-jupiter-params
+ test
+
+
+ ch.qos.logback
+ logback-classic
+ test
+
+
+ org.mockito
+ mockito-core
+ test
+
\ No newline at end of file
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..e4db00f2c
--- /dev/null
+++ b/common/src/main/java/org/a2aproject/sdk/common/logging/A2AEventType.java
@@ -0,0 +1,75 @@
+package org.a2aproject.sdk.common.logging;
+
+/**
+ * Enumeration of A2A protocol payload event types for structured logging.
+ *
+ * Each event type corresponds to a category of protocol message that can be
+ * logged with structured fields for observability and debugging.
+ *
+ */
+public enum A2AEventType {
+ /**
+ * Task request: message/send, message/stream, getTask, listTasks, cancelTask, etc.
+ */
+ TASK_REQUEST("task.request"),
+
+ /**
+ * Task state change: TaskStatusUpdateEvent and status transitions.
+ */
+ STATE_CHANGE("task.state_change"),
+
+ /**
+ * Result artifact: TaskArtifactUpdateEvent and artifact payloads.
+ */
+ ARTIFACT("task.artifact"),
+
+ /**
+ * Task result: final Task snapshot returned to client.
+ */
+ TASK_RESULT("task.result"),
+
+ /**
+ * Negotiation payload: extension negotiation, version negotiation, AgentCard capabilities.
+ */
+ NEGOTIATION("negotiation"),
+
+ /**
+ * Authorization decision: allow/deny from TaskAuthorizationProvider.
+ */
+ AUTHORIZATION("authorization"),
+
+ /**
+ * Notification subscription: push config create/get/list/delete, subscribeToTask.
+ */
+ NOTIFICATION_SUBSCRIPTION("notification.subscription"),
+
+ /**
+ * Notification delivery: push webhook dispatch to external URL.
+ */
+ NOTIFICATION_DELIVERY("notification.delivery"),
+
+ /**
+ * Streaming event: per-event SSE streaming payload.
+ */
+ STREAMING_EVENT("streaming.event"),
+
+ /**
+ * Generic payload for any other structured log.
+ */
+ GENERIC("generic");
+
+ 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..d21e69d98
--- /dev/null
+++ b/common/src/main/java/org/a2aproject/sdk/common/logging/A2APayloadLoggingConfig.java
@@ -0,0 +1,174 @@
+package org.a2aproject.sdk.common.logging;
+
+import java.util.Set;
+
+import org.jspecify.annotations.Nullable;
+
+/**
+ * Configuration for structured payload logging.
+ *
+ * Supports both system properties and programmatic configuration via builder.
+ *
+ * System Properties
+ *
+ * - {@code a2a.logging.payload.enabled} (boolean, default false) — enable payload logging
+ * - {@code a2a.logging.payload.max-size} (int, default 4096) — max payload size before truncation
+ * - {@code a2a.logging.payload.masking.enabled} (boolean, default true) — enable sensitive masking
+ * - {@code a2a.logging.payload.masking.strategy} (class name) — custom masking strategy
+ *
+ */
+public final class A2APayloadLoggingConfig {
+
+ public static final String ENABLED_PROPERTY = "a2a.logging.payload.enabled";
+ public static final String MAX_SIZE_PROPERTY = "a2a.logging.payload.max-size";
+ public static final String MASKING_ENABLED_PROPERTY = "a2a.logging.payload.masking.enabled";
+ public static final String MASKING_STRATEGY_PROPERTY = "a2a.logging.payload.masking.strategy";
+ public static final String MASKING_SENSITIVE_KEYS_PROPERTY = "a2a.logging.payload.masking.sensitive-keys";
+ public static final int DEFAULT_MAX_SIZE = 4096;
+
+ private final boolean enabled;
+ private final int maxSize;
+ private final boolean maskingEnabled;
+ private final @Nullable PayloadMaskingStrategy maskingStrategy;
+ private final @Nullable Set sensitiveKeys;
+
+ private A2APayloadLoggingConfig(Builder builder) {
+ this.enabled = builder.enabled;
+ this.maxSize = builder.maxSize;
+ this.maskingEnabled = builder.maskingEnabled;
+ this.maskingStrategy = builder.maskingStrategy;
+ this.sensitiveKeys = builder.sensitiveKeys;
+ }
+
+ public boolean isEnabled() {
+ return enabled;
+ }
+
+ public int getMaxSize() {
+ return maxSize;
+ }
+
+ public boolean isMaskingEnabled() {
+ return maskingEnabled;
+ }
+
+ public @Nullable PayloadMaskingStrategy getMaskingStrategy() {
+ return maskingStrategy;
+ }
+
+ public @Nullable Set getSensitiveKeys() {
+ return sensitiveKeys;
+ }
+
+ /**
+ * Creates config from system properties.
+ */
+ public static A2APayloadLoggingConfig fromSystemProperties() {
+ Builder builder = new Builder();
+ String enabledProp = System.getProperty(ENABLED_PROPERTY);
+ if (enabledProp != null) {
+ builder.enabled(Boolean.parseBoolean(enabledProp));
+ }
+ String maxSizeProp = System.getProperty(MAX_SIZE_PROPERTY);
+ if (maxSizeProp != null) {
+ try {
+ builder.maxSize(Integer.parseInt(maxSizeProp.trim()));
+ } catch (NumberFormatException ignored) {
+ // use default
+ }
+ }
+ String maskingEnabledProp = System.getProperty(MASKING_ENABLED_PROPERTY);
+ if (maskingEnabledProp != null) {
+ builder.maskingEnabled(Boolean.parseBoolean(maskingEnabledProp));
+ }
+ // Strategy loaded separately by logger via ServiceLoader / reflection;
+ // we don't instantiate here to avoid class loading issues in fromSystemProperties
+ String strategyClass = System.getProperty(MASKING_STRATEGY_PROPERTY);
+ if (strategyClass != null && !strategyClass.isBlank()) {
+ try {
+ Class> clazz = Class.forName(strategyClass.trim());
+ Object instance = clazz.getDeclaredConstructor().newInstance();
+ if (instance instanceof PayloadMaskingStrategy pms) {
+ builder.maskingStrategy(pms);
+ }
+ } catch (Exception ignored) {
+ }
+ }
+ String sensitiveKeysProp = System.getProperty(MASKING_SENSITIVE_KEYS_PROPERTY);
+ if (sensitiveKeysProp != null && !sensitiveKeysProp.isBlank()) {
+ Set keys = Set.of(sensitiveKeysProp.split(","));
+ // Trim each key
+ Set trimmed = new java.util.HashSet<>();
+ for (String k : keys) {
+ if (k != null && !k.isBlank()) {
+ trimmed.add(k.trim());
+ }
+ }
+ if (!trimmed.isEmpty()) {
+ builder.sensitiveKeys(trimmed);
+ }
+ }
+ return builder.build();
+ }
+
+ public Builder toBuilder() {
+ Builder b = new Builder();
+ b.enabled = this.enabled;
+ b.maxSize = this.maxSize;
+ b.maskingEnabled = this.maskingEnabled;
+ b.maskingStrategy = this.maskingStrategy;
+ b.sensitiveKeys = this.sensitiveKeys;
+ return b;
+ }
+
+ public static Builder builder() {
+ return new Builder();
+ }
+
+ public static final class Builder {
+ private boolean enabled = false;
+ private int maxSize = DEFAULT_MAX_SIZE;
+ private boolean maskingEnabled = true;
+ private @Nullable PayloadMaskingStrategy maskingStrategy = null;
+ private @Nullable Set sensitiveKeys = null;
+
+ public Builder enabled(boolean enabled) {
+ this.enabled = enabled;
+ return this;
+ }
+
+ public Builder maxSize(int maxSize) {
+ this.maxSize = maxSize;
+ return this;
+ }
+
+ public Builder maskingEnabled(boolean maskingEnabled) {
+ this.maskingEnabled = maskingEnabled;
+ return this;
+ }
+
+ public Builder maskingStrategy(@Nullable PayloadMaskingStrategy maskingStrategy) {
+ this.maskingStrategy = maskingStrategy;
+ return this;
+ }
+
+ public Builder sensitiveKeys(@Nullable Set sensitiveKeys) {
+ this.sensitiveKeys = sensitiveKeys;
+ return this;
+ }
+
+ public A2APayloadLoggingConfig build() {
+ return new A2APayloadLoggingConfig(this);
+ }
+ }
+
+ @Override
+ public String toString() {
+ return "A2APayloadLoggingConfig{"
+ + "enabled=" + enabled
+ + ", maxSize=" + maxSize
+ + ", maskingEnabled=" + maskingEnabled
+ + ", maskingStrategy=" + (maskingStrategy != null ? maskingStrategy.getClass().getName() : "default")
+ + '}';
+ }
+}
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..4433e70fe
--- /dev/null
+++ b/common/src/main/java/org/a2aproject/sdk/common/logging/DefaultPayloadMaskingStrategy.java
@@ -0,0 +1,204 @@
+package org.a2aproject.sdk.common.logging;
+
+import java.util.HashSet;
+import java.util.Set;
+
+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;
+
+/**
+ * Default masking strategy that redacts sensitive fields by key name.
+ *
+ * The strategy parses the payload as JSON and recursively traverses the structure,
+ * masking values whose keys contain sensitive substrings (case-insensitive).
+ * If parsing fails, it falls back to regex-based masking for robustness.
+ *
+ *
+ * Default sensitive keys (case-insensitive substring match):
+ *
+ * - token, credentials, password, secret, apikey, api_key
+ * - authorization, authentication, privatekey, private_key
+ * - accesstoken, access_token, refreshtoken, refresh_token
+ * - clientsecret, client_secret
+ * - url (push notification URLs are sensitive; can be disabled via builder)
+ *
+ *
+ */
+public class DefaultPayloadMaskingStrategy implements PayloadMaskingStrategy {
+
+ private static final String MASKED_VALUE = "***";
+
+ /**
+ * Default sensitive key substrings (lowercase).
+ */
+ public 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 sensitiveKeys;
+
+ /**
+ * Creates with default sensitive keys.
+ */
+ public DefaultPayloadMaskingStrategy() {
+ this.sensitiveKeys = normalizeKeys(DEFAULT_SENSITIVE_KEYS);
+ }
+
+ /**
+ * Creates with custom sensitive keys (case-insensitive substring match).
+ *
+ * @param sensitiveKeys set of sensitive key substrings (case-insensitive)
+ */
+ public DefaultPayloadMaskingStrategy(Set sensitiveKeys) {
+ if (sensitiveKeys == null || sensitiveKeys.isEmpty()) {
+ this.sensitiveKeys = normalizeKeys(DEFAULT_SENSITIVE_KEYS);
+ } else {
+ this.sensitiveKeys = normalizeKeys(sensitiveKeys);
+ }
+ }
+
+ private static Set normalizeKeys(Set keys) {
+ Set normalized = new HashSet<>();
+ for (String k : keys) {
+ if (k != null) {
+ normalized.add(k.toLowerCase());
+ }
+ }
+ return normalized;
+ }
+
+ /**
+ * Returns the configured sensitive keys (lowercase).
+ */
+ public Set getSensitiveKeys() {
+ return Set.copyOf(sensitiveKeys);
+ }
+
+ @Override
+ public String mask(String jsonPayload) {
+ if (jsonPayload == null || jsonPayload.isBlank()) {
+ return jsonPayload;
+ }
+ try {
+ JsonElement element = JsonParser.parseString(jsonPayload);
+ boolean masked = maskJsonElement(element, null);
+ // Return original if nothing was masked to avoid unnecessary serialization cost?
+ // But we still need to return serialized form if masked; to keep behavior consistent,
+ // we serialize only if masking occurred or to normalize. For simplicity, serialize only if masked.
+ if (masked) {
+ return element.toString();
+ }
+ return jsonPayload;
+ } catch (JsonSyntaxException e) {
+ // Fallback to regex masking for non-JSON or malformed payloads
+ return maskWithRegex(jsonPayload);
+ } catch (Exception e) {
+ // On any unexpected error, return original to avoid log loss
+ return jsonPayload;
+ }
+ }
+
+ /**
+ * Recursively masks JsonElement if its key is sensitive.
+ *
+ * @param element current element
+ * @param parentKey the key of this element in its parent object, may be null for root
+ * @return true if any masking occurred in this subtree
+ */
+ private boolean maskJsonElement(JsonElement element, String parentKey) {
+ if (element == null || element.isJsonNull()) {
+ return false;
+ }
+ if (parentKey != null && isSensitiveKey(parentKey)) {
+ // Mask the entire value regardless of type
+ // For objects/arrays, we replace with masked string at parent level (handled below)
+ // But if this is called recursively for child, parent already masked
+ return false; // shouldn't happen because parent masks before recursing
+ }
+
+ boolean masked = false;
+ if (element.isJsonObject()) {
+ JsonObject obj = element.getAsJsonObject();
+ // Collect keys to avoid concurrent modification
+ Set keys = new HashSet<>(obj.keySet());
+ for (String key : keys) {
+ JsonElement child = obj.get(key);
+ if (isSensitiveKey(key)) {
+ // Replace value with masked string
+ obj.addProperty(key, MASKED_VALUE);
+ masked = true;
+ } else if (child != null && (child.isJsonObject() || child.isJsonArray())) {
+ boolean childMasked = maskJsonElement(child, key);
+ if (childMasked) {
+ masked = true;
+ }
+ }
+ }
+ } else if (element.isJsonArray()) {
+ JsonArray array = element.getAsJsonArray();
+ for (int i = 0; i < array.size(); i++) {
+ JsonElement child = array.get(i);
+ if (child != null && (child.isJsonObject() || child.isJsonArray())) {
+ boolean childMasked = maskJsonElement(child, null);
+ if (childMasked) {
+ masked = true;
+ }
+ }
+ }
+ }
+ return masked;
+ }
+
+ private boolean isSensitiveKey(String key) {
+ if (key == null) {
+ return false;
+ }
+ String lower = key.toLowerCase();
+ for (String sensitive : sensitiveKeys) {
+ if (lower.contains(sensitive)) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ /**
+ * Fallback regex masking for non-JSON payloads.
+ * Masks patterns like "key": "value" where key is sensitive.
+ */
+ private String maskWithRegex(String payload) {
+ String result = payload;
+ for (String sensitive : sensitiveKeys) {
+ // Regex: "sensitiveKey" optional spaces : optional spaces "value" or 'value' or bare token
+ // We use case-insensitive matching for key
+ // This is best-effort; avoid catastrophic backtracking
+ String regex = "(\"(?i)" + escapeRegex(sensitive) + "[^\"]*\"\\s*:\\s*\")(\"[^\"]*\"|'[^']*'|[^,\\}\\]]+)";
+ // Replace value part with "***" preserving quotes if present
+ result = result.replaceAll(regex, "$1\"***\"");
+ }
+ return result;
+ }
+
+ private String escapeRegex(String input) {
+ return input.replaceAll("([\\\\.*+?\\[^\\]$(){}=!<>|:\\-])", "\\\\$1");
+ }
+}
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..6c90dd701
--- /dev/null
+++ b/common/src/main/java/org/a2aproject/sdk/common/logging/NoOpPayloadMaskingStrategy.java
@@ -0,0 +1,16 @@
+package org.a2aproject.sdk.common.logging;
+
+/**
+ * No-op masking strategy that returns the payload unchanged.
+ *
+ * Use this when sensitive data masking is disabled or when you want to preserve
+ * original payloads for debugging in secure environments.
+ *
+ */
+public class NoOpPayloadMaskingStrategy implements PayloadMaskingStrategy {
+
+ @Override
+ public String mask(String jsonPayload) {
+ return jsonPayload;
+ }
+}
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..cd5d17013
--- /dev/null
+++ b/common/src/main/java/org/a2aproject/sdk/common/logging/PayloadMaskingStrategy.java
@@ -0,0 +1,25 @@
+package org.a2aproject.sdk.common.logging;
+
+/**
+ * Strategy for masking sensitive data in payload logs.
+ *
+ * Implementations can provide custom logic to redact or replace sensitive fields
+ * (e.g., tokens, credentials, URLs) before payloads are written to logs.
+ *
+ *
+ * Users can configure a custom strategy via the system property
+ * {@code a2a.logging.payload.masking.strategy} (fully qualified class name)
+ * or programmatically via {@link A2APayloadLoggingConfig.Builder}.
+ *
+ */
+public interface PayloadMaskingStrategy {
+
+ /**
+ * Masks sensitive data in the given JSON payload string.
+ *
+ * @param jsonPayload the original JSON payload, may be null
+ * @return the masked payload, or original if masking not applicable. Must not return null
+ * if input is non-null; return original string on any error to avoid log loss.
+ */
+ String mask(String jsonPayload);
+}
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..61d8d4272
--- /dev/null
+++ b/common/src/main/java/org/a2aproject/sdk/common/logging/PayloadTruncator.java
@@ -0,0 +1,47 @@
+package org.a2aproject.sdk.common.logging;
+
+/**
+ * Utility for truncating payloads to a configurable size with a marker.
+ *
+ * Truncation is based on character length (not bytes) for simplicity and
+ * consistency with typical string handling. The truncated marker
+ * {@value #TRUNCATED_MARKER} is appended when truncation occurs.
+ *
+ */
+public final class PayloadTruncator {
+
+ /**
+ * Marker appended when payload is truncated.
+ */
+ public static final String TRUNCATED_MARKER = " [truncated]";
+
+ private PayloadTruncator() {
+ }
+
+ /**
+ * Truncates the payload if it exceeds maxSize.
+ *
+ * @param payload the payload string, may be null
+ * @param maxSize maximum allowed size in characters; if <=0, no truncation
+ * @return truncated payload with marker, or original if within limit; null if input null
+ */
+ public static String truncate(String payload, int maxSize) {
+ if (payload == null) {
+ return null;
+ }
+ if (maxSize <= 0 || payload.length() <= maxSize) {
+ return payload;
+ }
+ return payload.substring(0, maxSize) + TRUNCATED_MARKER;
+ }
+
+ /**
+ * Checks if payload was truncated.
+ *
+ * @param payload the payload to check
+ * @return true if payload ends with truncated marker
+ */
+ public static boolean isTruncated(String payload) {
+ return payload != null && payload.endsWith(TRUNCATED_MARKER);
+ }
+}
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..dc7c677c6
--- /dev/null
+++ b/common/src/main/java/org/a2aproject/sdk/common/logging/StructuredPayloadLogger.java
@@ -0,0 +1,454 @@
+package org.a2aproject.sdk.common.logging;
+
+import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.atomic.AtomicReference;
+
+import org.jspecify.annotations.Nullable;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Central logger for structured A2A payload logging.
+ *
+ * Provides DEBUG-level structured log events for key protocol messages:
+ * task requests, state changes, result artifacts, negotiation payloads,
+ * authorization decisions, notification subscriptions and deliveries.
+ *
+ *
+ * Features:
+ *
+ * - Configurable payload size truncation (default 4KB) with {@code [truncated]} marker
+ * - Sensitive data masking with customizable strategies
+ * - Trace correlation via {@code trace_id} / {@code span_id} from OpenTelemetry or MDC
+ * - Structured fields via SLF4J fluent API (keyValue) when available
+ *
+ *
+ *
+ * Logs are emitted only when:
+ *
+ * - Payload logging is enabled via {@link A2APayloadLoggingConfig} (system property {@code a2a.logging.payload.enabled=true} or programmatic config)
+ * - Underlying SLF4J logger is DEBUG enabled
+ *
+ *
+ */
+public final class StructuredPayloadLogger {
+
+ private static final Logger LOGGER = LoggerFactory.getLogger("org.a2aproject.sdk.payload");
+
+ /**
+ * System property to enable payload logging via logger level without config.
+ * For backward compatibility, also checks {@code org.a2aproject.sdk.payload.enabled}.
+ */
+ private static final String LEGACY_ENABLED_PROP = "org.a2aproject.sdk.payload.enabled";
+
+ private static final AtomicReference CONFIG_REF =
+ new AtomicReference<>(A2APayloadLoggingConfig.fromSystemProperties());
+
+ private static volatile PayloadMaskingStrategy maskingStrategy = createDefaultMaskingStrategy();
+
+ private StructuredPayloadLogger() {
+ }
+
+ /**
+ * Returns current config (volatile read).
+ */
+ public static A2APayloadLoggingConfig getConfig() {
+ return CONFIG_REF.get();
+ }
+
+ /**
+ * Sets config programmatically. Useful for tests or runtime reconfiguration.
+ *
+ * @param config new config
+ */
+ public static void setConfig(A2APayloadLoggingConfig config) {
+ if (config == null) {
+ throw new IllegalArgumentException("config must not be null");
+ }
+ CONFIG_REF.set(config);
+ // Refresh masking strategy if sensitive keys changed
+ if (config.getMaskingStrategy() != null) {
+ maskingStrategy = config.getMaskingStrategy();
+ } else if (config.getSensitiveKeys() != null) {
+ maskingStrategy = new DefaultPayloadMaskingStrategy(config.getSensitiveKeys());
+ } else {
+ maskingStrategy = createDefaultMaskingStrategy();
+ }
+ }
+
+ /**
+ * Resets to system properties (useful for tests).
+ */
+ public static void resetToSystemProperties() {
+ CONFIG_REF.set(A2APayloadLoggingConfig.fromSystemProperties());
+ maskingStrategy = createDefaultMaskingStrategy();
+ // Try to load strategy from system property if present
+ String strategyClass = System.getProperty(A2APayloadLoggingConfig.MASKING_STRATEGY_PROPERTY);
+ if (strategyClass != null && !strategyClass.isBlank()) {
+ try {
+ Class> clazz = Class.forName(strategyClass.trim());
+ Object instance = clazz.getDeclaredConstructor().newInstance();
+ if (instance instanceof PayloadMaskingStrategy pms) {
+ maskingStrategy = pms;
+ }
+ } catch (Exception ignored) {
+ }
+ }
+ }
+
+ /**
+ * Sets custom masking strategy.
+ */
+ public static void setMaskingStrategy(PayloadMaskingStrategy strategy) {
+ if (strategy == null) {
+ throw new IllegalArgumentException("strategy must not be null");
+ }
+ maskingStrategy = strategy;
+ }
+
+ public static PayloadMaskingStrategy getMaskingStrategy() {
+ return maskingStrategy;
+ }
+
+ /**
+ * Returns underlying SLF4J logger for payload events.
+ */
+ public static Logger getLogger() {
+ return LOGGER;
+ }
+
+ /**
+ * Checks if payload logging should be emitted.
+ * True when config enabled (or legacy property) and DEBUG enabled.
+ */
+ public static boolean isEnabled() {
+ A2APayloadLoggingConfig cfg = CONFIG_REF.get();
+ boolean enabled = cfg.isEnabled() || Boolean.getBoolean(LEGACY_ENABLED_PROP);
+ return enabled && LOGGER.isDebugEnabled();
+ }
+
+ /**
+ * Checks if logging would be enabled ignoring logger level (for testing).
+ */
+ public static boolean isConfigEnabled() {
+ A2APayloadLoggingConfig cfg = CONFIG_REF.get();
+ return cfg.isEnabled() || Boolean.getBoolean(LEGACY_ENABLED_PROP);
+ }
+
+ private static PayloadMaskingStrategy createDefaultMaskingStrategy() {
+ A2APayloadLoggingConfig cfg = CONFIG_REF.get();
+ Set sensitiveKeys = cfg.getSensitiveKeys();
+ if (sensitiveKeys != null) {
+ return new DefaultPayloadMaskingStrategy(sensitiveKeys);
+ }
+ // Check system property for sensitive keys override
+ String keysProp = System.getProperty(A2APayloadLoggingConfig.MASKING_SENSITIVE_KEYS_PROPERTY);
+ if (keysProp != null && !keysProp.isBlank()) {
+ Set keys = Set.of(keysProp.split(","));
+ java.util.Set trimmed = new java.util.HashSet<>();
+ for (String k : keys) {
+ if (k != null && !k.isBlank()) {
+ trimmed.add(k.trim());
+ }
+ }
+ if (!trimmed.isEmpty()) {
+ return new DefaultPayloadMaskingStrategy(trimmed);
+ }
+ }
+ return new DefaultPayloadMaskingStrategy();
+ }
+
+ // ==================== Core logging methods ====================
+
+ /**
+ * Logs a structured payload event.
+ *
+ * @param eventType the event type
+ * @param payload the payload as JSON string (or any string); may be null
+ */
+ public static void log(A2AEventType eventType, @Nullable String payload) {
+ log(eventType, payload, null);
+ }
+
+ /**
+ * Logs a structured payload event with additional context fields.
+ *
+ * @param eventType the event type
+ * @param payload the payload as JSON string; may be null
+ * @param context additional context key-values (e.g., taskId, operation), may be null
+ */
+ public static void log(A2AEventType eventType, @Nullable String payload, @Nullable Map context) {
+ if (!isEnabled()) {
+ return;
+ }
+ A2APayloadLoggingConfig cfg = CONFIG_REF.get();
+ String masked = payload;
+ if (cfg.isMaskingEnabled() && payload != null) {
+ try {
+ masked = maskingStrategy.mask(payload);
+ } catch (Exception e) {
+ // Fallback to original payload on masking failure to avoid log loss
+ masked = payload;
+ }
+ }
+ String truncated = PayloadTruncator.truncate(masked, cfg.getMaxSize());
+ String traceId = TraceCorrelation.getTraceId();
+ String spanId = TraceCorrelation.getSpanId();
+
+ // Build structured message
+ StringBuilder msg = new StringBuilder();
+ msg.append("a2a.payload event_type=").append(eventType.value());
+ if (truncated != null) {
+ msg.append(" payload=").append(truncated);
+ }
+ if (context != null && !context.isEmpty()) {
+ for (Map.Entry e : context.entrySet()) {
+ if (e.getKey() != null && e.getValue() != null) {
+ msg.append(" ").append(e.getKey()).append("=").append(e.getValue());
+ }
+ }
+ }
+ if (traceId != null) {
+ msg.append(" trace_id=").append(traceId);
+ }
+ if (spanId != null) {
+ msg.append(" span_id=").append(spanId);
+ }
+
+ // Attempt fluent API for structured logging (SLF4J 2.0+)
+ try {
+ var builder = LOGGER.atDebug();
+ // addKeyValue returns LoggingEventBuilder; we can chain
+ builder = addKeyValueSafe(builder, "a2a.event.type", eventType.value());
+ if (truncated != null) {
+ builder = addKeyValueSafe(builder, "a2a.payload", truncated);
+ builder = addKeyValueSafe(builder, "a2a.payload.size", String.valueOf(payload != null ? payload.length() : 0));
+ if (PayloadTruncator.isTruncated(truncated)) {
+ builder = addKeyValueSafe(builder, "a2a.payload.truncated", "true");
+ }
+ }
+ if (traceId != null) {
+ builder = addKeyValueSafe(builder, "trace_id", traceId);
+ }
+ if (spanId != null) {
+ builder = addKeyValueSafe(builder, "span_id", spanId);
+ }
+ if (context != null) {
+ for (Map.Entry e : context.entrySet()) {
+ if (e.getKey() != null && e.getValue() != null) {
+ builder = addKeyValueSafe(builder, e.getKey(), e.getValue());
+ }
+ }
+ }
+ builder.log(msg.toString());
+ return;
+ } catch (NoSuchMethodError | NoClassDefFoundError | Exception ignored) {
+ // Fallback to classic debug
+ }
+ // Fallback
+ LOGGER.debug(msg.toString());
+ }
+
+ /**
+ * Logs with object payload; attempts JSON serialization via toString() fallback.
+ * Callers that have access to JsonUtil should serialize first and call string overload.
+ *
+ * @param eventType event type
+ * @param payloadObject payload object; will be converted via toString() or JSON if possible
+ * @param context extra context
+ */
+ public static void logObject(A2AEventType eventType, @Nullable Object payloadObject, @Nullable Map context) {
+ if (!isEnabled()) {
+ return;
+ }
+ String payloadStr = null;
+ if (payloadObject != null) {
+ // Try to use JsonUtil if available via reflection to get proper JSON
+ payloadStr = trySerializeToJson(payloadObject);
+ if (payloadStr == null) {
+ payloadStr = payloadObject.toString();
+ }
+ }
+ log(eventType, payloadStr, context);
+ }
+
+ /**
+ * Convenience: logs without context.
+ */
+ public static void logObject(A2AEventType eventType, @Nullable Object payloadObject) {
+ logObject(eventType, payloadObject, null);
+ }
+
+ /**
+ * Attempts to serialize object to JSON using JsonUtil via reflection if available.
+ */
+ private static @Nullable String trySerializeToJson(Object payloadObject) {
+ try {
+ Class> jsonUtilClass = Class.forName("org.a2aproject.sdk.jsonrpc.common.json.JsonUtil");
+ // Try to find toJson method
+ var method = jsonUtilClass.getMethod("toJson", Object.class);
+ Object result = method.invoke(null, payloadObject);
+ if (result instanceof String s) {
+ return s;
+ }
+ } catch (ClassNotFoundException ignored) {
+ // JsonUtil not on classpath, fallback to toString
+ } catch (Exception ignored) {
+ }
+ // Try Gson directly if available
+ try {
+ Class> gsonClass = Class.forName("com.google.gson.Gson");
+ Object gson = gsonClass.getDeclaredConstructor().newInstance();
+ var toJsonMethod = gsonClass.getMethod("toJson", Object.class);
+ Object result = toJsonMethod.invoke(gson, payloadObject);
+ if (result instanceof String s) {
+ return s;
+ }
+ } catch (Exception ignored) {
+ }
+ return null;
+ }
+
+ @SuppressWarnings("unchecked")
+ private static org.slf4j.spi.LoggingEventBuilder addKeyValueSafe(org.slf4j.spi.LoggingEventBuilder builder, String key, String value) {
+ try {
+ return builder.addKeyValue(key, value);
+ } catch (Exception e) {
+ return builder;
+ }
+ }
+
+ // ==================== Convenience methods for specific event types ====================
+
+ /**
+ * Logs a task request payload.
+ *
+ * @param operation operation name (e.g., "message/send")
+ * @param payload JSON payload string
+ * @param taskId task ID if available
+ * @param contextId context ID if available
+ */
+ public static void logTaskRequest(String operation, @Nullable String payload, @Nullable String taskId, @Nullable String contextId) {
+ Map ctx = new java.util.HashMap<>();
+ ctx.put("a2a.operation", operation);
+ if (taskId != null) ctx.put("a2a.task_id", taskId);
+ if (contextId != null) ctx.put("a2a.context_id", contextId);
+ log(A2AEventType.TASK_REQUEST, payload, ctx);
+ }
+
+ /**
+ * Logs a task request with object payload.
+ */
+ public static void logTaskRequest(String operation, @Nullable Object payloadObject, @Nullable String taskId, @Nullable String contextId) {
+ Map ctx = new java.util.HashMap<>();
+ ctx.put("a2a.operation", operation);
+ if (taskId != null) ctx.put("a2a.task_id", taskId);
+ if (contextId != null) ctx.put("a2a.context_id", contextId);
+ logObject(A2AEventType.TASK_REQUEST, payloadObject, ctx);
+ }
+
+ /**
+ * Logs a state change event.
+ *
+ * @param payload JSON payload
+ * @param taskId task ID
+ * @param newState new state name
+ */
+ public static void logStateChange(@Nullable String payload, @Nullable String taskId, @Nullable String newState) {
+ Map ctx = new java.util.HashMap<>();
+ if (taskId != null) ctx.put("a2a.task_id", taskId);
+ if (newState != null) ctx.put("a2a.task_state", newState);
+ log(A2AEventType.STATE_CHANGE, payload, ctx);
+ }
+
+ /**
+ * Logs an artifact event.
+ *
+ * @param payload JSON payload
+ * @param taskId task ID
+ * @param artifactId artifact ID
+ */
+ public static void logArtifact(@Nullable String payload, @Nullable String taskId, @Nullable String artifactId) {
+ Map ctx = new java.util.HashMap<>();
+ if (taskId != null) ctx.put("a2a.task_id", taskId);
+ if (artifactId != null) ctx.put("a2a.artifact_id", artifactId);
+ log(A2AEventType.ARTIFACT, payload, ctx);
+ }
+
+ /**
+ * Logs an authorization decision.
+ *
+ * @param operation operation name
+ * @param taskId task ID
+ * @param allowed true if allowed
+ * @param reason reason or user info
+ */
+ public static void logAuthorization(String operation, @Nullable String taskId, boolean allowed, @Nullable String reason) {
+ Map ctx = new java.util.HashMap<>();
+ ctx.put("a2a.operation", operation);
+ if (taskId != null) ctx.put("a2a.task_id", taskId);
+ ctx.put("a2a.auth.allowed", String.valueOf(allowed));
+ if (reason != null) ctx.put("a2a.auth.reason", reason);
+ // Authorization payload is the decision itself; we log with minimal payload to avoid leaking
+ String payload = "{\"operation\":\"" + operation + "\",\"taskId\":\"" + taskId + "\",\"allowed\":" + allowed + "}";
+ log(A2AEventType.AUTHORIZATION, payload, ctx);
+ }
+
+ /**
+ * Logs a push subscription event (create/get/list/delete/subscribe).
+ *
+ * @param operation operation name
+ * @param payload JSON payload
+ * @param taskId task ID
+ * @param configId config ID
+ */
+ public static void logPushSubscription(String operation, @Nullable String payload, @Nullable String taskId, @Nullable String configId) {
+ Map ctx = new java.util.HashMap<>();
+ ctx.put("a2a.operation", operation);
+ if (taskId != null) ctx.put("a2a.task_id", taskId);
+ if (configId != null) ctx.put("a2a.config_id", configId);
+ log(A2AEventType.NOTIFICATION_SUBSCRIPTION, payload, ctx);
+ }
+
+ /**
+ * Logs a push delivery event.
+ *
+ * @param taskId task ID
+ * @param url push URL (will be masked)
+ * @param payload event payload
+ * @param success delivery success
+ */
+ public static void logPushDelivery(@Nullable String taskId, @Nullable String url, @Nullable String payload, boolean success) {
+ Map ctx = new java.util.HashMap<>();
+ if (taskId != null) ctx.put("a2a.task_id", taskId);
+ if (url != null) ctx.put("a2a.push.url", url);
+ ctx.put("a2a.push.success", String.valueOf(success));
+ log(A2AEventType.NOTIFICATION_DELIVERY, payload, ctx);
+ }
+
+ /**
+ * Logs a negotiation payload (extensions, capabilities).
+ *
+ * @param operation operation name
+ * @param payload JSON payload
+ */
+ public static void logNegotiation(String operation, @Nullable String payload) {
+ Map ctx = Map.of("a2a.operation", operation);
+ log(A2AEventType.NEGOTIATION, payload, ctx);
+ }
+
+ /**
+ * Logs a streaming event.
+ *
+ * @param payload JSON payload
+ * @param taskId task ID
+ * @param eventKind event kind (task, statusUpdate, artifactUpdate, message)
+ */
+ public static void logStreamingEvent(@Nullable String payload, @Nullable String taskId, @Nullable String eventKind) {
+ Map ctx = new java.util.HashMap<>();
+ if (taskId != null) ctx.put("a2a.task_id", taskId);
+ if (eventKind != null) ctx.put("a2a.streaming.kind", eventKind);
+ log(A2AEventType.STREAMING_EVENT, payload, ctx);
+ }
+}
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..564be97f0
--- /dev/null
+++ b/common/src/main/java/org/a2aproject/sdk/common/logging/TraceCorrelation.java
@@ -0,0 +1,169 @@
+package org.a2aproject.sdk.common.logging;
+
+import java.lang.reflect.Method;
+
+import org.jspecify.annotations.Nullable;
+import org.slf4j.MDC;
+
+/**
+ * Utility to correlate log entries with traces via trace_id and span_id.
+ *
+ * Tries to retrieve trace context from:
+ *
+ * - OpenTelemetry API via reflection (if on classpath): {@code io.opentelemetry.api.trace.Span.current().getSpanContext()}
+ * - SLF4J MDC keys: {@code traceId}, {@code trace_id}, {@code traceID}, {@code X-B3-TraceId}
+ * - Similarly for span: {@code spanId}, {@code span_id}
+ *
+ *
+ *
+ * Reflection is used to avoid a hard dependency on OpenTelemetry. If OTel is not
+ * on the classpath, the lookup gracefully falls back to MDC.
+ *
+ */
+public final class TraceCorrelation {
+
+ private static final String INVALID_TRACE_ID = "00000000000000000000000000000000";
+ private static final String INVALID_SPAN_ID = "0000000000000000";
+
+ private TraceCorrelation() {
+ }
+
+ /**
+ * Retrieves current trace ID from OpenTelemetry or MDC.
+ *
+ * @return trace ID or null if not available
+ */
+ public static @Nullable String getTraceId() {
+ String traceId = getTraceIdFromOtel();
+ if (traceId != null && !traceId.isBlank() && !INVALID_TRACE_ID.equals(traceId)) {
+ return traceId;
+ }
+ return getFromMdc("traceId", "trace_id", "traceID", "X-B3-TraceId", "X-B3-TraceID", "trace-id");
+ }
+
+ /**
+ * Retrieves current span ID from OpenTelemetry or MDC.
+ *
+ * @return span ID or null if not available
+ */
+ public static @Nullable String getSpanId() {
+ String spanId = getSpanIdFromOtel();
+ if (spanId != null && !spanId.isBlank() && !INVALID_SPAN_ID.equals(spanId)) {
+ return spanId;
+ }
+ return getFromMdc("spanId", "span_id", "spanID", "X-B3-SpanId", "X-B3-SpanID", "span-id");
+ }
+
+ private static @Nullable String getFromMdc(String... keys) {
+ for (String key : keys) {
+ String value = MDC.get(key);
+ if (value != null && !value.isBlank()) {
+ return value;
+ }
+ }
+ return null;
+ }
+
+ private static @Nullable String getTraceIdFromOtel() {
+ try {
+ Class> spanClass = Class.forName("io.opentelemetry.api.trace.Span");
+ Method currentMethod = spanClass.getMethod("current");
+ Object span = currentMethod.invoke(null);
+ if (span == null) {
+ return null;
+ }
+ Method getSpanContextMethod = spanClass.getMethod("getSpanContext");
+ Object spanContext = getSpanContextMethod.invoke(span);
+ if (spanContext == null) {
+ return null;
+ }
+ Class> spanContextClass = spanContext.getClass();
+ Method isValidMethod = spanContextClass.getMethod("isValid");
+ Boolean isValid = (Boolean) isValidMethod.invoke(spanContext);
+ if (isValid == null || !isValid) {
+ return null;
+ }
+ Method getTraceIdMethod = spanContextClass.getMethod("getTraceId");
+ Object traceId = getTraceIdMethod.invoke(spanContext);
+ if (traceId instanceof String str) {
+ return str.isBlank() ? null : str;
+ }
+ return null;
+ } catch (ClassNotFoundException e) {
+ // OTel not on classpath, ignore
+ return null;
+ } catch (Exception e) {
+ return null;
+ }
+ }
+
+ private static @Nullable String getSpanIdFromOtel() {
+ try {
+ Class> spanClass = Class.forName("io.opentelemetry.api.trace.Span");
+ Method currentMethod = spanClass.getMethod("current");
+ Object span = currentMethod.invoke(null);
+ if (span == null) {
+ return null;
+ }
+ Method getSpanContextMethod = spanClass.getMethod("getSpanContext");
+ Object spanContext = getSpanContextMethod.invoke(span);
+ if (spanContext == null) {
+ return null;
+ }
+ Class> spanContextClass = spanContext.getClass();
+ Method isValidMethod = spanContextClass.getMethod("isValid");
+ Boolean isValid = (Boolean) isValidMethod.invoke(spanContext);
+ if (isValid == null || !isValid) {
+ return null;
+ }
+ Method getSpanIdMethod = spanContextClass.getMethod("getSpanId");
+ Object spanId = getSpanIdMethod.invoke(spanContext);
+ if (spanId instanceof String str) {
+ return str.isBlank() ? null : str;
+ }
+ return null;
+ } catch (ClassNotFoundException e) {
+ return null;
+ } catch (Exception e) {
+ return null;
+ }
+ }
+
+ /**
+ * Injects trace correlation IDs into MDC for log correlation.
+ * Useful for manual propagation when OTel context is not automatically in MDC.
+ *
+ * @param traceId trace ID to set, may be null to remove
+ * @param spanId span ID to set, may be null to remove
+ */
+ public static void setMdc(@Nullable String traceId, @Nullable String spanId) {
+ if (traceId != null) {
+ MDC.put("trace_id", traceId);
+ MDC.put("traceId", traceId);
+ } else {
+ MDC.remove("trace_id");
+ MDC.remove("traceId");
+ }
+ if (spanId != null) {
+ MDC.put("span_id", spanId);
+ MDC.put("spanId", spanId);
+ } else {
+ MDC.remove("span_id");
+ MDC.remove("spanId");
+ }
+ }
+
+ /**
+ * Clears trace correlation from MDC.
+ */
+ public static void clearMdc() {
+ MDC.remove("trace_id");
+ MDC.remove("traceId");
+ MDC.remove("span_id");
+ MDC.remove("spanId");
+ MDC.remove("traceID");
+ MDC.remove("spanID");
+ MDC.remove("X-B3-TraceId");
+ MDC.remove("X-B3-SpanId");
+ }
+}
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..7310a98c4
--- /dev/null
+++ b/common/src/main/java/org/a2aproject/sdk/common/logging/package-info.java
@@ -0,0 +1,9 @@
+/**
+ * Structured payload logging for A2A protocol messages.
+ *
+ * Provides DEBUG-level structured log events for task requests, state changes,
+ * artifacts, negotiation, authorization, and notification events with
+ * configurable truncation and sensitive data masking.
+ *
+ */
+package org.a2aproject.sdk.common.logging;
diff --git a/common/src/test/java/org/a2aproject/sdk/common/logging/A2APayloadLoggingConfigTest.java b/common/src/test/java/org/a2aproject/sdk/common/logging/A2APayloadLoggingConfigTest.java
new file mode 100644
index 000000000..77daa6910
--- /dev/null
+++ b/common/src/test/java/org/a2aproject/sdk/common/logging/A2APayloadLoggingConfigTest.java
@@ -0,0 +1,75 @@
+package org.a2aproject.sdk.common.logging;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.Test;
+
+public class A2APayloadLoggingConfigTest {
+
+ @AfterEach
+ void tearDown() {
+ System.clearProperty(A2APayloadLoggingConfig.ENABLED_PROPERTY);
+ System.clearProperty(A2APayloadLoggingConfig.MAX_SIZE_PROPERTY);
+ System.clearProperty(A2APayloadLoggingConfig.MASKING_ENABLED_PROPERTY);
+ }
+
+ @Test
+ void testDefaultConfig() {
+ A2APayloadLoggingConfig config = A2APayloadLoggingConfig.builder().build();
+ assertFalse(config.isEnabled());
+ assertEquals(4096, config.getMaxSize());
+ assertTrue(config.isMaskingEnabled());
+ }
+
+ @Test
+ void testBuilder() {
+ A2APayloadLoggingConfig config = A2APayloadLoggingConfig.builder()
+ .enabled(true)
+ .maxSize(8192)
+ .maskingEnabled(false)
+ .build();
+ assertTrue(config.isEnabled());
+ assertEquals(8192, config.getMaxSize());
+ assertFalse(config.isMaskingEnabled());
+ }
+
+ @Test
+ void testFromSystemPropertiesEnabled() {
+ System.setProperty(A2APayloadLoggingConfig.ENABLED_PROPERTY, "true");
+ A2APayloadLoggingConfig cfg = A2APayloadLoggingConfig.fromSystemProperties();
+ assertTrue(cfg.isEnabled());
+ }
+
+ @Test
+ void testFromSystemPropertiesMaxSize() {
+ System.setProperty(A2APayloadLoggingConfig.MAX_SIZE_PROPERTY, "2048");
+ A2APayloadLoggingConfig cfg = A2APayloadLoggingConfig.fromSystemProperties();
+ assertEquals(2048, cfg.getMaxSize());
+ }
+
+ @Test
+ void testFromSystemPropertiesMaskingDisabled() {
+ System.setProperty(A2APayloadLoggingConfig.MASKING_ENABLED_PROPERTY, "false");
+ A2APayloadLoggingConfig cfg = A2APayloadLoggingConfig.fromSystemProperties();
+ assertFalse(cfg.isMaskingEnabled());
+ }
+
+ @Test
+ void testFromSystemPropertiesInvalidNumberUsesDefault() {
+ System.setProperty(A2APayloadLoggingConfig.MAX_SIZE_PROPERTY, "invalid");
+ A2APayloadLoggingConfig cfg = A2APayloadLoggingConfig.fromSystemProperties();
+ assertEquals(4096, cfg.getMaxSize());
+ }
+
+ @Test
+ void testToBuilder() {
+ A2APayloadLoggingConfig original = A2APayloadLoggingConfig.builder().enabled(true).maxSize(1234).build();
+ A2APayloadLoggingConfig copy = original.toBuilder().maxSize(5678).build();
+ assertTrue(copy.isEnabled());
+ assertEquals(5678, copy.getMaxSize());
+ assertEquals(1234, original.getMaxSize());
+ }
+}
diff --git a/common/src/test/java/org/a2aproject/sdk/common/logging/DefaultPayloadMaskingStrategyTest.java b/common/src/test/java/org/a2aproject/sdk/common/logging/DefaultPayloadMaskingStrategyTest.java
new file mode 100644
index 000000000..329893993
--- /dev/null
+++ b/common/src/test/java/org/a2aproject/sdk/common/logging/DefaultPayloadMaskingStrategyTest.java
@@ -0,0 +1,114 @@
+package org.a2aproject.sdk.common.logging;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.util.Set;
+
+import org.junit.jupiter.api.Test;
+
+public class DefaultPayloadMaskingStrategyTest {
+
+ @Test
+ public void testMaskSensitiveFields() {
+ DefaultPayloadMaskingStrategy strategy = new DefaultPayloadMaskingStrategy();
+ String json = "{\"token\":\"secret123\",\"user\":\"john\",\"credentials\":\"mycreds\"}";
+ String masked = strategy.mask(json);
+ assertTrue(masked.contains("\"token\":\"***\""));
+ assertTrue(masked.contains("\"credentials\":\"***\""));
+ assertFalse(masked.contains("secret123"));
+ assertFalse(masked.contains("mycreds"));
+ assertTrue(masked.contains("\"user\":\"john\""));
+ }
+
+ @Test
+ public void testMaskNestedObject() {
+ DefaultPayloadMaskingStrategy strategy = new DefaultPayloadMaskingStrategy();
+ String json = "{\"config\":{\"url\":\"https://example.com\",\"token\":\"abc\"},\"data\":\"value\"}";
+ String masked = strategy.mask(json);
+ assertTrue(masked.contains("\"token\":\"***\""));
+ assertTrue(masked.contains("\"url\":\"***\""));
+ assertFalse(masked.contains("abc"));
+ assertFalse(masked.contains("https://example.com"));
+ assertTrue(masked.contains("\"data\":\"value\""));
+ }
+
+ @Test
+ public void testMaskAuthenticationObject() {
+ DefaultPayloadMaskingStrategy strategy = new DefaultPayloadMaskingStrategy();
+ String json = "{\"authentication\":{\"scheme\":\"bearer\",\"credentials\":\"supersecret\"}}";
+ String masked = strategy.mask(json);
+ // authentication key is sensitive, so whole object should be masked
+ assertTrue(masked.contains("\"authentication\":\"***\""));
+ assertFalse(masked.contains("supersecret"));
+ }
+
+ @Test
+ public void testMaskArray() {
+ DefaultPayloadMaskingStrategy strategy = new DefaultPayloadMaskingStrategy();
+ String json = "{\"items\":[{\"token\":\"t1\"},{\"token\":\"t2\"}]}";
+ String masked = strategy.mask(json);
+ assertTrue(masked.contains("\"token\":\"***\""));
+ assertFalse(masked.contains("t1"));
+ assertFalse(masked.contains("t2"));
+ }
+
+ @Test
+ public void testCustomSensitiveKeys() {
+ DefaultPayloadMaskingStrategy strategy = new DefaultPayloadMaskingStrategy(Set.of("myCustomField"));
+ String json = "{\"myCustomField\":\"sensitive\",\"other\":\"value\"}";
+ String masked = strategy.mask(json);
+ assertTrue(masked.contains("\"myCustomField\":\"***\""));
+ assertTrue(masked.contains("\"other\":\"value\""));
+ }
+
+ @Test
+ public void testNoSensitiveData() {
+ DefaultPayloadMaskingStrategy strategy = new DefaultPayloadMaskingStrategy();
+ String json = "{\"name\":\"test\",\"value\":123}";
+ String masked = strategy.mask(json);
+ assertEquals(json, masked);
+ }
+
+ @Test
+ public void testNullInput() {
+ DefaultPayloadMaskingStrategy strategy = new DefaultPayloadMaskingStrategy();
+ assertEquals(null, strategy.mask(null));
+ }
+
+ @Test
+ public void testBlankInput() {
+ DefaultPayloadMaskingStrategy strategy = new DefaultPayloadMaskingStrategy();
+ String blank = " ";
+ assertEquals(blank, strategy.mask(blank));
+ }
+
+ @Test
+ public void testNoOpStrategy() {
+ NoOpPayloadMaskingStrategy strategy = new NoOpPayloadMaskingStrategy();
+ String json = "{\"token\":\"secret\"}";
+ assertEquals(json, strategy.mask(json));
+ }
+
+ @Test
+ public void testPushNotificationConfigMasking() {
+ DefaultPayloadMaskingStrategy strategy = new DefaultPayloadMaskingStrategy();
+ String json = "{\"taskId\":\"123\",\"url\":\"https://webhook.example.com\",\"token\":\"push-token-xyz\",\"authentication\":{\"credentials\":\"auth-creds\"}}";
+ String masked = strategy.mask(json);
+ assertTrue(masked.contains("\"url\":\"***\""));
+ assertTrue(masked.contains("\"token\":\"***\""));
+ // authentication object is sensitive, should be masked wholly
+ assertTrue(masked.contains("\"authentication\":\"***\""));
+ assertFalse(masked.contains("push-token-xyz"));
+ }
+
+ @Test
+ public void testUrlFieldMasking() {
+ DefaultPayloadMaskingStrategy strategy = new DefaultPayloadMaskingStrategy(Set.of("url"));
+ String json = "{\"pushUrl\":\"https://example.com/hook\"}";
+ String masked = strategy.mask(json);
+ // contains 'url' substring, should be masked
+ assertTrue(masked.contains("\"pushUrl\":\"***\""));
+ }
+}
diff --git a/common/src/test/java/org/a2aproject/sdk/common/logging/PayloadTruncatorTest.java b/common/src/test/java/org/a2aproject/sdk/common/logging/PayloadTruncatorTest.java
new file mode 100644
index 000000000..1afae6634
--- /dev/null
+++ b/common/src/test/java/org/a2aproject/sdk/common/logging/PayloadTruncatorTest.java
@@ -0,0 +1,58 @@
+package org.a2aproject.sdk.common.logging;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import org.junit.jupiter.api.Test;
+
+public class PayloadTruncatorTest {
+
+ @Test
+ public void testTruncateWithinLimit() {
+ String payload = "short payload";
+ assertEquals(payload, PayloadTruncator.truncate(payload, 4096));
+ assertFalse(PayloadTruncator.isTruncated(PayloadTruncator.truncate(payload, 4096)));
+ }
+
+ @Test
+ public void testTruncateExceedsLimit() {
+ String payload = "a".repeat(5000);
+ String truncated = PayloadTruncator.truncate(payload, 4096);
+ assertTrue(truncated.endsWith(PayloadTruncator.TRUNCATED_MARKER));
+ assertTrue(truncated.length() == 4096 + PayloadTruncator.TRUNCATED_MARKER.length());
+ assertTrue(PayloadTruncator.isTruncated(truncated));
+ }
+
+ @Test
+ public void testTruncateExactLimit() {
+ String payload = "a".repeat(4096);
+ String result = PayloadTruncator.truncate(payload, 4096);
+ assertEquals(payload, result);
+ assertFalse(PayloadTruncator.isTruncated(result));
+ }
+
+ @Test
+ public void testTruncateNull() {
+ assertNull(PayloadTruncator.truncate(null, 4096));
+ }
+
+ @Test
+ public void testTruncateZeroMaxSize() {
+ String payload = "test";
+ assertEquals(payload, PayloadTruncator.truncate(payload, 0));
+ }
+
+ @Test
+ public void testIsTruncatedNull() {
+ assertFalse(PayloadTruncator.isTruncated(null));
+ }
+
+ @Test
+ public void testDefaultMaxSize() {
+ String payload = "a".repeat(4097);
+ String truncated = PayloadTruncator.truncate(payload, A2APayloadLoggingConfig.DEFAULT_MAX_SIZE);
+ assertTrue(PayloadTruncator.isTruncated(truncated));
+ }
+}
diff --git a/common/src/test/java/org/a2aproject/sdk/common/logging/StructuredPayloadLoggerTest.java b/common/src/test/java/org/a2aproject/sdk/common/logging/StructuredPayloadLoggerTest.java
new file mode 100644
index 000000000..48e3ccab3
--- /dev/null
+++ b/common/src/test/java/org/a2aproject/sdk/common/logging/StructuredPayloadLoggerTest.java
@@ -0,0 +1,216 @@
+package org.a2aproject.sdk.common.logging;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.slf4j.MDC;
+
+import ch.qos.logback.classic.Level;
+import ch.qos.logback.classic.Logger;
+import ch.qos.logback.classic.spi.ILoggingEvent;
+import ch.qos.logback.core.read.ListAppender;
+import org.slf4j.LoggerFactory;
+
+public class StructuredPayloadLoggerTest {
+
+ private ListAppender appender;
+ private Logger payloadLogger;
+ private A2APayloadLoggingConfig originalConfig;
+
+ @BeforeEach
+ void setUp() {
+ originalConfig = StructuredPayloadLogger.getConfig();
+ payloadLogger = (Logger) LoggerFactory.getLogger("org.a2aproject.sdk.payload");
+ payloadLogger.setLevel(Level.DEBUG);
+ appender = new ListAppender<>();
+ appender.start();
+ payloadLogger.addAppender(appender);
+ }
+
+ @AfterEach
+ void tearDown() {
+ payloadLogger.detachAppender(appender);
+ StructuredPayloadLogger.setConfig(originalConfig);
+ MDC.clear();
+ TraceCorrelation.clearMdc();
+ System.clearProperty(A2APayloadLoggingConfig.ENABLED_PROPERTY);
+ System.clearProperty(A2APayloadLoggingConfig.MAX_SIZE_PROPERTY);
+ System.clearProperty(A2APayloadLoggingConfig.MASKING_ENABLED_PROPERTY);
+ }
+
+ @Test
+ void testLogWithTruncation() {
+ A2APayloadLoggingConfig config = A2APayloadLoggingConfig.builder()
+ .enabled(true)
+ .maxSize(10)
+ .maskingEnabled(false)
+ .build();
+ StructuredPayloadLogger.setConfig(config);
+
+ String largePayload = "a".repeat(20);
+ StructuredPayloadLogger.log(A2AEventType.TASK_REQUEST, largePayload);
+
+ assertEquals(1, appender.list.size());
+ ILoggingEvent event = appender.list.get(0);
+ // Check message contains truncated marker
+ assertTrue(event.getFormattedMessage().contains(PayloadTruncator.TRUNCATED_MARKER));
+ // Check structured keyValues
+ boolean hasTruncatedFlag = event.getKeyValuePairs().stream()
+ .anyMatch(kvp -> "a2a.payload.truncated".equals(kvp.key) && "true".equals(kvp.value));
+ assertTrue(hasTruncatedFlag);
+ }
+
+ @Test
+ void testLogWithMasking() {
+ A2APayloadLoggingConfig config = A2APayloadLoggingConfig.builder()
+ .enabled(true)
+ .maxSize(4096)
+ .maskingEnabled(true)
+ .build();
+ StructuredPayloadLogger.setConfig(config);
+
+ String payload = "{\"token\":\"secret\",\"data\":\"value\"}";
+ StructuredPayloadLogger.log(A2AEventType.TASK_REQUEST, payload);
+
+ assertEquals(1, appender.list.size());
+ ILoggingEvent event = appender.list.get(0);
+ // Masked payload should not contain secret
+ assertFalse(event.getFormattedMessage().contains("secret"));
+ assertTrue(event.getFormattedMessage().contains("\"token\":\"***\""));
+ }
+
+ @Test
+ void testLogWithoutMasking() {
+ A2APayloadLoggingConfig config = A2APayloadLoggingConfig.builder()
+ .enabled(true)
+ .maxSize(4096)
+ .maskingEnabled(false)
+ .build();
+ StructuredPayloadLogger.setConfig(config);
+
+ String payload = "{\"token\":\"secret\",\"data\":\"value\"}";
+ StructuredPayloadLogger.log(A2AEventType.TASK_REQUEST, payload);
+
+ assertEquals(1, appender.list.size());
+ ILoggingEvent event = appender.list.get(0);
+ assertTrue(event.getFormattedMessage().contains("secret"));
+ }
+
+ @Test
+ void testLogDisabledWhenConfigDisabled() {
+ A2APayloadLoggingConfig config = A2APayloadLoggingConfig.builder()
+ .enabled(false)
+ .build();
+ StructuredPayloadLogger.setConfig(config);
+ StructuredPayloadLogger.log(A2AEventType.TASK_REQUEST, "{\"data\":\"value\"}");
+ assertEquals(0, appender.list.size());
+ }
+
+ @Test
+ void testLogWithTraceCorrelationViaMDC() {
+ A2APayloadLoggingConfig config = A2APayloadLoggingConfig.builder()
+ .enabled(true)
+ .build();
+ StructuredPayloadLogger.setConfig(config);
+ MDC.put("trace_id", "trace-123");
+ MDC.put("span_id", "span-456");
+
+ StructuredPayloadLogger.log(A2AEventType.TASK_REQUEST, "{\"data\":\"value\"}");
+
+ assertEquals(1, appender.list.size());
+ ILoggingEvent event = appender.list.get(0);
+ String msg = event.getFormattedMessage();
+ assertTrue(msg.contains("trace_id=trace-123"));
+ assertTrue(msg.contains("span_id=span-456"));
+ // Check keyValues
+ boolean hasTrace = event.getKeyValuePairs().stream().anyMatch(kvp -> "trace_id".equals(kvp.key) && "trace-123".equals(kvp.value));
+ assertTrue(hasTrace);
+ }
+
+ @Test
+ void testCustomMaskingStrategy() {
+ PayloadMaskingStrategy custom = payload -> payload.replace("secret", "***CUSTOM***");
+ A2APayloadLoggingConfig config = A2APayloadLoggingConfig.builder()
+ .enabled(true)
+ .maskingEnabled(true)
+ .maskingStrategy(custom)
+ .build();
+ StructuredPayloadLogger.setConfig(config);
+ // Also set via setter to override default creation
+ StructuredPayloadLogger.setMaskingStrategy(custom);
+
+ StructuredPayloadLogger.log(A2AEventType.TASK_REQUEST, "{\"data\":\"secret\"}");
+
+ assertEquals(1, appender.list.size());
+ assertTrue(appender.list.get(0).getFormattedMessage().contains("***CUSTOM***"));
+ assertFalse(appender.list.get(0).getFormattedMessage().contains("secret"));
+ }
+
+ @Test
+ void testConfigFromSystemProperties() {
+ System.setProperty(A2APayloadLoggingConfig.ENABLED_PROPERTY, "true");
+ System.setProperty(A2APayloadLoggingConfig.MAX_SIZE_PROPERTY, "100");
+ A2APayloadLoggingConfig cfg = A2APayloadLoggingConfig.fromSystemProperties();
+ assertTrue(cfg.isEnabled());
+ assertEquals(100, cfg.getMaxSize());
+ }
+
+ @Test
+ void testDefaultMaskingKeysCoverPushNotification() {
+ DefaultPayloadMaskingStrategy strategy = new DefaultPayloadMaskingStrategy();
+ String payload = "{\"url\":\"https://hook.example.com\",\"token\":\"mytoken\",\"taskId\":\"123\"}";
+ String masked = strategy.mask(payload);
+ assertTrue(masked.contains("\"url\":\"***\""));
+ assertTrue(masked.contains("\"token\":\"***\""));
+ assertFalse(masked.contains("mytoken"));
+ assertFalse(masked.contains("https://hook.example.com"));
+ // taskId should not be masked
+ assertTrue(masked.contains("123"));
+ }
+
+ @Test
+ void testLogTaskRequestConvenience() {
+ A2APayloadLoggingConfig config = A2APayloadLoggingConfig.builder().enabled(true).build();
+ StructuredPayloadLogger.setConfig(config);
+
+ StructuredPayloadLogger.logTaskRequest("message/send", "{\"msg\":\"hello\"}", "task-123", "ctx-456");
+ assertEquals(1, appender.list.size());
+ String msg = appender.list.get(0).getFormattedMessage();
+ assertTrue(msg.contains("a2a.task_id=task-123"));
+ assertTrue(msg.contains("a2a.context_id=ctx-456"));
+ assertTrue(msg.contains("a2a.operation=message/send"));
+ }
+
+ @Test
+ void testLogAuthorization() {
+ A2APayloadLoggingConfig config = A2APayloadLoggingConfig.builder().enabled(true).build();
+ StructuredPayloadLogger.setConfig(config);
+
+ StructuredPayloadLogger.logAuthorization("GET_TASK", "task-123", true, "allowed by owner");
+ assertEquals(1, appender.list.size());
+ String msg = appender.list.get(0).getFormattedMessage();
+ assertTrue(msg.contains("a2a.auth.allowed=true"));
+ assertTrue(msg.contains("a2a.task_id=task-123"));
+ }
+
+ @Test
+ void testLogPushDelivery() {
+ A2APayloadLoggingConfig config = A2APayloadLoggingConfig.builder().enabled(true).build();
+ StructuredPayloadLogger.setConfig(config);
+
+ StructuredPayloadLogger.logPushDelivery("task-1", "https://example.com/hook", "{\"event\":\"data\"}", true);
+ assertEquals(1, appender.list.size());
+ String msg = appender.list.get(0).getFormattedMessage();
+ assertTrue(msg.contains("a2a.task_id=task-1"));
+ assertTrue(msg.contains("a2a.push.success=true"));
+ }
+}
diff --git a/common/src/test/java/org/a2aproject/sdk/common/logging/TraceCorrelationTest.java b/common/src/test/java/org/a2aproject/sdk/common/logging/TraceCorrelationTest.java
new file mode 100644
index 000000000..c34836580
--- /dev/null
+++ b/common/src/test/java/org/a2aproject/sdk/common/logging/TraceCorrelationTest.java
@@ -0,0 +1,59 @@
+package org.a2aproject.sdk.common.logging;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
+
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.Test;
+import org.slf4j.MDC;
+
+public class TraceCorrelationTest {
+
+ @AfterEach
+ void tearDown() {
+ MDC.clear();
+ TraceCorrelation.clearMdc();
+ }
+
+ @Test
+ void testGetTraceIdFromMDC() {
+ MDC.put("trace_id", "trace-abc-123");
+ assertEquals("trace-abc-123", TraceCorrelation.getTraceId());
+ }
+
+ @Test
+ void testGetSpanIdFromMDC() {
+ MDC.put("span_id", "span-xyz-789");
+ assertEquals("span-xyz-789", TraceCorrelation.getSpanId());
+ }
+
+ @Test
+ void testGetTraceIdAlternativeKeys() {
+ MDC.put("traceId", "trace-alt");
+ assertEquals("trace-alt", TraceCorrelation.getTraceId());
+ }
+
+ @Test
+ void testNoTraceReturnsNull() {
+ assertNull(TraceCorrelation.getTraceId());
+ assertNull(TraceCorrelation.getSpanId());
+ }
+
+ @Test
+ void testSetAndClearMdc() {
+ TraceCorrelation.setMdc("t-123", "s-456");
+ assertEquals("t-123", MDC.get("trace_id"));
+ assertEquals("s-456", MDC.get("span_id"));
+ TraceCorrelation.clearMdc();
+ assertNull(MDC.get("trace_id"));
+ assertNull(MDC.get("span_id"));
+ }
+
+ @Test
+ void testSetMdcNullRemoves() {
+ MDC.put("trace_id", "existing");
+ TraceCorrelation.setMdc(null, null);
+ assertNull(MDC.get("trace_id"));
+ assertNull(MDC.get("traceId"));
+ }
+}
diff --git a/docs/content/dev/extra/payload-logging.md b/docs/content/dev/extra/payload-logging.md
new file mode 100644
index 000000000..98539e5cd
--- /dev/null
+++ b/docs/content/dev/extra/payload-logging.md
@@ -0,0 +1,237 @@
+---
+title: Structured Payload Logging
+description: Structured payload logging for A2A protocol messages with truncation, masking, and trace correlation.
+layout: page
+---
+
+# Structured Payload Logging
+
+Provides structured, DEBUG-level log events for A2A protocol message payloads with configurable truncation, sensitive-data masking, and trace correlation.
+
+This is the **proper logging mechanism** for payload inspection — unlike the previous opt-in `toString()` on span attributes, it gives you structured fields, safe masking, and trace-linked logs suitable for production.
+
+## Why Not Span Attributes?
+
+The previous mechanism (`-Dorg.a2aproject.sdk.server.extract.request=true` / `...response=true`) puts raw `toString()` into span attributes:
+
+- No truncation → risk of huge spans
+- No masking → sensitive data leaks into tracing backend
+- Not structured → cannot query by fields
+- Not a log → no log-level control, no audit trail
+
+Payload logging fixes all of these: DEBUG-structured logs with masking, truncation, and `trace_id`/`span_id` correlation.
+
+## What Is Logged
+
+When enabled, the SDK emits structured logs (logger `org.a2aproject.sdk.payload` at DEBUG) for:
+
+| Category | Event Type | Logged Entities |
+|----------|------------|-----------------|
+| **Task requests** | `task.request` | `message/send`, `message/stream`, `tasks/get`, `tasks/list`, `tasks/cancel` |
+| **State changes** | `task.state_change` | `TaskStatusUpdateEvent` (e.g., `submitted → working → completed`) |
+| **Result artifacts** | `task.artifact` | `TaskArtifactUpdateEvent` with `Artifact` parts |
+| **Task results** | `task.result` | Final `Task` snapshots |
+| **Negotiation** | `negotiation` | Extension lists, `GetExtendedAgentCard` |
+| **Authorization** | `authorization` | Allow/deny decisions from `TaskAuthorizationProvider` |
+| **Push subscriptions** | `notification.subscription` | Create/get/list/delete push configs, `subscribeToTask` |
+| **Push deliveries** | `notification.delivery` | Webhook dispatch attempts (url, success) |
+| **Streaming events** | `streaming.event` | Per-event SSE payloads |
+
+Each log line includes:
+
+- `a2a.event.type` — one of the values above
+- `a2a.payload` — the JSON payload (masked, truncated)
+- `a2a.payload.size` — original size
+- `a2a.payload.truncated` — `true` if truncated
+- `a2a.task_id`, `a2a.context_id`, `a2a.operation`, etc. — as applicable
+- `trace_id` / `span_id` — from OpenTelemetry `Span.current()` or MDC (`trace_id`/`traceId`, `span_id`/`spanId`)
+
+Example (Logback/Text):
+
+```
+10:15:23.123 [executor-thread-1] DEBUG org.a2aproject.sdk.payload - a2a.payload event_type=task.request payload={"message":{"taskId":"t-123","parts":[{"text":"hello"}]}} a2a.task_id=t-123 a2a.operation=message/send trace_id=abc123 span_id=def456
+```
+
+With a JSON encoder (e.g., logstash-logback), the `keyValue` pairs become JSON fields.
+
+## Enabling
+
+### System Properties (quickest)
+
+```bash
+# Enable payload logging
+-Da2a.logging.payload.enabled=true
+
+# Optional tuning (defaults shown)
+-Da2a.logging.payload.max-size=4096
+-Da2a.logging.payload.masking.enabled=true
+```
+
+And set the logger to DEBUG (Logback `logback.xml` or `application.properties`):
+
+```xml
+
+```
+
+```properties
+quarkus.log.category."org.a2aproject.sdk.payload".level=DEBUG
+```
+
+### Programmatic (Quarkus, tests)
+
+```java
+import org.a2aproject.sdk.common.logging.*;
+
+A2APayloadLoggingConfig cfg = A2APayloadLoggingConfig.builder()
+ .enabled(true)
+ .maxSize(4096)
+ .maskingEnabled(true)
+ .build();
+StructuredPayloadLogger.setConfig(cfg);
+
+// Optional: custom masking strategy
+StructuredPayloadLogger.setMaskingStrategy(new MyMaskingStrategy());
+```
+
+### In `a2a-defaults.properties`
+
+These defaults ship with `server-common`:
+
+```properties
+a2a.logging.payload.enabled=false
+a2a.logging.payload.max-size=4096
+a2a.logging.payload.masking.enabled=true
+```
+
+Override via MicroProfile Config, system properties, or environment variables per your runtime.
+
+## Truncation
+
+- Default limit is **4096 characters** (system property `a2a.logging.payload.max-size`).
+- If the payload exceeds the limit, it is cut and ` [truncated]` is appended.
+- Log field `a2a.payload.truncated=true` signals truncation; `a2a.payload.size` keeps original length.
+
+```json
+{"a2a.payload":"{\"large\": \"...\"} [truncated]","a2a.payload.truncated":"true","a2a.payload.size":"8192"}
+```
+
+## Sensitive Data Masking
+
+By default, fields whose keys contain these substrings (case-insensitive) are masked to `"***"`:
+
+`token`, `credentials`, `password`, `secret`, `apikey`/`api_key`, `privatekey`/`private_key`, `authorization`, `authentication`, `accesstoken`/`access_token`, `refreshtoken`/`refresh_token`, `clientsecret`/`client_secret`, `url`
+
+This covers push notification `url`/`token`, `authentication.credentials`, security schemes, etc.
+
+- Masking is **recursive**: nested objects and arrays are traversed.
+- Non-JSON payloads fall back to regex masking.
+- Malformed payloads return the original string (never drop a log due to masking).
+
+### Customizing Keys
+
+```bash
+-Da2a.logging.payload.masking.sensitive-keys=token,mySecret,internalUrl
+```
+
+Or programmatically:
+
+```java
+DefaultPayloadMaskingStrategy strategy = new DefaultPayloadMaskingStrategy(Set.of("token","mySecret"));
+StructuredPayloadLogger.setMaskingStrategy(strategy);
+```
+
+### Disabling Masking
+
+```bash
+-Da2a.logging.payload.masking.enabled=false
+```
+
+Or `NoOpPayloadMaskingStrategy`:
+
+```java
+StructuredPayloadLogger.setMaskingStrategy(new NoOpPayloadMaskingStrategy());
+```
+
+### Custom Strategy
+
+Implement `PayloadMaskingStrategy`:
+
+```java
+public class MyMasking implements PayloadMaskingStrategy {
+ public String mask(String json) {
+ // your logic, e.g., encrypt, hash, or different placeholder
+ return ...;
+ }
+}
+```
+
+Register via system property:
+
+```bash
+-Da2a.logging.payload.masking.strategy=com.example.MyMasking
+```
+
+Or via `A2APayloadLoggingConfig.builder().maskingStrategy(new MyMasking()).build()`.
+
+## Trace Correlation
+
+Logs auto-include `trace_id`/`span_id` if available:
+
+1. **OpenTelemetry**: reflection on `io.opentelemetry.api.trace.Span.current().getSpanContext()` (no hard dependency — if OTel not on classpath, skipped).
+2. **MDC**: keys `trace_id`/`traceId`/`X-B3-TraceId` and `span_id`/`spanId`/`X-B3-SpanId`.
+
+If you use OpenTelemetry, spans and logs share the same IDs — link them in your backend (Jaeger, Grafana, etc.) for root-cause analysis.
+
+You can also set MDC manually:
+
+```java
+TraceCorrelation.setMdc(traceId, spanId);
+// ... log will pick it up
+TraceCorrelation.clearMdc();
+```
+
+## Architecture
+
+```
+MessageSendParams → DefaultRequestHandler → StructuredPayloadLogger → SLF4J (DEBUG)
+ ├─ JsonUtil.toJson (serialize)
+ ├─ PayloadMaskingStrategy (mask)
+ ├─ PayloadTruncator (4KB → [truncated])
+ └─ TraceCorrelation (trace_id/span_id)
+
+AgentEmitter.updateStatus / addArtifact → ServerPayloadLoggingHelper → StructuredPayloadLogger
+BasePushNotificationSender.dispatch → ServerPayloadLoggingHelper.logPushDelivery
+DefaultRequestHandler.enforce* → ServerPayloadLoggingHelper.logAuthDecision
+JSONRPCTransport / OpenTelemetry decorators → JsonPayloadLoggingHelper → StructuredPayloadLogger
+```
+
+All call sites check `StructuredPayloadLogger.isEnabled()` first (config enabled + logger DEBUG), so overhead is near-zero when disabled.
+
+## Security Best Practices
+
+- **Keep masking ON** in production (default).
+- **Don't enable `extract.request/response` span attributes** in production — they have no masking/truncation.
+- **Use a JSON log encoder** (e.g., logstash-logback-encoder) to get true structured fields; the keyValues are then queryable in Loki/Elasticsearch.
+- **Sampling**: payload logging can be verbose for high-throughput agents; enable selectively or with a short retention for diagnostics.
+
+## Troubleshooting
+
+**No logs appear?**
+
+- Ensure `a2a.logging.payload.enabled=true` and logger level DEBUG.
+- Check `A2APayloadLoggingConfig.fromSystemProperties()` is re-read after you set system properties (or call `StructuredPayloadLogger.resetToSystemProperties()` in tests).
+
+**Payload shows `***` unexpectedly?**
+
+- A field name matched a sensitive key (e.g., `url` masks `pushUrl`). Customize `sensitive-keys` or provide your own strategy.
+
+**Trace IDs missing?**
+
+- Ensure OTel is initialized and the current `Span` is valid (`isValid()`), or populate MDC (`trace_id`, `span_id`).
+- If using Quarkus, the `quarkus-opentelemetry` extension propagates context automatically; check your collector config.
+
+## See Also
+
+- [OpenTelemetry](../extra/opentelemetry) — span-based tracing (payload logging adds log/trace linking)
+- [Configuration](../../configuration) — `a2a.*` properties
+- [Server](../../server) — request handler and task lifecycle
diff --git a/docs/content/dev/extras.md b/docs/content/dev/extras.md
index c8daf3470..6083162c9 100644
--- a/docs/content/dev/extras.md
+++ b/docs/content/dev/extras.md
@@ -47,3 +47,7 @@ Serve multiple tenants from a single A2A server with per-tenant `AgentExecutor`
## [OpenTelemetry](../extra/opentelemetry)
Distributed tracing, metrics, and context propagation for A2A servers and clients using OpenTelemetry. The server module (`a2a-java-sdk-opentelemetry-server`) adds automatic span creation for all protocol methods with context propagation across async boundaries. Client modules (`a2a-java-sdk-opentelemetry-client`, `a2a-java-sdk-opentelemetry-client-propagation`) instrument A2A client operations.
+
+## [Structured Payload Logging](../extra/payload-logging)
+
+DEBUG-level structured logs for every A2A payload (task requests, state changes, artifacts, auth decisions, push notifications) with 4KB truncation, sensitive-field masking, and `trace_id`/`span_id` correlation. Enable via `a2a.logging.payload.enabled=true` and `org.a2aproject.sdk.payload` logger at DEBUG.
diff --git a/extras/opentelemetry/client/src/main/java/org/a2aproject/sdk/extras/opentelemetry/client/OpenTelemetryClientTransport.java b/extras/opentelemetry/client/src/main/java/org/a2aproject/sdk/extras/opentelemetry/client/OpenTelemetryClientTransport.java
index 574253978..18467554a 100644
--- a/extras/opentelemetry/client/src/main/java/org/a2aproject/sdk/extras/opentelemetry/client/OpenTelemetryClientTransport.java
+++ b/extras/opentelemetry/client/src/main/java/org/a2aproject/sdk/extras/opentelemetry/client/OpenTelemetryClientTransport.java
@@ -15,6 +15,7 @@
import org.a2aproject.sdk.client.transport.spi.ClientTransport;
import org.a2aproject.sdk.client.transport.spi.interceptors.ClientCallContext;
+import org.a2aproject.sdk.jsonrpc.common.logging.JsonPayloadLoggingHelper;
import org.a2aproject.sdk.jsonrpc.common.wrappers.ListTasksResult;
import org.a2aproject.sdk.spec.A2AClientException;
import org.a2aproject.sdk.spec.A2AMethods;
@@ -92,12 +93,14 @@ public EventKind sendMessage(MessageSendParams request, @Nullable ClientCallCont
}
Span span = spanBuilder.startSpan();
try (Scope scope = span.makeCurrent()) {
+ JsonPayloadLoggingHelper.logTaskRequest(A2AMethods.SEND_MESSAGE_METHOD, request, request.message() != null ? request.message().taskId() : null, request.message() != null ? request.message().contextId() : null);
EventKind result = delegate.sendMessage(request, clientContext);
if (result != null && extractResponse()) {
span.setAttribute(GENAI_RESPONSE, result.toString());
}
if (result != null) {
span.setStatus(StatusCode.OK);
+ JsonPayloadLoggingHelper.logTaskResult(result, result instanceof Task t ? t.id() : result instanceof org.a2aproject.sdk.spec.Message m ? m.taskId() : null);
}
return result;
} catch (Exception ex) {
@@ -450,7 +453,11 @@ public void accept(StreamingEventKind t) {
spanBuilder.setAttribute("gen_ai.agent.a2a.streaming-event", t.toString());
spanBuilder.addLink(context);
Span span = spanBuilder.startSpan();
- try {
+ try (io.opentelemetry.context.Scope scope = span.makeCurrent()) {
+ String taskId = t instanceof Task task ? task.id() : t instanceof org.a2aproject.sdk.spec.Message m ? m.taskId()
+ : t instanceof org.a2aproject.sdk.spec.TaskStatusUpdateEvent tue ? tue.taskId()
+ : t instanceof org.a2aproject.sdk.spec.TaskArtifactUpdateEvent taue ? taue.taskId() : null;
+ JsonPayloadLoggingHelper.logStreamingEvent(t, taskId, t != null ? t.getClass().getSimpleName() : null);
delegate.accept(t);
span.setStatus(StatusCode.OK);
} finally {
diff --git a/extras/opentelemetry/server/src/main/java/org/a2aproject/sdk/extras/opentelemetry/server/OpenTelemetryRequestHandlerDecorator.java b/extras/opentelemetry/server/src/main/java/org/a2aproject/sdk/extras/opentelemetry/server/OpenTelemetryRequestHandlerDecorator.java
index 2166b06b9..03732c79e 100644
--- a/extras/opentelemetry/server/src/main/java/org/a2aproject/sdk/extras/opentelemetry/server/OpenTelemetryRequestHandlerDecorator.java
+++ b/extras/opentelemetry/server/src/main/java/org/a2aproject/sdk/extras/opentelemetry/server/OpenTelemetryRequestHandlerDecorator.java
@@ -33,6 +33,7 @@
import org.a2aproject.sdk.spec.TaskIdParams;
import org.a2aproject.sdk.spec.TaskPushNotificationConfig;
import org.a2aproject.sdk.spec.TaskQueryParams;
+import org.a2aproject.sdk.server.logging.ServerPayloadLoggingHelper;
import io.opentelemetry.api.trace.Span;
import io.opentelemetry.api.trace.SpanKind;
import io.opentelemetry.api.trace.StatusCode;
@@ -111,6 +112,9 @@ public Task onGetTask(TaskQueryParams params, ServerCallContext context) throws
spanBuilder.setAttribute(GENAI_REQUEST, params.toString());
}
+ // Structured payload logging with trace correlation (DEBUG level, respects config)
+ ServerPayloadLoggingHelper.logTaskRequest(A2AMethods.GET_TASK_METHOD, params, params.id(), null);
+
Span span = spanBuilder.startSpan();
try (Scope scope = span.makeCurrent()) {
@@ -119,6 +123,9 @@ public Task onGetTask(TaskQueryParams params, ServerCallContext context) throws
if (result != null && extractResponse()) {
span.setAttribute(GENAI_RESPONSE, result.toString());
}
+ if (result != null) {
+ ServerPayloadLoggingHelper.logTaskResult(result, result.id());
+ }
span.setStatus(StatusCode.OK);
return result;
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..11a294f58
--- /dev/null
+++ b/jsonrpc-common/src/main/java/org/a2aproject/sdk/jsonrpc/common/logging/JsonPayloadLoggingHelper.java
@@ -0,0 +1,112 @@
+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.jsonrpc.common.json.JsonUtil;
+import org.jspecify.annotations.Nullable;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Helper for payload logging with JSON serialization via JsonUtil.
+ *
+ * Provides convenience methods to log A2A protocol payloads as JSON strings,
+ * handling truncation, masking, and trace correlation via {@link StructuredPayloadLogger}.
+ *
+ */
+public final class JsonPayloadLoggingHelper {
+
+ private static final Logger LOGGER = LoggerFactory.getLogger(JsonPayloadLoggingHelper.class);
+
+ private JsonPayloadLoggingHelper() {
+ }
+
+ /**
+ * Serializes object to JSON safely, fallback to toString.
+ */
+ public static @Nullable String toJsonSafe(@Nullable Object obj) {
+ if (obj == null) {
+ return null;
+ }
+ if (obj instanceof String s) {
+ return s;
+ }
+ try {
+ return JsonUtil.toJson(obj);
+ } catch (Exception e) {
+ LOGGER.debug("Failed to serialize payload for logging: {}", e.getMessage());
+ try {
+ return obj.toString();
+ } catch (Exception ex) {
+ return null;
+ }
+ }
+ }
+
+ public static void logTaskRequest(String operation, @Nullable Object payload, @Nullable String taskId, @Nullable String contextId) {
+ if (!StructuredPayloadLogger.isEnabled()) {
+ return;
+ }
+ String json = toJsonSafe(payload);
+ StructuredPayloadLogger.logTaskRequest(operation, json, taskId, contextId);
+ }
+
+ public static void logTaskResult(@Nullable Object result, @Nullable String taskId) {
+ if (!StructuredPayloadLogger.isEnabled()) {
+ return;
+ }
+ String json = toJsonSafe(result);
+ java.util.Map ctx = new java.util.HashMap<>();
+ if (taskId != null) ctx.put("a2a.task_id", taskId);
+ ctx.put("a2a.operation", "task.result");
+ StructuredPayloadLogger.log(A2AEventType.TASK_RESULT, json, ctx);
+ }
+
+ public static void logStateChange(@Nullable Object event, @Nullable String taskId, @Nullable String newState) {
+ if (!StructuredPayloadLogger.isEnabled()) {
+ return;
+ }
+ String json = toJsonSafe(event);
+ StructuredPayloadLogger.logStateChange(json, taskId, newState);
+ }
+
+ public static void logArtifact(@Nullable Object event, @Nullable String taskId, @Nullable String artifactId) {
+ if (!StructuredPayloadLogger.isEnabled()) {
+ return;
+ }
+ String json = toJsonSafe(event);
+ StructuredPayloadLogger.logArtifact(json, taskId, artifactId);
+ }
+
+ public static void logStreamingEvent(@Nullable Object event, @Nullable String taskId, @Nullable String kind) {
+ if (!StructuredPayloadLogger.isEnabled()) {
+ return;
+ }
+ String json = toJsonSafe(event);
+ StructuredPayloadLogger.logStreamingEvent(json, taskId, kind);
+ }
+
+ public static void logPushSubscription(String operation, @Nullable Object payload, @Nullable String taskId, @Nullable String configId) {
+ if (!StructuredPayloadLogger.isEnabled()) {
+ return;
+ }
+ String json = toJsonSafe(payload);
+ StructuredPayloadLogger.logPushSubscription(operation, json, taskId, configId);
+ }
+
+ public static void logPushDelivery(@Nullable String taskId, @Nullable String url, @Nullable Object event, boolean success) {
+ if (!StructuredPayloadLogger.isEnabled()) {
+ return;
+ }
+ String json = toJsonSafe(event);
+ StructuredPayloadLogger.logPushDelivery(taskId, url, json, success);
+ }
+
+ public static void logNegotiation(String operation, @Nullable Object payload) {
+ if (!StructuredPayloadLogger.isEnabled()) {
+ return;
+ }
+ String json = toJsonSafe(payload);
+ StructuredPayloadLogger.logNegotiation(operation, json);
+ }
+}
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..a64f1ac2e
--- /dev/null
+++ b/jsonrpc-common/src/main/java/org/a2aproject/sdk/jsonrpc/common/logging/package-info.java
@@ -0,0 +1,4 @@
+/**
+ * JSON payload logging helpers delegating to structured logger.
+ */
+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..5d8281e06
--- /dev/null
+++ b/server-common/src/main/java/org/a2aproject/sdk/server/logging/ServerPayloadLoggingHelper.java
@@ -0,0 +1,121 @@
+package org.a2aproject.sdk.server.logging;
+
+import org.a2aproject.sdk.common.logging.A2AEventType;
+import org.a2aproject.sdk.common.logging.StructuredPayloadLogger;
+import org.a2aproject.sdk.jsonrpc.common.json.JsonUtil;
+import org.jspecify.annotations.Nullable;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Helper for server-side structured payload logging.
+ *
+ * Serializes A2A protocol objects to JSON via {@link JsonUtil} and delegates
+ * to {@link StructuredPayloadLogger} with appropriate event types and context.
+ *
+ */
+public final class ServerPayloadLoggingHelper {
+
+ private static final Logger LOGGER = LoggerFactory.getLogger(ServerPayloadLoggingHelper.class);
+
+ private ServerPayloadLoggingHelper() {
+ }
+
+ /**
+ * Safely serializes object to JSON string via JsonUtil.
+ */
+ public static @Nullable String toJsonSafe(@Nullable Object obj) {
+ if (obj == null) {
+ return null;
+ }
+ // Fast path for already-string
+ if (obj instanceof String s) {
+ return s;
+ }
+ try {
+ return JsonUtil.toJson(obj);
+ } catch (Exception e) {
+ // Fallback to toString on serialization failure (should not happen for valid spec types)
+ LOGGER.debug("Failed to serialize payload for logging: {}", e.getMessage());
+ try {
+ return obj.toString();
+ } catch (Exception ex) {
+ return null;
+ }
+ }
+ }
+
+ public static void logTaskRequest(String operation, @Nullable Object payload, @Nullable String taskId, @Nullable String contextId) {
+ if (!StructuredPayloadLogger.isEnabled()) {
+ return;
+ }
+ String json = toJsonSafe(payload);
+ StructuredPayloadLogger.logTaskRequest(operation, json, taskId, contextId);
+ }
+
+ public static void logTaskResult(@Nullable Object result, @Nullable String taskId) {
+ if (!StructuredPayloadLogger.isEnabled()) {
+ return;
+ }
+ String json = toJsonSafe(result);
+ java.util.Map ctx = new java.util.HashMap<>();
+ if (taskId != null) ctx.put("a2a.task_id", taskId);
+ ctx.put("a2a.operation", "task.result");
+ StructuredPayloadLogger.log(A2AEventType.TASK_RESULT, json, ctx);
+ }
+
+ public static void logStateChange(@Nullable Object event, @Nullable String taskId, @Nullable String newState) {
+ if (!StructuredPayloadLogger.isEnabled()) {
+ return;
+ }
+ String json = toJsonSafe(event);
+ StructuredPayloadLogger.logStateChange(json, taskId, newState);
+ }
+
+ public static void logArtifact(@Nullable Object event, @Nullable String taskId, @Nullable String artifactId) {
+ if (!StructuredPayloadLogger.isEnabled()) {
+ return;
+ }
+ String json = toJsonSafe(event);
+ StructuredPayloadLogger.logArtifact(json, taskId, artifactId);
+ }
+
+ public static void logStreamingEvent(@Nullable Object event, @Nullable String taskId, @Nullable String kind) {
+ if (!StructuredPayloadLogger.isEnabled()) {
+ return;
+ }
+ String json = toJsonSafe(event);
+ StructuredPayloadLogger.logStreamingEvent(json, taskId, kind);
+ }
+
+ public static void logAuthDecision(String operation, @Nullable String taskId, boolean allowed, @Nullable String reason) {
+ if (!StructuredPayloadLogger.isEnabled()) {
+ return;
+ }
+ StructuredPayloadLogger.logAuthorization(operation, taskId, allowed, reason);
+ }
+
+ public static void logPushSubscription(String operation, @Nullable Object payload, @Nullable String taskId, @Nullable String configId) {
+ if (!StructuredPayloadLogger.isEnabled()) {
+ return;
+ }
+ String json = toJsonSafe(payload);
+ StructuredPayloadLogger.logPushSubscription(operation, json, taskId, configId);
+ }
+
+ public static void logPushDelivery(@Nullable String taskId, @Nullable String url, @Nullable Object event, boolean success) {
+ if (!StructuredPayloadLogger.isEnabled()) {
+ return;
+ }
+ String json = toJsonSafe(event);
+ StructuredPayloadLogger.logPushDelivery(taskId, url, json, success);
+ }
+
+ public static void logNegotiation(String operation, @Nullable Object payload) {
+ if (!StructuredPayloadLogger.isEnabled()) {
+ return;
+ }
+ String json = toJsonSafe(payload);
+ StructuredPayloadLogger.logNegotiation(operation, json);
+ }
+}
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..e41349dbc
--- /dev/null
+++ b/server-common/src/main/java/org/a2aproject/sdk/server/logging/package-info.java
@@ -0,0 +1,4 @@
+/**
+ * Server-side structured payload logging helpers.
+ */
+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..814af4f9f 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;
@@ -504,6 +505,7 @@ public DefaultRequestHandler build() {
@Override
public Task onGetTask(TaskQueryParams params, ServerCallContext context) throws A2AError {
LOGGER.debug("onGetTask {}", params.id());
+ ServerPayloadLoggingHelper.logTaskRequest("tasks/get", params, params.id(), null);
enforceRead(context, params.id(), TaskOperation.GET_TASK);
Task task = taskStore.get(params.id());
if (task == null) {
@@ -512,6 +514,7 @@ public Task onGetTask(TaskQueryParams params, ServerCallContext context) throws
}
task = limitTaskHistory(task, params.historyLength());
LOGGER.debug("Task found {}", task);
+ ServerPayloadLoggingHelper.logTaskResult(task, task.id());
return task;
}
@@ -542,11 +545,15 @@ 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.logAuthDecision(operation.name(), taskId, false, "no provider, authorization required");
throw new TaskNotFoundError();
}
+ ServerPayloadLoggingHelper.logAuthDecision(operation.name(), taskId, true, "no provider, authorization not required");
return;
}
- if (!authorizationProvider.checkRead(context, taskId, operation)) {
+ boolean allowed = authorizationProvider.checkRead(context, taskId, operation);
+ ServerPayloadLoggingHelper.logAuthDecision(operation.name(), taskId, allowed, allowed ? "checkRead allowed" : "checkRead denied");
+ if (!allowed) {
throw new TaskNotFoundError();
}
}
@@ -554,11 +561,15 @@ private void enforceRead(ServerCallContext context, String taskId, TaskOperation
private void enforceWrite(ServerCallContext context, String taskId, TaskOperation operation) throws A2AError {
if (authorizationProvider == null) {
if (authorizationRequired) {
+ ServerPayloadLoggingHelper.logAuthDecision(operation.name(), taskId, false, "no provider, authorization required");
throw new TaskNotFoundError();
}
+ ServerPayloadLoggingHelper.logAuthDecision(operation.name(), taskId, true, "no provider, authorization not required");
return;
}
- if (!authorizationProvider.checkWrite(context, taskId, operation)) {
+ boolean allowed = authorizationProvider.checkWrite(context, taskId, operation);
+ ServerPayloadLoggingHelper.logAuthDecision(operation.name(), taskId, allowed, allowed ? "checkWrite allowed" : "checkWrite denied");
+ if (!allowed) {
throw new TaskNotFoundError();
}
}
@@ -566,11 +577,15 @@ private void enforceWrite(ServerCallContext context, String taskId, TaskOperatio
private void enforceCreate(ServerCallContext context, TaskOperation operation) throws A2AError {
if (authorizationProvider == null) {
if (authorizationRequired) {
+ ServerPayloadLoggingHelper.logAuthDecision(operation.name(), null, false, "no provider, authorization required");
throw new TaskNotFoundError();
}
+ ServerPayloadLoggingHelper.logAuthDecision(operation.name(), null, true, "no provider, authorization not required");
return;
}
- if (!authorizationProvider.checkCreate(context, operation)) {
+ boolean allowed = authorizationProvider.checkCreate(context, operation);
+ ServerPayloadLoggingHelper.logAuthDecision(operation.name(), null, allowed, allowed ? "checkCreate allowed" : "checkCreate denied");
+ if (!allowed) {
throw new TaskNotFoundError();
}
}
@@ -578,11 +593,15 @@ private void enforceCreate(ServerCallContext context, TaskOperation operation) t
private void enforceListRead(ServerCallContext context) throws A2AError {
if (authorizationProvider == null) {
if (authorizationRequired) {
+ ServerPayloadLoggingHelper.logAuthDecision(TaskOperation.LIST_TASKS.name(), LIST_TASKS_SCOPE_ID, false, "no provider, authorization required");
throw new TaskNotFoundError();
}
+ ServerPayloadLoggingHelper.logAuthDecision(TaskOperation.LIST_TASKS.name(), LIST_TASKS_SCOPE_ID, true, "no provider, authorization not required");
return;
}
- if (!authorizationProvider.checkRead(context, LIST_TASKS_SCOPE_ID, TaskOperation.LIST_TASKS)) {
+ boolean allowed = authorizationProvider.checkRead(context, LIST_TASKS_SCOPE_ID, TaskOperation.LIST_TASKS);
+ ServerPayloadLoggingHelper.logAuthDecision(TaskOperation.LIST_TASKS.name(), LIST_TASKS_SCOPE_ID, allowed, allowed ? "checkRead allowed" : "checkRead denied");
+ if (!allowed) {
throw new TaskNotFoundError();
}
}
@@ -609,6 +628,7 @@ private void recordOwnershipIfNeeded(ServerCallContext context, @Nullable String
@Override
public ListTasksResult onListTasks(ListTasksParams params, ServerCallContext context) throws A2AError {
+ ServerPayloadLoggingHelper.logTaskRequest("tasks/list", params, null, params.contextId());
enforceListRead(context);
LOGGER.debug("onListTasks with contextId={}, status={}, pageSize={}, pageToken={}, statusTimestampAfter={}",
params.contextId(), params.status(), params.pageSize(), params.pageToken(), params.statusTimestampAfter());
@@ -647,11 +667,13 @@ public ListTasksResult onListTasks(ListTasksParams params, ServerCallContext con
}
}
LOGGER.debug("Found {} tasks (total: {})", result.pageSize(), result.totalSize());
+ ServerPayloadLoggingHelper.logTaskResult(result, null);
return result;
}
@Override
public Task onCancelTask(CancelTaskParams params, ServerCallContext context) throws A2AError {
+ ServerPayloadLoggingHelper.logTaskRequest("tasks/cancel", params, params.id(), null);
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.
@@ -753,6 +775,11 @@ private Task doCancelTask(CancelTaskParams params, ServerCallContext context) th
public EventKind onMessageSend(MessageSendParams rawParams, ServerCallContext context) throws A2AError {
MessageSendParams params = normalizeBlankIds(rawParams);
LOGGER.debug("onMessageSend - task: {}; context {}", params.message().taskId(), params.message().contextId());
+ ServerPayloadLoggingHelper.logTaskRequest("message/send", params, params.message().taskId(), params.message().contextId());
+ // Log negotiation payload if extensions present
+ if (params.message().extensions() != null && !params.message().extensions().isEmpty()) {
+ ServerPayloadLoggingHelper.logNegotiation("message/send extensions", params.message().extensions());
+ }
String msgTaskId = params.message().taskId();
if (msgTaskId != null) {
enforceWrite(context, msgTaskId, TaskOperation.MESSAGE_SEND);
@@ -957,6 +984,7 @@ public EventKind onMessageSend(MessageSendParams rawParams, ServerCallContext co
recordOwnershipIfNeeded(context, extractTaskId(kind), TaskOperation.MESSAGE_SEND);
LOGGER.debug("Returning: {}", kind);
+ ServerPayloadLoggingHelper.logTaskResult(kind, extractTaskId(kind));
return kind;
}
@@ -967,6 +995,10 @@ public Flow.Publisher onMessageSendStream(
MessageSendParams params = normalizeBlankIds(rawParams);
LOGGER.debug("onMessageSendStream START - task: {}; context: {}; runningAgents: {}",
params.message().taskId(), params.message().contextId(), runningAgents.size());
+ ServerPayloadLoggingHelper.logTaskRequest("message/stream", params, params.message().taskId(), params.message().contextId());
+ if (params.message().extensions() != null && !params.message().extensions().isEmpty()) {
+ ServerPayloadLoggingHelper.logNegotiation("message/stream extensions", params.message().extensions());
+ }
String msgTaskId = params.message().taskId();
if (msgTaskId != null) {
enforceWrite(context, msgTaskId, TaskOperation.MESSAGE_SEND_STREAM);
@@ -1074,6 +1106,9 @@ public void cancel() {
@Override
public void onNext(StreamingEventKind item) {
LOGGER.debug("onNext: {} for task {}", item.getClass().getSimpleName(), taskId.get());
+ String taskIdForLog = extractTaskId(item);
+ String kind = item != null ? item.getClass().getSimpleName() : "unknown";
+ ServerPayloadLoggingHelper.logStreamingEvent(item, taskIdForLog, kind);
if (item instanceof Task task) {
Integer historyLength = params.configuration() != null ? params.configuration().historyLength() : null;
item = limitTaskHistory(task, historyLength);
@@ -1129,6 +1164,7 @@ public void onComplete() {
@Override
public TaskPushNotificationConfig onCreateTaskPushNotificationConfig(
TaskPushNotificationConfig params, ServerCallContext context) throws A2AError {
+ ServerPayloadLoggingHelper.logPushSubscription("tasks/pushNotificationConfig/create", params, params.taskId(), params.id());
if (pushConfigStore == null || !pushNotificationsEnabled) {
throw new UnsupportedOperationError();
}
@@ -1143,12 +1179,14 @@ public TaskPushNotificationConfig onCreateTaskPushNotificationConfig(
String version = context != null ? context.getRequestedProtocolVersion() : null;
TaskPushNotificationConfig result = pushConfigStore.setInfo(params, version);
+ ServerPayloadLoggingHelper.logPushSubscription("tasks/pushNotificationConfig/create/result", result, result.taskId(), result.id());
return result;
}
@Override
public TaskPushNotificationConfig onGetTaskPushNotificationConfig(
GetTaskPushNotificationConfigParams params, ServerCallContext context) throws A2AError {
+ ServerPayloadLoggingHelper.logPushSubscription("tasks/pushNotificationConfig/get", params, params.taskId(), params.id());
if (pushConfigStore == null) {
throw new UnsupportedOperationError();
}
@@ -1165,7 +1203,9 @@ public TaskPushNotificationConfig onGetTaskPushNotificationConfig(
String requestedConfigId = params.id();
String configId = requestedConfigId == null || requestedConfigId.isEmpty() ? params.taskId() : requestedConfigId;
- return getTaskPushNotificationConfig(listTaskPushNotificationConfigsResult, configId);
+ TaskPushNotificationConfig result = getTaskPushNotificationConfig(listTaskPushNotificationConfigsResult, configId);
+ ServerPayloadLoggingHelper.logPushSubscription("tasks/pushNotificationConfig/get/result", result, result.taskId(), result.id());
+ return result;
}
private TaskPushNotificationConfig getTaskPushNotificationConfig(ListTaskPushNotificationConfigsResult notificationConfigList,
@@ -1181,6 +1221,7 @@ private TaskPushNotificationConfig getTaskPushNotificationConfig(ListTaskPushNot
@Override
public Flow.Publisher onSubscribeToTask(TaskIdParams params, ServerCallContext context) throws A2AError {
LOGGER.debug("onSubscribeToTask - taskId: {}", params.id());
+ ServerPayloadLoggingHelper.logPushSubscription("tasks/subscribe", params, params.id(), null);
enforceRead(context, params.id(), TaskOperation.SUBSCRIBE_TO_TASK);
Task task = taskStore.get(params.id());
if (task == null) {
@@ -1228,6 +1269,7 @@ public Flow.Publisher onSubscribeToTask(TaskIdParams params,
@Override
public ListTaskPushNotificationConfigsResult onListTaskPushNotificationConfigs(
ListTaskPushNotificationConfigsParams params, ServerCallContext context) throws A2AError {
+ ServerPayloadLoggingHelper.logPushSubscription("tasks/pushNotificationConfig/list", params, params.id(), null);
if (pushConfigStore == null) {
throw new UnsupportedOperationError();
}
@@ -1236,12 +1278,15 @@ public ListTaskPushNotificationConfigsResult onListTaskPushNotificationConfigs(
if (task == null) {
throw new TaskNotFoundError();
}
- return pushConfigStore.getInfo(params);
+ ListTaskPushNotificationConfigsResult result = pushConfigStore.getInfo(params);
+ ServerPayloadLoggingHelper.logPushSubscription("tasks/pushNotificationConfig/list/result", result, params.id(), null);
+ return result;
}
@Override
public void onDeleteTaskPushNotificationConfig(
DeleteTaskPushNotificationConfigParams params, ServerCallContext context) throws A2AError {
+ ServerPayloadLoggingHelper.logPushSubscription("tasks/pushNotificationConfig/delete", params, params.taskId(), params.id());
if (pushConfigStore == null) {
throw new UnsupportedOperationError();
}
@@ -1253,6 +1298,7 @@ public void onDeleteTaskPushNotificationConfig(
}
pushConfigStore.deleteInfo(params.taskId(), params.id());
+ ServerPayloadLoggingHelper.logPushSubscription("tasks/pushNotificationConfig/delete/result", null, params.taskId(), params.id());
}
private boolean shouldAddPushInfo(MessageSendParams params) {
@@ -1523,8 +1569,10 @@ AgentExecutor resolveAgentExecutor(@Nullable String tenant) {
@Override
public void authorizeTaskAccess(@Nullable String requestedTaskId, ServerCallContext context, TaskOperation operation) throws A2AError {
if (requestedTaskId == null || requestedTaskId.isEmpty()) {
+ ServerPayloadLoggingHelper.logAuthDecision(operation.name(), requestedTaskId, true, "no taskId, skipping auth");
return;
}
+ ServerPayloadLoggingHelper.logAuthDecision(operation.name(), requestedTaskId, true, "authorizeTaskAccess check");
enforceRead(context, requestedTaskId, operation);
Task task = taskStore.get(requestedTaskId);
if (task == null) {
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..6c44daf71 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, taskId, taskState.name());
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, taskId, artifactId);
eventQueue.enqueueEvent(event);
}
@@ -316,6 +319,7 @@ public boolean tryFail(A2AError error) {
if (!terminalStateReached.compareAndSet(false, true)) {
return false;
}
+ ServerPayloadLoggingHelper.logTaskResult(error, taskId);
enqueueTerminalEvent(error);
// Status transition happens automatically in MainEventBusProcessor
return true;
@@ -333,6 +337,7 @@ public boolean tryFailWithTimeout(A2AError error, long timeout, TimeUnit unit) {
if (!terminalStateReached.compareAndSet(false, true)) {
return false;
}
+ ServerPayloadLoggingHelper.logTaskResult(error, taskId);
enqueueTerminalEvent(error, timeout, unit);
return true;
}
@@ -532,6 +537,7 @@ public void sendMessage(List> parts) {
*/
public void sendMessage(List> parts, @Nullable Map metadata) {
Message message = newAgentMessage(parts, metadata);
+ ServerPayloadLoggingHelper.logTaskRequest("message", message, taskId, contextId);
eventQueue.enqueueEvent(message);
}
@@ -566,6 +572,7 @@ public void sendMessage(Message message) {
LOGGER.error("Message contextId mismatch: expected={}, actual={}", contextId, message.contextId());
throw new IllegalArgumentException("Message contextId does not match the emitter's contextId");
}
+ ServerPayloadLoggingHelper.logTaskRequest("message", message, taskId, contextId);
eventQueue.enqueueEvent(message);
}
@@ -596,6 +603,7 @@ public void sendMessage(Message message) {
* @since 1.0.0
*/
public void addTask(Task task) {
+ ServerPayloadLoggingHelper.logTaskResult(task, task.id());
eventQueue.enqueueEvent(task);
}
@@ -624,6 +632,16 @@ public void addTask(Task task) {
* @since 1.0.0
*/
public void emitEvent(Event event) {
+ // Log based on event type
+ if (event instanceof TaskStatusUpdateEvent tue) {
+ ServerPayloadLoggingHelper.logStateChange(event, tue.taskId(), tue.status() != null ? tue.status().state().name() : null);
+ } else if (event instanceof TaskArtifactUpdateEvent taue) {
+ ServerPayloadLoggingHelper.logArtifact(event, taue.taskId(), taue.artifact() != null ? taue.artifact().artifactId() : null);
+ } else if (event instanceof Task t) {
+ ServerPayloadLoggingHelper.logTaskResult(event, t.id());
+ } else if (event instanceof Message m) {
+ ServerPayloadLoggingHelper.logTaskRequest("message", event, m.taskId(), m.contextId());
+ }
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..a00d0c3a3 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
@@ -19,6 +19,7 @@
import org.a2aproject.sdk.client.http.A2AHttpClient;
import org.a2aproject.sdk.client.http.A2AHttpClientFactory;
import org.a2aproject.sdk.jsonrpc.common.json.JsonUtil;
+import org.a2aproject.sdk.server.logging.ServerPayloadLoggingHelper;
import org.a2aproject.sdk.spec.AuthenticationInfo;
import org.a2aproject.sdk.spec.ListTaskPushNotificationConfigsParams;
import org.a2aproject.sdk.spec.ListTaskPushNotificationConfigsResult;
@@ -179,11 +180,15 @@ private boolean dispatchNotification(StreamingEventKind event,
TaskPushNotificationConfig pushInfo,
Map versionsByConfigId) {
String url = pushInfo.url();
+ String taskId = extractTaskId(event);
+ // Log subscription delivery attempt with payload (will be masked/truncated)
+ ServerPayloadLoggingHelper.logPushDelivery(taskId, url, event, true);
try {
urlValidator.validate(url);
} catch (IllegalArgumentException e) {
LOGGER.warn("Rejecting push notification to {}: {}", url, e.getMessage());
+ ServerPayloadLoggingHelper.logPushDelivery(taskId, url, event, false);
return false;
}
@@ -242,17 +247,22 @@ private boolean dispatchNotification(StreamingEventKind event,
}
}
+ boolean success;
try {
postBuilder
.url(url)
.addHeader(CONTENT_TYPE, APPLICATION_JSON)
.body(body)
.post();
+ success = true;
} catch (IOException | InterruptedException e) {
LOGGER.debug("Error pushing data to " + url + ": {}", e.getMessage(), e);
+ ServerPayloadLoggingHelper.logPushDelivery(taskId, url, event, false);
return false;
}
- return true;
+ // Log successful delivery with body (already masked via helper)
+ ServerPayloadLoggingHelper.logPushDelivery(taskId, url, event, success);
+ return success;
}
/**
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..d2892e8d5 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;
@@ -77,6 +78,7 @@ boolean saveTaskEvent(Task task, boolean isReplicated, @Nullable AtomicReference
&& task.status() != null && task.status().state() != null) {
validateStateTransition(current.status().state(), task.status().state(), task.id());
}
+ ServerPayloadLoggingHelper.logTaskResult(task, task.id());
Task savedTask = saveTask(task, isReplicated);
if (taskSnapshot != null) {
taskSnapshot.set(savedTask);
@@ -117,6 +119,7 @@ boolean saveTaskEvent(TaskStatusUpdateEvent event, boolean isReplicated, @Nullab
}
task = builder.build();
+ ServerPayloadLoggingHelper.logStateChange(event, event.taskId(), newState != null ? newState.name() : null);
Task savedTask = saveTask(task, isReplicated);
if (taskSnapshot != null) {
taskSnapshot.set(savedTask);
@@ -138,6 +141,7 @@ boolean saveTaskEvent(TaskArtifactUpdateEvent event, boolean isReplicated, @Null
throw new IllegalStateException("taskId should not be null after checkIdsAndUpdateIfNecessary");
}
task = appendArtifactToTask(task, event, nonNullTaskId);
+ ServerPayloadLoggingHelper.logArtifact(event, event.taskId(), event.artifact() != null ? event.artifact().artifactId() : null);
Task savedTask = saveTask(task, isReplicated);
if (taskSnapshot != null) {
taskSnapshot.set(savedTask);
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..871a4a91b 100644
--- a/server-common/src/main/resources/META-INF/a2a-defaults.properties
+++ b/server-common/src/main/resources/META-INF/a2a-defaults.properties
@@ -59,6 +59,19 @@ a2a.push-notification.url.allowed-schemes=https
# addresses (loopback, link-local, site-local, cloud metadata). Defaults to
# false to prevent SSRF. Set to true ONLY in trusted development environments.
a2a.push-notification.url.allow-private-network-targets=false
+# Payload Logging - Structured payload logging
+# When true, structured log events are emitted at DEBUG level for task requests,
+# state changes, artifacts, authorization decisions, and notification events.
+# Payloads are masked (sensitive fields replaced with ***) and truncated (default 4KB).
+a2a.logging.payload.enabled=false
+# Maximum payload size in characters before truncation (with [truncated] marker)
+a2a.logging.payload.max-size=4096
+# When true, sensitive fields (token, credentials, etc.) are masked before logging
+a2a.logging.payload.masking.enabled=true
+# Comma-separated list of sensitive key substrings (case-insensitive) to mask
+# Defaults to token, credentials, password, secret, apikey, authorization, etc.
+# a2a.logging.payload.masking.sensitive-keys=token,credentials,password,secret
+
# 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