diff --git a/awsagentprovider/build.gradle.kts b/awsagentprovider/build.gradle.kts index 7e9211052e..6b9b75e3d5 100644 --- a/awsagentprovider/build.gradle.kts +++ b/awsagentprovider/build.gradle.kts @@ -41,9 +41,12 @@ dependencies { // Import AWS SDK v1 core for ARN parsing utilities implementation("com.amazonaws:aws-java-sdk-core:1.12.773") // Export configuration - compileOnly("io.opentelemetry:opentelemetry-exporter-otlp") + implementation("io.opentelemetry:opentelemetry-exporter-otlp") // For Udp emitter compileOnly("io.opentelemetry:opentelemetry-exporter-otlp-common") + // For HTTP SigV4 emitter + implementation("software.amazon.awssdk:auth:2.30.14") + implementation("software.amazon.awssdk:http-auth-aws:2.30.14") testImplementation("io.opentelemetry:opentelemetry-sdk-extension-autoconfigure") testImplementation("io.opentelemetry:opentelemetry-sdk-testing") diff --git a/awsagentprovider/src/main/java/software/amazon/opentelemetry/javaagent/providers/AwsApplicationSignalsCustomizerProvider.java b/awsagentprovider/src/main/java/software/amazon/opentelemetry/javaagent/providers/AwsApplicationSignalsCustomizerProvider.java index b3d04a7a8c..9f023c119f 100644 --- a/awsagentprovider/src/main/java/software/amazon/opentelemetry/javaagent/providers/AwsApplicationSignalsCustomizerProvider.java +++ b/awsagentprovider/src/main/java/software/amazon/opentelemetry/javaagent/providers/AwsApplicationSignalsCustomizerProvider.java @@ -51,6 +51,7 @@ import java.util.Set; import java.util.logging.Level; import java.util.logging.Logger; +import java.util.regex.Pattern; /** * This customizer performs the following customizations: @@ -70,6 +71,8 @@ public class AwsApplicationSignalsCustomizerProvider implements AutoConfigurationCustomizerProvider { static final String AWS_LAMBDA_FUNCTION_NAME_CONFIG = "AWS_LAMBDA_FUNCTION_NAME"; + private static final String XRAY_OTLP_ENDPOINT_PATTERN = + "^https://xray\\.([a-z0-9-]+)\\.amazonaws\\.com/v1/traces$"; private static final Duration DEFAULT_METRIC_EXPORT_INTERVAL = Duration.ofMinutes(1); private static final Logger logger = @@ -121,6 +124,16 @@ static boolean isLambdaEnvironment() { return System.getenv(AWS_LAMBDA_FUNCTION_NAME_CONFIG) != null; } + static boolean isXrayOtlpEndpoint(String otlpEndpoint) { + if (otlpEndpoint == null) { + return false; + } + + return Pattern.compile(XRAY_OTLP_ENDPOINT_PATTERN) + .matcher(otlpEndpoint.toLowerCase()) + .matches(); + } + private boolean isApplicationSignalsEnabled(ConfigProperties configProps) { return configProps.getBoolean( APPLICATION_SIGNALS_ENABLED_CONFIG, @@ -221,6 +234,10 @@ private SdkTracerProviderBuilder customizeTracerProviderBuilder( return tracerProviderBuilder; } + if (isXrayOtlpEndpoint(System.getenv(OTEL_EXPORTER_OTLP_TRACES_ENDPOINT_CONFIG))) { + return tracerProviderBuilder; + } + // Construct meterProvider MetricExporter metricsExporter = ApplicationSignalsExporterProvider.INSTANCE.createExporter(configProps); @@ -286,6 +303,14 @@ private SpanExporter customizeSpanExporter( .build(); } } + // When running OTLP endpoint for X-Ray backend, use custom exporter for SigV4 authentication + else if (spanExporter instanceof OtlpHttpSpanExporter + && isXrayOtlpEndpoint(System.getenv(OTEL_EXPORTER_OTLP_TRACES_ENDPOINT_CONFIG))) { + spanExporter = + new OtlpAwsSpanExporter( + (OtlpHttpSpanExporter) spanExporter, + System.getenv(OTEL_EXPORTER_OTLP_TRACES_ENDPOINT_CONFIG)); + } if (isApplicationSignalsEnabled(configProps)) { return AwsMetricAttributesSpanExporterBuilder.create( diff --git a/awsagentprovider/src/main/java/software/amazon/opentelemetry/javaagent/providers/OtlpAwsSpanExporter.java b/awsagentprovider/src/main/java/software/amazon/opentelemetry/javaagent/providers/OtlpAwsSpanExporter.java new file mode 100644 index 0000000000..c4a777dfe5 --- /dev/null +++ b/awsagentprovider/src/main/java/software/amazon/opentelemetry/javaagent/providers/OtlpAwsSpanExporter.java @@ -0,0 +1,159 @@ +/* + * Copyright 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. + * 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.opentelemetry.javaagent.providers; + +import io.opentelemetry.exporter.internal.otlp.traces.TraceRequestMarshaler; +import io.opentelemetry.exporter.otlp.http.trace.OtlpHttpSpanExporter; +import io.opentelemetry.sdk.common.CompletableResultCode; +import io.opentelemetry.sdk.trace.data.SpanData; +import io.opentelemetry.sdk.trace.export.SpanExporter; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.net.URI; +import java.util.ArrayList; +import java.util.Collection; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.function.Supplier; +import javax.annotation.concurrent.Immutable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import software.amazon.awssdk.auth.credentials.AwsCredentials; +import software.amazon.awssdk.auth.credentials.DefaultCredentialsProvider; +import software.amazon.awssdk.http.SdkHttpFullRequest; +import software.amazon.awssdk.http.SdkHttpMethod; +import software.amazon.awssdk.http.SdkHttpRequest; +import software.amazon.awssdk.http.auth.aws.signer.AwsV4HttpSigner; +import software.amazon.awssdk.http.auth.spi.signer.SignedRequest; + +/** + * This exporter extends the functionality of the OtlpHttpSpanExporter to allow spans to be exported + * to the XRay OTLP endpoint https://xray.[AWSRegion].amazonaws.com/v1/traces. Utilizes the AWSSDK + * library to sign and directly inject SigV4 Authentication to the exported request's headers. ... + */ +@Immutable +public class OtlpAwsSpanExporter implements SpanExporter { + private static final String SERVICE_NAME = "xray"; + private static final Logger logger = LoggerFactory.getLogger(OtlpAwsSpanExporter.class); + + private final OtlpHttpSpanExporter parentExporter; + private final String awsRegion; + private final String endpoint; + private Collection spanData; + + public OtlpAwsSpanExporter(String endpoint) { + this.parentExporter = + OtlpHttpSpanExporter.builder() + .setEndpoint(endpoint) + .setHeaders(new SigV4AuthHeaderSupplier()) + .build(); + + this.awsRegion = endpoint.split("\\.")[1]; + this.endpoint = endpoint; + this.spanData = new ArrayList<>(); + } + + public OtlpAwsSpanExporter(OtlpHttpSpanExporter parentExporter, String endpoint) { + this.parentExporter = + parentExporter.toBuilder() + .setEndpoint(endpoint) + .setHeaders(new SigV4AuthHeaderSupplier()) + .build(); + + this.awsRegion = endpoint.split("\\.")[1]; + this.endpoint = endpoint; + this.spanData = new ArrayList<>(); + } + + /** + * Overrides the upstream implementation of export. All behaviors are the same except if the + * endpoint is an XRay OTLP endpoint, we will sign the request with SigV4 in headers before + * sending it to the endpoint. Otherwise, we will skip signing. + */ + @Override + public CompletableResultCode export(Collection spans) { + this.spanData = spans; + return this.parentExporter.export(spans); + } + + @Override + public CompletableResultCode flush() { + return this.parentExporter.flush(); + } + + @Override + public CompletableResultCode shutdown() { + return this.parentExporter.shutdown(); + } + + @Override + public String toString() { + return this.parentExporter.toString(); + } + + private final class SigV4AuthHeaderSupplier implements Supplier> { + + @Override + public Map get() { + try { + ByteArrayOutputStream encodedSpans = new ByteArrayOutputStream(); + TraceRequestMarshaler.create(OtlpAwsSpanExporter.this.spanData).writeBinaryTo(encodedSpans); + + SdkHttpRequest httpRequest = + SdkHttpFullRequest.builder() + .uri(URI.create(OtlpAwsSpanExporter.this.endpoint)) + .method(SdkHttpMethod.POST) + .putHeader("Content-Type", "application/x-protobuf") + .contentStreamProvider(() -> new ByteArrayInputStream(encodedSpans.toByteArray())) + .build(); + + AwsCredentials credentials = DefaultCredentialsProvider.create().resolveCredentials(); + + SignedRequest signedRequest = + AwsV4HttpSigner.create() + .sign( + b -> + b.identity(credentials) + .request(httpRequest) + .putProperty(AwsV4HttpSigner.SERVICE_SIGNING_NAME, SERVICE_NAME) + .putProperty( + AwsV4HttpSigner.REGION_NAME, OtlpAwsSpanExporter.this.awsRegion) + .payload(() -> new ByteArrayInputStream(encodedSpans.toByteArray()))); + + Map result = new HashMap<>(); + + Map> headers = signedRequest.request().headers(); + headers.forEach( + (key, values) -> { + if (!values.isEmpty()) { + result.put(key, values.get(0)); + } + }); + + return result; + + } catch (Exception e) { + logger.error( + "Failed to sign/authenticate the given exported Span request to OTLP CloudWatch endpoint with error: {}", + e.getMessage()); + + return new HashMap<>(); + } + } + } +} diff --git a/awsagentprovider/src/test/java/software/amazon/opentelemetry/javaagent/providers/OtlpAwsSpanExporterTest.java b/awsagentprovider/src/test/java/software/amazon/opentelemetry/javaagent/providers/OtlpAwsSpanExporterTest.java new file mode 100644 index 0000000000..252ae3e900 --- /dev/null +++ b/awsagentprovider/src/test/java/software/amazon/opentelemetry/javaagent/providers/OtlpAwsSpanExporterTest.java @@ -0,0 +1,211 @@ +/* + * Copyright 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. + * 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.opentelemetry.javaagent.providers; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; +import static org.mockito.Mockito.when; + +import io.opentelemetry.exporter.otlp.http.trace.OtlpHttpSpanExporter; +import io.opentelemetry.exporter.otlp.http.trace.OtlpHttpSpanExporterBuilder; +import io.opentelemetry.sdk.common.CompletableResultCode; +import io.opentelemetry.sdk.trace.export.SpanExporter; +import java.net.URI; +import java.util.List; +import java.util.Map; +import java.util.function.Consumer; +import java.util.function.Supplier; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.MockedStatic; +import org.mockito.junit.jupiter.MockitoExtension; +import software.amazon.awssdk.auth.credentials.AwsBasicCredentials; +import software.amazon.awssdk.auth.credentials.AwsCredentials; +import software.amazon.awssdk.auth.credentials.DefaultCredentialsProvider; +import software.amazon.awssdk.core.exception.SdkClientException; +import software.amazon.awssdk.http.SdkHttpFullRequest; +import software.amazon.awssdk.http.SdkHttpMethod; +import software.amazon.awssdk.http.auth.aws.signer.AwsV4HttpSigner; +import software.amazon.awssdk.http.auth.spi.signer.SignRequest.Builder; +import software.amazon.awssdk.http.auth.spi.signer.SignedRequest; +import software.amazon.awssdk.identity.spi.AwsCredentialsIdentity; + +@ExtendWith(MockitoExtension.class) +public class OtlpAwsSpanExporterTest { + private static final String XRAY_OTLP_ENDPOINT = "https://xray.us-east-1.amazonaws.com/v1/traces"; + private static final String AUTHORIZATION_HEADER = "Authorization"; + private static final String X_AMZ_DATE_HEADER = "X-Amz-Date"; + private static final String X_AMZ_SECURITY_TOKEN_HEADER = "X-Amz-Security-Token"; + + private static final String EXPECTED_AUTH_HEADER = + "AWS4-HMAC-SHA256 Credential=test_key/some_date/us-east-1/xray/aws4_request"; + private static final String EXPECTED_AUTH_X_AMZ_DATE = "some_date"; + private static final String EXPECTED_AUTH_SECURITY_TOKEN = "test_token"; + + AwsCredentials credentials = AwsBasicCredentials.create("test_access_key", "test_secret_key"); + SignedRequest signedRequest = + SignedRequest.builder() + .request( + SdkHttpFullRequest.builder() + .method(SdkHttpMethod.POST) + .uri(URI.create(XRAY_OTLP_ENDPOINT)) + .putHeader(AUTHORIZATION_HEADER, EXPECTED_AUTH_HEADER) + .putHeader(X_AMZ_DATE_HEADER, EXPECTED_AUTH_X_AMZ_DATE) + .putHeader(X_AMZ_SECURITY_TOKEN_HEADER, EXPECTED_AUTH_SECURITY_TOKEN) + .build()) + .build(); + + private MockedStatic mockDefaultCredentialsProvider; + private MockedStatic mockAwsV4HttpSigner; + private MockedStatic otlpSpanExporterMock; + + @Mock private DefaultCredentialsProvider credentialsProvider; + @Mock private AwsV4HttpSigner signer; + @Mock private OtlpHttpSpanExporterBuilder mockBuilder; + @Mock private OtlpHttpSpanExporter mockExporter; + + private ArgumentCaptor>> headersCaptor; + + @BeforeEach + void setup() { + this.mockDefaultCredentialsProvider = mockStatic(DefaultCredentialsProvider.class); + this.mockDefaultCredentialsProvider + .when(DefaultCredentialsProvider::create) + .thenReturn(credentialsProvider); + + this.mockAwsV4HttpSigner = mockStatic(AwsV4HttpSigner.class); + this.mockAwsV4HttpSigner.when(AwsV4HttpSigner::create).thenReturn(this.signer); + + this.otlpSpanExporterMock = mockStatic(OtlpHttpSpanExporter.class); + + this.headersCaptor = ArgumentCaptor.forClass(Supplier.class); + + when(OtlpHttpSpanExporter.builder()).thenReturn(mockBuilder); + when(this.mockBuilder.setEndpoint(any())).thenReturn(mockBuilder); + when(this.mockBuilder.setHeaders(headersCaptor.capture())).thenReturn(mockBuilder); + when(this.mockBuilder.build()).thenReturn(mockExporter); + when(this.mockExporter.export(any())).thenReturn(CompletableResultCode.ofSuccess()); + } + + @AfterEach + void afterEach() { + reset(this.signer, this.credentialsProvider); + this.mockDefaultCredentialsProvider.close(); + this.mockAwsV4HttpSigner.close(); + this.otlpSpanExporterMock.close(); + } + + @Test + void testAwsSpanExporterAddsSigV4Headers() { + + SpanExporter exporter = new OtlpAwsSpanExporter(XRAY_OTLP_ENDPOINT); + when(this.credentialsProvider.resolveCredentials()).thenReturn(this.credentials); + when(this.signer.sign((Consumer>) any())) + .thenReturn(this.signedRequest); + + exporter.export(List.of()); + + Map headers = this.headersCaptor.getValue().get(); + + assertTrue(headers.containsKey(X_AMZ_DATE_HEADER)); + assertTrue(headers.containsKey(AUTHORIZATION_HEADER)); + assertTrue(headers.containsKey(X_AMZ_SECURITY_TOKEN_HEADER)); + + assertEquals(EXPECTED_AUTH_HEADER, headers.get(AUTHORIZATION_HEADER)); + assertEquals(EXPECTED_AUTH_X_AMZ_DATE, headers.get(X_AMZ_DATE_HEADER)); + assertEquals(EXPECTED_AUTH_SECURITY_TOKEN, headers.get(X_AMZ_SECURITY_TOKEN_HEADER)); + } + + @Test + void testAwsSpanExporterExportCorrectlyAddsDifferentSigV4Headers() { + SpanExporter exporter = new OtlpAwsSpanExporter(XRAY_OTLP_ENDPOINT); + + for (int i = 0; i < 10; i += 1) { + String newAuthHeader = EXPECTED_AUTH_HEADER + i; + String newXAmzDate = EXPECTED_AUTH_X_AMZ_DATE + i; + String newXAmzSecurityToken = EXPECTED_AUTH_SECURITY_TOKEN + i; + + SignedRequest newSignedRequest = + SignedRequest.builder() + .request( + SdkHttpFullRequest.builder() + .method(SdkHttpMethod.POST) + .uri(URI.create(XRAY_OTLP_ENDPOINT)) + .putHeader(AUTHORIZATION_HEADER, newAuthHeader) + .putHeader(X_AMZ_DATE_HEADER, newXAmzDate) + .putHeader(X_AMZ_SECURITY_TOKEN_HEADER, newXAmzSecurityToken) + .build()) + .build(); + + when(this.credentialsProvider.resolveCredentials()).thenReturn(this.credentials); + doReturn(newSignedRequest).when(this.signer).sign(any(Consumer.class)); + + exporter.export(List.of()); + + Map headers = this.headersCaptor.getValue().get(); + + assertTrue(headers.containsKey(X_AMZ_DATE_HEADER)); + assertTrue(headers.containsKey(AUTHORIZATION_HEADER)); + assertTrue(headers.containsKey(X_AMZ_SECURITY_TOKEN_HEADER)); + + assertEquals(newAuthHeader, headers.get(AUTHORIZATION_HEADER)); + assertEquals(newXAmzDate, headers.get(X_AMZ_DATE_HEADER)); + assertEquals(newXAmzSecurityToken, headers.get(X_AMZ_SECURITY_TOKEN_HEADER)); + } + } + + @Test + void testAwsSpanExporterDoesNotAddSigV4HeadersIfFailureToRetrieveCredentials() { + + when(this.credentialsProvider.resolveCredentials()) + .thenThrow(SdkClientException.builder().message("bad credentials").build()); + + SpanExporter exporter = new OtlpAwsSpanExporter(XRAY_OTLP_ENDPOINT); + + exporter.export(List.of()); + + Supplier> headersSupplier = headersCaptor.getValue(); + Map headers = headersSupplier.get(); + + assertFalse(headers.containsKey(X_AMZ_DATE_HEADER)); + assertFalse(headers.containsKey(AUTHORIZATION_HEADER)); + assertFalse(headers.containsKey(X_AMZ_SECURITY_TOKEN_HEADER)); + + verifyNoInteractions(this.signer); + } + + @Test + void testAwsSpanExporterDoesNotAddSigV4HeadersIfFailureToSignHeaders() { + + when(this.credentialsProvider.resolveCredentials()).thenReturn(this.credentials); + when(this.signer.sign((Consumer>) any())) + .thenThrow(SdkClientException.builder().message("bad signature").build()); + + SpanExporter exporter = new OtlpAwsSpanExporter(XRAY_OTLP_ENDPOINT); + + exporter.export(List.of()); + + Map headers = this.headersCaptor.getValue().get(); + + assertFalse(headers.containsKey(X_AMZ_DATE_HEADER)); + assertFalse(headers.containsKey(AUTHORIZATION_HEADER)); + assertFalse(headers.containsKey(X_AMZ_SECURITY_TOKEN_HEADER)); + } +}