From 5a24e4b3501fc747dd93e8f4a09296121d354c9c Mon Sep 17 00:00:00 2001 From: Trask Stalnaker Date: Sat, 14 Feb 2026 11:46:59 -0800 Subject: [PATCH 1/2] Stabilize LogRecordBuilder setException --- .../api/logs/LogRecordBuilder.java | 25 +++++++++++++++ .../logs/ExtendedLogRecordBuilder.java | 3 +- .../internal/AbstractDefaultLoggerTest.java | 1 + .../sdk/logs/ExtendedSdkLogRecordBuilder.java | 19 ++--------- .../sdk/logs/SdkLogRecordBuilder.java | 29 +++++++++++++++++ .../sdk/logs/SdkLogRecordBuilderTest.java | 30 +++++++++++++++++ .../sdk/logs/ExtendedLoggerBuilderTest.java | 12 +++---- .../testing/assertj/LogRecordDataAssert.java | 32 +++++++++++++++++++ 8 files changed, 126 insertions(+), 25 deletions(-) diff --git a/api/all/src/main/java/io/opentelemetry/api/logs/LogRecordBuilder.java b/api/all/src/main/java/io/opentelemetry/api/logs/LogRecordBuilder.java index a8ac9d4d203..8ac84253a14 100644 --- a/api/all/src/main/java/io/opentelemetry/api/logs/LogRecordBuilder.java +++ b/api/all/src/main/java/io/opentelemetry/api/logs/LogRecordBuilder.java @@ -14,6 +14,8 @@ import io.opentelemetry.api.common.Attributes; import io.opentelemetry.api.common.Value; import io.opentelemetry.context.Context; +import java.io.PrintWriter; +import java.io.StringWriter; import java.time.Instant; import java.util.concurrent.TimeUnit; import javax.annotation.Nullable; @@ -210,6 +212,29 @@ default LogRecordBuilder setEventName(String eventName) { return this; } + /** + * Set {@code exception.type}, {@code exception.message}, and {@code exception.stacktrace} + * attributes based on the {@code throwable}. + */ + default LogRecordBuilder setException(Throwable throwable) { + if (throwable != null) { + String exceptionType = throwable.getClass().getCanonicalName(); + if (exceptionType != null) { + setAttribute("exception.type", exceptionType); + } + String exceptionMessage = throwable.getMessage(); + if (exceptionMessage != null) { + setAttribute("exception.message", exceptionMessage); + } + StringWriter stringWriter = new StringWriter(); + try (PrintWriter printWriter = new PrintWriter(stringWriter)) { + throwable.printStackTrace(printWriter); + } + setAttribute("exception.stacktrace", stringWriter.toString()); + } + return this; + } + /** Emit the log record. */ void emit(); } diff --git a/api/incubator/src/main/java/io/opentelemetry/api/incubator/logs/ExtendedLogRecordBuilder.java b/api/incubator/src/main/java/io/opentelemetry/api/incubator/logs/ExtendedLogRecordBuilder.java index de25aabe4d4..9d784785366 100644 --- a/api/incubator/src/main/java/io/opentelemetry/api/incubator/logs/ExtendedLogRecordBuilder.java +++ b/api/incubator/src/main/java/io/opentelemetry/api/incubator/logs/ExtendedLogRecordBuilder.java @@ -133,6 +133,7 @@ default ExtendedLogRecordBuilder setAllAttributes(ExtendedAttributes attributes) */ ExtendedLogRecordBuilder setAttribute(ExtendedAttributeKey key, T value); - /** Set standard {@code exception.*} attributes based on the {@code throwable}. */ + /** {@inheritDoc} */ + @Override ExtendedLogRecordBuilder setException(Throwable throwable); } diff --git a/api/testing-internal/src/main/java/io/opentelemetry/api/testing/internal/AbstractDefaultLoggerTest.java b/api/testing-internal/src/main/java/io/opentelemetry/api/testing/internal/AbstractDefaultLoggerTest.java index 0b4e9eb98ed..8cba6548026 100644 --- a/api/testing-internal/src/main/java/io/opentelemetry/api/testing/internal/AbstractDefaultLoggerTest.java +++ b/api/testing-internal/src/main/java/io/opentelemetry/api/testing/internal/AbstractDefaultLoggerTest.java @@ -63,6 +63,7 @@ void buildAndEmit() { .setBody(Value.of("body")) .setAttribute(AttributeKey.stringKey("key1"), "value1") .setAllAttributes(Attributes.builder().put("key2", "value2").build()) + .setException(new RuntimeException("error")) .emit()) .doesNotThrowAnyException(); } diff --git a/sdk/logs/src/main/java/io/opentelemetry/sdk/logs/ExtendedSdkLogRecordBuilder.java b/sdk/logs/src/main/java/io/opentelemetry/sdk/logs/ExtendedSdkLogRecordBuilder.java index 53818c7155c..b45fd0029e9 100644 --- a/sdk/logs/src/main/java/io/opentelemetry/sdk/logs/ExtendedSdkLogRecordBuilder.java +++ b/sdk/logs/src/main/java/io/opentelemetry/sdk/logs/ExtendedSdkLogRecordBuilder.java @@ -39,17 +39,7 @@ public ExtendedSdkLogRecordBuilder setEventName(String eventName) { @Override public ExtendedSdkLogRecordBuilder setException(Throwable throwable) { - if (throwable == null) { - return this; - } - - loggerSharedState - .getExceptionAttributeResolver() - .setExceptionAttributes( - this::setExceptionAttribute, - throwable, - loggerSharedState.getLogLimits().getMaxAttributeValueLength()); - + super.setException(throwable); return this; } @@ -145,11 +135,8 @@ protected ReadWriteLogRecord createLogRecord(Context context, long observedTimes extendedAttributes); } - /** - * Sets an exception-derived attribute only if it hasn't already been set by the user. This - * ensures user-set attributes take precedence over exception-derived attributes. - */ - private void setExceptionAttribute(AttributeKey key, @Nullable T value) { + @Override + protected void setExceptionAttribute(AttributeKey key, @Nullable T value) { if (key == null || key.getKey().isEmpty() || value == null) { return; } diff --git a/sdk/logs/src/main/java/io/opentelemetry/sdk/logs/SdkLogRecordBuilder.java b/sdk/logs/src/main/java/io/opentelemetry/sdk/logs/SdkLogRecordBuilder.java index 4958d957880..a99165d33e5 100644 --- a/sdk/logs/src/main/java/io/opentelemetry/sdk/logs/SdkLogRecordBuilder.java +++ b/sdk/logs/src/main/java/io/opentelemetry/sdk/logs/SdkLogRecordBuilder.java @@ -105,6 +105,22 @@ public SdkLogRecordBuilder setBody(Value value) { return this; } + @Override + public SdkLogRecordBuilder setException(Throwable throwable) { + if (throwable == null) { + return this; + } + + loggerSharedState + .getExceptionAttributeResolver() + .setExceptionAttributes( + this::setExceptionAttribute, + throwable, + loggerSharedState.getLogLimits().getMaxAttributeValueLength()); + + return this; + } + @Override public SdkLogRecordBuilder setAttribute(AttributeKey key, @Nullable T value) { if (key == null || key.getKey().isEmpty() || value == null) { @@ -139,6 +155,19 @@ public void emit() { .onEmit(context, createLogRecord(context, observedTimestampEpochNanos)); } + /** + * Sets an exception-derived attribute only if it hasn't already been set by the user. This + * ensures user-set attributes take precedence over exception-derived attributes. + */ + protected void setExceptionAttribute(AttributeKey key, @Nullable T value) { + if (key == null || key.getKey().isEmpty() || value == null) { + return; + } + if (attributes == null || attributes.get(key) == null) { + setAttribute(key, value); + } + } + protected ReadWriteLogRecord createLogRecord(Context context, long observedTimestampEpochNanos) { return SdkReadWriteLogRecord.create( loggerSharedState.getLogLimits(), diff --git a/sdk/logs/src/test/java/io/opentelemetry/sdk/logs/SdkLogRecordBuilderTest.java b/sdk/logs/src/test/java/io/opentelemetry/sdk/logs/SdkLogRecordBuilderTest.java index bbe9305b0dc..54f2d15199f 100644 --- a/sdk/logs/src/test/java/io/opentelemetry/sdk/logs/SdkLogRecordBuilderTest.java +++ b/sdk/logs/src/test/java/io/opentelemetry/sdk/logs/SdkLogRecordBuilderTest.java @@ -28,6 +28,7 @@ import io.opentelemetry.context.Context; import io.opentelemetry.sdk.common.Clock; import io.opentelemetry.sdk.common.InstrumentationScopeInfo; +import io.opentelemetry.sdk.common.internal.ExceptionAttributeResolver; import io.opentelemetry.sdk.logs.internal.LoggerConfig; import io.opentelemetry.sdk.resources.Resource; import java.time.Instant; @@ -63,6 +64,8 @@ void setup() { when(loggerSharedState.getClock()).thenReturn(clock); when(loggerSharedState.getLoggerInstrumentation()) .thenReturn(new SdkLoggerInstrumentation(MeterProvider::noop)); + when(loggerSharedState.getExceptionAttributeResolver()) + .thenReturn(ExceptionAttributeResolver.getDefault()); SdkLogger logger = new SdkLogger(loggerSharedState, SCOPE_INFO, LoggerConfig.enabled()); builder = new SdkLogRecordBuilder(loggerSharedState, SCOPE_INFO, logger); @@ -166,4 +169,31 @@ void testConvenienceAttributeMethods() { equalTo(booleanKey("bk"), true), equalTo(longKey("ik"), 13L)); } + + @Test + void setException() { + Exception exception = new Exception("error"); + + builder.setException(exception).emit(); + + assertThat(emittedLog.get().toLogRecordData()).hasException(exception); + } + + @Test + void setException_UserAttributesTakePrecedence() { + builder + .setAttribute(stringKey("exception.message"), "custom message") + .setException(new Exception("error")) + .emit(); + + assertThat(emittedLog.get().toLogRecordData()) + .hasAttributesSatisfying( + attributes -> { + assertThat(attributes.get(stringKey("exception.type"))) + .isEqualTo("java.lang.Exception"); + assertThat(attributes.get(stringKey("exception.message"))) + .isEqualTo("custom message"); + assertThat(attributes.get(stringKey("exception.stacktrace"))).isNotNull(); + }); + } } diff --git a/sdk/logs/src/testIncubating/java/io/opentelemetry/sdk/logs/ExtendedLoggerBuilderTest.java b/sdk/logs/src/testIncubating/java/io/opentelemetry/sdk/logs/ExtendedLoggerBuilderTest.java index b7074c9b26b..3c800bf71a9 100644 --- a/sdk/logs/src/testIncubating/java/io/opentelemetry/sdk/logs/ExtendedLoggerBuilderTest.java +++ b/sdk/logs/src/testIncubating/java/io/opentelemetry/sdk/logs/ExtendedLoggerBuilderTest.java @@ -12,7 +12,6 @@ import static io.opentelemetry.semconv.ExceptionAttributes.EXCEPTION_STACKTRACE; import static io.opentelemetry.semconv.ExceptionAttributes.EXCEPTION_TYPE; -import io.opentelemetry.api.incubator.logs.ExtendedLogRecordBuilder; import io.opentelemetry.api.logs.Logger; import io.opentelemetry.sdk.common.internal.ExceptionAttributeResolver; import io.opentelemetry.sdk.logs.export.SimpleLogRecordProcessor; @@ -30,9 +29,7 @@ class ExtendedLoggerBuilderTest { void setException_DefaultResolver() { Logger logger = loggerProviderBuilder.build().get("logger"); - ((ExtendedLogRecordBuilder) logger.logRecordBuilder()) - .setException(new Exception("error")) - .emit(); + logger.logRecordBuilder().setException(new Exception("error")).emit(); assertThat(exporter.getFinishedLogRecordItems()) .satisfiesExactly( @@ -64,9 +61,7 @@ public void setExceptionAttributes( Logger logger = loggerProviderBuilder.build().get("logger"); - ((ExtendedLogRecordBuilder) logger.logRecordBuilder()) - .setException(new Exception("error")) - .emit(); + logger.logRecordBuilder().setException(new Exception("error")).emit(); assertThat(exporter.getFinishedLogRecordItems()) .satisfiesExactly( @@ -81,7 +76,8 @@ public void setExceptionAttributes( void setException_UserAttributesTakePrecedence() { Logger logger = loggerProviderBuilder.build().get("logger"); - ((ExtendedLogRecordBuilder) logger.logRecordBuilder()) + logger + .logRecordBuilder() .setAttribute(EXCEPTION_MESSAGE, "custom message") .setException(new Exception("error")) .emit(); diff --git a/sdk/testing/src/main/java/io/opentelemetry/sdk/testing/assertj/LogRecordDataAssert.java b/sdk/testing/src/main/java/io/opentelemetry/sdk/testing/assertj/LogRecordDataAssert.java index a369a5e5575..9f69b49651e 100644 --- a/sdk/testing/src/main/java/io/opentelemetry/sdk/testing/assertj/LogRecordDataAssert.java +++ b/sdk/testing/src/main/java/io/opentelemetry/sdk/testing/assertj/LogRecordDataAssert.java @@ -37,6 +37,13 @@ */ public final class LogRecordDataAssert extends AbstractAssert { + private static final AttributeKey EXCEPTION_TYPE = + AttributeKey.stringKey("exception.type"); + private static final AttributeKey EXCEPTION_MESSAGE = + AttributeKey.stringKey("exception.message"); + private static final AttributeKey EXCEPTION_STACKTRACE = + AttributeKey.stringKey("exception.stacktrace"); + LogRecordDataAssert(@Nullable LogRecordData actual) { super(actual, LogRecordDataAssert.class); } @@ -350,6 +357,31 @@ public LogRecordDataAssert hasBodyField(AttributeKey key, T value) { return this; } + /** + * Asserts the log has exception attributes for the given {@link Throwable}. The stack trace is + * not matched against. + */ + @SuppressWarnings("NullAway") + public LogRecordDataAssert hasException(Throwable exception) { + isNotNull(); + + assertThat(actual.getAttributes()) + .as("exception.type") + .containsEntry(EXCEPTION_TYPE, exception.getClass().getCanonicalName()); + if (exception.getMessage() != null) { + assertThat(actual.getAttributes()) + .as("exception.message") + .containsEntry(EXCEPTION_MESSAGE, exception.getMessage()); + } + + // Exceptions used in assertions always have a different stack trace, just confirm it was + // recorded. + String stackTrace = actual.getAttributes().get(EXCEPTION_STACKTRACE); + assertThat(stackTrace).as("exception.stacktrace").isNotNull(); + + return this; + } + /** Asserts the log has the given attributes. */ public LogRecordDataAssert hasAttributes(Attributes attributes) { isNotNull(); From 9e245c243e7dd43b3afdce4580b3b0e2ac6d68ca Mon Sep 17 00:00:00 2001 From: Trask Stalnaker Date: Sat, 14 Feb 2026 14:14:31 -0800 Subject: [PATCH 2/2] jApiCmp --- docs/apidiffs/current_vs_latest/opentelemetry-api.txt | 3 +++ docs/apidiffs/current_vs_latest/opentelemetry-sdk-testing.txt | 4 +++- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/docs/apidiffs/current_vs_latest/opentelemetry-api.txt b/docs/apidiffs/current_vs_latest/opentelemetry-api.txt index 08d05547f55..fa63730c831 100644 --- a/docs/apidiffs/current_vs_latest/opentelemetry-api.txt +++ b/docs/apidiffs/current_vs_latest/opentelemetry-api.txt @@ -1,4 +1,7 @@ Comparing source compatibility of opentelemetry-api-1.60.0-SNAPSHOT.jar against opentelemetry-api-1.59.0.jar +*** MODIFIED INTERFACE: PUBLIC ABSTRACT io.opentelemetry.api.logs.LogRecordBuilder (not serializable) + === CLASS FILE FORMAT VERSION: 52.0 <- 52.0 + +++ NEW METHOD: PUBLIC(+) io.opentelemetry.api.logs.LogRecordBuilder setException(java.lang.Throwable) *** MODIFIED INTERFACE: PUBLIC ABSTRACT io.opentelemetry.api.trace.TraceFlags (not serializable) === CLASS FILE FORMAT VERSION: 52.0 <- 52.0 +++ NEW METHOD: PUBLIC(+) STATIC(+) io.opentelemetry.api.trace.TraceFlagsBuilder builder() diff --git a/docs/apidiffs/current_vs_latest/opentelemetry-sdk-testing.txt b/docs/apidiffs/current_vs_latest/opentelemetry-sdk-testing.txt index 5150f6abc29..0b17c7dbffb 100644 --- a/docs/apidiffs/current_vs_latest/opentelemetry-sdk-testing.txt +++ b/docs/apidiffs/current_vs_latest/opentelemetry-sdk-testing.txt @@ -1,2 +1,4 @@ Comparing source compatibility of opentelemetry-sdk-testing-1.60.0-SNAPSHOT.jar against opentelemetry-sdk-testing-1.59.0.jar -No changes. \ No newline at end of file +*** MODIFIED CLASS: PUBLIC FINAL io.opentelemetry.sdk.testing.assertj.LogRecordDataAssert (not serializable) + === CLASS FILE FORMAT VERSION: 52.0 <- 52.0 + +++ NEW METHOD: PUBLIC(+) io.opentelemetry.sdk.testing.assertj.LogRecordDataAssert hasException(java.lang.Throwable)