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