diff --git a/.changes/next-release/feature-SDKCore-4c730ed.json b/.changes/next-release/feature-SDKCore-4c730ed.json new file mode 100644 index 000000000000..3237b6031148 --- /dev/null +++ b/.changes/next-release/feature-SDKCore-4c730ed.json @@ -0,0 +1,5 @@ +{ + "type": "feature", + "category": "SDK Core", + "description": "Added OpenTelemetryErrorFilteringInterceptor, globally registered by default, to allow suppressing false positive OpenTelemetry span errors for expected exception classes. The exceptions to ignore can be configured programmatically at startup or declaratively via system properties/environment variables." +} diff --git a/core/sdk-core/src/main/java/software/amazon/awssdk/core/SdkSystemSetting.java b/core/sdk-core/src/main/java/software/amazon/awssdk/core/SdkSystemSetting.java index db0ee4d67f8f..ece347cdd58c 100644 --- a/core/sdk-core/src/main/java/software/amazon/awssdk/core/SdkSystemSetting.java +++ b/core/sdk-core/src/main/java/software/amazon/awssdk/core/SdkSystemSetting.java @@ -270,7 +270,13 @@ public enum SdkSystemSetting implements SystemSetting { * defaults including STANDARD as the default retry mode, reduced base backoff delays, differentiated token bucket * costs, and other v2.1 retry specification changes. When {@code false} (the default), the SDK uses v2.0 retry behavior. */ - AWS_NEW_RETRIES_2026("aws.newRetries2026", null); + AWS_NEW_RETRIES_2026("aws.newRetries2026", null), + + /** + * Configure a list of exception classes to ignore when marking OpenTelemetry span status. + * This is a comma-delimited list of fully qualified exception class names. + */ + AWS_OTEL_IGNORED_EXCEPTIONS("aws.otel.ignoredExceptions", null); private final String systemProperty; private final String defaultValue; diff --git a/core/sdk-core/src/main/java/software/amazon/awssdk/core/interceptor/OpenTelemetryErrorFilteringInterceptor.java b/core/sdk-core/src/main/java/software/amazon/awssdk/core/interceptor/OpenTelemetryErrorFilteringInterceptor.java new file mode 100644 index 000000000000..f97c858d40a6 --- /dev/null +++ b/core/sdk-core/src/main/java/software/amazon/awssdk/core/interceptor/OpenTelemetryErrorFilteringInterceptor.java @@ -0,0 +1,175 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. This file 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.awssdk.core.interceptor; + +import java.lang.reflect.Method; +import java.util.Collection; +import java.util.Collections; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import software.amazon.awssdk.annotations.SdkPublicApi; +import software.amazon.awssdk.core.SdkSystemSetting; +import software.amazon.awssdk.utils.Logger; + +/** + * An {@link ExecutionInterceptor} that filters expected exceptions from being + * recorded as errors in OpenTelemetry spans. + *

+ * This interceptor uses reflection to detect if OpenTelemetry is available on the classpath. + * If present, it intercepts exceptions and sets the current OpenTelemetry span's status to + * {@code StatusCode.OK} if the exception is registered to be ignored. This prevents OpenTelemetry's + * default exception handling from flagging these expected exceptions (e.g. DynamoDB Conditional + * Check Failures) as span errors. + */ +@SdkPublicApi +public final class OpenTelemetryErrorFilteringInterceptor implements ExecutionInterceptor { + private static final Logger log = Logger.loggerFor(OpenTelemetryErrorFilteringInterceptor.class); + + private static final Object STATUS_CODE_OK; + private static final Method SET_STATUS_METHOD; + private static final Method CURRENT_SPAN_METHOD; + private static final Set> IGNORED_EXCEPTIONS = ConcurrentHashMap.newKeySet(); + + static { + Object statusCodeOk = null; + Method setStatusMethod = null; + Method currentSpanMethod = null; + + try { + Class spanClass = Class.forName("io.opentelemetry.api.trace.Span"); + Class statusCodeClass = Class.forName("io.opentelemetry.api.trace.StatusCode"); + + // Look up StatusCode.OK + for (Object constant : statusCodeClass.getEnumConstants()) { + if ("OK".equals(((Enum) constant).name())) { + statusCodeOk = constant; + break; + } + } + + // Look up Span.current() + currentSpanMethod = spanClass.getMethod("current"); + + // Look up Span.setStatus(StatusCode) + setStatusMethod = spanClass.getMethod("setStatus", statusCodeClass); + } catch (ClassNotFoundException e) { + log.debug(() -> "OpenTelemetry classes were not found on the classpath. " + + "OpenTelemetryErrorFilteringInterceptor will be a no-op."); + } catch (Exception e) { + log.warn(() -> "Failed to initialize OpenTelemetry reflection. Interceptor will be a no-op.", e); + } + + STATUS_CODE_OK = statusCodeOk; + SET_STATUS_METHOD = setStatusMethod; + CURRENT_SPAN_METHOD = currentSpanMethod; + + initializeFromSystemSettings(); + } + + /** + * Registers exception classes to be ignored. + * If any exception in the cause chain matches or inherits from these classes, the OpenTelemetry span status + * will be set to OK to prevent it from being recorded as an error. + */ + @SafeVarargs + public static void addIgnoredExceptions(Class... exceptions) { + if (exceptions != null) { + Collections.addAll(IGNORED_EXCEPTIONS, exceptions); + } + } + + /** + * Registers exception classes to be ignored. + * If any exception in the cause chain matches or inherits from these classes, the OpenTelemetry span status + * will be set to OK to prevent it from being recorded as an error. + */ + public static void addIgnoredExceptions(Collection> exceptions) { + if (exceptions != null) { + IGNORED_EXCEPTIONS.addAll(exceptions); + } + } + + /** + * Clears all registered ignored exceptions. Intended for testing. + */ + static void clearIgnoredExceptions() { + IGNORED_EXCEPTIONS.clear(); + } + + static void initializeFromSystemSettings() { + try { + SdkSystemSetting.AWS_OTEL_IGNORED_EXCEPTIONS.getStringValue().ifPresent(setting -> { + for (String entry : setting.split(",")) { + String className = entry.trim(); + if (!className.isEmpty()) { + try { + Class clazz = Class.forName(className); + if (Throwable.class.isAssignableFrom(clazz)) { + @SuppressWarnings("unchecked") + Class throwableClazz = (Class) clazz; + IGNORED_EXCEPTIONS.add(throwableClazz); + } else { + log.warn(() -> "Configured class " + className + " is not a subclass of Throwable."); + } + } catch (ClassNotFoundException e) { + log.warn(() -> "Configured exception class not found: " + className, e); + } + } + } + }); + } catch (Exception e) { + log.warn(() -> "Failed to initialize ignored exceptions from system settings.", e); + } + } + + @Override + public Throwable modifyException(Context.FailedExecution context, ExecutionAttributes executionAttributes) { + if (STATUS_CODE_OK == null || SET_STATUS_METHOD == null || CURRENT_SPAN_METHOD == null || IGNORED_EXCEPTIONS.isEmpty()) { + return context.exception(); + } + + try { + Throwable exception = context.exception(); + if (shouldIgnoreException(exception)) { + Object currentSpan = CURRENT_SPAN_METHOD.invoke(null); + if (currentSpan != null) { + SET_STATUS_METHOD.invoke(currentSpan, STATUS_CODE_OK); + } + } + } catch (ReflectiveOperationException | RuntimeException e) { + log.debug(() -> "Failed to set OpenTelemetry span status to OK.", e); + } + + return context.exception(); + } + + private boolean shouldIgnoreException(Throwable exception) { + Throwable current = exception; + while (current != null) { + for (Class ignoredClass : IGNORED_EXCEPTIONS) { + if (ignoredClass.isInstance(current)) { + return true; + } + } + Throwable cause = current.getCause(); + if (cause == current) { + break; + } + current = cause; + } + return false; + } +} diff --git a/core/sdk-core/src/main/resources/software/amazon/awssdk/global/handlers/execution.interceptors b/core/sdk-core/src/main/resources/software/amazon/awssdk/global/handlers/execution.interceptors new file mode 100644 index 000000000000..55a53937ace9 --- /dev/null +++ b/core/sdk-core/src/main/resources/software/amazon/awssdk/global/handlers/execution.interceptors @@ -0,0 +1 @@ +software.amazon.awssdk.core.interceptor.OpenTelemetryErrorFilteringInterceptor diff --git a/core/sdk-core/src/test/java/io/opentelemetry/api/trace/Span.java b/core/sdk-core/src/test/java/io/opentelemetry/api/trace/Span.java new file mode 100644 index 000000000000..fe8ac3b07868 --- /dev/null +++ b/core/sdk-core/src/test/java/io/opentelemetry/api/trace/Span.java @@ -0,0 +1,38 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. This file 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 io.opentelemetry.api.trace; + +public class Span { + private static final ThreadLocal CURRENT = ThreadLocal.withInitial(Span::new); + private static final ThreadLocal LAST_STATUS = new ThreadLocal<>(); + + public static Span current() { + return CURRENT.get(); + } + + public Span setStatus(StatusCode statusCode) { + LAST_STATUS.set(statusCode); + return this; + } + + public static StatusCode getLastStatus() { + return LAST_STATUS.get(); + } + + public static void clear() { + LAST_STATUS.remove(); + } +} diff --git a/core/sdk-core/src/test/java/io/opentelemetry/api/trace/StatusCode.java b/core/sdk-core/src/test/java/io/opentelemetry/api/trace/StatusCode.java new file mode 100644 index 000000000000..2525fded9626 --- /dev/null +++ b/core/sdk-core/src/test/java/io/opentelemetry/api/trace/StatusCode.java @@ -0,0 +1,22 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. This file 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 io.opentelemetry.api.trace; + +public enum StatusCode { + UNSET, + OK, + ERROR +} diff --git a/core/sdk-core/src/test/java/software/amazon/awssdk/core/interceptor/OpenTelemetryErrorFilteringInterceptorTest.java b/core/sdk-core/src/test/java/software/amazon/awssdk/core/interceptor/OpenTelemetryErrorFilteringInterceptorTest.java new file mode 100644 index 000000000000..c2c5f6f12039 --- /dev/null +++ b/core/sdk-core/src/test/java/software/amazon/awssdk/core/interceptor/OpenTelemetryErrorFilteringInterceptorTest.java @@ -0,0 +1,118 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. This file 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.awssdk.core.interceptor; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; + +import io.opentelemetry.api.trace.Span; +import io.opentelemetry.api.trace.StatusCode; +import java.io.IOException; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import software.amazon.awssdk.core.SdkRequest; +import software.amazon.awssdk.core.internal.interceptor.DefaultFailedExecutionContext; + +class OpenTelemetryErrorFilteringInterceptorTest { + private final OpenTelemetryErrorFilteringInterceptor interceptor = new OpenTelemetryErrorFilteringInterceptor(); + private SdkRequest mockRequest; + private InterceptorContext mockInterceptorContext; + + @BeforeEach + void setUp() { + mockRequest = mock(SdkRequest.class); + mockInterceptorContext = InterceptorContext.builder().request(mockRequest).build(); + Span.clear(); + OpenTelemetryErrorFilteringInterceptor.clearIgnoredExceptions(); + System.clearProperty("aws.otel.ignoredExceptions"); + } + + @AfterEach + void tearDown() { + System.clearProperty("aws.otel.ignoredExceptions"); + } + + @Test + void modifyException_ignoredException_setsSpanStatusToOk() { + OpenTelemetryErrorFilteringInterceptor.addIgnoredExceptions(IOException.class); + + Context.FailedExecution context = DefaultFailedExecutionContext.builder() + .interceptorContext(mockInterceptorContext) + .exception(new IOException("Expected error")) + .build(); + + interceptor.modifyException(context, new ExecutionAttributes()); + + assertThat(Span.getLastStatus()).isEqualTo(StatusCode.OK); + } + + @Test + void modifyException_nonIgnoredException_doesNotSetSpanStatus() { + OpenTelemetryErrorFilteringInterceptor.addIgnoredExceptions(IOException.class); + + Context.FailedExecution context = DefaultFailedExecutionContext.builder() + .interceptorContext(mockInterceptorContext) + .exception(new RuntimeException("Unexpected error")) + .build(); + + interceptor.modifyException(context, new ExecutionAttributes()); + + assertThat(Span.getLastStatus()).isNull(); + } + + @Test + void modifyException_ignoredExceptionInChain_setsSpanStatusToOk() { + OpenTelemetryErrorFilteringInterceptor.addIgnoredExceptions(IOException.class); + + RuntimeException wrappingException = new RuntimeException("Wrapped", new IOException("Root cause")); + Context.FailedExecution context = DefaultFailedExecutionContext.builder() + .interceptorContext(mockInterceptorContext) + .exception(wrappingException) + .build(); + + interceptor.modifyException(context, new ExecutionAttributes()); + + assertThat(Span.getLastStatus()).isEqualTo(StatusCode.OK); + } + + @Test + void modifyException_systemPropertyConfiguration_loadsExceptionsAndSetsSpanStatusToOk() { + System.setProperty("aws.otel.ignoredExceptions", "java.io.IOException, java.lang.IllegalArgumentException"); + OpenTelemetryErrorFilteringInterceptor.initializeFromSystemSettings(); + + Context.FailedExecution context = DefaultFailedExecutionContext.builder() + .interceptorContext(mockInterceptorContext) + .exception(new IllegalArgumentException("Invalid argument")) + .build(); + + interceptor.modifyException(context, new ExecutionAttributes()); + + assertThat(Span.getLastStatus()).isEqualTo(StatusCode.OK); + } + + @Test + void modifyException_emptyIgnoredExceptions_doesNotSetSpanStatus() { + Context.FailedExecution context = DefaultFailedExecutionContext.builder() + .interceptorContext(mockInterceptorContext) + .exception(new IOException("Expected error")) + .build(); + + interceptor.modifyException(context, new ExecutionAttributes()); + + assertThat(Span.getLastStatus()).isNull(); + } +}