diff --git a/exporters/aws-opentelemetry-xray-lambda-exporter/build.gradle.kts b/exporters/aws-opentelemetry-xray-lambda-exporter/build.gradle.kts new file mode 100644 index 0000000000..8da1f138e5 --- /dev/null +++ b/exporters/aws-opentelemetry-xray-lambda-exporter/build.gradle.kts @@ -0,0 +1,101 @@ +/* + * 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. + */ + +plugins { + id("java") + id("java-library") + id("maven-publish") +} + +group = "software.amazon.distro.opentelemetry.exporter.xray.lambda" +version = "0.1.0" + +dependencies { + implementation(platform("io.opentelemetry.instrumentation:opentelemetry-instrumentation-bom:2.10.0")) + compileOnly("io.opentelemetry:opentelemetry-api") + compileOnly("io.opentelemetry:opentelemetry-sdk") + compileOnly("io.opentelemetry:opentelemetry-exporter-otlp-common") + compileOnly("com.google.code.findbugs:jsr305:3.0.2") + testImplementation("io.opentelemetry:opentelemetry-api") + testImplementation("io.opentelemetry:opentelemetry-sdk") + testImplementation("io.opentelemetry:opentelemetry-exporter-otlp-common") + testImplementation(platform("org.junit:junit-bom:5.9.2")) + testImplementation("org.junit.jupiter:junit-jupiter-api") + testImplementation("org.junit.jupiter:junit-jupiter-engine") + testImplementation("org.mockito:mockito-core:5.3.1") + testImplementation("org.assertj:assertj-core:3.24.2") + testImplementation("org.mockito:mockito-junit-jupiter:5.3.1") +} + +java { + withSourcesJar() + withJavadocJar() +} + +tasks.javadoc { + options { + (this as CoreJavadocOptions).addStringOption("Xdoclint:none", "-quiet") + } + isFailOnError = false +} + +sourceSets { + main { + java { + srcDirs("src/main/java") + } + } + test { + java { + srcDirs("src/test/java") + } + } +} + +tasks.test { + useJUnitPlatform() + testLogging { + events("passed", "skipped", "failed") + } +} + +tasks.jar { + manifest { + attributes( + "Implementation-Title" to project.name, + "Implementation-Version" to project.version, + ) + } + duplicatesStrategy = DuplicatesStrategy.EXCLUDE +} + +tasks.named("javadocJar") { + duplicatesStrategy = DuplicatesStrategy.EXCLUDE +} + +tasks.named("sourcesJar") { + duplicatesStrategy = DuplicatesStrategy.EXCLUDE +} + +publishing { + publications { + create("mavenJava") { + from(components["java"]) + groupId = project.group.toString() + artifactId = "aws-opentelemetry-xray-lambda-exporter" + version = project.version.toString() + } + } +} diff --git a/exporters/aws-opentelemetry-xray-lambda-exporter/settings.gradle.kts b/exporters/aws-opentelemetry-xray-lambda-exporter/settings.gradle.kts new file mode 100644 index 0000000000..800c841bf5 --- /dev/null +++ b/exporters/aws-opentelemetry-xray-lambda-exporter/settings.gradle.kts @@ -0,0 +1,12 @@ +rootProject.name = "aws-opentelemetry-xray-lambda-exporter" + +dependencyResolutionManagement { + repositories { + mavenCentral() + mavenLocal() + + maven { + setUrl("https://oss.sonatype.org/content/repositories/snapshots") + } + } +} \ No newline at end of file diff --git a/exporters/aws-opentelemetry-xray-lambda-exporter/src/main/java/software/amazon/distro/opentelemetry/exporter/xray/lambda/AwsXrayLambdaExporter.java b/exporters/aws-opentelemetry-xray-lambda-exporter/src/main/java/software/amazon/distro/opentelemetry/exporter/xray/lambda/AwsXrayLambdaExporter.java new file mode 100644 index 0000000000..211be66f8d --- /dev/null +++ b/exporters/aws-opentelemetry-xray-lambda-exporter/src/main/java/software/amazon/distro/opentelemetry/exporter/xray/lambda/AwsXrayLambdaExporter.java @@ -0,0 +1,97 @@ +/* + * 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.distro.opentelemetry.exporter.xray.lambda; + +import io.opentelemetry.exporter.internal.otlp.traces.TraceRequestMarshaler; +import io.opentelemetry.sdk.common.CompletableResultCode; +import io.opentelemetry.sdk.trace.data.SpanData; +import io.opentelemetry.sdk.trace.export.SpanExporter; +import java.io.ByteArrayOutputStream; +import java.nio.charset.StandardCharsets; +import java.util.Base64; +import java.util.Collection; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.logging.Level; +import java.util.logging.Logger; +import javax.annotation.concurrent.Immutable; + +/** + * Exports spans via UDP, using OpenTelemetry's protobuf model. The protobuf modelled spans are + * Base64 encoded and prefixed with AWS X-Ray specific information before being sent over to {@link + * UdpSender}. + * + *

This exporter is NOT meant for generic use since the payload is prefixed with AWS X-Ray + * specific information. + */ +@Immutable +public class AwsXrayLambdaExporter implements SpanExporter { + + private static final Logger logger = Logger.getLogger(AwsXrayLambdaExporter.class.getName()); + + private final AtomicBoolean isShutdown = new AtomicBoolean(); + + private final UdpSender sender; + private final String payloadPrefix; + + AwsXrayLambdaExporter(UdpSender sender, String payloadPrefix) { + this.sender = sender; + this.payloadPrefix = payloadPrefix; + } + + @Override + public CompletableResultCode export(Collection spans) { + if (isShutdown.get()) { + return CompletableResultCode.ofFailure(); + } + + TraceRequestMarshaler exportRequest = TraceRequestMarshaler.create(spans); + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + try { + exportRequest.writeBinaryTo(baos); + String payload = payloadPrefix + Base64.getEncoder().encodeToString(baos.toByteArray()); + sender.send(payload.getBytes(StandardCharsets.UTF_8)); + return CompletableResultCode.ofSuccess(); + } catch (Exception e) { + logger.log(Level.SEVERE, "Failed to export spans. Error: " + e.getMessage(), e); + return CompletableResultCode.ofFailure(); + } + } + + @Override + public CompletableResultCode flush() { + // TODO: implement + return CompletableResultCode.ofSuccess(); + } + + @Override + public CompletableResultCode shutdown() { + if (!isShutdown.compareAndSet(false, true)) { + logger.log(Level.INFO, "Calling shutdown() multiple times."); + return CompletableResultCode.ofSuccess(); + } + return sender.shutdown(); + } + + // Visible for testing + UdpSender getSender() { + return sender; + } + + // Visible for testing + String getPayloadPrefix() { + return payloadPrefix; + } +} diff --git a/exporters/aws-opentelemetry-xray-lambda-exporter/src/main/java/software/amazon/distro/opentelemetry/exporter/xray/lambda/AwsXrayLambdaExporterBuilder.java b/exporters/aws-opentelemetry-xray-lambda-exporter/src/main/java/software/amazon/distro/opentelemetry/exporter/xray/lambda/AwsXrayLambdaExporterBuilder.java new file mode 100644 index 0000000000..885a2b082e --- /dev/null +++ b/exporters/aws-opentelemetry-xray-lambda-exporter/src/main/java/software/amazon/distro/opentelemetry/exporter/xray/lambda/AwsXrayLambdaExporterBuilder.java @@ -0,0 +1,121 @@ +/* + * 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.distro.opentelemetry.exporter.xray.lambda; + +import static java.util.Objects.requireNonNull; + +import java.util.Map; + +public final class AwsXrayLambdaExporterBuilder { + + private static final String DEFAULT_HOST = "127.0.0.1"; + private static final int DEFAULT_PORT = 2000; + + // The protocol header and delimiter is required for sending data to X-Ray Daemon or when running + // in Lambda. + // https://docs.aws.amazon.com/xray/latest/devguide/xray-api-sendingdata.html#xray-api-daemon + private static final String PROTOCOL_HEADER = "{\"format\": \"json\", \"version\": 1}"; + private static final char PROTOCOL_DELIMITER = '\n'; + + // These prefixes help the backend identify if the spans payload is sampled or not. + private static final String FORMAT_OTEL_SAMPLED_TRACES_BINARY_PREFIX = "T1S"; + private static final String FORMAT_OTEL_UNSAMPLED_TRACES_BINARY_PREFIX = "T1U"; + + private UdpSender sender; + private String tracePayloadPrefix = FORMAT_OTEL_SAMPLED_TRACES_BINARY_PREFIX; + private Map environmentVariables = System.getenv(); + + private static final String AWS_LAMBDA_FUNCTION_NAME_CONFIG = "AWS_LAMBDA_FUNCTION_NAME"; + private static final String AWS_XRAY_DAEMON_ADDRESS_CONFIG = "AWS_XRAY_DAEMON_ADDRESS"; + + public AwsXrayLambdaExporterBuilder setEndpoint(String endpoint) { + requireNonNull(endpoint, "endpoint must not be null"); + try { + this.sender = createSenderFromEndpoint(endpoint); + } catch (Exception e) { + throw new IllegalArgumentException("Invalid endpoint, must be a valid URL: " + endpoint, e); + } + return this; + } + + public AwsXrayLambdaExporterBuilder setPayloadSampleDecision(TracePayloadSampleDecision decision) { + this.tracePayloadPrefix = + decision == TracePayloadSampleDecision.SAMPLED + ? FORMAT_OTEL_SAMPLED_TRACES_BINARY_PREFIX + : FORMAT_OTEL_UNSAMPLED_TRACES_BINARY_PREFIX; + return this; + } + + private UdpSender createSenderFromEndpoint(String endpoint) { + String[] parts = endpoint.split(":"); + String host = parts[0]; + int port = Integer.parseInt(parts[1]); + return new UdpSender(host, port); + } + + // For testing purposes + AwsXrayLambdaExporterBuilder withEnvironmentVariables(Map env) { + this.environmentVariables = env; + return this; + } + + // NEW: Added getter for testing + Map getEnvironmentVariables() { + return environmentVariables; + } + + public AwsXrayLambdaExporter build() { + if (sender == null) { + String endpoint = null; + + // If in Lambda environment, try to get X-Ray daemon address + if (isLambdaEnvironment()) { + endpoint = environmentVariables.get(AWS_XRAY_DAEMON_ADDRESS_CONFIG); + if (endpoint != null && !endpoint.isEmpty()) { + try { + this.sender = createSenderFromEndpoint(endpoint); + return new AwsXrayLambdaExporter( + this.sender, PROTOCOL_HEADER + PROTOCOL_DELIMITER + tracePayloadPrefix); + } catch (Exception e) { + // Fallback to defaults if parsing fails + this.sender = new UdpSender(DEFAULT_HOST, DEFAULT_PORT); + } + } + } + + // Use defaults if not in Lambda or if daemon address is invalid/unavailable + this.sender = new UdpSender(DEFAULT_HOST, DEFAULT_PORT); + } + return new AwsXrayLambdaExporter( + this.sender, PROTOCOL_HEADER + PROTOCOL_DELIMITER + tracePayloadPrefix); + } + + private boolean isLambdaEnvironment() { + String functionName = environmentVariables.get(AWS_LAMBDA_FUNCTION_NAME_CONFIG); + return functionName != null && !functionName.isEmpty(); + } + + // Only for testing + AwsXrayLambdaExporterBuilder setSender(UdpSender sender) { + this.sender = sender; + return this; + } +} + +enum TracePayloadSampleDecision { + SAMPLED, + UNSAMPLED +} diff --git a/exporters/aws-opentelemetry-xray-lambda-exporter/src/main/java/software/amazon/distro/opentelemetry/exporter/xray/lambda/UdpSender.java b/exporters/aws-opentelemetry-xray-lambda-exporter/src/main/java/software/amazon/distro/opentelemetry/exporter/xray/lambda/UdpSender.java new file mode 100644 index 0000000000..c98a14dd9f --- /dev/null +++ b/exporters/aws-opentelemetry-xray-lambda-exporter/src/main/java/software/amazon/distro/opentelemetry/exporter/xray/lambda/UdpSender.java @@ -0,0 +1,76 @@ +/* + * 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.distro.opentelemetry.exporter.xray.lambda; + +import io.opentelemetry.sdk.common.CompletableResultCode; +import java.io.IOException; +import java.net.DatagramPacket; +import java.net.DatagramSocket; +import java.net.InetSocketAddress; +import java.net.SocketException; +import java.util.logging.Level; +import java.util.logging.Logger; + +/** + * This class represents a UDP sender that sends data to a specified endpoint. It is used to send + * data to a remote host and port using UDP protocol. + */ +public class UdpSender { + private static final Logger logger = Logger.getLogger(UdpSender.class.getName()); + + private DatagramSocket socket; + private final InetSocketAddress endpoint; + + public UdpSender(String host, int port) { + this.endpoint = new InetSocketAddress(host, port); + try { + this.socket = new DatagramSocket(); + } catch (SocketException e) { + logger.log(Level.SEVERE, "Exception while instantiating UdpSender socket.", e); + } + } + + public CompletableResultCode shutdown() { + try { + if (socket == null) { + return CompletableResultCode.ofSuccess(); + } + socket.close(); + return CompletableResultCode.ofSuccess(); + } catch (Exception e) { + logger.log(Level.SEVERE, "Exception while closing UdpSender socket.", e); + return CompletableResultCode.ofFailure(); + } + } + + public void send(byte[] data) { + if (socket == null) { + logger.log(Level.WARNING, "UdpSender socket is null. Cannot send data."); + return; + } + DatagramPacket packet = new DatagramPacket(data, data.length, endpoint); + try { + socket.send(packet); + } catch (IOException e) { + logger.log(Level.SEVERE, "Exception while sending data.", e); + } + } + + // Visible for testing + InetSocketAddress getEndpoint() { + return endpoint; + } +} diff --git a/exporters/aws-opentelemetry-xray-lambda-exporter/src/test/java/software/amazon/distro/opentelemetry/exporter/xray/lambda/AwsXrayLambdaExporterTest.java b/exporters/aws-opentelemetry-xray-lambda-exporter/src/test/java/software/amazon/distro/opentelemetry/exporter/xray/lambda/AwsXrayLambdaExporterTest.java new file mode 100644 index 0000000000..02105d3910 --- /dev/null +++ b/exporters/aws-opentelemetry-xray-lambda-exporter/src/test/java/software/amazon/distro/opentelemetry/exporter/xray/lambda/AwsXrayLambdaExporterTest.java @@ -0,0 +1,175 @@ +/* + * 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.distro.opentelemetry.exporter.xray.lambda; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.*; + +import io.opentelemetry.api.common.AttributeKey; +import io.opentelemetry.api.common.Attributes; +import io.opentelemetry.api.trace.*; +import io.opentelemetry.sdk.common.InstrumentationScopeInfo; +import io.opentelemetry.sdk.resources.Resource; +import io.opentelemetry.sdk.trace.data.SpanData; +import io.opentelemetry.sdk.trace.data.StatusData; +import java.nio.charset.StandardCharsets; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; +import org.junit.jupiter.api.Test; + +public class AwsXrayLambdaExporterTest { + + @Test + public void testUdpExporterWithDefaults() { + AwsXrayLambdaExporter exporter = new AwsXrayLambdaExporterBuilder().build(); + UdpSender sender = exporter.getSender(); + assertThat(sender.getEndpoint().getHostName()) + .isEqualTo("localhost"); // getHostName implicitly converts 127.0.0.1 to localhost + assertThat(sender.getEndpoint().getPort()).isEqualTo(2000); + assertThat(exporter.getPayloadPrefix()).endsWith("T1S"); + } + + @Test + public void testUdpExporterWithCustomEndpointAndSample() { + AwsXrayLambdaExporter exporter = + new AwsXrayLambdaExporterBuilder() + .setEndpoint("somehost:1000") + .setPayloadSampleDecision(TracePayloadSampleDecision.UNSAMPLED) + .build(); + UdpSender sender = exporter.getSender(); + assertThat(sender.getEndpoint().getHostName()).isEqualTo("somehost"); + assertThat(sender.getEndpoint().getPort()).isEqualTo(1000); + assertThat(exporter.getPayloadPrefix()).endsWith("T1U"); + } + + @Test + public void testUdpExporterWithInvalidEndpoint() { + assertThatThrownBy( + () -> { + new AwsXrayLambdaExporterBuilder().setEndpoint("invalidhost"); + }) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Invalid endpoint, must be a valid URL: invalidhost"); + } + + @Test + public void shouldUseExpectedEnvironmentVariablesToConfigureEndpoint() { + // Create a test environment map + Map testEnv = new HashMap<>(); + testEnv.put("AWS_LAMBDA_FUNCTION_NAME", "testFunctionName"); + testEnv.put("AWS_XRAY_DAEMON_ADDRESS", "someaddress:1234"); + + // Create builder with test environment + AwsXrayLambdaExporterBuilder builder = + new AwsXrayLambdaExporterBuilder().withEnvironmentVariables(testEnv); + + // Verify that environment variables are set correctly + assertThat(builder.getEnvironmentVariables()) + .containsEntry("AWS_LAMBDA_FUNCTION_NAME", "testFunctionName") + .containsEntry("AWS_XRAY_DAEMON_ADDRESS", "someaddress:1234"); + + // Build the exporter and verify the configuration + AwsXrayLambdaExporter exporter = builder.build(); + UdpSender sender = exporter.getSender(); + + assertThat(sender.getEndpoint().getHostName()).isEqualTo("someaddress"); + assertThat(sender.getEndpoint().getPort()).isEqualTo(1234); + } + + @Test + public void testExportDefaultBehavior() { + UdpSender senderMock = mock(UdpSender.class); + + // mock SpanData + SpanData spanData = buildSpanDataMock(); + + AwsXrayLambdaExporter exporter = new AwsXrayLambdaExporterBuilder().setSender(senderMock).build(); + exporter.export(Collections.singletonList(spanData)); + + // assert that the senderMock.send is called once + verify(senderMock, times(1)).send(any(byte[].class)); + verify(senderMock) + .send( + argThat( + (byte[] bytes) -> { + assertThat(bytes.length).isGreaterThan(0); + String payload = new String(bytes, StandardCharsets.UTF_8); + assertThat(payload) + .startsWith("{\"format\": \"json\", \"version\": 1}" + "\n" + "T1S"); + return true; + })); + } + + @Test + public void testExportWithSampledFalse() { + UdpSender senderMock = mock(UdpSender.class); + + // mock SpanData + SpanData spanData = buildSpanDataMock(); + + AwsXrayLambdaExporter exporter = + new AwsXrayLambdaExporterBuilder() + .setSender(senderMock) + .setPayloadSampleDecision(TracePayloadSampleDecision.UNSAMPLED) + .build(); + exporter.export(Collections.singletonList(spanData)); + + verify(senderMock, times(1)).send(any(byte[].class)); + verify(senderMock) + .send( + argThat( + (byte[] bytes) -> { + assertThat(bytes.length).isGreaterThan(0); + String payload = new String(bytes, StandardCharsets.UTF_8); + assertThat(payload) + .startsWith("{\"format\": \"json\", \"version\": 1}" + "\n" + "T1U"); + return true; + })); + } + + private SpanData buildSpanDataMock() { + SpanData mockSpanData = mock(SpanData.class); + + Attributes spanAttributes = + Attributes.of(AttributeKey.stringKey("original key"), "original value"); + when(mockSpanData.getAttributes()).thenReturn(spanAttributes); + when(mockSpanData.getTotalAttributeCount()).thenReturn(spanAttributes.size()); + when(mockSpanData.getKind()).thenReturn(SpanKind.SERVER); + + SpanContext parentSpanContextMock = mock(SpanContext.class); + when(mockSpanData.getParentSpanContext()).thenReturn(parentSpanContextMock); + + SpanContext spanContextMock = mock(SpanContext.class); + TraceFlags spanContextTraceFlagsMock = mock(TraceFlags.class); + when(spanContextMock.isValid()).thenReturn(true); + when(spanContextMock.getTraceFlags()).thenReturn(spanContextTraceFlagsMock); + when(mockSpanData.getSpanContext()).thenReturn(spanContextMock); + + TraceState traceState = TraceState.builder().build(); + when(spanContextMock.getTraceState()).thenReturn(traceState); + + when(mockSpanData.getStatus()).thenReturn(StatusData.unset()); + when(mockSpanData.getInstrumentationScopeInfo()) + .thenReturn(InstrumentationScopeInfo.create("Dummy Scope")); + + Resource testResource = Resource.empty(); + when(mockSpanData.getResource()).thenReturn(testResource); + + return mockSpanData; + } +}