Repository navigation
Feature request: Allow explicit serialization of custom exception attributes in LambdaJsonEncoder #2728
Description
Use case
Applications often carry structured data on their exceptions, e.g. PaymentDeclinedException with a code and reason. With the Logback LambdaJsonEncoder, logger.error("Payment failed", e) produces an error object with only name, message and stack, so querying or grouping errors by error.code in CloudWatch Logs Insights isn't possible. Today the workaround is to repeat the attributes at every call site as key-value arguments.
Location (aws-powertools/powertools-lambda-java @ default branch 6455f45):
LambdaJsonEncoder.java:136-149—serializeException(ILoggingEvent, …).LambdaJsonEncoder.java:258-266— writes exactly three fields intoerror.
Current code:
private void serializeException(JsonSerializer serializer, String className, String message, String stackTrace) {
Map<Object, Object> map = new HashMap<>();
map.put(EXCEPTION_MSG_ATTR_NAME, message);
map.put(EXCEPTION_CLASS_ATTR_NAME, className);
map.put(EXCEPTION_STACK_ATTR_NAME, stackTrace);
serializer.writeRaw(',');
serializer.writeObjectField(EXCEPTION_ATTR_NAME, map);
}Problem: The error object always contains only name, message and stack. The existing throwableConverter option only changes how the stack text is rendered; it cannot add structured attributes, and serializeException is private, so there is no extension point.
Reproduction (observed with a unit test on main at d767ff5; current main 6455f45 differs only by docs dependency bumps and a powertools-batch MultiThreadMDC change, so the code paths above are unchanged):
class PaymentDeclinedException extends RuntimeException {
private final int code;
private final String reason;
PaymentDeclinedException(String msg, int code, String reason) { super(msg); this.code = code; this.reason = reason; }
public int getCode() { return code; }
public String getReason() { return reason; }
}
logger.error("Payment failed", new PaymentDeclinedException("Declined", 402, "insufficient_funds"));
// output shape (stack abbreviated):
// {"level":"ERROR","message":"Payment failed","error":{"stack":"...","name":"...PaymentDeclinedException","message":"Declined"},...}
// no "code" / "reason"Solution/User Experience
Already implemented in aws-powertools/powertools-lambda-typescript
PR: aws-powertools/powertools-lambda-typescript#3195 — "feat(logger): include enumerable properties in formatted errors" (merged 2024-10-15T09:11:27Z, merge commit 4b80d9ff77a).
The TypeScript Logger now copies an error's own and inherited enumerable properties (other than name, message, stack, cause) into the formatted error, so custom attributes such as code reach the log. JavaScript's enumerable properties have no direct Java equivalent, which is why an explicit provider seems the safer fit here.
How powertools-lambda-typescript implemented it:
--- a/packages/logger/src/formatter/LogFormatter.ts
+++ b/packages/logger/src/formatter/LogFormatter.ts
@@ -104,16 +104,24 @@ abstract class LogFormatter {
* @param error - Error to format
*/
public formatError(error: Error): LogAttributes {
- return {
- name: error.name,
+ const { name, message, stack, cause, ...errorAttributes } = error;
+ const formattedError: LogAttributes = {
+ name,
location: this.getCodeLocation(error.stack),
- message: error.message,
- stack: error.stack,
+ message,
+ stack,
cause:
error.cause instanceof Error
? this.formatError(error.cause)
: error.cause,
};
+ for (const key in error) {
+ if (typeof key === 'string' && !['name', 'message', 'stack', 'cause'].includes(key)) {
+ formattedError[key] = (errorAttributes as Record<string, unknown>)[key];
+ }
+ }
+
+ return formattedError;
}Suggested fix
This adds an opt-in ExceptionAttributesProvider that LambdaJsonEncoder calls with the logged exception; the returned attributes are added to the error object, name/message/stack are written last so they cannot be overridden, and without a provider the output is unchanged:
diff --git a/powertools-logging/powertools-logging-logback/src/main/java/software/amazon/lambda/powertools/logging/logback/ExceptionAttributesProvider.java b/powertools-logging/powertools-logging-logback/src/main/java/software/amazon/lambda/powertools/logging/logback/ExceptionAttributesProvider.java
new file mode 100644
--- /dev/null
+++ b/powertools-logging/powertools-logging-logback/src/main/java/software/amazon/lambda/powertools/logging/logback/ExceptionAttributesProvider.java
@@ -0,0 +1,38 @@
+/*
+ * Copyright 2023 Amazon.com, Inc. or its affiliates.
+ * Licensed under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ * http://www.apache.org/licenses/LICENSE-2.0
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ */
+
+package software.amazon.lambda.powertools.logging.logback;
+
+import java.util.Map;
+
+/**
+ * Provides additional attributes to add to the "error" object logged by the {@link LambdaJsonEncoder}.
+ * The standard attributes (name, message, stack) cannot be overridden.
+ * <br/>
+ * <pre>{@code
+ * <encoder class="software.amazon.lambda.powertools.logging.logback.LambdaJsonEncoder">
+ * <exceptionAttributesProvider class="com.example.PaymentExceptionAttributes"/>
+ * </encoder>
+ * }</pre>
+ */
+public interface ExceptionAttributesProvider {
+
+ /**
+ * Returns the attributes to add to the "error" object for the given exception.
+ *
+ * @param throwable the logged exception
+ * @return extra attributes to add to the "error" object, or an empty map for none
+ */
+ Map<String, Object> attributes(Throwable throwable);
+}
diff --git a/powertools-logging/powertools-logging-logback/src/main/java/software/amazon/lambda/powertools/logging/logback/LambdaJsonEncoder.java b/powertools-logging/powertools-logging-logback/src/main/java/software/amazon/lambda/powertools/logging/logback/LambdaJsonEncoder.java
--- a/powertools-logging/powertools-logging-logback/src/main/java/software/amazon/lambda/powertools/logging/logback/LambdaJsonEncoder.java
+++ b/powertools-logging/powertools-logging-logback/src/main/java/software/amazon/lambda/powertools/logging/logback/LambdaJsonEncoder.java
@@ -51,6 +51,7 @@ public class LambdaJsonEncoder extends EncoderBase<ILoggingEvent> {
protected String timestampFormatTimezoneId = null;
private boolean includeThreadInfo = false;
private boolean includePowertoolsInfo = true;
+ private ExceptionAttributesProvider exceptionAttributesProvider = null;
@Override
public byte[] headerBytes() {
@@ -137,15 +138,15 @@ public class LambdaJsonEncoder extends EncoderBase<ILoggingEvent> {
IThrowableProxy throwableProxy = event.getThrowableProxy();
if (throwableProxy != null) {
if (throwableConverter != null) {
- serializeException(serializer, throwableProxy.getClassName(),
+ serializeException(serializer, throwableProxy, throwableProxy.getClassName(),
throwableProxy.getMessage(), throwableConverter.convert(event));
} else if (throwableProxy instanceof ThrowableProxy) {
Throwable throwable = ((ThrowableProxy) throwableProxy).getThrowable();
- serializeException(serializer, throwable.getClass().getName(), throwable.getMessage(),
- Arrays.toString(throwable.getStackTrace()));
+ serializeException(serializer, throwableProxy, throwable.getClass().getName(),
+ throwable.getMessage(), Arrays.toString(throwable.getStackTrace()));
} else {
- serializeException(serializer, throwableProxy.getClassName(), throwableProxy.getMessage(), throwableProxyConverter.convert(
- event));
+ serializeException(serializer, throwableProxy, throwableProxy.getClassName(),
+ throwableProxy.getMessage(), throwableProxyConverter.convert(event));
}
}
}
@@ -255,8 +256,33 @@ public class LambdaJsonEncoder extends EncoderBase<ILoggingEvent> {
this.includePowertoolsInfo = includePowertoolsInfo;
}
- private void serializeException(JsonSerializer serializer, String className, String message, String stackTrace) {
+ /**
+ * Specify a provider of additional attributes to add to the "error" object
+ * (default is <b>null</b>, no additional attributes):
+ * <br/>
+ * <pre>{@code
+ * <encoder class="software.amazon.lambda.powertools.logging.logback.LambdaJsonEncoder">
+ * <exceptionAttributesProvider class="com.example.PaymentExceptionAttributes"/>
+ * </encoder>
+ * }</pre>
+ *
+ * @param exceptionAttributesProvider provider of the additional exception attributes
+ */
+ public void setExceptionAttributesProvider(ExceptionAttributesProvider exceptionAttributesProvider) {
+ this.exceptionAttributesProvider = exceptionAttributesProvider;
+ }
+
+ private void serializeException(JsonSerializer serializer, IThrowableProxy throwableProxy, String className,
+ String message, String stackTrace) {
Map<Object, Object> map = new HashMap<>();
+ if (exceptionAttributesProvider != null && throwableProxy instanceof ThrowableProxy) {
+ Map<String, Object> attributes =
+ exceptionAttributesProvider.attributes(((ThrowableProxy) throwableProxy).getThrowable());
+ if (attributes != null) {
+ map.putAll(attributes);
+ }
+ }
+ // standard attributes are put last so that they cannot be overridden
map.put(EXCEPTION_MSG_ATTR_NAME, message);
map.put(EXCEPTION_CLASS_ATTR_NAME, className);
map.put(EXCEPTION_STACK_ATTR_NAME, stackTrace);Verified on d767ff5 (OpenJDK 25.0.5, Maven 3.9.16): logging the PaymentDeclinedException above, the error object has no code/reason without this patch; with this patch and a provider for code and reason added to the test setup, the test passes ("code":402, "reason":"insufficient_funds"), as do tests that name/message/stack cannot be overridden and that the output without a provider is unchanged; no other test results change.
I can submit a PR for this.
Alternative solutions
- Add the attributes at each call site with StructuredArguments / fluent key-values: works, but must be repeated everywhere the exception is logged and puts them outside `error`.
- Write a custom encoder subclass: `serializeException` is private, so this means copying the encoder.
- Automatic reflection of getters (closest to TypeScript): convenient but risky for secrets and unexpected getter side effects; not proposed.Acknowledgment
- This feature request meets Powertools for AWS Lambda (Java) Tenets
- Should this be considered in other Powertools for AWS Lambda languages? i.e. Python, TypeScript, and .NET
Acknowledgment
- This feature request meets Powertools for AWS Lambda (Java) Tenets
- Should this be considered in other Powertools for AWS Lambda languages? i.e. Python, TypeScript, and .NET
Related issues checked
Checked: hits were unrelated — log buffering (#2095), TenantId logging (#2471), the v2 logging module (#1435) and dependency bumps.
Activity