diff --git a/pom.xml b/pom.xml index 1f5b67402..ee6d910d5 100644 --- a/pom.xml +++ b/pom.xml @@ -14,8 +14,8 @@ --> + xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"> 4.0.0 software.amazon.lambda @@ -25,7 +25,8 @@ Powertools for AWS Lambda (Java) - Parent - A suite of utilities for AWS Lambda Functions that makes tracing with AWS X-Ray, structured logging and creating custom metrics asynchronously easier. + A suite of utilities for AWS Lambda Functions that makes tracing with AWS X-Ray, structured logging and creating + custom metrics asynchronously easier. https://github.com/aws-powertools/powertools-lambda-java @@ -77,6 +78,7 @@ powertools-parameters/powertools-parameters-appconfig powertools-parameters/powertools-parameters-tests examples + powertools-tracing-opentelemetry @@ -119,6 +121,8 @@ 2.3.0 1.5.0 0.11.5 + 1.65.0 + 1.59.0-alpha @@ -313,6 +317,26 @@ commons-lang3 3.20.0 + + io.opentelemetry + opentelemetry-api + ${opentelemetry-api.version} + + + io.opentelemetry + opentelemetry-sdk + ${opentelemetry-api.version} + + + io.opentelemetry + opentelemetry-exporter-otlp + ${opentelemetry-api.version} + + + io.opentelemetry.contrib + opentelemetry-aws-xray-propagator + ${opentelemetry.aws.xray.propagator.version} + @@ -393,6 +417,12 @@ 3.13.2 test + + io.opentelemetry + opentelemetry-sdk-testing + ${opentelemetry-api.version} + test + @@ -471,7 +501,8 @@ true true - true + true + @@ -692,7 +723,9 @@ maven-surefire-plugin - --add-opens java.base/java.util=ALL-UNNAMED --add-opens java.base/java.lang=ALL-UNNAMED + --add-opens java.base/java.util=ALL-UNNAMED --add-opens + java.base/java.lang=ALL-UNNAMED + diff --git a/powertools-common/src/main/java/software/amazon/lambda/powertools/common/internal/SystemWrapper.java b/powertools-common/src/main/java/software/amazon/lambda/powertools/common/internal/SystemWrapper.java index 6dc4e9d9f..cc8ea39e9 100644 --- a/powertools-common/src/main/java/software/amazon/lambda/powertools/common/internal/SystemWrapper.java +++ b/powertools-common/src/main/java/software/amazon/lambda/powertools/common/internal/SystemWrapper.java @@ -22,6 +22,10 @@ public static String getenv(String name) { return System.getenv(name); } + public static boolean containsKey(String key) { + return System.getenv().containsKey(key); + } + public static String getProperty(String name) { return System.getProperty(name); } diff --git a/powertools-tracing-opentelemetry/pom.xml b/powertools-tracing-opentelemetry/pom.xml new file mode 100644 index 000000000..976d8a723 --- /dev/null +++ b/powertools-tracing-opentelemetry/pom.xml @@ -0,0 +1,156 @@ + + + + 4.0.0 + + powertools-tracing-opentelemetry + jar + + + software.amazon.lambda + powertools-parent + 2.10.0 + + + Powertools for AWS Lambda (Java) - Tracing OpenTelemetry + + A suite of utilities for AWS Lambda Functions that makes tracing with OpenTelemetry, structured logging and + creating custom metrics asynchronously easier. + + + + + io.opentelemetry + opentelemetry-api + + + io.opentelemetry + opentelemetry-sdk + + + io.opentelemetry + opentelemetry-exporter-otlp + + + io.opentelemetry.contrib + opentelemetry-aws-xray-propagator + + + org.aspectj + aspectjrt + provided + + + software.amazon.lambda + powertools-common + + + software.amazon.awssdk + aws-core + + + software.amazon.awssdk + sdk-core + + + com.amazonaws + aws-lambda-java-core + + + com.amazonaws + aws-lambda-java-events + + + com.fasterxml.jackson.core + jackson-databind + + + + + io.opentelemetry + opentelemetry-sdk-testing + test + + + org.mockito + mockito-core + test + + + org.mockito + mockito-junit-jupiter + test + + + org.junit.jupiter + junit-jupiter-api + test + + + org.junit.jupiter + junit-jupiter-engine + test + + + software.amazon.lambda + powertools-common + ${project.version} + test-jar + test + + + org.slf4j + slf4j-simple + test + + + org.junit-pioneer + junit-pioneer + test + + + org.apache.commons + commons-lang3 + test + + + org.aspectj + aspectjweaver + test + + + org.assertj + assertj-core + test + + + + + + + org.apache.maven.plugins + maven-surefire-plugin + + + on-demand + + + + + + + \ No newline at end of file diff --git a/powertools-tracing-opentelemetry/src/main/java/software/amazon/lambda/powertools/tracing/opentelemetry/CaptureMode.java b/powertools-tracing-opentelemetry/src/main/java/software/amazon/lambda/powertools/tracing/opentelemetry/CaptureMode.java new file mode 100644 index 000000000..40ad84307 --- /dev/null +++ b/powertools-tracing-opentelemetry/src/main/java/software/amazon/lambda/powertools/tracing/opentelemetry/CaptureMode.java @@ -0,0 +1,48 @@ +/* + * 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.tracing.opentelemetry; + +/** + * Defines how method responses and errors are captured by tracing. + */ +public enum CaptureMode { + + /** + * Capture response and errors according to environment variables. + */ + ENVIRONMENT_VAR, + + /** + * Capture the method response. + */ + RESPONSE, + + /** + * Capture errors thrown by the method. + */ + ERROR, + + /** + * Capture both the method response and errors. + */ + RESPONSE_AND_ERROR, + + /** + * Disable response and error capture. + */ + DISABLED +} \ No newline at end of file diff --git a/powertools-tracing-opentelemetry/src/main/java/software/amazon/lambda/powertools/tracing/opentelemetry/Tracing.java b/powertools-tracing-opentelemetry/src/main/java/software/amazon/lambda/powertools/tracing/opentelemetry/Tracing.java new file mode 100644 index 000000000..820026cc2 --- /dev/null +++ b/powertools-tracing-opentelemetry/src/main/java/software/amazon/lambda/powertools/tracing/opentelemetry/Tracing.java @@ -0,0 +1,59 @@ +/* + * 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.tracing.opentelemetry; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Annotation to enable OpenTelemetry tracing for the annotated method. + * Automatically creates and manages an OpenTelemetry span for the method invocation. + *

+ * This annotation allows configuration of the namespace, span name, and capture mode + * for tracing purposes. If no explicit configuration is provided, default values are used. + */ +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.METHOD) +public @interface Tracing { + /** + * The namespace associated with the span. + * + *

If empty, the default Powertools service name is used. + * + * @return the namespace + */ + String namespace() default ""; + + /** + * The name of the span. + * + *

If empty, the annotated method name is used. + * + * @return the span name + */ + String spanName() default ""; + + /** + * Controls whether the method response and/or errors are captured + * as span data. + * + * @return the capture mode + */ + CaptureMode captureMode() default CaptureMode.ENVIRONMENT_VAR; +} diff --git a/powertools-tracing-opentelemetry/src/main/java/software/amazon/lambda/powertools/tracing/opentelemetry/TracingOpenTelemetry.java b/powertools-tracing-opentelemetry/src/main/java/software/amazon/lambda/powertools/tracing/opentelemetry/TracingOpenTelemetry.java new file mode 100644 index 000000000..9585ca03f --- /dev/null +++ b/powertools-tracing-opentelemetry/src/main/java/software/amazon/lambda/powertools/tracing/opentelemetry/TracingOpenTelemetry.java @@ -0,0 +1,444 @@ +/* + * 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.tracing.opentelemetry; + +import io.opentelemetry.api.common.Attributes; +import io.opentelemetry.api.trace.Span; +import io.opentelemetry.api.trace.SpanBuilder; +import io.opentelemetry.api.trace.SpanContext; +import io.opentelemetry.api.trace.SpanKind; +import io.opentelemetry.api.trace.Tracer; +import io.opentelemetry.context.Context; +import io.opentelemetry.context.propagation.TextMapGetter; +import io.opentelemetry.context.propagation.TextMapPropagator; +import io.opentelemetry.context.propagation.TextMapSetter; +import io.opentelemetry.sdk.common.CompletableResultCode; +import java.util.Collections; +import java.util.List; +import java.util.Objects; +import java.util.concurrent.TimeUnit; +import software.amazon.lambda.powertools.tracing.opentelemetry.context.LambdaEventContextExtractorResolver; +import software.amazon.lambda.powertools.tracing.opentelemetry.internal.SpanOperation; +import software.amazon.lambda.powertools.tracing.opentelemetry.internal.SpanScope; +import software.amazon.lambda.powertools.tracing.opentelemetry.provider.OpenTelemetryProvider; + +/** + * A utility class responsible for managing OpenTelemetry tracing functionality, + * including creating and managing spans, handling context propagation, and facilitating + * relevant operations for distributed tracing. + *

+ * This class provides methods to manage the life cycle of spans, propagate and extract + * context, flush telemetry data, and execute operations within spans. It also supports + * configuration via a builder pattern. + *

+ * The class is designed to be thread-safe and offers a default singleton instance + * for convenience. + */ +public final class TracingOpenTelemetry { + + private static final TracingOpenTelemetry DEFAULT_INSTANCE = new TracingOpenTelemetry(); + private final Tracer tracer; + private final TextMapPropagator propagator; + private final LambdaEventContextExtractorResolver eventContextExtractorResolver; + + private TracingOpenTelemetry(Builder builder) { + this.tracer = Objects.requireNonNull(builder.tracer, "tracer must not be null"); + this.propagator = Objects.requireNonNull(builder.propagator, "propagator must not be null"); + this.eventContextExtractorResolver = Objects.requireNonNull( + builder.eventContextExtractorResolver, + "eventContextExtractorResolver must not be null" + ); + } + + public TracingOpenTelemetry() { + this(OpenTelemetryProvider.tracer()); + } + + + public TracingOpenTelemetry(Tracer tracer) { + this(tracer, createDefaultPropagator(), createDefaultEventContextExtractorResolver()); + } + + + public TracingOpenTelemetry( + Tracer tracer, + TextMapPropagator propagator, + LambdaEventContextExtractorResolver eventContextExtractorResolver + ) { + + this.tracer = Objects.requireNonNull(tracer, "tracer must not be null"); + this.propagator = Objects.requireNonNull(propagator, "propagator must not be null"); + this.eventContextExtractorResolver = Objects.requireNonNull( + eventContextExtractorResolver, + "eventContextExtractorResolver must not be null" + ); + } + + /** + * Provides access to the current Tracer instance. + * + * @return the Tracer instance associated with the current context + */ + public Tracer tracer() { + return tracer; + } + + /** + * Provides the current TextMapPropagator instance. + * + * @return the TextMapPropagator instance used for propagating context information. + */ + public TextMapPropagator propagator() { + return propagator; + } + + /** + * Retrieves the instance of LambdaEventContextExtractorResolver. + * + * @return the resolver used to extract context from Lambda events. + */ + public LambdaEventContextExtractorResolver eventContextExtractorResolver() { + return eventContextExtractorResolver; + } + + /** + * Retrieves the current active span within the context. + * + * @return the currently active span, or null if there is no active span + */ + public Span currentSpan() { + return Span.current(); + } + + /** + * Forces all pending spans and related telemetry data to be processed and exported. + * This method sends the pending data using the default timeout period. + * + * @return a {@code CompletableResultCode} indicating the success or failure of the flush operation + */ + public CompletableResultCode flush() { + return flush(5, TimeUnit.SECONDS); + } + + /** + * Forces all pending spans and related telemetry data to be processed and exported + * within a specified timeout period. + * + * @param timeout the maximum duration to wait for the flush operation to complete + * @param unit the time unit of the {@code timeout} parameter + * @return a {@code CompletableResultCode} indicating the success or failure of the flush operation + */ + public CompletableResultCode flush(long timeout, TimeUnit unit) { + return OpenTelemetryProvider.forceFlush().join(timeout, unit); + } + + /** + * Starts a new OpenTelemetry span with the given name and a default {@link SpanKind#INTERNAL} kind. + * + * @param name the name of the span to be created + * @return a {@link SpanScope} instance that manages the lifecycle of the span and its associated context + */ + public SpanScope addSpan(String name) { + return addSpan(name, SpanKind.INTERNAL); + } + + /** + * Starts a new OpenTelemetry span with the given name, kind, and default attributes. + * + * @param name the name of the span to be created + * @param kind the kind of the span, e.g., {@link SpanKind#INTERNAL}, {@link SpanKind#CLIENT}, etc. + * @return a {@link SpanScope} instance that manages the lifecycle of the span and its associated context + */ + public SpanScope addSpan(String name, SpanKind kind) { + + return addSpan(name, kind, Attributes.empty()); + } + + /** + * Starts a new OpenTelemetry span with the given name, kind, and attributes, + * using the current thread context as the parent context. + * + * @param name the name of the span to be created + * @param kind the kind of the span, such as {@code SpanKind.INTERNAL}, {@code SpanKind.CLIENT}, etc. + * @param attributes the attributes to associate with the span + * @return a {@code SpanScope} instance that manages the lifecycle of the span and its associated context + */ + public SpanScope addSpan(String name, SpanKind kind, Attributes attributes) { + + return addSpan(name, kind, attributes, Context.current()); + } + + /** + * Starts a new OpenTelemetry span with the given name, kind, attributes, and parent context. + * The span is returned encapsulated in a {@code SpanScope}, which manages the lifecycle + * of the span and its associated context. + * + * @param name the name of the span to be created + * @param kind the type of the span, such as {@code SpanKind.INTERNAL}, {@code SpanKind.CLIENT}, etc. + * @param attributes the attributes to associate with the span + * @param parentContext the parent context to use for the span + * @return a {@code SpanScope} instance that manages the lifecycle of the span and its related context + */ + public SpanScope addSpan(String name, SpanKind kind, Attributes attributes, Context parentContext) { + + return addSpan(name, kind, attributes, parentContext, Collections.emptyList()); + } + + /** + * Starts a new OpenTelemetry span with the given configuration, including name, kind, attributes, + * parent context, and links to other spans represented by their {@code SpanContext}s. + * The resulting span is encapsulated within a {@code SpanScope} for proper lifecycle management. + * + * @param name the name of the span to be created; must not be null + * @param kind the type of the span, such as {@code SpanKind.INTERNAL}, {@code SpanKind.CLIENT}, etc.; + * must not be null + * @param attributes the attributes to associate with the span; must not be null + * @param parentContext the parent context to use for the span; must not be null + * @param spanContexts the list of {@code SpanContext} instances to link to the created span; must not be null + * @return a {@code SpanScope} instance that manages the lifecycle of the span and its associated context + * @throws NullPointerException if any of the parameters are null + */ + public SpanScope addSpan( + String name, + SpanKind kind, + Attributes attributes, + Context parentContext, + List spanContexts + ) { + + Objects.requireNonNull(name, "name must not be null"); + Objects.requireNonNull(kind, "kind must not be null"); + Objects.requireNonNull(attributes, "attributes must not be null"); + Objects.requireNonNull(parentContext, "parentContext must not be null"); + Objects.requireNonNull(spanContexts, "spanContexts must not be null"); + + SpanBuilder spanBuilder = tracer + .spanBuilder(name) + .setSpanKind(kind) + .setParent(parentContext) + .setAllAttributes(attributes); + + spanContexts.forEach(spanBuilder::addLink); + + return new SpanScope(spanBuilder.startSpan()); + } + + /** + * Executes a given operation within the context of an OpenTelemetry span with the specified name. + * The span is created with the default {@link SpanKind#INTERNAL} and no additional attributes. + * Any exceptions thrown during the operation will be recorded in the span. + * + * @param the type of result returned by the operation + * @param name the name of the span to be created; must not be null + * @param operation the operation to execute within the span context; must not be null + * @return the result of the operation + * @throws Exception if an error occurs during the execution of the operation + */ + public T withSpan(String name, SpanOperation operation) throws Exception { + + return withSpan(name, SpanKind.INTERNAL, Attributes.empty(), operation); + } + + /** + * Executes a given operation within the context of an OpenTelemetry span + * with the specified name, kind, and attributes. The span is created and + * managed within the method. Any exceptions thrown during the operation + * are recorded in the span before being propagated. + * + * @param the type of result returned by the operation + * @param name the name of the span to be created; must not be null + * @param kind the kind of the span, such as {@code SpanKind.INTERNAL} + * or {@code SpanKind.CLIENT}; must not be null + * @param attributes the attributes to associate with the span; must not be null + * @param operation the operation to execute within the span context; must not be null + * @return the result of the operation + * @throws Exception if an error occurs during the execution of the operation + */ + public T withSpan( + String name, + SpanKind kind, + Attributes attributes, + SpanOperation operation + ) throws Exception { + Objects.requireNonNull(operation, "operation must not be null"); + + try (SpanScope scope = addSpan(name, kind, attributes)) { + try { + return operation.execute(scope.span()); + } catch (Exception exception) { + scope.recordException(exception); + throw exception; + } + } + } + + /** + * Extracts a {@code Context} from the given carrier using the specified {@link TextMapGetter}. + * + * @param the type of the carrier from which the context is extracted + * @param carrier the carrier object that holds context propagation data; must not be null + * @param getter the {@link TextMapGetter} used to read propagation fields from the carrier; must not be null + * @return the extracted {@code Context}, or the current context if no context could be extracted + * @throws NullPointerException if the carrier or getter is null + */ + public Context extractContext(T carrier, TextMapGetter getter) { + + return extractContext(Context.current(), carrier, getter); + } + + /** + * Extracts a {@link Context} from the given carrier using the specified {@link TextMapGetter}. + * + * @param context the initial {@link Context} used as the baseline for extraction; must not be null + * @param carrier the carrier of the propagation fields; must not be null + * @param getter the {@link TextMapGetter} used to read propagation fields from the carrier; must not be null + * @return the extracted {@link Context} containing the propagated values + */ + public Context extractContext(Context context, T carrier, TextMapGetter getter) { + + Objects.requireNonNull(context, "context must not be null"); + Objects.requireNonNull(carrier, "carrier must not be null"); + Objects.requireNonNull(getter, "getter must not be null"); + + return propagator.extract(context, carrier, getter); + } + + /** + * Injects the current context into the specified carrier using the provided TextMapSetter. + * + * @param The type of the carrier into which the context will be injected. + * @param carrier The carrier object that will hold the injected context. + * @param setter The TextMapSetter implementation used to set the context into the carrier. + */ + public void injectContext(T carrier, TextMapSetter setter) { + + injectContext(Context.current(), carrier, setter); + } + + /** + * Injects the provided {@code Context} into the specified carrier using the given {@code TextMapSetter}. + * + * @param context the context to inject; must not be null + * @param carrier the carrier into which the context will be injected; must not be null + * @param setter the {@code TextMapSetter} used to define how the context is set on the carrier; must not be null + * @param the type of the carrier + */ + public void injectContext(Context context, T carrier, TextMapSetter setter) { + + Objects.requireNonNull(context, "context must not be null"); + Objects.requireNonNull(carrier, "carrier must not be null"); + Objects.requireNonNull(setter, "setter must not be null"); + + propagator.inject(context, carrier, setter); + } + + private static TextMapPropagator createDefaultPropagator() { + return OpenTelemetryProvider.propagator(); + } + + private static LambdaEventContextExtractorResolver createDefaultEventContextExtractorResolver() { + return LambdaEventContextExtractorResolver.create(); + } + + /** + * Creates and returns the default instance of the TracingOpenTelemetry. + * + * @return The default instance of TracingOpenTelemetry. + */ + public static TracingOpenTelemetry create() { + return DEFAULT_INSTANCE; + } + + /** + * Creates and returns a new instance of the Builder. + * + * @return a new Builder instance + */ + public static Builder builder() { + return new Builder(); + } + + /** + * Builder class for creating instances of TracingOpenTelemetry. + * This class provides a fluent API for configuring and constructing + * a TracingOpenTelemetry object. + *

+ * The Builder allows customization of the following components: + * - Tracer: A tracer instance used for tracing operations. + * - TextMapPropagator: A propagator responsible for context propagation. + * - LambdaEventContextExtractorResolver: A resolver for extracting context from Lambda events. + */ + public static final class Builder { + + private Tracer tracer; + private TextMapPropagator propagator = createDefaultPropagator(); + private LambdaEventContextExtractorResolver eventContextExtractorResolver = + createDefaultEventContextExtractorResolver(); + + /** + * Sets the tracer instance to be used for tracing operations. + * This method allows specifying a custom tracer, which will + * be used to create and manage spans in tracing contexts. + * + * @param tracer the tracer instance to be used for tracing + * @return the updated Builder instance for method chaining + */ + public Builder tracer(Tracer tracer) { + this.tracer = tracer; + return this; + } + + /** + * Sets the {@link TextMapPropagator} to be used for context propagation. + * This allows specifying a custom propagator to handle the injection and extraction + * of context data across process boundaries. + * + * @param propagator the {@link TextMapPropagator} instance to be used for context propagation + * @return the updated Builder instance for method chaining + */ + public Builder propagator(TextMapPropagator propagator) { + this.propagator = propagator; + return this; + } + + /** + * Sets the {@link LambdaEventContextExtractorResolver} to be used for extracting + * context from AWS Lambda events. This allows specifying a custom resolver + * to handle the extraction of trace context from various types of AWS Lambda + * event sources. + * + * @param eventContextExtractorResolver the {@link LambdaEventContextExtractorResolver} instance + * to be used for extracting trace context from Lambda events + * @return the updated Builder instance for method chaining + */ + public Builder eventContextExtractorResolver( + LambdaEventContextExtractorResolver eventContextExtractorResolver) { + this.eventContextExtractorResolver = eventContextExtractorResolver; + return this; + } + + /** + * Constructs a new instance of TracingOpenTelemetry using the current state of the Builder. + * This method finalizes the configuration and returns the configured TracingOpenTelemetry instance. + * + * @return a fully configured TracingOpenTelemetry instance + */ + public TracingOpenTelemetry build() { + return new TracingOpenTelemetry(this); + } + } +} \ No newline at end of file diff --git a/powertools-tracing-opentelemetry/src/main/java/software/amazon/lambda/powertools/tracing/opentelemetry/context/ApiGatewayTraceContextExtractor.java b/powertools-tracing-opentelemetry/src/main/java/software/amazon/lambda/powertools/tracing/opentelemetry/context/ApiGatewayTraceContextExtractor.java new file mode 100644 index 000000000..a6aa98917 --- /dev/null +++ b/powertools-tracing-opentelemetry/src/main/java/software/amazon/lambda/powertools/tracing/opentelemetry/context/ApiGatewayTraceContextExtractor.java @@ -0,0 +1,124 @@ +/* + * 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.tracing.opentelemetry.context; + +import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyRequestEvent; +import io.opentelemetry.api.trace.Span; +import io.opentelemetry.api.trace.SpanKind; +import io.opentelemetry.context.Context; +import io.opentelemetry.context.propagation.TextMapPropagator; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; +import software.amazon.lambda.powertools.tracing.opentelemetry.provider.OpenTelemetryProvider; + +/** + * An implementation of {@link LambdaEventContextExtractor} that extracts and enriches tracing context + * information from API Gateway events. This class supports distributed tracing by leveraging OpenTelemetry + * to propagate and enrich trace data from API Gateway-provided HTTP headers and metadata. + *

+ * This extractor handles events of type {@link APIGatewayProxyRequestEvent}. + */ +public final class ApiGatewayTraceContextExtractor implements LambdaEventContextExtractor { + + + @Override + public boolean supports(Object event) { + return event instanceof APIGatewayProxyRequestEvent; + } + + @Override + public ExtractedTraceContext extract(Object event, Context parentContext, TextMapPropagator propagator) { + + APIGatewayProxyRequestEvent apiGatewayEvent = (APIGatewayProxyRequestEvent) event; + + Map headers = apiGatewayEvent.getHeaders(); + + if (headers == null || headers.isEmpty()) { + return new ExtractedTraceContext(parentContext, List.of(), SpanKind.SERVER); + } + + Context context = propagator.extract( + parentContext, + headers, + OpenTelemetryProvider.textMapGetter() + ); + + return new ExtractedTraceContext(context, List.of(), SpanKind.SERVER); + } + + @Override + public void enrichSpan(Object event, Span span) { + + APIGatewayProxyRequestEvent apiGatewayEvent = (APIGatewayProxyRequestEvent) event; + + if (apiGatewayEvent.getHttpMethod() != null) { + span.setAttribute("http.request.method", apiGatewayEvent.getHttpMethod()); + } + + if (apiGatewayEvent.getPath() != null) { + span.setAttribute("url.path", apiGatewayEvent.getPath()); + } + + if (apiGatewayEvent.getQueryStringParameters() != null) { + + String queryString = apiGatewayEvent.getQueryStringParameters() + .entrySet() + .stream() + .map(entry -> entry.getKey() + "=" + entry.getValue()) + .collect(Collectors.joining("&")); + + span.setAttribute("url.query", queryString); + } + + if (apiGatewayEvent.getHeaders() != null) { + + apiGatewayEvent.getHeaders() + .entrySet() + .stream() + .filter(entry -> "user-agent".equalsIgnoreCase(entry.getKey())) + .map(Map.Entry::getValue) + .findFirst() + .ifPresent(userAgent -> span.setAttribute("user_agent.original", userAgent)); + } + + if (apiGatewayEvent.getRequestContext() != null) { + + APIGatewayProxyRequestEvent.ProxyRequestContext requestContext = + apiGatewayEvent.getRequestContext(); + + if (requestContext.getRequestId() != null) { + span.setAttribute("aws.request_id", requestContext.getRequestId()); + } + + if (requestContext.getStage() != null) { + span.setAttribute("aws.apigateway.stage", requestContext.getStage()); + } + + if (requestContext.getResourceId() != null) { + span.setAttribute("aws.apigateway.resource_id", requestContext.getResourceId()); + } + + if (requestContext.getResourcePath() != null) { + span.setAttribute("aws.apigateway.resource_path", requestContext.getResourcePath()); + } + } + + } + + +} \ No newline at end of file diff --git a/powertools-tracing-opentelemetry/src/main/java/software/amazon/lambda/powertools/tracing/opentelemetry/context/DynamoDbTraceContextExtractor.java b/powertools-tracing-opentelemetry/src/main/java/software/amazon/lambda/powertools/tracing/opentelemetry/context/DynamoDbTraceContextExtractor.java new file mode 100644 index 000000000..acdbe1bab --- /dev/null +++ b/powertools-tracing-opentelemetry/src/main/java/software/amazon/lambda/powertools/tracing/opentelemetry/context/DynamoDbTraceContextExtractor.java @@ -0,0 +1,97 @@ +/* + * 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.tracing.opentelemetry.context; + +import com.amazonaws.services.lambda.runtime.events.DynamodbEvent; +import io.opentelemetry.api.trace.Span; +import io.opentelemetry.api.trace.SpanKind; +import io.opentelemetry.context.Context; +import io.opentelemetry.context.propagation.TextMapPropagator; +import java.util.List; +import java.util.Objects; + +/** + * A specialized implementation of {@link LambdaEventContextExtractor} for handling AWS DynamoDB Streams events. + * This class enables extraction of tracing context, enrichment of OpenTelemetry spans, and determination of + * compatibility with DynamoDB Streams events for distributed tracing purposes. + *

+ * Instances of this class focus on the following: + * - Verifying if an event is a DynamoDB Streams event. + * - Extracting trace context information in scenarios where trace context propagation is applicable. + * - Enriching OpenTelemetry spans with metadata derived from DynamoDB Streams events, such as stream names + * and record batch sizes. + *

+ * Note: Due to limitations in DynamoDB Streams metadata, W3C trace context propagation (e.g., `traceparent`) + * is not supported by default. Future enhancements for dedicated propagation strategies may be required. + */ +public final class DynamoDbTraceContextExtractor implements LambdaEventContextExtractor { + + @Override + public boolean supports(Object event) { + return event instanceof DynamodbEvent; + } + + @Override + public ExtractedTraceContext extract(Object event, Context parentContext, TextMapPropagator propagator) { + + /* + * DynamoDB Streams records do not expose message attributes + * that can be used for W3C trace context propagation. + * + * Do not assume that traceparent is stored inside the DynamoDB + * record payload. Propagation through DynamoDB Streams should be + * defined by a dedicated propagation strategy if supported in + * the future. + */ + return new ExtractedTraceContext(parentContext, List.of(), SpanKind.CONSUMER); + } + + @Override + public void enrichSpan(Object event, Span span) { + + DynamodbEvent dynamoDBEvent = (DynamodbEvent) event; + + if (dynamoDBEvent.getRecords() == null || dynamoDBEvent.getRecords().isEmpty()) { + return; + } + + DynamodbEvent.DynamodbStreamRecord record = dynamoDBEvent.getRecords() + .stream() + .filter(Objects::nonNull) + .findFirst() + .orElse(null); + + if (record == null) { + return; + } + + span.setAttribute("messaging.system", "aws.dynamodb"); + + span.setAttribute("messaging.batch.message_count", dynamoDBEvent.getRecords().size()); + if (record.getEventSourceARN() != null) { + span.setAttribute("messaging.destination.name", extractStreamName(record.getEventSourceARN())); + } + } + + private String extractStreamName(String streamArn) { + int separator = streamArn.lastIndexOf('/'); + + return separator >= 0 + ? streamArn.substring(separator + 1) + : streamArn; + } +} diff --git a/powertools-tracing-opentelemetry/src/main/java/software/amazon/lambda/powertools/tracing/opentelemetry/context/ExtractedTraceContext.java b/powertools-tracing-opentelemetry/src/main/java/software/amazon/lambda/powertools/tracing/opentelemetry/context/ExtractedTraceContext.java new file mode 100644 index 000000000..3a7abd2e7 --- /dev/null +++ b/powertools-tracing-opentelemetry/src/main/java/software/amazon/lambda/powertools/tracing/opentelemetry/context/ExtractedTraceContext.java @@ -0,0 +1,81 @@ +/* + * 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.tracing.opentelemetry.context; + +import io.opentelemetry.api.trace.SpanContext; +import io.opentelemetry.api.trace.SpanKind; +import io.opentelemetry.context.Context; +import java.util.List; + +/** + * Represents the trace context extracted during event processing in an OpenTelemetry-based tracing system. + * This class encapsulates the parent context, a collection of span contexts, and the span kind associated + * with the extracted trace. + *

+ * Instances of this class are immutable, ensuring thread-safety when utilized in multi-threaded environments. + */ +public final class ExtractedTraceContext { + + private final Context parentContext; + private final List spanContexts; + private final SpanKind spanKind; + + public ExtractedTraceContext(Context parentContext, List spanContexts, SpanKind spanKind) { + this.parentContext = parentContext; + this.spanContexts = spanContexts; + this.spanKind = spanKind; + } + + public ExtractedTraceContext(Context parentContext, List spanContexts) { + this.parentContext = parentContext; + this.spanContexts = spanContexts; + this.spanKind = SpanKind.SERVER; + } + + /** + * Returns the parent context associated with this extracted trace context. + * The parent context provides the linkage to the pre-existing context + * in the OpenTelemetry system, enabling context propagation. + * + * @return the parent {@link Context} of this extracted trace context + */ + public Context context() { + return parentContext; + } + + /** + * Returns the collection of {@link SpanContext} instances associated with this extracted trace context. + * Span contexts represent individual trace spans, enabling correlation and telemetry processing + * across distributed systems. + * + * @return a list of {@link SpanContext} instances associated with this trace context + */ + public List spanContexts() { + return spanContexts; + } + + /** + * Returns the span kind associated with this extracted trace context. + * The span kind indicates the role of the span in a distributed trace, + * such as SERVER, CLIENT, PRODUCER, or CONSUMER. + * + * @return the {@link SpanKind} of this extracted trace context + */ + public SpanKind spanKind() { + return spanKind; + } +} diff --git a/powertools-tracing-opentelemetry/src/main/java/software/amazon/lambda/powertools/tracing/opentelemetry/context/KinesisTraceContextExtractor.java b/powertools-tracing-opentelemetry/src/main/java/software/amazon/lambda/powertools/tracing/opentelemetry/context/KinesisTraceContextExtractor.java new file mode 100644 index 000000000..739fcb9a7 --- /dev/null +++ b/powertools-tracing-opentelemetry/src/main/java/software/amazon/lambda/powertools/tracing/opentelemetry/context/KinesisTraceContextExtractor.java @@ -0,0 +1,117 @@ +/* + * 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.tracing.opentelemetry.context; + +import com.amazonaws.services.lambda.runtime.events.KinesisEvent; +import io.opentelemetry.api.trace.Span; +import io.opentelemetry.api.trace.SpanKind; +import io.opentelemetry.context.Context; +import io.opentelemetry.context.propagation.TextMapPropagator; +import java.util.List; + +/** + * An implementation of the {@link LambdaEventContextExtractor} interface designed + * for AWS Lambda functions that are triggered by Kinesis events. This class + * provides methods for extracting trace context, determining support for + * Kinesis events, and enriching spans with metadata specific to Kinesis. + *

+ * Trace propagation for Kinesis is limited, as Kinesis records do not inherently + * include W3C trace context propagation attributes. As such, this implementation + * assumes trace context is not present in the payload and instead defines how + * future propagation strategies could be supported. + *

+ * This class primarily handles the following responsibilities: + * - Identifies whether a given event is a Kinesis event. + * - Extracts minimal trace context from a Kinesis event, returning a consumer + * span kind without assuming additional trace attributes. + * - Enriches spans with Kinesis-specific attributes, such as partition key, + * sequence number, approximate arrival timestamp, and stream name. + *

+ * It is intended for use in distributed tracing scenarios within AWS Lambda + * functions, ensuring that spans generated for Kinesis events are annotated + * with meaningful metadata. + */ +public final class KinesisTraceContextExtractor + implements LambdaEventContextExtractor { + + @Override + public boolean supports(Object event) { + return event instanceof KinesisEvent; + } + + @Override + public ExtractedTraceContext extract(Object event, Context parentContext, TextMapPropagator propagator) { + + /* + * Kinesis records do not expose message attributes + * that can be used for W3C trace context propagation. + * + * Do not assume that traceparent is stored inside the Kinesis + * record payload. Propagation through Kinesis should be + * defined by a dedicated propagation strategy if supported in + * the future. + */ + + return new ExtractedTraceContext(parentContext, List.of(), SpanKind.CONSUMER); + } + + @Override + public void enrichSpan(Object event, Span span) { + + KinesisEvent kinesisEvent = (KinesisEvent) event; + + if (kinesisEvent.getRecords() == null || kinesisEvent.getRecords().isEmpty()) { + return; + } + + KinesisEvent.KinesisEventRecord firstRecord = kinesisEvent.getRecords().get(0); + + if (firstRecord == null || firstRecord.getKinesis() == null) { + return; + } + + KinesisEvent.Record kinesis = firstRecord.getKinesis(); + + span.setAttribute("messaging.system", "aws.kinesis"); + + if (kinesis.getPartitionKey() != null) { + span.setAttribute("messaging.partition_key", kinesis.getPartitionKey()); + } + + if (kinesis.getSequenceNumber() != null) { + span.setAttribute("messaging.message.id", kinesis.getSequenceNumber()); + } + + if (kinesis.getApproximateArrivalTimestamp() != null) { + span.setAttribute("messaging.message.receive.timestamp", + kinesis.getApproximateArrivalTimestamp().getTime()); + } + + if (firstRecord.getEventSourceARN() != null) { + span.setAttribute("messaging.destination.name", extractStreamName(firstRecord.getEventSourceARN())); + } + } + + + private String extractStreamName(String arn) { + int separator = arn.lastIndexOf('/'); + + return separator >= 0 + ? arn.substring(separator + 1) + : arn; + } +} diff --git a/powertools-tracing-opentelemetry/src/main/java/software/amazon/lambda/powertools/tracing/opentelemetry/context/LambdaEventContextExtractor.java b/powertools-tracing-opentelemetry/src/main/java/software/amazon/lambda/powertools/tracing/opentelemetry/context/LambdaEventContextExtractor.java new file mode 100644 index 000000000..8bc2c9741 --- /dev/null +++ b/powertools-tracing-opentelemetry/src/main/java/software/amazon/lambda/powertools/tracing/opentelemetry/context/LambdaEventContextExtractor.java @@ -0,0 +1,72 @@ +/* + * 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.tracing.opentelemetry.context; + +import io.opentelemetry.api.trace.Span; +import io.opentelemetry.context.Context; +import io.opentelemetry.context.propagation.TextMapPropagator; + +/** + * Defines a contract for extracting, enriching, and validating tracing context information + * from AWS Lambda event objects in order to support distributed tracing. + *

+ * Implementations of this interface are intended to handle specific types of AWS Lambda + * event sources, such as SQS, SNS, DynamoDB, Kinesis, or API Gateway events. The methods + * within this interface facilitate propagating and enriching trace information within + * OpenTelemetry spans and contexts. + */ +public interface LambdaEventContextExtractor { + + /** + * Determines whether the provided event is supported by this context extractor. + * + * @param event The AWS Lambda event to check for compatibility. Typically, this would + * be an event source object such as SQS, SNS, DynamoDB, Kinesis, API + * Gateway, or other supported AWS Lambda event types. + * @return true if the given event type is supported by this extractor; + * false otherwise. + */ + boolean supports(Object event); + + /** + * Enriches the provided OpenTelemetry span with metadata extracted from the given AWS Lambda event. + * This method is intended to populate the span with attributes that are specific to the event type, + * such as metadata about the source, destination, or other relevant contextual information. + * + * @param event The AWS Lambda event object containing the data from which span attributes are derived. + * This could be an event-specific object, such as an S3Event, SQS event, or API Gateway event. + * @param span The OpenTelemetry {@link Span} to be enriched with attributes based on the provided event. + */ + void enrichSpan(Object event, Span span); + + /** + * Extracts trace context information from the given AWS Lambda event to facilitate distributed tracing. + * This method utilizes the provided `TextMapPropagator` to extract trace context information and creates + * an {@link ExtractedTraceContext} object containing the extracted data. + * + * @param event The AWS Lambda event object from which trace context should be extracted. This could be + * an event-specific object like S3Event, SQS event, or API Gateway event. + * @param parentContext The parent OpenTelemetry {@link Context} that serves as the starting point for + * trace extraction. This is typically passed from the Lambda function's invocation. + * @param propagator A {@link TextMapPropagator} instance used to extract trace context from the event + * metadata or headers. + * @return An {@link ExtractedTraceContext} containing the extracted trace data, including the parent context, + * span contexts, and span kind. If no trace information is found, an {@link ExtractedTraceContext} + * with an empty list of span contexts is returned. + */ + ExtractedTraceContext extract(Object event, Context parentContext, TextMapPropagator propagator); +} \ No newline at end of file diff --git a/powertools-tracing-opentelemetry/src/main/java/software/amazon/lambda/powertools/tracing/opentelemetry/context/LambdaEventContextExtractorResolver.java b/powertools-tracing-opentelemetry/src/main/java/software/amazon/lambda/powertools/tracing/opentelemetry/context/LambdaEventContextExtractorResolver.java new file mode 100644 index 000000000..a020207ff --- /dev/null +++ b/powertools-tracing-opentelemetry/src/main/java/software/amazon/lambda/powertools/tracing/opentelemetry/context/LambdaEventContextExtractorResolver.java @@ -0,0 +1,103 @@ +/* + * 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.tracing.opentelemetry.context; + +import io.opentelemetry.api.trace.Span; +import io.opentelemetry.context.Context; +import io.opentelemetry.context.propagation.TextMapPropagator; +import java.util.List; + +/** + * Resolves and delegates processing of Lambda-specific event contexts to the appropriate + * {@link LambdaEventContextExtractor} implementation based on the event type. + * This resolver allows for dynamic extraction and span enrichment tailored to + * various AWS Lambda event sources (e.g., API Gateway, SQS, SNS, etc.). + *

+ * This class is immutable and thread-safe. + */ +public final class LambdaEventContextExtractorResolver { + + private final List extractors; + + public LambdaEventContextExtractorResolver(List extractors) { + + this.extractors = List.copyOf(extractors); + } + + /** + * Creates and returns an instance of {@link LambdaEventContextExtractorResolver} configured with + * a predefined set of {@link LambdaEventContextExtractor} implementations. These extractors are specialized in + * processing different types of AWS Lambda event sources, such as API Gateway, SQS, SNS, Kinesis, DynamoDB, and S3. + * + * @return a new instance of {@link LambdaEventContextExtractorResolver} with predefined extractors for + * handling various AWS Lambda event contexts. + */ + public static LambdaEventContextExtractorResolver create() { + return new LambdaEventContextExtractorResolver( + List.of( + new ApiGatewayTraceContextExtractor(), + new SqsTraceContextExtractor(), + new SnsTraceContextExtractor(), + new KinesisTraceContextExtractor(), + new DynamoDbTraceContextExtractor(), + new S3TraceContextExtractor() + ) + ); + } + + /** + * Extracts trace context information from a Lambda event using the appropriate + * {@link LambdaEventContextExtractor} implementation that supports the event type. + * This method delegates the extraction to the first extractor in the configured list + * that supports the provided event type. If no suitable extractor is found, a default + * {@link ExtractedTraceContext} is returned using the provided parent context. + * + * @param event the Lambda event from which to extract the trace context + * @param parentContext the parent {@link Context} to be used as the base for the extraction + * @param propagator the {@link TextMapPropagator} used to extract propagation information from the event + * @return an {@link ExtractedTraceContext} containing the extracted trace context or a default one + * if no supporting extractor is found + */ + public ExtractedTraceContext extract(Object event, Context parentContext, TextMapPropagator propagator) { + + return extractors.stream() + .filter(extractor -> extractor.supports(event)) + .findFirst() + .map(extractor -> + extractor.extract( + event, + parentContext, + propagator)) + .orElse(new ExtractedTraceContext(parentContext, List.of())); + } + + /** + * Enriches a given {@link Span} with contextual information extracted from + * the specified event. This method evaluates a list of configured extractors + * and delegates the enrichment process to the first extractor that supports + * the provided event type. + * + * @param event the event object containing context information to be added to the span + * @param span the {@link Span} instance to be enriched with extracted information + */ + public void enrichSpan(Object event, Span span) { + extractors.stream() + .filter(extractor -> extractor.supports(event)) + .findFirst() + .ifPresent(extractor -> extractor.enrichSpan(event, span)); + } +} diff --git a/powertools-tracing-opentelemetry/src/main/java/software/amazon/lambda/powertools/tracing/opentelemetry/context/S3TraceContextExtractor.java b/powertools-tracing-opentelemetry/src/main/java/software/amazon/lambda/powertools/tracing/opentelemetry/context/S3TraceContextExtractor.java new file mode 100644 index 000000000..f6e314f5a --- /dev/null +++ b/powertools-tracing-opentelemetry/src/main/java/software/amazon/lambda/powertools/tracing/opentelemetry/context/S3TraceContextExtractor.java @@ -0,0 +1,91 @@ +/* + * 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.tracing.opentelemetry.context; + +import com.amazonaws.services.lambda.runtime.events.S3Event; +import com.amazonaws.services.lambda.runtime.events.models.s3.S3EventNotification; +import io.opentelemetry.api.trace.Span; +import io.opentelemetry.api.trace.SpanKind; +import io.opentelemetry.context.Context; +import io.opentelemetry.context.propagation.TextMapPropagator; +import java.util.List; +import java.util.Objects; + +/** + * A context extractor implementation for handling AWS S3 event notifications within an AWS Lambda environment. + * This extractor is responsible for determining if an event can be processed, extracting trace context + * information, and enriching spans with metadata related to the S3 event. + *

+ * This implementation assumes that S3 event payloads do not contain trace context attributes (e.g., + * traceparent or tracestate) and handles them accordingly. + */ +public final class S3TraceContextExtractor implements LambdaEventContextExtractor { + + @Override + public boolean supports(Object event) { + return event instanceof S3Event; + } + + @Override + public ExtractedTraceContext extract(Object event, Context parentContext, TextMapPropagator propagator) { + + /* + * S3 event notifications do not expose message attributes + * equivalent to SQS/SNS that can be passed directly to a + * TextMapPropagator. + * + * Do not assume that traceparent/tracestate are embedded + * inside the S3 event payload. + */ + return new ExtractedTraceContext(parentContext, List.of(), SpanKind.CONSUMER); + } + + @Override + public void enrichSpan(Object event, Span span) { + + S3Event s3Event = (S3Event) event; + + if (s3Event.getRecords() == null || s3Event.getRecords().isEmpty()) { + return; + } + + span.setAttribute("messaging.system", "aws.s3"); + + span.setAttribute("messaging.batch.message_count", s3Event.getRecords().size()); + + S3EventNotification.S3EventNotificationRecord record = + s3Event.getRecords() + .stream() + .filter(Objects::nonNull) + .findFirst() + .orElse(null); + + if (record == null || record.getS3() == null) { + return; + } + + if (record.getS3().getBucket() != null + && record.getS3().getBucket().getName() != null) { + + span.setAttribute("messaging.destination.name", record.getS3().getBucket().getName()); + } + + if (record.getEventName() != null) { + span.setAttribute("messaging.event.type", record.getEventName()); + } + } +} diff --git a/powertools-tracing-opentelemetry/src/main/java/software/amazon/lambda/powertools/tracing/opentelemetry/context/SnsTraceContextExtractor.java b/powertools-tracing-opentelemetry/src/main/java/software/amazon/lambda/powertools/tracing/opentelemetry/context/SnsTraceContextExtractor.java new file mode 100644 index 000000000..3bd260554 --- /dev/null +++ b/powertools-tracing-opentelemetry/src/main/java/software/amazon/lambda/powertools/tracing/opentelemetry/context/SnsTraceContextExtractor.java @@ -0,0 +1,146 @@ +/* + * 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.tracing.opentelemetry.context; + +import com.amazonaws.services.lambda.runtime.events.SNSEvent; +import io.opentelemetry.api.trace.Span; +import io.opentelemetry.api.trace.SpanContext; +import io.opentelemetry.api.trace.SpanKind; +import io.opentelemetry.context.Context; +import io.opentelemetry.context.propagation.TextMapPropagator; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; +import software.amazon.lambda.powertools.tracing.opentelemetry.provider.OpenTelemetryProvider; + +/** + * An implementation of {@link LambdaEventContextExtractor} specifically designed to handle AWS Simple Notification + * Service (SNS) events. + * This class provides mechanisms to extract trace context from SNS event records and enrich spans with relevant + * messaging attributes. + * It supports processing instances of {@code SNSEvent}. + * + *

    + *
  • {@code supports}: Determines if the given event is an instance of SNS event.
  • + *
  • {@code extract}: Extracts trace context data from message attributes of the SNS event records and generates + * an {@link ExtractedTraceContext}.
  • + *
  • {@code enrichSpan}: Enriches the span with attributes pertaining to the SNS messaging system, such as the + * topic name and messaging system specific values.
  • + *
+ *

+ * This class also ensures trace propagation by parsing SNS message attributes and converting them into OpenTelemetry + * context. + * It supports multi-record SNS events and handles cases where certain records or attributes are invalid. + */ +public final class SnsTraceContextExtractor implements LambdaEventContextExtractor { + + @Override + public boolean supports(Object event) { + return event instanceof SNSEvent; + } + + @Override + public ExtractedTraceContext extract(Object event, Context parentContext, TextMapPropagator propagator) { + + SNSEvent snsEvent = (SNSEvent) event; + + if (snsEvent.getRecords() == null || snsEvent.getRecords().isEmpty()) { + return new ExtractedTraceContext(parentContext, List.of(), SpanKind.CONSUMER); + } + + List spanContexts = new ArrayList<>(); + + for (SNSEvent.SNSRecord record : snsEvent.getRecords()) { + + if (record == null || record.getSNS() == null) { + continue; + } + + Map attributes = record.getSNS().getMessageAttributes(); + + if (attributes == null || attributes.isEmpty()) { + continue; + } + + Map propagationAttributes = attributes.entrySet() + .stream() + .filter(entry -> entry.getValue() != null) + .filter(entry -> entry.getValue().getValue() != null) + .collect(Collectors.toMap( + Map.Entry::getKey, + entry -> entry.getValue().getValue() + )); + + if (propagationAttributes.isEmpty()) { + continue; + } + + Context extractedContext = propagator.extract( + Context.root(), + propagationAttributes, + OpenTelemetryProvider.textMapGetter() + ); + + SpanContext spanContext = Span.fromContext(extractedContext).getSpanContext(); + + if (spanContext.isValid()) { + spanContexts.add(spanContext); + } + } + + Context parent = spanContexts.isEmpty() + ? parentContext + : Context.root().with(Span.wrap(spanContexts.get(0))); + + return new ExtractedTraceContext(parent, spanContexts, SpanKind.CONSUMER); + } + + @Override + public void enrichSpan(Object event, Span span) { + + SNSEvent snsEvent = (SNSEvent) event; + + if (snsEvent.getRecords() == null || snsEvent.getRecords().isEmpty()) { + return; + } + + SNSEvent.SNSRecord record = snsEvent.getRecords() + .stream() + .filter(r -> r != null && r.getSNS() != null) + .findFirst() + .orElse(null); + + if (record == null) { + return; + } + + span.setAttribute("messaging.system", "aws.sns"); + + if (record.getSNS().getTopicArn() != null) { + span.setAttribute("messaging.destination.name", extractTopicName(record.getSNS().getTopicArn())); + } + } + + private String extractTopicName(String topicArn) { + int separator = topicArn.lastIndexOf(':'); + + return separator >= 0 + ? topicArn.substring(separator + 1) + : topicArn; + } +} \ No newline at end of file diff --git a/powertools-tracing-opentelemetry/src/main/java/software/amazon/lambda/powertools/tracing/opentelemetry/context/SqsTraceContextExtractor.java b/powertools-tracing-opentelemetry/src/main/java/software/amazon/lambda/powertools/tracing/opentelemetry/context/SqsTraceContextExtractor.java new file mode 100644 index 000000000..d8427e5f8 --- /dev/null +++ b/powertools-tracing-opentelemetry/src/main/java/software/amazon/lambda/powertools/tracing/opentelemetry/context/SqsTraceContextExtractor.java @@ -0,0 +1,137 @@ +/* + * 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.tracing.opentelemetry.context; + +import com.amazonaws.services.lambda.runtime.events.SQSEvent; +import io.opentelemetry.api.trace.Span; +import io.opentelemetry.api.trace.SpanContext; +import io.opentelemetry.api.trace.SpanKind; +import io.opentelemetry.context.Context; +import io.opentelemetry.context.propagation.TextMapPropagator; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; +import software.amazon.lambda.powertools.tracing.opentelemetry.provider.OpenTelemetryProvider; + +/** + * SqsTraceContextExtractor is responsible for extracting and enriching trace context + * information from AWS SQS events in the context of AWS Lambda functions. It implements + * the {@code LambdaEventContextExtractor} interface, providing functionality to determine + * support for an event, extract trace context, and enrich spans with additional attributes. + *

+ * The class processes SQS events by iterating through the batch of SQS messages, extracting + * propagation headers from message attributes, and building trace context information to be + * propagated and used by OpenTelemetry. + *

+ * Key functionalities include: + * - Determining if the extractor supports the provided event. + * - Extracting trace context from propagation headers present in SQS message attributes. + * - Enriching spans with messaging system details, including the number of messages in a batch + * and the queue name from the event source. + *

+ * This class is intended for use with AWS Lambda functions processing SQS events for tracing + * distributed systems. + *

+ * Thread-safety: This class is immutable and thread-safe. + */ +public final class SqsTraceContextExtractor implements LambdaEventContextExtractor { + + @Override + public boolean supports(Object event) { + return event instanceof SQSEvent; + } + + @Override + public ExtractedTraceContext extract(Object event, Context parentContext, TextMapPropagator propagator) { + + SQSEvent sqsEvent = (SQSEvent) event; + + if (sqsEvent.getRecords() == null || sqsEvent.getRecords().isEmpty()) { + return new ExtractedTraceContext(parentContext, List.of(), SpanKind.CONSUMER); + } + + List spanContexts = new ArrayList<>(); + + for (SQSEvent.SQSMessage message : sqsEvent.getRecords()) { + + if (message == null || message.getMessageAttributes() == null) { + continue; + } + + Map attributes = message.getMessageAttributes(); + + if (attributes.isEmpty()) { + continue; + } + + Map propagationAttributes = attributes.entrySet() + .stream() + .filter(entry -> entry.getValue() != null) + .filter(entry -> entry.getValue().getStringValue() != null) + .collect(Collectors.toMap( + Map.Entry::getKey, + entry -> entry.getValue().getStringValue() + )); + + Context extractedContext = propagator.extract( + Context.root(), + propagationAttributes, + OpenTelemetryProvider.textMapGetter() + ); + + SpanContext spanContext = Span.fromContext(extractedContext).getSpanContext(); + + if (spanContext.isValid()) { + spanContexts.add(spanContext); + } + } + + Context parent = spanContexts.isEmpty() + ? parentContext + : Context.root().with(Span.wrap(spanContexts.get(0))); + + return new ExtractedTraceContext(parent, spanContexts, SpanKind.CONSUMER); + } + + @Override + public void enrichSpan(Object event, Span span) { + SQSEvent sqsEvent = (SQSEvent) event; + + if (sqsEvent.getRecords() == null || sqsEvent.getRecords().isEmpty()) { + return; + } + + span.setAttribute("messaging.system", "aws.sqs"); + + span.setAttribute("messaging.batch.message_count", sqsEvent.getRecords().size()); + + SQSEvent.SQSMessage message = sqsEvent.getRecords().get(0); + + if (message.getEventSourceArn() != null) { + span.setAttribute("messaging.destination.name", extractQueueName(message.getEventSourceArn())); + } + } + + private String extractQueueName(String arn) { + int separator = arn.lastIndexOf(':'); + + return separator >= 0 + ? arn.substring(separator + 1) + : arn; + } +} \ No newline at end of file diff --git a/powertools-tracing-opentelemetry/src/main/java/software/amazon/lambda/powertools/tracing/opentelemetry/context/TraceContextPropagationMode.java b/powertools-tracing-opentelemetry/src/main/java/software/amazon/lambda/powertools/tracing/opentelemetry/context/TraceContextPropagationMode.java new file mode 100644 index 000000000..407ff58c9 --- /dev/null +++ b/powertools-tracing-opentelemetry/src/main/java/software/amazon/lambda/powertools/tracing/opentelemetry/context/TraceContextPropagationMode.java @@ -0,0 +1,28 @@ +/* + * 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.tracing.opentelemetry.context; + +/** + * Enum representing different modes of trace context propagation. + *

+ * Trace context propagation defines how tracing information is passed + * between distributed systems to capture the relationship between trace spans. + */ +public enum TraceContextPropagationMode { + PARENT, + LINK +} diff --git a/powertools-tracing-opentelemetry/src/main/java/software/amazon/lambda/powertools/tracing/opentelemetry/internal/AttributesConstants.java b/powertools-tracing-opentelemetry/src/main/java/software/amazon/lambda/powertools/tracing/opentelemetry/internal/AttributesConstants.java new file mode 100644 index 000000000..d299b2b78 --- /dev/null +++ b/powertools-tracing-opentelemetry/src/main/java/software/amazon/lambda/powertools/tracing/opentelemetry/internal/AttributesConstants.java @@ -0,0 +1,60 @@ +/* + * 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.tracing.opentelemetry.internal; + +/** + * A utility class that holds constant values for various attribute names and configurations + * used in AWS Lambda and Powertools for AWS Lambda. These constants are mainly used for + * telemetry, tracing, and environment variable configuration within the application. + *

+ * This class is designed as a final class with a private constructor to prevent instantiation + * and ensure it acts solely as a container for constants. + */ +public final class AttributesConstants { + + private AttributesConstants() { + // Constant holder class + } + + public static final String AWS_LAMBDA_FUNCTION_NAME = "AWS_LAMBDA_FUNCTION_NAME"; + + public static final String AWS_LAMBDA_FUNCTION_VERSION = "AWS_LAMBDA_FUNCTION_VERSION"; + + public static final String AWS_LAMBDA_FUNCTION_MEMORY_SIZE = "AWS_LAMBDA_FUNCTION_MEMORY_SIZE"; + + public static final String AWS_LAMBDA_LOG_STREAM_NAME = "AWS_LAMBDA_LOG_STREAM_NAME"; + + public static final String AWS_REGION = "AWS_REGION"; + + public static final String AWS_LAMBDA_FUNCTION_ARN = "AWS_LAMBDA_FUNCTION_ARN"; + + public static final String TELEMETRY_DISTRO_NAME = "powertools-for-aws-lambda"; + + public static final String FAAS_COLDSTART = "faas.coldstart"; + + public static final String FAAS_INVOCATION_ID = "faas.invocation_id"; + + public static final String RESPONSE_ATTRIBUTE = "aws.lambda.powertools.response"; + + public static final String CAPTURE_RESPONSE_ENV = "POWERTOOLS_TRACER_CAPTURE_RESPONSE"; + + public static final String CAPTURE_ERROR_ENV = "POWERTOOLS_TRACER_CAPTURE_ERROR"; + + public static final String TRACEPARENT = "traceparent"; + + public static final String TRACESTATE = "tracestate"; +} diff --git a/powertools-tracing-opentelemetry/src/main/java/software/amazon/lambda/powertools/tracing/opentelemetry/internal/LambdaResource.java b/powertools-tracing-opentelemetry/src/main/java/software/amazon/lambda/powertools/tracing/opentelemetry/internal/LambdaResource.java new file mode 100644 index 000000000..1f8646377 --- /dev/null +++ b/powertools-tracing-opentelemetry/src/main/java/software/amazon/lambda/powertools/tracing/opentelemetry/internal/LambdaResource.java @@ -0,0 +1,159 @@ +/* + * 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.tracing.opentelemetry.internal; + +import io.opentelemetry.api.common.Attributes; +import io.opentelemetry.api.common.AttributesBuilder; +import io.opentelemetry.sdk.resources.Resource; +import software.amazon.lambda.powertools.common.internal.SystemWrapper; + +/** + * The {@code LambdaResource} class is a utility for creating a representation of + * an AWS Lambda execution environment in the form of a {@code Resource} object. + * It extracts and structures metadata about the Lambda function's runtime environment, + * which is useful for telemetry and observability purposes. + * + *

Responsibilities:

+ *
    + * - Populates resource attributes based on AWS Lambda-specific environment variables. + * - Includes attributes related to the cloud provider, service, function details, and + * OpenTelemetry metadata. + * - Processes function execution context such as memory size and account ID from the + * AWS Lambda environment. + * - Ensures only relevant and non-empty values are added as attributes. + *

    + * This class is designed to be final and non-instantiable, serving purely as a + * container for a static method. + */ +public final class LambdaResource { + + private LambdaResource() { + } + + /** + * Creates a Resource instance populated with attributes derived from the + * AWS Lambda environment. The attributes include cloud provider information, + * service details, function memory size, account ID, and OpenTelemetry metadata. + *

    + * It retrieves environment variables specific to AWS Lambda and processes + * them to build a comprehensive resource description. + * + * @return a Resource object containing attributes about the AWS Lambda environment + */ + public static Resource create() { + AttributesBuilder attributes = Attributes.builder(); + + putIfPresent( + attributes, + "cloud.provider", + "aws" + ); + + putIfPresent( + attributes, + "cloud.region", + SystemWrapper.getenv(AttributesConstants.AWS_REGION) + ); + + putIfPresent( + attributes, + "service.name", + SystemWrapper.getenv(AttributesConstants.AWS_LAMBDA_FUNCTION_NAME) + ); + + putIfPresent( + attributes, + "service.version", + SystemWrapper.getenv(AttributesConstants.AWS_LAMBDA_FUNCTION_VERSION) + ); + + putIfPresent( + attributes, + "faas.name", + SystemWrapper.getenv(AttributesConstants.AWS_LAMBDA_FUNCTION_NAME) + ); + + putIfPresent( + attributes, + "faas.version", + SystemWrapper.getenv(AttributesConstants.AWS_LAMBDA_FUNCTION_VERSION) + ); + + putIfPresent( + attributes, + "faas.instance", + SystemWrapper.getenv(AttributesConstants.AWS_LAMBDA_LOG_STREAM_NAME) + ); + + String memory = SystemWrapper.getenv(AttributesConstants.AWS_LAMBDA_FUNCTION_MEMORY_SIZE); + + if (memory != null) { + attributes.put( + "faas.max_memory", + Long.parseLong(memory) + ); + } + + String functionArn = SystemWrapper.getenv(AttributesConstants.AWS_LAMBDA_FUNCTION_ARN); + + if (functionArn != null) { + String accountId = extractAccountId(functionArn); + + if (accountId != null) { + attributes.put( + "cloud.account.id", + accountId + ); + } + } + + attributes.put( + "telemetry.sdk.name", + "opentelemetry" + ); + + attributes.put( + "telemetry.distro.name", + AttributesConstants.TELEMETRY_DISTRO_NAME + ); + + attributes.put( + "telemetry.sdk.language", + "java" + ); + + return Resource.create(attributes.build()); + } + + private static void putIfPresent( + AttributesBuilder attributes, + String key, + String value) { + + if (value != null && !value.isBlank()) { + attributes.put(key, value); + } + } + + private static String extractAccountId(String arn) { + String[] parts = arn.split(":"); + + return parts.length > 4 + ? parts[4] + : null; + } +} \ No newline at end of file diff --git a/powertools-tracing-opentelemetry/src/main/java/software/amazon/lambda/powertools/tracing/opentelemetry/internal/SpanOperation.java b/powertools-tracing-opentelemetry/src/main/java/software/amazon/lambda/powertools/tracing/opentelemetry/internal/SpanOperation.java new file mode 100644 index 000000000..174829d5a --- /dev/null +++ b/powertools-tracing-opentelemetry/src/main/java/software/amazon/lambda/powertools/tracing/opentelemetry/internal/SpanOperation.java @@ -0,0 +1,47 @@ +/* + * 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.tracing.opentelemetry.internal; + +import io.opentelemetry.api.trace.Span; + +/** + * Represents a functional interface used to execute a custom operation within + * the context of a given {@link Span}. This interface requires implementing a + * single method that performs an operation with the span and optionally + * returns a result. + * + *

    + * The {@code SpanOperation} interface enables tracing and manipulation of + * a span during its lifecycle, such as setting attributes, adding events, + * or updating status codes. It can be used alongside frameworks that support + * OpenTelemetry for distributed tracing. + * + * @param the type of result returned by the custom span operation + */ +@FunctionalInterface +public interface SpanOperation { + + /** + * Executes a custom operation within the context of the provided {@link Span}. + * This method allows for interaction with the span, such as adding events, + * setting attributes, or manipulating its status during the operation. + * + * @param span the {@link Span} within whose context the operation will be executed + * @throws Exception if an error occurs during the execution of the operation + */ + T execute(Span span) throws Exception; +} diff --git a/powertools-tracing-opentelemetry/src/main/java/software/amazon/lambda/powertools/tracing/opentelemetry/internal/SpanScope.java b/powertools-tracing-opentelemetry/src/main/java/software/amazon/lambda/powertools/tracing/opentelemetry/internal/SpanScope.java new file mode 100644 index 000000000..944927af5 --- /dev/null +++ b/powertools-tracing-opentelemetry/src/main/java/software/amazon/lambda/powertools/tracing/opentelemetry/internal/SpanScope.java @@ -0,0 +1,122 @@ +/* + * 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.tracing.opentelemetry.internal; + +import io.opentelemetry.api.common.Attributes; +import io.opentelemetry.api.trace.Span; +import io.opentelemetry.api.trace.StatusCode; +import io.opentelemetry.context.Scope; + +/** + * A utility class that combines a {@link Span} and its associated {@link Scope}, + * managing the lifecycle of both. This class ensures that the span is properly ended and the scope is + * closed when the {@code SpanScope} is no longer needed. + * + *

    + * The {@code SpanScope} class facilitates interaction with the {@link Span} during its lifecycle by + * providing methods to set its status, add events, and record exceptions. Upon closing, the span is + * finalized, and the associated scope is released. + *

    + * + *

    Thread Safety

    + * This class is not thread-safe and must be used only within the thread it was created. + * + *

    Usage

    + * Instances of this class should be used in a try-with-resources block to ensure proper cleanup. + * + *

    Important Notes

    + * - The {@link Span} should be created and managed by an OpenTelemetry tracer or similar system. + * - Always close the {@code SpanScope} to release resources and end the span. + */ +public final class SpanScope implements AutoCloseable { + + private final Span span; + private final Scope scope; + + public SpanScope(Span span) { + this.span = span; + this.scope = span.makeCurrent(); + } + + /** + * Retrieves the {@link Span} associated with this {@code SpanScope}. + * + * @return the {@link Span} managed by this {@code SpanScope}. + */ + public Span span() { + return span; + } + + /** + * Updates the status of the associated span. + * + * @param status the {@link StatusCode} to set for the span + * @return the current {@code SpanScope} instance for method chaining + */ + public SpanScope setStatus(StatusCode status) { + span.setStatus(status); + return this; + } + + /** + * Adds an event to the associated {@link Span} with the specified name. + * + * @param name the name of the event to be added to the span + * @return the current {@code SpanScope} instance for method chaining + */ + public SpanScope addEvent(String name) { + span.addEvent(name); + return this; + } + + /** + * Adds an event with the specified name and attributes to the associated {@link Span}. + * + * @param name the name of the event to add + * @param attributes the attributes associated with the event + * @return the current {@code SpanScope} instance for method chaining + */ + public SpanScope addEvent(String name, Attributes attributes) { + span.addEvent(name, attributes); + return this; + } + + /** + * Records an exception in the associated {@link Span} and sets its status to {@code ERROR}. + * This method is used to log and signal the occurrence of an error condition within the span. + * + * @param throwable the {@link Throwable} instance representing the exception to record + */ + public void recordException(Throwable throwable) { + span.recordException(throwable); + span.setStatus(StatusCode.ERROR); + } + + /** + * Closes the underlying resources associated with this {@code SpanScope}. + * This method ensures that the {@code scope} is closed to release any associated + * context and marks the end of the {@code span}'s lifecycle by calling its {@code end()} method. + *

    + * This method should be invoked to properly clean up resources and signal the end + * of the tracing span when the scope is no longer needed. + */ + @Override + public void close() { + scope.close(); + span.end(); + } +} \ No newline at end of file diff --git a/powertools-tracing-opentelemetry/src/main/java/software/amazon/lambda/powertools/tracing/opentelemetry/internal/TracingOpenTelemetryAspect.java b/powertools-tracing-opentelemetry/src/main/java/software/amazon/lambda/powertools/tracing/opentelemetry/internal/TracingOpenTelemetryAspect.java new file mode 100644 index 000000000..7a7458924 --- /dev/null +++ b/powertools-tracing-opentelemetry/src/main/java/software/amazon/lambda/powertools/tracing/opentelemetry/internal/TracingOpenTelemetryAspect.java @@ -0,0 +1,287 @@ +/* + * 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.tracing.opentelemetry.internal; + +import static software.amazon.lambda.powertools.common.internal.LambdaHandlerProcessor.coldStartDone; +import static software.amazon.lambda.powertools.common.internal.LambdaHandlerProcessor.isColdStart; +import static software.amazon.lambda.powertools.common.internal.LambdaHandlerProcessor.isHandlerMethod; + +import io.opentelemetry.api.common.Attributes; +import io.opentelemetry.api.trace.Span; +import io.opentelemetry.api.trace.SpanKind; +import io.opentelemetry.context.Context; +import java.util.Objects; +import java.util.Optional; +import org.aspectj.lang.ProceedingJoinPoint; +import org.aspectj.lang.annotation.Around; +import org.aspectj.lang.annotation.Aspect; +import org.aspectj.lang.annotation.Pointcut; +import software.amazon.lambda.powertools.common.internal.LambdaHandlerProcessor; +import software.amazon.lambda.powertools.common.internal.SystemWrapper; +import software.amazon.lambda.powertools.tracing.opentelemetry.Tracing; +import software.amazon.lambda.powertools.tracing.opentelemetry.TracingOpenTelemetry; +import software.amazon.lambda.powertools.tracing.opentelemetry.context.ExtractedTraceContext; +import software.amazon.lambda.powertools.tracing.opentelemetry.context.TraceContextPropagationMode; +import software.amazon.lambda.powertools.tracing.opentelemetry.provider.OpenTelemetryProvider; + +/** + * TracingOpenTelemetryAspect is an AspectJ aspect that facilitates tracing for methods annotated + * with the {@link Tracing} annotation. It integrates with OpenTelemetry to automatically create and + * manage spans for annotated methods, capturing execution context, responses, and errors. + * + *

    Functional Overview:

    + * - Creates and manages OpenTelemetry spans around methods annotated with {@link Tracing}. + * - Supports both handler and internal method spans. + * - Extracts contextual information if available to enrich spans. + * - Captures response data and errors based on configurable capture modes. + * - Flushes telemetry data upon span completion. + * + *

    Key Methods:

    + *
      + *
    • configure - Sets a custom {@link TracingOpenTelemetry} instance to be used.
    • + *
    • callAt - Defines the pointcut for methods annotated with {@link Tracing}.
    • + *
    • around - Core functionality that wraps the target method execution with a span.
    • + *
    + * + *

    Trace Context Handling:

    + * - Extracts trace context for handler methods for more seamless propagation. + * - Supports linking spans or connecting to existing parent spans based on configuration. + * + *

    Capture Modes:

    + * - The capture modes ({@link Tracing.CaptureMode}) dictate whether and how responses and errors are + * recorded in spans: + * - RESPONSE_AND_ERROR: Captures both responses and errors. + * - RESPONSE: Captures only responses. + * - ERROR: Captures only errors. + * - DISABLED: Disables any capture. + * - ENVIRONMENT_VAR: Determines capture based on environment variables. + * + *

    Span Creation:

    + * - Handler spans include additional AWS Lambda-related metadata if applicable. + * - Internal method spans are marked with a default {@link SpanKind#INTERNAL}. + * + *

    Error Handling:

    + * - Ensures exceptions are propagated while recording them in the span if enabled. + * + *

    Thread Safety:

    + * - The class ensures thread safety for span management in concurrent environments. + *

    + * Note: This class requires OpenTelemetry to be properly configured in the application context. + */ +@Aspect +public final class TracingOpenTelemetryAspect { + + // Cannot be final for testing purposes + private static TracingOpenTelemetry tracingOtel = TracingOpenTelemetry.create(); + + public static void configure(TracingOpenTelemetry tracing) { + tracingOtel = Objects.requireNonNull(tracing); + } + + @SuppressWarnings("EmptyMethod") + @Pointcut("@annotation(tracing)") + public void callAt(Tracing tracing) { + } + + @Around( + value = "callAt(tracing) && execution(@Tracing * *.*(..))", + argNames = "pjp,tracing" + ) + public Object around(ProceedingJoinPoint pjp, Tracing tracing) throws Throwable { + + String spanName = tracing.spanName().isEmpty() + ? pjp.getSignature().getName() + : tracing.spanName(); + + if (isHandlerMethod(pjp)) { + return traceHandler(pjp, tracing, spanName); + } + + return traceMethod(pjp, tracing, spanName); + } + + private Object traceHandler(ProceedingJoinPoint pjp, Tracing tracing, String spanName) throws Throwable { + + ExtractedTraceContext extractedTraceContext = extractTraceContext(pjp); + + try (SpanScope scope = addHandlerSpan(spanName, extractedTraceContext)) { + + Span span = scope.span(); + + tracingOtel.eventContextExtractorResolver().enrichSpan(pjp.getArgs()[0], span); + + addLambdaInvocationAttributes(pjp, span); + + try { + + Object result = pjp.proceed(pjp.getArgs()); + + captureResponse(span, tracing, result); + + coldStartDone(); + + return result; + + } catch (Throwable throwable) { + + captureError(scope, tracing, throwable); + + throw throwable; + } + } finally { + tracingOtel.flush(); + } + } + + private SpanScope addHandlerSpan(String spanName, ExtractedTraceContext extractedTraceContext) { + + if (shouldUseSpanLinks(extractedTraceContext)) { + return tracingOtel.addSpan( + spanName, + extractedTraceContext.spanKind(), + handlerAttributes(), + Context.current(), + extractedTraceContext.spanContexts() + ); + } + + return tracingOtel.addSpan( + spanName, + extractedTraceContext.spanKind(), + handlerAttributes(), + extractedTraceContext.context() + ); + } + + private boolean shouldUseSpanLinks(ExtractedTraceContext extractedTraceContext) { + + return OpenTelemetryProvider.traceContextPropagationMode() == TraceContextPropagationMode.LINK + && !extractedTraceContext.spanContexts().isEmpty(); + } + + private Object traceMethod(ProceedingJoinPoint pjp, Tracing tracing, String spanName) throws Throwable { + + try (SpanScope scope = tracingOtel.addSpan(spanName, SpanKind.INTERNAL, Attributes.empty(), + Context.current())) { + + Span span = scope.span(); + + try { + Object result = pjp.proceed(pjp.getArgs()); + + captureResponse(span, tracing, result); + + return result; + + } catch (Throwable throwable) { + + captureError(scope, tracing, throwable); + + throw throwable; + } + } + } + + private ExtractedTraceContext extractTraceContext(ProceedingJoinPoint pjp) { + + return tracingOtel.eventContextExtractorResolver().extract( + pjp.getArgs()[0], + Context.current(), + tracingOtel.propagator() + ); + } + + private Attributes handlerAttributes() { + return Attributes.builder() + .put(AttributesConstants.FAAS_COLDSTART, isColdStart()) + .build(); + } + + private void addLambdaInvocationAttributes(ProceedingJoinPoint pjp, Span span) { + + Optional.ofNullable(LambdaHandlerProcessor.extractContext(pjp)) + .ifPresent( + context -> span.setAttribute(AttributesConstants.FAAS_INVOCATION_ID, context.getAwsRequestId() + ) + ); + } + + private void captureResponse(Span span, Tracing tracing, Object response) throws Exception { + + if (!isCaptureResponseEnabled(tracing)) { + return; + } + + span.setAttribute( + AttributesConstants.RESPONSE_ATTRIBUTE, + OpenTelemetryProvider.objectMapper().writeValueAsString(response) + ); + } + + private void captureError(SpanScope scope, Tracing tracing, Throwable throwable) { + + if (isCaptureErrorEnabled(tracing)) { + scope.recordException(throwable); + } + } + + private boolean isCaptureResponseEnabled(Tracing tracing) { + switch (tracing.captureMode()) { + case ENVIRONMENT_VAR: + return isEnvironmentVariableSet( + AttributesConstants.CAPTURE_RESPONSE_ENV) + && environmentVariable( + AttributesConstants.CAPTURE_RESPONSE_ENV); + + case RESPONSE: + case RESPONSE_AND_ERROR: + return true; + + case DISABLED: + case ERROR: + default: + return false; + } + } + + private boolean isCaptureErrorEnabled(Tracing tracing) { + switch (tracing.captureMode()) { + case ENVIRONMENT_VAR: + return isEnvironmentVariableSet( + AttributesConstants.CAPTURE_ERROR_ENV) + && environmentVariable( + AttributesConstants.CAPTURE_ERROR_ENV); + + case ERROR: + case RESPONSE_AND_ERROR: + return true; + + case DISABLED: + case RESPONSE: + default: + return false; + } + } + + private boolean environmentVariable(String key) { + return Boolean.parseBoolean(SystemWrapper.getenv(key)); + } + + private boolean isEnvironmentVariableSet(String key) { + return SystemWrapper.containsKey(key); + } +} \ No newline at end of file diff --git a/powertools-tracing-opentelemetry/src/main/java/software/amazon/lambda/powertools/tracing/opentelemetry/provider/OpenTelemetryProvider.java b/powertools-tracing-opentelemetry/src/main/java/software/amazon/lambda/powertools/tracing/opentelemetry/provider/OpenTelemetryProvider.java new file mode 100644 index 000000000..b9b5b3903 --- /dev/null +++ b/powertools-tracing-opentelemetry/src/main/java/software/amazon/lambda/powertools/tracing/opentelemetry/provider/OpenTelemetryProvider.java @@ -0,0 +1,353 @@ +/* + * 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.tracing.opentelemetry.provider; + +import com.fasterxml.jackson.databind.ObjectMapper; +import io.opentelemetry.api.GlobalOpenTelemetry; +import io.opentelemetry.api.OpenTelemetry; +import io.opentelemetry.api.trace.Tracer; +import io.opentelemetry.api.trace.propagation.W3CTraceContextPropagator; +import io.opentelemetry.context.propagation.ContextPropagators; +import io.opentelemetry.context.propagation.TextMapGetter; +import io.opentelemetry.context.propagation.TextMapPropagator; +import io.opentelemetry.contrib.awsxray.propagator.AwsXrayPropagator; +import io.opentelemetry.exporter.otlp.http.trace.OtlpHttpSpanExporter; +import io.opentelemetry.exporter.otlp.http.trace.OtlpHttpSpanExporterBuilder; +import io.opentelemetry.exporter.otlp.trace.OtlpGrpcSpanExporter; +import io.opentelemetry.exporter.otlp.trace.OtlpGrpcSpanExporterBuilder; +import io.opentelemetry.sdk.OpenTelemetrySdk; +import io.opentelemetry.sdk.common.CompletableResultCode; +import io.opentelemetry.sdk.trace.SdkTracerProvider; +import io.opentelemetry.sdk.trace.export.BatchSpanProcessor; +import io.opentelemetry.sdk.trace.export.SpanExporter; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.TimeUnit; +import software.amazon.lambda.powertools.common.internal.SystemWrapper; +import software.amazon.lambda.powertools.tracing.opentelemetry.context.TraceContextPropagationMode; +import software.amazon.lambda.powertools.tracing.opentelemetry.internal.LambdaResource; + +/** + * Provides a managed OpenTelemetry instance tailored for AWS Lambda Powertools. + * This class enables easy integration of tracing capabilities using OpenTelemetry + * for AWS Lambda function monitoring. + *

    + * It supports automatic instrumentation configuration through environment variables + * and enables customized configurations for propagators, tracing mode, exporter, + * and tracer provider. + *

    + * OpenTelemetryProvider ensures compatibility with the ADOT Lambda layer and javaagent, + * using the configured global OpenTelemetry instance when available. If no global + * configuration exists, it creates and uses a Lambda-optimized configuration. + *

    + * Key functionalities include: + * - Access to a pre-configured {@code Tracer}. + * - Support for multiple propagation formats (e.g., W3C Trace Context, AWS X-Ray). + * - Batch span processing with configurable export batch size, queue size, and timeouts. + * - Lambda-optimized default resource configuration. + * - Parsing environment variables for OTLP configuration (e.g., protocol, endpoint). + *

    + * This class cannot be instantiated directly and provides its functionalities + * through static methods. + */ +public final class OpenTelemetryProvider { + + private static final String INSTRUMENTATION_NAME = "aws-lambda-powertools"; + private static final String OTEL_EXPORTER_OTLP_TRACES_PROTOCOL = "OTEL_EXPORTER_OTLP_TRACES_PROTOCOL"; + private static final String OTEL_EXPORTER_OTLP_TRACES_ENDPOINT = "OTEL_EXPORTER_OTLP_TRACES_ENDPOINT"; + private static final String OTEL_EXPORTER_OTLP_TRACES_HEADERS = "OTEL_EXPORTER_OTLP_TRACES_HEADERS"; + private static final String TRACE_CONTEXT_PROPAGATION_MODE_ENV = "POWERTOOLS_TRACE_CONTEXT_PROPAGATION_MODE"; + + private static final int MAX_EXPORT_BATCH_SIZE = 10; + private static final int MAX_QUEUE_SIZE = 100; + private static final long SCHEDULE_DELAY_MILLIS = 1_000; + private static final long EXPORT_TIMEOUT_MILLIS = 3_000; + + private static final TextMapPropagator PROPAGATOR = createPropagator(); + + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + + private static final TextMapGetter> TEXT_MAP_GETTER = createTextMapGetter(); + + private static final TraceContextPropagationMode TRACE_CONTEXT_PROPAGATION_MODE = retrieveTraceContextMode(); + + private static final SdkTracerProvider SDK_TRACER_PROVIDER = createTracerProvider(); + + private static final OpenTelemetry OPEN_TELEMETRY = initializeOpenTelemetry(); + + + private OpenTelemetryProvider() { + } + + /** + * Provides a pre-configured singleton instance of {@link ObjectMapper}. + *

    + * This method is intended for consistent JSON processing across various + * components by returning an {@code ObjectMapper} instance that is shared + * across the application. + * + * @return A shared instance of {@link ObjectMapper}. + */ + public static ObjectMapper objectMapper() { + return OBJECT_MAPPER; + } + + /** + * Retrieves the current trace context propagation mode for OpenTelemetry tracing. + *

    + * The trace context propagation mode determines how trace context is propagated + * between spans, such as whether it uses a parent-child relationship or establishes + * links between related spans. + * + * @return The current {@link TraceContextPropagationMode}, which may be either + * {@code PARENT} or {@code LINK}, indicating the selected trace context + * propagation strategy. + */ + public static TraceContextPropagationMode traceContextPropagationMode() { + return TRACE_CONTEXT_PROPAGATION_MODE; + } + + /** + * Retrieves a pre-configured instance of {@link Tracer} from the OpenTelemetry SDK. + *

    + * The returned {@link Tracer} is associated with the specified instrumentation name, + * enabling tracing for specific operations and contexts within the application. + * This method leverages the global OpenTelemetry configuration, making it suitable + * for use in environments where consistent instrumentation is required. + * + * @return A {@link Tracer} instance for instrumenting and generating trace data. + */ + public static Tracer tracer() { + return OPEN_TELEMETRY.getTracer(INSTRUMENTATION_NAME); + } + + /** + * Retrieves a pre-configured instance of {@link TextMapPropagator}. + *

    + * The returned {@link TextMapPropagator} is configured to propagate + * tracing context information across process boundaries. This is + * used to encode and decode trace context in a key-value format, + * enabling distributed tracing in various systems. + * + * @return A pre-configured {@link TextMapPropagator} instance for trace context propagation. + */ + public static TextMapPropagator propagator() { + return PROPAGATOR; + } + + /** + * Provides a static {@link TextMapGetter} instance for extracting trace context + * information from a {@link Map} containing string key-value pairs. + *

    + * The returned {@link TextMapGetter} is used to interpret trace propagation + * attributes from a map structure, enabling distributed tracing functionality. + * + * @return A {@link TextMapGetter} instance that facilitates extracting trace + * context data from a {@link Map} of string keys and values. + */ + public static TextMapGetter> textMapGetter() { + return TEXT_MAP_GETTER; + } + + private static OpenTelemetry initializeOpenTelemetry() { + + if (GlobalOpenTelemetry.isSet()) { + return GlobalOpenTelemetry.get(); + } + + return createDefaultOpenTelemetry(); + } + + /** + * Forces all pending telemetry data to be processed and exported, ensuring that + * any remaining spans or related information are handled by the OpenTelemetry + * SDK or the globally configured OpenTelemetry instance. + *

    + * If a global OpenTelemetry instance is available, the operation will immediately + * succeed. Otherwise, it delegates the flush operation to the SDK's tracer provider. + * + * @return A {@link CompletableResultCode} indicating the success or failure of the + * flush operation. It may represent an immediate success if the global + * OpenTelemetry instance is set, or the result of flushing managed by the + * SDK tracer provider otherwise. + */ + public static CompletableResultCode forceFlush() { + if (GlobalOpenTelemetry.isSet()) { + return CompletableResultCode.ofSuccess(); + } + return SDK_TRACER_PROVIDER.forceFlush(); + } + + /** + * Creates the Powertools default OpenTelemetry configuration. + */ + private static OpenTelemetry createDefaultOpenTelemetry() { + + return OpenTelemetrySdk.builder() + .setTracerProvider(SDK_TRACER_PROVIDER) + .setPropagators(ContextPropagators.create(PROPAGATOR)) + .build(); + } + + private static TraceContextPropagationMode retrieveTraceContextMode() { + + String value = SystemWrapper.getenv(TRACE_CONTEXT_PROPAGATION_MODE_ENV); + + if (value == null || value.isBlank()) { + return TraceContextPropagationMode.PARENT; + } + + try { + return TraceContextPropagationMode.valueOf(value.trim().toUpperCase()); + } catch (IllegalArgumentException exception) { + return TraceContextPropagationMode.PARENT; + } + } + + private static TextMapGetter> createTextMapGetter() { + + return new TextMapGetter<>() { + + @Override + public Iterable keys(Map carrier) { + + return carrier == null + ? Collections.emptyList() + : carrier.keySet(); + } + + @Override + public String get(Map carrier, String key) { + + return carrier == null + ? null + : carrier.get(key); + } + }; + } + + private static SdkTracerProvider createTracerProvider() { + + SpanExporter exporter = createExporter(); + + BatchSpanProcessor processor = BatchSpanProcessor.builder(exporter) + .setMaxExportBatchSize(MAX_EXPORT_BATCH_SIZE) + .setMaxQueueSize(MAX_QUEUE_SIZE) + .setScheduleDelay(SCHEDULE_DELAY_MILLIS, TimeUnit.MILLISECONDS) + .setExporterTimeout(EXPORT_TIMEOUT_MILLIS, TimeUnit.MILLISECONDS) + .build(); + + return SdkTracerProvider.builder() + .setResource(LambdaResource.create()) + .addSpanProcessor(processor) + .build(); + } + + private static SpanExporter createExporter() { + + String protocol = SystemWrapper.getenv(OTEL_EXPORTER_OTLP_TRACES_PROTOCOL); + String endpoint = SystemWrapper.getenv(OTEL_EXPORTER_OTLP_TRACES_ENDPOINT); + String headers = SystemWrapper.getenv(OTEL_EXPORTER_OTLP_TRACES_HEADERS); + + Map headerMap = parseHeaders(headers); + + if (protocol == null || protocol.isBlank()) { + OtlpGrpcSpanExporterBuilder builder = OtlpGrpcSpanExporter.builder() + .setTimeout(EXPORT_TIMEOUT_MILLIS, TimeUnit.MILLISECONDS) + .setHeaders(() -> headerMap); + + if (endpoint != null && !endpoint.isBlank()) { + builder.setEndpoint(endpoint); + } + + return builder.build(); + } + + switch (protocol.trim().toLowerCase()) { + case "grpc": + OtlpGrpcSpanExporterBuilder grpcBuilder = OtlpGrpcSpanExporter.builder() + .setTimeout(EXPORT_TIMEOUT_MILLIS, TimeUnit.MILLISECONDS) + .setHeaders(() -> headerMap); + + if (endpoint != null && !endpoint.isBlank()) { + grpcBuilder.setEndpoint(endpoint); + } + + return grpcBuilder.build(); + + case "http/protobuf": + OtlpHttpSpanExporterBuilder httpBuilder = OtlpHttpSpanExporter.builder() + .setTimeout(EXPORT_TIMEOUT_MILLIS, TimeUnit.MILLISECONDS) + .setHeaders(() -> headerMap); + + if (endpoint != null && !endpoint.isBlank()) { + httpBuilder.setEndpoint(endpoint); + } + + return httpBuilder.build(); + + default: + throw new IllegalArgumentException( + "Unsupported OTLP protocol: " + protocol + ); + } + } + + private static Map parseHeaders(String headers) { + + if (headers == null || headers.isBlank()) { + return Collections.emptyMap(); + } + + Map result = new HashMap<>(); + + String[] entries = headers.split(","); + + for (String entry : entries) { + + String[] parts = entry.split("=", 2); + + if (parts.length != 2) { + throw new IllegalArgumentException( + "Invalid OTLP header: " + entry + ); + } + + String key = parts[0].trim(); + String value = parts[1].trim(); + + if (key.isEmpty()) { + throw new IllegalArgumentException( + "OTLP header name cannot be empty" + ); + } + + result.put(key, value); + } + + return result; + } + + private static TextMapPropagator createPropagator() { + + return TextMapPropagator.composite( + W3CTraceContextPropagator.getInstance(), + AwsXrayPropagator.getInstance() + ); + } +} \ No newline at end of file diff --git a/powertools-tracing-opentelemetry/src/test/java/software/amazon/lambda/powertools/tracing/opentelemetry/TracingOpenTelemetryTest.java b/powertools-tracing-opentelemetry/src/test/java/software/amazon/lambda/powertools/tracing/opentelemetry/TracingOpenTelemetryTest.java new file mode 100644 index 000000000..0a4380a76 --- /dev/null +++ b/powertools-tracing-opentelemetry/src/test/java/software/amazon/lambda/powertools/tracing/opentelemetry/TracingOpenTelemetryTest.java @@ -0,0 +1,517 @@ + +/* + * 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.tracing.opentelemetry; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import io.opentelemetry.api.common.AttributeKey; +import io.opentelemetry.api.common.Attributes; +import io.opentelemetry.api.trace.Span; +import io.opentelemetry.api.trace.SpanContext; +import io.opentelemetry.api.trace.SpanKind; +import io.opentelemetry.api.trace.Tracer; +import io.opentelemetry.context.Context; +import io.opentelemetry.context.propagation.TextMapGetter; +import io.opentelemetry.context.propagation.TextMapPropagator; +import io.opentelemetry.context.propagation.TextMapSetter; +import io.opentelemetry.sdk.testing.exporter.InMemorySpanExporter; +import io.opentelemetry.sdk.trace.SdkTracerProvider; +import io.opentelemetry.sdk.trace.export.SimpleSpanProcessor; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.TimeUnit; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import software.amazon.lambda.powertools.tracing.opentelemetry.internal.SpanScope; + +class TracingOpenTelemetryTest { + + private InMemorySpanExporter exporter; + private SdkTracerProvider tracerProvider; + private Tracer tracer; + + public static final TextMapGetter> MAP_GETTER = new TextMapGetter<>() { + @Override + public Iterable keys(Map carrier) { + return carrier.keySet(); + } + + @Override + public String get(Map carrier, String key) { + return carrier.get(key); + } + }; + + public static final TextMapSetter> MAP_SETTER = Map::put; + + @BeforeEach + void setUp() { + exporter = InMemorySpanExporter.create(); + tracerProvider = SdkTracerProvider.builder() + .addSpanProcessor(SimpleSpanProcessor.create(exporter)) + .build(); + tracer = tracerProvider.get("test-tracer"); + } + + @AfterEach + void tearDown() { + tracerProvider.close(); + } + + @Test + void testDefaultConstructor() { + + TracingOpenTelemetry tracing = new TracingOpenTelemetry(); + + assertThat(tracing).isNotNull(); + assertThat(tracing.tracer()).isNotNull(); + assertThat(tracing.propagator()).isNotNull(); + assertThat(tracing.eventContextExtractorResolver()).isNotNull(); + } + + @Test + void testConstructorWithTracer() { + + TracingOpenTelemetry tracing = new TracingOpenTelemetry(tracer); + + assertThat(tracing.tracer()).isEqualTo(tracer); + assertThat(tracing.propagator()).isNotNull(); + assertThat(tracing.eventContextExtractorResolver()).isNotNull(); + } + + @Test + void testCreate() { + + TracingOpenTelemetry tracing = TracingOpenTelemetry.create(); + + assertThat(tracing).isNotNull(); + assertThat(tracing.tracer()).isNotNull(); + } + + @Test + void testBuilder() { + + TracingOpenTelemetry tracing = TracingOpenTelemetry.builder() + .tracer(tracer) + .build(); + + assertThat(tracing).isNotNull(); + assertThat(tracing.tracer()).isEqualTo(tracer); + } + + @Test + void testAddSpanWithName() { + + TracingOpenTelemetry tracing = new TracingOpenTelemetry(tracer); + + try (SpanScope scope = tracing.addSpan("test-span")) { + + assertThat(scope.span().getSpanContext().isValid()).isTrue(); + assertThat(Span.current()).isEqualTo(scope.span()); + } + + assertThat(exporter.getFinishedSpanItems()).hasSize(1); + assertThat(exporter.getFinishedSpanItems().get(0).getName()).isEqualTo("test-span"); + assertThat(exporter.getFinishedSpanItems().get(0).getKind()).isEqualTo(SpanKind.INTERNAL); + } + + @Test + void testAddSpanWithNameAndKind() { + + TracingOpenTelemetry tracing = new TracingOpenTelemetry(tracer); + + try (SpanScope scope = tracing.addSpan("client-span", SpanKind.CLIENT)) { + + assertThat(scope.span().getSpanContext().isValid()).isTrue(); + } + + assertThat(exporter.getFinishedSpanItems()).hasSize(1); + assertThat(exporter.getFinishedSpanItems().get(0).getKind()).isEqualTo(SpanKind.CLIENT); + } + + @Test + void testAddSpanWithAttributes() { + + TracingOpenTelemetry tracing = new TracingOpenTelemetry(tracer); + Attributes attributes = Attributes.builder() + .put("key1", "value1") + .put("key2", 123L) + .build(); + + try (SpanScope scope = tracing.addSpan("span-with-attrs", SpanKind.INTERNAL, attributes)) { + assertThat(scope.span()).isNotNull(); + } + + assertThat(exporter.getFinishedSpanItems()).hasSize(1); + assertThat(exporter.getFinishedSpanItems().get(0).getAttributes().get(AttributeKey.stringKey("key1"))) + .isEqualTo("value1"); + assertThat(exporter.getFinishedSpanItems().get(0).getAttributes().get(AttributeKey.longKey("key2"))) + .isEqualTo(123L); + } + + @Test + void testAddSpanEndsWhenScopeIsClosed() { + + TracingOpenTelemetry tracing = new TracingOpenTelemetry(tracer); + + try (SpanScope ignored = tracing.addSpan("closing-span")) { + assertThat(exporter.getFinishedSpanItems()).isEmpty(); + } + + assertThat(exporter.getFinishedSpanItems()).hasSize(1); + assertThat(exporter.getFinishedSpanItems().get(0).getName()).isEqualTo("closing-span"); + } + + @Test + void testAddSpanRestoresPreviousSpan() { + + TracingOpenTelemetry tracing = new TracingOpenTelemetry(tracer); + + try (SpanScope outer = tracing.addSpan("outer")) { + assertThat(Span.current()).isEqualTo(outer.span()); + + try (SpanScope inner = tracing.addSpan("inner")) { + assertThat(Span.current()).isEqualTo(inner.span()); + } + + assertThat(Span.current()).isEqualTo(outer.span()); + } + + assertThat(exporter.getFinishedSpanItems()).hasSize(2); + } + + @Test + void testAddSpanWithSpanContextLinks() { + + TracingOpenTelemetry tracing = new TracingOpenTelemetry(tracer); + + SpanContext linkContext1 = SpanContext.createFromRemoteParent( + "00000000000000000000000000000001", + "0000000000000001", + io.opentelemetry.api.trace.TraceFlags.getSampled(), + io.opentelemetry.api.trace.TraceState.getDefault() + ); + + SpanContext linkContext2 = SpanContext.createFromRemoteParent( + "00000000000000000000000000000002", + "0000000000000002", + io.opentelemetry.api.trace.TraceFlags.getSampled(), + io.opentelemetry.api.trace.TraceState.getDefault() + ); + + try (SpanScope scope = tracing.addSpan( + "span-with-links", + SpanKind.INTERNAL, + Attributes.empty(), + Context.current(), + Arrays.asList(linkContext1, linkContext2) + )) { + assertThat(scope.span()).isNotNull(); + } + + assertThat(exporter.getFinishedSpanItems()).hasSize(1); + assertThat(exporter.getFinishedSpanItems().get(0).getLinks()).hasSize(2); + } + + @Test + void testWithSpan() throws Exception { + + TracingOpenTelemetry tracing = new TracingOpenTelemetry(tracer); + + String result = tracing.withSpan("operation-span", span -> { + assertThat(span).isNotNull(); + return "success"; + }); + + assertThat(result).isEqualTo("success"); + assertThat(exporter.getFinishedSpanItems()).hasSize(1); + assertThat(exporter.getFinishedSpanItems().get(0).getName()).isEqualTo("operation-span"); + } + + @Test + void testWithSpanRecordsException() { + + TracingOpenTelemetry tracing = new TracingOpenTelemetry(tracer); + RuntimeException exception = new RuntimeException("boom"); + + assertThatThrownBy(() -> + tracing.withSpan("failing-span", span -> { + throw exception; + }) + ).isSameAs(exception); + + assertThat(exporter.getFinishedSpanItems()).hasSize(1); + assertThat(exporter.getFinishedSpanItems().get(0).getEvents()).hasSize(1); + assertThat(exporter.getFinishedSpanItems().get(0).getEvents().get(0).getName()) + .isEqualTo("exception"); + assertThat(exporter.getFinishedSpanItems().get(0).getStatus().getStatusCode()) + .isEqualTo(io.opentelemetry.api.trace.StatusCode.ERROR); + } + + @Test + void testWithSpanWithKindAndAttributes() throws Exception { + + TracingOpenTelemetry tracing = new TracingOpenTelemetry(tracer); + Attributes attributes = Attributes.builder() + .put("custom", "attribute") + .build(); + + Integer result = tracing.withSpan("custom-span", SpanKind.SERVER, attributes, span -> 42); + + assertThat(result).isEqualTo(42); + assertThat(exporter.getFinishedSpanItems()).hasSize(1); + assertThat(exporter.getFinishedSpanItems().get(0).getKind()).isEqualTo(SpanKind.SERVER); + } + + @Test + void testCurrentSpan() { + + TracingOpenTelemetry tracing = new TracingOpenTelemetry(tracer); + + try (SpanScope scope = tracing.addSpan("current-test")) { + Span current = tracing.currentSpan(); + + assertThat(current).isEqualTo(scope.span()); + } + } + + @Test + void testExtractContext() { + + TracingOpenTelemetry tracing = new TracingOpenTelemetry(tracer); + Map headers = new HashMap<>(); + headers.put("traceparent", "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01"); + + Context context = tracing.extractContext(headers, MAP_GETTER); + SpanContext spanContext = Span.fromContext(context).getSpanContext(); + + assertThat(spanContext.isValid()).isTrue(); + assertThat(spanContext.isRemote()).isTrue(); + assertThat(spanContext.getTraceId()).isEqualTo("4bf92f3577b34da6a3ce929d0e0e4736"); + assertThat(spanContext.getSpanId()).isEqualTo("00f067aa0ba902b7"); + assertThat(spanContext.getTraceFlags().isSampled()).isTrue(); + } + + @Test + void testExtractContextWithParentContext() { + + TracingOpenTelemetry tracing = new TracingOpenTelemetry(tracer); + Map headers = new HashMap<>(); + headers.put("traceparent", "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01"); + + Context parentContext = Context.current(); + + Context context = tracing.extractContext(parentContext, headers, MAP_GETTER); + SpanContext spanContext = Span.fromContext(context).getSpanContext(); + + assertThat(spanContext.isValid()).isTrue(); + } + + @Test + void testExtractContextWithMissingTraceparent() { + + TracingOpenTelemetry tracing = new TracingOpenTelemetry(tracer); + Map headers = new HashMap<>(); + + Context context = tracing.extractContext(headers, MAP_GETTER); + + assertThat(Span.fromContext(context).getSpanContext().isValid()).isFalse(); + } + + @Test + void testInjectContext() { + + TracingOpenTelemetry tracing = new TracingOpenTelemetry(tracer); + Map carrier = new HashMap<>(); + + try (SpanScope scope = tracing.addSpan("inject-test")) { + tracing.injectContext(carrier, MAP_SETTER); + } + + assertThat(carrier).containsKey("traceparent"); + } + + @Test + void testInjectContextWithSpecificContext() { + + TracingOpenTelemetry tracing = new TracingOpenTelemetry(tracer); + Map carrier = new HashMap<>(); + + try (SpanScope scope = tracing.addSpan("inject-test")) { + Context context = Context.current(); + tracing.injectContext(context, carrier, MAP_SETTER); + } + + assertThat(carrier).containsKey("traceparent"); + } + + @Test + void testFlush() { + + TracingOpenTelemetry tracing = new TracingOpenTelemetry(tracer); + + try (SpanScope ignored = tracing.addSpan("flush-test")) { + // span created + } + + tracing.flush(); + + assertThat(exporter.getFinishedSpanItems()).hasSize(1); + } + + @Test + void testFlushWithTimeout() { + + TracingOpenTelemetry tracing = new TracingOpenTelemetry(tracer); + + try (SpanScope ignored = tracing.addSpan("flush-timeout-test")) { + // span created + } + + tracing.flush(10, TimeUnit.SECONDS); + + assertThat(exporter.getFinishedSpanItems()).hasSize(1); + } + + @Test + void testBuilderWithCustomPropagator() { + + TextMapPropagator customPropagator = TextMapPropagator.noop(); + + TracingOpenTelemetry tracing = TracingOpenTelemetry.builder() + .tracer(tracer) + .propagator(customPropagator) + .build(); + + assertThat(tracing.propagator()).isEqualTo(customPropagator); + } + + @Test + void testAddSpanThrowsNullPointerExceptionForNullName() { + + TracingOpenTelemetry tracing = new TracingOpenTelemetry(tracer); + + assertThatThrownBy(() -> + tracing.addSpan(null, SpanKind.INTERNAL, Attributes.empty(), Context.current(), Collections.emptyList()) + ).isInstanceOf(NullPointerException.class) + .hasMessageContaining("name must not be null"); + } + + @Test + void testAddSpanThrowsNullPointerExceptionForNullKind() { + + TracingOpenTelemetry tracing = new TracingOpenTelemetry(tracer); + + assertThatThrownBy(() -> + tracing.addSpan("test", null, Attributes.empty(), Context.current(), Collections.emptyList()) + ).isInstanceOf(NullPointerException.class) + .hasMessageContaining("kind must not be null"); + } + + @Test + void testAddSpanThrowsNullPointerExceptionForNullAttributes() { + + TracingOpenTelemetry tracing = new TracingOpenTelemetry(tracer); + + assertThatThrownBy(() -> + tracing.addSpan("test", SpanKind.INTERNAL, null, Context.current(), Collections.emptyList()) + ).isInstanceOf(NullPointerException.class) + .hasMessageContaining("attributes must not be null"); + } + + @Test + void testAddSpanThrowsNullPointerExceptionForNullParentContext() { + + TracingOpenTelemetry tracing = new TracingOpenTelemetry(tracer); + + assertThatThrownBy(() -> + tracing.addSpan("test", SpanKind.INTERNAL, Attributes.empty(), null, Collections.emptyList()) + ).isInstanceOf(NullPointerException.class) + .hasMessageContaining("parentContext must not be null"); + } + + @Test + void testAddSpanThrowsNullPointerExceptionForNullSpanContexts() { + + TracingOpenTelemetry tracing = new TracingOpenTelemetry(tracer); + + assertThatThrownBy(() -> + tracing.addSpan("test", SpanKind.INTERNAL, Attributes.empty(), Context.current(), null) + ).isInstanceOf(NullPointerException.class) + .hasMessageContaining("spanContexts must not be null"); + } + + @Test + void testWithSpanThrowsNullPointerExceptionForNullOperation() { + + TracingOpenTelemetry tracing = new TracingOpenTelemetry(tracer); + + assertThatThrownBy(() -> + tracing.withSpan("test", SpanKind.INTERNAL, Attributes.empty(), null) + ).isInstanceOf(NullPointerException.class) + .hasMessageContaining("operation must not be null"); + } + + @Test + void testExtractContextThrowsNullPointerExceptionForNullCarrier() { + + TracingOpenTelemetry tracing = new TracingOpenTelemetry(tracer); + + assertThatThrownBy(() -> + tracing.extractContext(Context.current(), null, MAP_GETTER) + ).isInstanceOf(NullPointerException.class) + .hasMessageContaining("carrier must not be null"); + } + + @Test + void testExtractContextThrowsNullPointerExceptionForNullGetter() { + + TracingOpenTelemetry tracing = new TracingOpenTelemetry(tracer); + Map carrier = new HashMap<>(); + + assertThatThrownBy(() -> + tracing.extractContext(Context.current(), carrier, null) + ).isInstanceOf(NullPointerException.class) + .hasMessageContaining("getter must not be null"); + } + + @Test + void testInjectContextThrowsNullPointerExceptionForNullCarrier() { + + TracingOpenTelemetry tracing = new TracingOpenTelemetry(tracer); + + assertThatThrownBy(() -> + tracing.injectContext(Context.current(), null, MAP_SETTER) + ).isInstanceOf(NullPointerException.class) + .hasMessageContaining("carrier must not be null"); + } + + @Test + void testInjectContextThrowsNullPointerExceptionForNullSetter() { + + TracingOpenTelemetry tracing = new TracingOpenTelemetry(tracer); + Map carrier = new HashMap<>(); + + assertThatThrownBy(() -> + tracing.injectContext(Context.current(), carrier, null) + ).isInstanceOf(NullPointerException.class) + .hasMessageContaining("setter must not be null"); + } +} \ No newline at end of file diff --git a/powertools-tracing-opentelemetry/src/test/java/software/amazon/lambda/powertools/tracing/opentelemetry/context/ApiGatewayTraceContextExtractorTest.java b/powertools-tracing-opentelemetry/src/test/java/software/amazon/lambda/powertools/tracing/opentelemetry/context/ApiGatewayTraceContextExtractorTest.java new file mode 100644 index 000000000..27736c480 --- /dev/null +++ b/powertools-tracing-opentelemetry/src/test/java/software/amazon/lambda/powertools/tracing/opentelemetry/context/ApiGatewayTraceContextExtractorTest.java @@ -0,0 +1,284 @@ +/* + * 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.tracing.opentelemetry.context; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; + +import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyRequestEvent; +import io.opentelemetry.api.trace.Span; +import io.opentelemetry.api.trace.SpanKind; +import io.opentelemetry.api.trace.propagation.W3CTraceContextPropagator; +import io.opentelemetry.context.Context; +import io.opentelemetry.context.propagation.TextMapPropagator; +import java.util.Map; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class ApiGatewayTraceContextExtractorTest { + + @Mock + private Span span; + + @Mock + private TextMapPropagator propagator; + + @InjectMocks + private ApiGatewayTraceContextExtractor extractor; + + @Test + void shouldSupportApiGatewayProxyRequestEvent() { + APIGatewayProxyRequestEvent event = new APIGatewayProxyRequestEvent(); + + boolean result = extractor.supports(event); + + assertThat(result).isTrue(); + } + + @Test + void shouldNotSupportNonApiGatewayEvent() { + assertThat(extractor.supports("Some non-API Gateway event")).isFalse(); + assertThat(extractor.supports(new Object())).isFalse(); + assertThat(extractor.supports(null)).isFalse(); + } + + @Test + void shouldExtractTraceContextWithServerSpanKind() { + APIGatewayProxyRequestEvent event = new APIGatewayProxyRequestEvent() + .withHeaders(Map.of("traceparent", "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01")); + Context parentContext = Context.current(); + + ExtractedTraceContext extractedContext = + extractor.extract(event, parentContext, W3CTraceContextPropagator.getInstance()); + + assertThat(extractedContext).isNotNull(); + assertThat(extractedContext.spanContexts()).isEmpty(); + assertThat(extractedContext.spanKind()).isEqualTo(SpanKind.SERVER); + } + + @Test + void shouldExtractTraceContextWhenHeadersAreEmpty() { + APIGatewayProxyRequestEvent event = new APIGatewayProxyRequestEvent() + .withHeaders(Map.of()); + Context parentContext = Context.current(); + + ExtractedTraceContext extractedContext = extractor.extract(event, parentContext, propagator); + + assertThat(extractedContext).isNotNull(); + assertThat(extractedContext.context()).isEqualTo(parentContext); + assertThat(extractedContext.spanContexts()).isEmpty(); + assertThat(extractedContext.spanKind()).isEqualTo(SpanKind.SERVER); + } + + @Test + void shouldExtractTraceContextWhenHeadersAreNull() { + APIGatewayProxyRequestEvent event = new APIGatewayProxyRequestEvent() + .withHeaders(null); + Context parentContext = Context.current(); + + ExtractedTraceContext extractedContext = extractor.extract(event, parentContext, propagator); + + assertThat(extractedContext).isNotNull(); + assertThat(extractedContext.context()).isEqualTo(parentContext); + assertThat(extractedContext.spanContexts()).isEmpty(); + assertThat(extractedContext.spanKind()).isEqualTo(SpanKind.SERVER); + } + + @Test + void shouldEnrichSpanWithHttpMethod() { + APIGatewayProxyRequestEvent event = new APIGatewayProxyRequestEvent() + .withHttpMethod("POST"); + + extractor.enrichSpan(event, span); + + verify(span).setAttribute("http.request.method", "POST"); + } + + @Test + void shouldEnrichSpanWithPath() { + APIGatewayProxyRequestEvent event = new APIGatewayProxyRequestEvent() + .withPath("/api/users"); + + extractor.enrichSpan(event, span); + + verify(span).setAttribute("url.path", "/api/users"); + } + + @Test + void shouldEnrichSpanWithQueryString() { + APIGatewayProxyRequestEvent event = new APIGatewayProxyRequestEvent() + .withQueryStringParameters(Map.of("name", "test", "page", "1")); + + extractor.enrichSpan(event, span); + + verify(span).setAttribute(eq("url.query"), anyString()); + } + + @Test + void shouldEnrichSpanWithUserAgent() { + APIGatewayProxyRequestEvent event = new APIGatewayProxyRequestEvent() + .withHeaders(Map.of("user-agent", "Mozilla/5.0")); + + extractor.enrichSpan(event, span); + + verify(span).setAttribute("user_agent.original", "Mozilla/5.0"); + } + + @Test + void shouldEnrichSpanWithUserAgentCaseInsensitive() { + APIGatewayProxyRequestEvent event = new APIGatewayProxyRequestEvent() + .withHeaders(Map.of("User-Agent", "PostmanRuntime/7.26.8")); + + extractor.enrichSpan(event, span); + + verify(span).setAttribute("user_agent.original", "PostmanRuntime/7.26.8"); + } + + @Test + void shouldEnrichSpanWithRequestContext() { + APIGatewayProxyRequestEvent.ProxyRequestContext requestContext = + new APIGatewayProxyRequestEvent.ProxyRequestContext(); + requestContext.setRequestId("request-123"); + requestContext.setStage("prod"); + requestContext.setResourceId("resource-456"); + requestContext.setResourcePath("/users/{id}"); + + APIGatewayProxyRequestEvent event = new APIGatewayProxyRequestEvent() + .withRequestContext(requestContext); + + extractor.enrichSpan(event, span); + + verify(span).setAttribute("aws.request_id", "request-123"); + verify(span).setAttribute("aws.apigateway.stage", "prod"); + verify(span).setAttribute("aws.apigateway.resource_id", "resource-456"); + verify(span).setAttribute("aws.apigateway.resource_path", "/users/{id}"); + } + + @Test + void shouldEnrichSpanWithAllAttributes() { + APIGatewayProxyRequestEvent.ProxyRequestContext requestContext = + new APIGatewayProxyRequestEvent.ProxyRequestContext(); + requestContext.setRequestId("request-789"); + requestContext.setStage("dev"); + requestContext.setResourceId("resource-101"); + requestContext.setResourcePath("/api/products"); + + APIGatewayProxyRequestEvent event = new APIGatewayProxyRequestEvent() + .withHttpMethod("GET") + .withPath("/api/products") + .withQueryStringParameters(Map.of("category", "electronics")) + .withHeaders(Map.of("user-agent", "PostmanRuntime/7.26.8")) + .withRequestContext(requestContext); + + extractor.enrichSpan(event, span); + + verify(span).setAttribute("http.request.method", "GET"); + verify(span).setAttribute("url.path", "/api/products"); + verify(span).setAttribute(eq("url.query"), anyString()); + verify(span).setAttribute("user_agent.original", "PostmanRuntime/7.26.8"); + verify(span).setAttribute("aws.request_id", "request-789"); + verify(span).setAttribute("aws.apigateway.stage", "dev"); + verify(span).setAttribute("aws.apigateway.resource_id", "resource-101"); + verify(span).setAttribute("aws.apigateway.resource_path", "/api/products"); + } + + @Test + void shouldNotEnrichSpanWhenHttpMethodIsNull() { + APIGatewayProxyRequestEvent event = new APIGatewayProxyRequestEvent() + .withHttpMethod(null); + + extractor.enrichSpan(event, span); + + verify(span, never()).setAttribute(eq("http.request.method"), anyString()); + } + + @Test + void shouldNotEnrichSpanWhenPathIsNull() { + APIGatewayProxyRequestEvent event = new APIGatewayProxyRequestEvent() + .withPath(null); + + extractor.enrichSpan(event, span); + + verify(span, never()).setAttribute(eq("url.path"), anyString()); + } + + @Test + void shouldNotEnrichSpanWhenQueryStringParametersIsNull() { + APIGatewayProxyRequestEvent event = new APIGatewayProxyRequestEvent() + .withQueryStringParameters(null); + + extractor.enrichSpan(event, span); + + verify(span, never()).setAttribute(eq("url.query"), anyString()); + } + + @Test + void shouldNotEnrichSpanWithUserAgentWhenHeadersIsNull() { + APIGatewayProxyRequestEvent event = new APIGatewayProxyRequestEvent() + .withHeaders(null); + + extractor.enrichSpan(event, span); + + verify(span, never()).setAttribute(eq("user_agent.original"), anyString()); + } + + @Test + void shouldNotEnrichSpanWhenRequestContextIsNull() { + APIGatewayProxyRequestEvent event = new APIGatewayProxyRequestEvent() + .withRequestContext(null); + + extractor.enrichSpan(event, span); + + verify(span, never()).setAttribute(eq("aws.request_id"), anyString()); + verify(span, never()).setAttribute(eq("aws.apigateway.stage"), anyString()); + verify(span, never()).setAttribute(eq("aws.apigateway.resource_id"), anyString()); + verify(span, never()).setAttribute(eq("aws.apigateway.resource_path"), anyString()); + } + + @Test + void shouldNotEnrichSpanWithRequestIdWhenRequestIdIsNull() { + APIGatewayProxyRequestEvent.ProxyRequestContext requestContext = + new APIGatewayProxyRequestEvent.ProxyRequestContext(); + requestContext.setRequestId(null); + + APIGatewayProxyRequestEvent event = new APIGatewayProxyRequestEvent() + .withRequestContext(requestContext); + + extractor.enrichSpan(event, span); + + verify(span, never()).setAttribute(eq("aws.request_id"), anyString()); + } + + @Test + void shouldNotEnrichSpanWithStageWhenStageIsNull() { + APIGatewayProxyRequestEvent.ProxyRequestContext requestContext = + new APIGatewayProxyRequestEvent.ProxyRequestContext(); + requestContext.setStage(null); + + APIGatewayProxyRequestEvent event = new APIGatewayProxyRequestEvent() + .withRequestContext(requestContext); + + extractor.enrichSpan(event, span); + + verify(span, never()).setAttribute(eq("aws.apigateway.stage"), anyString()); + } +} \ No newline at end of file diff --git a/powertools-tracing-opentelemetry/src/test/java/software/amazon/lambda/powertools/tracing/opentelemetry/context/DynamoDbTraceContextExtractorTest.java b/powertools-tracing-opentelemetry/src/test/java/software/amazon/lambda/powertools/tracing/opentelemetry/context/DynamoDbTraceContextExtractorTest.java new file mode 100644 index 000000000..73b98c7ff --- /dev/null +++ b/powertools-tracing-opentelemetry/src/test/java/software/amazon/lambda/powertools/tracing/opentelemetry/context/DynamoDbTraceContextExtractorTest.java @@ -0,0 +1,175 @@ +/* + * 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.tracing.opentelemetry.context; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; + +import com.amazonaws.services.lambda.runtime.events.DynamodbEvent; +import io.opentelemetry.api.trace.Span; +import io.opentelemetry.api.trace.SpanKind; +import io.opentelemetry.context.Context; +import io.opentelemetry.context.propagation.TextMapPropagator; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class DynamoDbTraceContextExtractorTest { + + @Mock + private Span span; + + @Mock + private TextMapPropagator propagator; + + @InjectMocks + private DynamoDbTraceContextExtractor extractor; + + @Test + void shouldSupportDynamodbEvent() { + DynamodbEvent dynamodbEvent = new DynamodbEvent(); + + boolean result = extractor.supports(dynamodbEvent); + + assertThat(result).isTrue(); + } + + @Test + void shouldNotSupportNonDynamodbEvent() { + assertThat(extractor.supports("Some non-Dynamodb event")).isFalse(); + assertThat(extractor.supports(new Object())).isFalse(); + assertThat(extractor.supports(null)).isFalse(); + } + + @Test + void shouldExtractTraceContextWithConsumerSpanKindAndNoSpanContexts() { + DynamodbEvent event = new DynamodbEvent(); + Context parentContext = Context.current(); + + ExtractedTraceContext extractedContext = extractor.extract(event, parentContext, propagator); + + assertThat(extractedContext).isNotNull(); + assertThat(extractedContext.context()).isEqualTo(parentContext); + assertThat(extractedContext.spanContexts()).isEmpty(); + assertThat(extractedContext.spanKind()).isEqualTo(SpanKind.CONSUMER); + } + + @Test + void shouldNotEnrichSpanWhenRecordsIsNull() { + DynamodbEvent event = new DynamodbEvent(); + event.setRecords(null); + + extractor.enrichSpan(event, span); + + verifyNoInteractions(span); + } + + @Test + void shouldNotEnrichSpanWhenRecordsIsEmpty() { + DynamodbEvent event = new DynamodbEvent(); + event.setRecords(Collections.emptyList()); + + extractor.enrichSpan(event, span); + + verifyNoInteractions(span); + } + + @Test + void shouldNotEnrichSpanWhenAllRecordsAreNull() { + DynamodbEvent event = new DynamodbEvent(); + event.setRecords(Collections.singletonList(null)); + + extractor.enrichSpan(event, span); + + verifyNoInteractions(span); + } + + @Test + void shouldEnrichSpanWithDynamoDbMetadataAndStreamNameFromArn() { + DynamodbEvent.DynamodbStreamRecord record = new DynamodbEvent.DynamodbStreamRecord(); + record.setEventSourceARN( + "arn:aws:dynamodb:us-east-1:123456789012:table/TestTable/stream/2024-01-01T00:00:00.000"); + + DynamodbEvent event = new DynamodbEvent(); + event.setRecords(List.of(record)); + + extractor.enrichSpan(event, span); + + verify(span).setAttribute("messaging.system", "aws.dynamodb"); + verify(span).setAttribute("messaging.batch.message_count", 1); + verify(span).setAttribute("messaging.destination.name", "2024-01-01T00:00:00.000"); + } + + @Test + void shouldEnrichSpanWhenArnHasNoSlashSeparator() { + DynamodbEvent.DynamodbStreamRecord record = new DynamodbEvent.DynamodbStreamRecord(); + record.setEventSourceARN("stream-name-without-separator"); + + DynamodbEvent event = new DynamodbEvent(); + event.setRecords(List.of(record)); + + extractor.enrichSpan(event, span); + + verify(span).setAttribute("messaging.system", "aws.dynamodb"); + verify(span).setAttribute("messaging.batch.message_count", 1); + verify(span).setAttribute("messaging.destination.name", "stream-name-without-separator"); + } + + @Test + void shouldEnrichSpanWithoutDestinationNameWhenEventSourceArnIsNull() { + DynamodbEvent.DynamodbStreamRecord record = new DynamodbEvent.DynamodbStreamRecord(); + record.setEventSourceARN(null); + + DynamodbEvent event = new DynamodbEvent(); + event.setRecords(List.of(record)); + + extractor.enrichSpan(event, span); + + verify(span).setAttribute("messaging.system", "aws.dynamodb"); + verify(span).setAttribute("messaging.batch.message_count", 1); + verify(span, never()).setAttribute(eq("messaging.destination.name"), anyString()); + } + + @Test + void shouldEnrichSpanWithBatchCountAndFirstNonNullRecordMetadata() { + DynamodbEvent.DynamodbStreamRecord firstRecord = null; + + DynamodbEvent.DynamodbStreamRecord secondRecord = new DynamodbEvent.DynamodbStreamRecord(); + secondRecord.setEventSourceARN( + "arn:aws:dynamodb:eu-west-1:123456789012:table/Orders/stream/orders-stream-2024"); + + DynamodbEvent.DynamodbStreamRecord thirdRecord = new DynamodbEvent.DynamodbStreamRecord(); + thirdRecord.setEventSourceARN("arn:aws:dynamodb:eu-west-1:123456789012:table/Orders/stream/another-stream"); + + DynamodbEvent event = new DynamodbEvent(); + event.setRecords(Arrays.asList(firstRecord, secondRecord, thirdRecord)); + + extractor.enrichSpan(event, span); + + verify(span).setAttribute("messaging.system", "aws.dynamodb"); + verify(span).setAttribute("messaging.batch.message_count", 3); + verify(span).setAttribute("messaging.destination.name", "orders-stream-2024"); + } +} \ No newline at end of file diff --git a/powertools-tracing-opentelemetry/src/test/java/software/amazon/lambda/powertools/tracing/opentelemetry/context/KinesisTraceContextExtractorTest.java b/powertools-tracing-opentelemetry/src/test/java/software/amazon/lambda/powertools/tracing/opentelemetry/context/KinesisTraceContextExtractorTest.java new file mode 100644 index 000000000..6f86e7936 --- /dev/null +++ b/powertools-tracing-opentelemetry/src/test/java/software/amazon/lambda/powertools/tracing/opentelemetry/context/KinesisTraceContextExtractorTest.java @@ -0,0 +1,230 @@ +/* + * 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.tracing.opentelemetry.context; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; + +import com.amazonaws.services.lambda.runtime.events.KinesisEvent; +import io.opentelemetry.api.trace.Span; +import io.opentelemetry.api.trace.SpanKind; +import io.opentelemetry.context.Context; +import io.opentelemetry.context.propagation.TextMapPropagator; +import java.util.Collections; +import java.util.Date; +import java.util.List; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class KinesisTraceContextExtractorTest { + + @Mock + private Span span; + + @Mock + private TextMapPropagator propagator; + + @InjectMocks + private KinesisTraceContextExtractor extractor; + + @Test + void shouldSupportKinesisEvent() { + KinesisEvent kinesisEvent = new KinesisEvent(); + + boolean result = extractor.supports(kinesisEvent); + + assertThat(result).isTrue(); + } + + @Test + void shouldNotSupportNonKinesisEvent() { + assertThat(extractor.supports("Some non-Kinesis event")).isFalse(); + assertThat(extractor.supports(new Object())).isFalse(); + assertThat(extractor.supports(null)).isFalse(); + } + + @Test + void shouldExtractTraceContextWithConsumerSpanKindAndNoSpanContexts() { + KinesisEvent event = new KinesisEvent(); + Context parentContext = Context.current(); + + ExtractedTraceContext extractedContext = extractor.extract(event, parentContext, propagator); + + assertThat(extractedContext).isNotNull(); + assertThat(extractedContext.context()).isEqualTo(parentContext); + assertThat(extractedContext.spanContexts()).isEmpty(); + assertThat(extractedContext.spanKind()).isEqualTo(SpanKind.CONSUMER); + } + + @Test + void shouldNotEnrichSpanWhenRecordsIsNull() { + KinesisEvent event = new KinesisEvent(); + event.setRecords(null); + + extractor.enrichSpan(event, span); + + verifyNoInteractions(span); + } + + @Test + void shouldNotEnrichSpanWhenRecordsIsEmpty() { + KinesisEvent event = new KinesisEvent(); + event.setRecords(Collections.emptyList()); + + extractor.enrichSpan(event, span); + + verifyNoInteractions(span); + } + + @Test + void shouldNotEnrichSpanWhenFirstRecordIsNull() { + KinesisEvent event = new KinesisEvent(); + event.setRecords(Collections.singletonList(null)); + + extractor.enrichSpan(event, span); + + verifyNoInteractions(span); + } + + @Test + void shouldNotEnrichSpanWhenKinesisIsNull() { + KinesisEvent.KinesisEventRecord record = new KinesisEvent.KinesisEventRecord(); + record.setKinesis(null); + + KinesisEvent event = new KinesisEvent(); + event.setRecords(List.of(record)); + + extractor.enrichSpan(event, span); + + verifyNoInteractions(span); + } + + @Test + void shouldEnrichSpanWithKinesisMetadata() { + KinesisEvent.Record kinesis = new KinesisEvent.Record(); + kinesis.setPartitionKey("partition-1"); + kinesis.setSequenceNumber("12345678901234567890"); + kinesis.setApproximateArrivalTimestamp(new Date(1609459200000L)); + + KinesisEvent.KinesisEventRecord record = new KinesisEvent.KinesisEventRecord(); + record.setKinesis(kinesis); + record.setEventSourceARN("arn:aws:kinesis:us-east-1:123456789012:stream/test-stream"); + + KinesisEvent event = new KinesisEvent(); + event.setRecords(List.of(record)); + + extractor.enrichSpan(event, span); + + verify(span).setAttribute("messaging.system", "aws.kinesis"); + verify(span).setAttribute("messaging.partition_key", "partition-1"); + verify(span).setAttribute("messaging.message.id", "12345678901234567890"); + verify(span).setAttribute("messaging.message.receive.timestamp", 1609459200000L); + verify(span).setAttribute("messaging.destination.name", "test-stream"); + } + + @Test + void shouldEnrichSpanWithStreamNameFromArnWithoutSlash() { + KinesisEvent.Record kinesis = new KinesisEvent.Record(); + + KinesisEvent.KinesisEventRecord record = new KinesisEvent.KinesisEventRecord(); + record.setKinesis(kinesis); + record.setEventSourceARN("stream-name-without-separator"); + + KinesisEvent event = new KinesisEvent(); + event.setRecords(List.of(record)); + + extractor.enrichSpan(event, span); + + verify(span).setAttribute("messaging.system", "aws.kinesis"); + verify(span).setAttribute("messaging.destination.name", "stream-name-without-separator"); + } + + @Test + void shouldNotEnrichSpanWithPartitionKeyWhenNull() { + KinesisEvent.Record kinesis = new KinesisEvent.Record(); + kinesis.setPartitionKey(null); + + KinesisEvent.KinesisEventRecord record = new KinesisEvent.KinesisEventRecord(); + record.setKinesis(kinesis); + + KinesisEvent event = new KinesisEvent(); + event.setRecords(List.of(record)); + + extractor.enrichSpan(event, span); + + verify(span).setAttribute("messaging.system", "aws.kinesis"); + verify(span, never()).setAttribute(eq("messaging.partition_key"), anyString()); + } + + @Test + void shouldNotEnrichSpanWithSequenceNumberWhenNull() { + KinesisEvent.Record kinesis = new KinesisEvent.Record(); + kinesis.setSequenceNumber(null); + + KinesisEvent.KinesisEventRecord record = new KinesisEvent.KinesisEventRecord(); + record.setKinesis(kinesis); + + KinesisEvent event = new KinesisEvent(); + event.setRecords(List.of(record)); + + extractor.enrichSpan(event, span); + + verify(span).setAttribute("messaging.system", "aws.kinesis"); + verify(span, never()).setAttribute(eq("messaging.message.id"), anyString()); + } + + @Test + void shouldNotEnrichSpanWithTimestampWhenNull() { + KinesisEvent.Record kinesis = new KinesisEvent.Record(); + kinesis.setApproximateArrivalTimestamp(null); + + KinesisEvent.KinesisEventRecord record = new KinesisEvent.KinesisEventRecord(); + record.setKinesis(kinesis); + + KinesisEvent event = new KinesisEvent(); + event.setRecords(List.of(record)); + + extractor.enrichSpan(event, span); + + verify(span).setAttribute("messaging.system", "aws.kinesis"); + verify(span, never()).setAttribute(eq("messaging.message.receive.timestamp"), anyLong()); + } + + @Test + void shouldNotEnrichSpanWithDestinationNameWhenEventSourceArnIsNull() { + KinesisEvent.Record kinesis = new KinesisEvent.Record(); + + KinesisEvent.KinesisEventRecord record = new KinesisEvent.KinesisEventRecord(); + record.setKinesis(kinesis); + record.setEventSourceARN(null); + + KinesisEvent event = new KinesisEvent(); + event.setRecords(List.of(record)); + + extractor.enrichSpan(event, span); + + verify(span).setAttribute("messaging.system", "aws.kinesis"); + verify(span, never()).setAttribute(eq("messaging.destination.name"), anyString()); + } +} \ No newline at end of file diff --git a/powertools-tracing-opentelemetry/src/test/java/software/amazon/lambda/powertools/tracing/opentelemetry/context/LambdaEventContextExtractorResolverTest.java b/powertools-tracing-opentelemetry/src/test/java/software/amazon/lambda/powertools/tracing/opentelemetry/context/LambdaEventContextExtractorResolverTest.java new file mode 100644 index 000000000..5cd6c5bf5 --- /dev/null +++ b/powertools-tracing-opentelemetry/src/test/java/software/amazon/lambda/powertools/tracing/opentelemetry/context/LambdaEventContextExtractorResolverTest.java @@ -0,0 +1,420 @@ +package software.amazon.lambda.powertools.tracing.opentelemetry.context; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyRequestEvent; +import com.amazonaws.services.lambda.runtime.events.DynamodbEvent; +import com.amazonaws.services.lambda.runtime.events.KinesisEvent; +import com.amazonaws.services.lambda.runtime.events.S3Event; +import com.amazonaws.services.lambda.runtime.events.SNSEvent; +import com.amazonaws.services.lambda.runtime.events.SQSEvent; +import io.opentelemetry.api.trace.Span; +import io.opentelemetry.api.trace.SpanContext; +import io.opentelemetry.api.trace.SpanKind; +import io.opentelemetry.context.Context; +import io.opentelemetry.context.propagation.TextMapPropagator; +import io.opentelemetry.sdk.trace.SdkTracerProvider; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class LambdaEventContextExtractorResolverTest { + + @Mock + private LambdaEventContextExtractor mockExtractor1; + + @Mock + private LambdaEventContextExtractor mockExtractor2; + + @Mock + private TextMapPropagator propagator; + + private Context parentContext; + private SdkTracerProvider tracerProvider; + private Span testSpan; + + @BeforeEach + void setUp() { + tracerProvider = SdkTracerProvider.builder().build(); + testSpan = tracerProvider.get("test").spanBuilder("test-span").startSpan(); + parentContext = Context.current(); + } + + @Test + void testCreate() { + + LambdaEventContextExtractorResolver resolver = LambdaEventContextExtractorResolver.create(); + + assertThat(resolver).isNotNull(); + } + + @Test + void testExtractWithSupportedExtractor() { + + Object event = new APIGatewayProxyRequestEvent(); + + SpanContext spanContext = SpanContext.createFromRemoteParent( + "00000000000000000000000000000001", + "0000000000000001", + io.opentelemetry.api.trace.TraceFlags.getSampled(), + io.opentelemetry.api.trace.TraceState.getDefault() + ); + + ExtractedTraceContext expectedContext = new ExtractedTraceContext( + parentContext, + List.of(spanContext), + SpanKind.SERVER + ); + + when(mockExtractor1.supports(event)).thenReturn(true); + when(mockExtractor1.extract(event, parentContext, propagator)).thenReturn(expectedContext); + + LambdaEventContextExtractorResolver resolver = new LambdaEventContextExtractorResolver( + List.of(mockExtractor1, mockExtractor2) + ); + + ExtractedTraceContext result = resolver.extract(event, parentContext, propagator); + + assertThat(result).isEqualTo(expectedContext); + assertThat(result.context()).isEqualTo(parentContext); + assertThat(result.spanContexts()).hasSize(1); + assertThat(result.spanKind()).isEqualTo(SpanKind.SERVER); + verify(mockExtractor1).supports(event); + verify(mockExtractor1).extract(event, parentContext, propagator); + verify(mockExtractor2, never()).supports(any()); + } + + @Test + void testExtractWithNoSupportingExtractor() { + + Object event = new Object(); + + when(mockExtractor1.supports(event)).thenReturn(false); + when(mockExtractor2.supports(event)).thenReturn(false); + + LambdaEventContextExtractorResolver resolver = new LambdaEventContextExtractorResolver( + List.of(mockExtractor1, mockExtractor2) + ); + + ExtractedTraceContext result = resolver.extract(event, parentContext, propagator); + + assertThat(result).isNotNull(); + assertThat(result.context()).isEqualTo(parentContext); + assertThat(result.spanContexts()).isEmpty(); + assertThat(result.spanKind()).isEqualTo(SpanKind.SERVER); + verify(mockExtractor1).supports(event); + verify(mockExtractor2).supports(event); + verify(mockExtractor1, never()).extract(any(), any(), any()); + verify(mockExtractor2, never()).extract(any(), any(), any()); + } + + @Test + void testExtractWithMultipleExtractorsFirstMatch() { + + Object event = new SQSEvent(); + + ExtractedTraceContext expectedContext = new ExtractedTraceContext( + parentContext, + Collections.emptyList() + ); + + when(mockExtractor1.supports(event)).thenReturn(true); + when(mockExtractor1.extract(event, parentContext, propagator)).thenReturn(expectedContext); + + LambdaEventContextExtractorResolver resolver = new LambdaEventContextExtractorResolver( + List.of(mockExtractor1, mockExtractor2) + ); + + ExtractedTraceContext result = resolver.extract(event, parentContext, propagator); + + assertThat(result).isEqualTo(expectedContext); + verify(mockExtractor1).supports(event); + verify(mockExtractor1).extract(event, parentContext, propagator); + verify(mockExtractor2, never()).supports(any()); + verify(mockExtractor2, never()).extract(any(), any(), any()); + } + + @Test + void testEnrichSpanWithSupportedExtractor() { + + Object event = new SNSEvent(); + + when(mockExtractor1.supports(event)).thenReturn(true); + + LambdaEventContextExtractorResolver resolver = new LambdaEventContextExtractorResolver( + List.of(mockExtractor1, mockExtractor2) + ); + + resolver.enrichSpan(event, testSpan); + + verify(mockExtractor1).supports(event); + verify(mockExtractor1).enrichSpan(event, testSpan); + verify(mockExtractor2, never()).supports(any()); + verify(mockExtractor2, never()).enrichSpan(any(), any()); + } + + @Test + void testEnrichSpanWithNoSupportingExtractor() { + + Object event = new Object(); + + when(mockExtractor1.supports(event)).thenReturn(false); + when(mockExtractor2.supports(event)).thenReturn(false); + + LambdaEventContextExtractorResolver resolver = new LambdaEventContextExtractorResolver( + List.of(mockExtractor1, mockExtractor2) + ); + + resolver.enrichSpan(event, testSpan); + + verify(mockExtractor1).supports(event); + verify(mockExtractor2).supports(event); + verify(mockExtractor1, never()).enrichSpan(any(), any()); + verify(mockExtractor2, never()).enrichSpan(any(), any()); + } + + @Test + void testEnrichSpanWithMultipleExtractorsFirstMatch() { + + Object event = new KinesisEvent(); + + when(mockExtractor1.supports(event)).thenReturn(true); + + LambdaEventContextExtractorResolver resolver = new LambdaEventContextExtractorResolver( + List.of(mockExtractor1, mockExtractor2) + ); + + resolver.enrichSpan(event, testSpan); + + verify(mockExtractor1).supports(event); + verify(mockExtractor1).enrichSpan(event, testSpan); + verify(mockExtractor2, never()).supports(any()); + verify(mockExtractor2, never()).enrichSpan(any(), any()); + } + + @Test + void testCreateWithAllDefaultExtractors() { + + LambdaEventContextExtractorResolver resolver = LambdaEventContextExtractorResolver.create(); + + APIGatewayProxyRequestEvent apiGatewayEvent = new APIGatewayProxyRequestEvent(); + apiGatewayEvent.setHeaders(new HashMap<>()); + + SQSEvent sqsEvent = new SQSEvent(); + sqsEvent.setRecords(new ArrayList<>()); + + SNSEvent snsEvent = new SNSEvent(); + snsEvent.setRecords(new ArrayList<>()); + + KinesisEvent kinesisEvent = new KinesisEvent(); + kinesisEvent.setRecords(new ArrayList<>()); + + DynamodbEvent dynamoDbEvent = new DynamodbEvent(); + dynamoDbEvent.setRecords(new ArrayList<>()); + + S3Event s3Event = new S3Event(new ArrayList<>()); + + ExtractedTraceContext apiResult = resolver.extract(apiGatewayEvent, parentContext, propagator); + assertThat(apiResult).isNotNull(); + + ExtractedTraceContext sqsResult = resolver.extract(sqsEvent, parentContext, propagator); + assertThat(sqsResult).isNotNull(); + + ExtractedTraceContext snsResult = resolver.extract(snsEvent, parentContext, propagator); + assertThat(snsResult).isNotNull(); + + ExtractedTraceContext kinesisResult = resolver.extract(kinesisEvent, parentContext, propagator); + assertThat(kinesisResult).isNotNull(); + + ExtractedTraceContext dynamoDbResult = resolver.extract(dynamoDbEvent, parentContext, propagator); + assertThat(dynamoDbResult).isNotNull(); + + ExtractedTraceContext s3Result = resolver.extract(s3Event, parentContext, propagator); + assertThat(s3Result).isNotNull(); + } + + @Test + void testConstructorCreatesImmutableCopy() { + + List originalList = new ArrayList<>(); + originalList.add(mockExtractor1); + + LambdaEventContextExtractorResolver resolver = new LambdaEventContextExtractorResolver(originalList); + + originalList.add(mockExtractor2); + + Object event = new Object(); + when(mockExtractor1.supports(event)).thenReturn(false); + + resolver.extract(event, parentContext, propagator); + + verify(mockExtractor1).supports(event); + verify(mockExtractor2, never()).supports(any()); + } + + @Test + void testExtractWithEmptyExtractorList() { + + LambdaEventContextExtractorResolver resolver = new LambdaEventContextExtractorResolver( + Collections.emptyList() + ); + Object event = new Object(); + + ExtractedTraceContext result = resolver.extract(event, parentContext, propagator); + + assertThat(result).isNotNull(); + assertThat(result.context()).isEqualTo(parentContext); + assertThat(result.spanContexts()).isEmpty(); + } + + @Test + void testEnrichSpanWithEmptyExtractorList() { + + LambdaEventContextExtractorResolver resolver = new LambdaEventContextExtractorResolver( + Collections.emptyList() + ); + Object event = new Object(); + + resolver.enrichSpan(event, testSpan); + } + + @Test + void testExtractWithNullEvent() { + + when(mockExtractor1.supports(null)).thenReturn(false); + + LambdaEventContextExtractorResolver resolver = new LambdaEventContextExtractorResolver( + List.of(mockExtractor1) + ); + + ExtractedTraceContext result = resolver.extract(null, parentContext, propagator); + + assertThat(result).isNotNull(); + assertThat(result.spanContexts()).isEmpty(); + verify(mockExtractor1).supports(null); + } + + @Test + void testEnrichSpanWithNullEvent() { + + when(mockExtractor1.supports(null)).thenReturn(false); + + LambdaEventContextExtractorResolver resolver = new LambdaEventContextExtractorResolver( + List.of(mockExtractor1) + ); + + resolver.enrichSpan(null, testSpan); + verify(mockExtractor1).supports(null); + } + + @Test + void testExtractReturnsContextWithMultipleSpanContexts() { + + Object event = new DynamodbEvent(); + + SpanContext spanContext1 = SpanContext.createFromRemoteParent( + "00000000000000000000000000000001", + "0000000000000001", + io.opentelemetry.api.trace.TraceFlags.getSampled(), + io.opentelemetry.api.trace.TraceState.getDefault() + ); + + SpanContext spanContext2 = SpanContext.createFromRemoteParent( + "00000000000000000000000000000002", + "0000000000000002", + io.opentelemetry.api.trace.TraceFlags.getSampled(), + io.opentelemetry.api.trace.TraceState.getDefault() + ); + + ExtractedTraceContext expectedContext = new ExtractedTraceContext( + parentContext, + Arrays.asList(spanContext1, spanContext2), + SpanKind.CONSUMER + ); + + when(mockExtractor1.supports(event)).thenReturn(true); + when(mockExtractor1.extract(event, parentContext, propagator)).thenReturn(expectedContext); + + LambdaEventContextExtractorResolver resolver = new LambdaEventContextExtractorResolver( + List.of(mockExtractor1) + ); + + + ExtractedTraceContext result = resolver.extract(event, parentContext, propagator); + + + assertThat(result.spanContexts()).hasSize(2); + assertThat(result.spanKind()).isEqualTo(SpanKind.CONSUMER); + } + + @Test + void testExtractWithDifferentSpanKinds() { + + Object event = new Object(); + + ExtractedTraceContext clientContext = new ExtractedTraceContext( + parentContext, + Collections.emptyList(), + SpanKind.CLIENT + ); + + when(mockExtractor1.supports(event)).thenReturn(true); + when(mockExtractor1.extract(event, parentContext, propagator)).thenReturn(clientContext); + + LambdaEventContextExtractorResolver resolver = new LambdaEventContextExtractorResolver( + List.of(mockExtractor1) + ); + + ExtractedTraceContext result = resolver.extract(event, parentContext, propagator); + + assertThat(result.spanKind()).isEqualTo(SpanKind.CLIENT); + } + + @Test + void testExtractorOrderMatters() { + + Object event = new Object(); + + ExtractedTraceContext context1 = new ExtractedTraceContext( + parentContext, + Collections.emptyList(), + SpanKind.SERVER + ); + + ExtractedTraceContext context2 = new ExtractedTraceContext( + parentContext, + Collections.emptyList(), + SpanKind.CLIENT + ); + + when(mockExtractor1.supports(event)).thenReturn(true); + when(mockExtractor1.extract(event, parentContext, propagator)).thenReturn(context1); + when(mockExtractor2.supports(event)).thenReturn(true); + when(mockExtractor2.extract(event, parentContext, propagator)).thenReturn(context2); + + LambdaEventContextExtractorResolver resolver1 = new LambdaEventContextExtractorResolver( + List.of(mockExtractor1, mockExtractor2) + ); + ExtractedTraceContext result1 = resolver1.extract(event, parentContext, propagator); + + LambdaEventContextExtractorResolver resolver2 = new LambdaEventContextExtractorResolver( + List.of(mockExtractor2, mockExtractor1) + ); + ExtractedTraceContext result2 = resolver2.extract(event, parentContext, propagator); + + assertThat(result1.spanKind()).isEqualTo(SpanKind.SERVER); + assertThat(result2.spanKind()).isEqualTo(SpanKind.CLIENT); + } +} \ No newline at end of file diff --git a/powertools-tracing-opentelemetry/src/test/java/software/amazon/lambda/powertools/tracing/opentelemetry/context/S3TraceContextExtractorTest.java b/powertools-tracing-opentelemetry/src/test/java/software/amazon/lambda/powertools/tracing/opentelemetry/context/S3TraceContextExtractorTest.java new file mode 100644 index 000000000..4a3f20e22 --- /dev/null +++ b/powertools-tracing-opentelemetry/src/test/java/software/amazon/lambda/powertools/tracing/opentelemetry/context/S3TraceContextExtractorTest.java @@ -0,0 +1,184 @@ +/* + * 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.tracing.opentelemetry.context; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; + +import com.amazonaws.services.lambda.runtime.events.S3Event; +import com.amazonaws.services.lambda.runtime.events.models.s3.S3EventNotification; +import io.opentelemetry.api.trace.Span; +import io.opentelemetry.api.trace.SpanKind; +import io.opentelemetry.context.Context; +import io.opentelemetry.context.propagation.TextMapPropagator; +import java.util.Collections; +import java.util.List; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class S3TraceContextExtractorTest { + + @Mock + private Span span; + + @Mock + private TextMapPropagator propagator; + + @InjectMocks + private S3TraceContextExtractor extractor; + + @Test + void shouldSupportS3Event() { + S3Event s3Event = new S3Event(Collections.emptyList()); + + boolean result = extractor.supports(s3Event); + + assertThat(result).isTrue(); + } + + @Test + void shouldNotSupportNonS3Event() { + assertThat(extractor.supports("Some non-S3 event")).isFalse(); + assertThat(extractor.supports(new Object())).isFalse(); + assertThat(extractor.supports(null)).isFalse(); + } + + @Test + void shouldExtractTraceContextWithConsumerSpanKindAndNoSpanContexts() { + S3Event event = new S3Event(Collections.emptyList()); + Context parentContext = Context.current(); + + ExtractedTraceContext extractedContext = extractor.extract(event, parentContext, propagator); + + assertThat(extractedContext).isNotNull(); + assertThat(extractedContext.context()).isEqualTo(parentContext); + assertThat(extractedContext.spanContexts()).isEmpty(); + assertThat(extractedContext.spanKind()).isEqualTo(SpanKind.CONSUMER); + } + + @Test + void shouldNotEnrichSpanWhenRecordsIsNull() { + S3Event event = new S3Event(null); + + extractor.enrichSpan(event, span); + + verifyNoInteractions(span); + } + + @Test + void shouldNotEnrichSpanWhenRecordsIsEmpty() { + S3Event event = new S3Event(Collections.emptyList()); + + extractor.enrichSpan(event, span); + + verifyNoInteractions(span); + } + + @Test + void shouldNotEnrichSpanWhenAllRecordsAreNull() { + S3Event event = new S3Event(Collections.singletonList(null)); + + extractor.enrichSpan(event, span); + + verify(span).setAttribute("messaging.system", "aws.s3"); + verify(span).setAttribute("messaging.batch.message_count", 1); + verify(span, never()).setAttribute(eq("messaging.destination.name"), anyString()); + verify(span, never()).setAttribute(eq("messaging.event.type"), anyString()); + } + + @Test + void shouldEnrichSpanWithS3Metadata() { + S3EventNotification.S3BucketEntity bucket = new S3EventNotification.S3BucketEntity( + "test-bucket", null, null); + S3EventNotification.S3ObjectEntity object = new S3EventNotification.S3ObjectEntity( + "test-key", 1024L, null, null, null); + S3EventNotification.S3Entity s3 = new S3EventNotification.S3Entity( + null, bucket, object, null); + + S3EventNotification.S3EventNotificationRecord record = new S3EventNotification.S3EventNotificationRecord( + null, "ObjectCreated:Put", null, null, null, null, null, s3, null); + + S3Event event = new S3Event(List.of(record)); + + extractor.enrichSpan(event, span); + + verify(span).setAttribute("messaging.system", "aws.s3"); + verify(span).setAttribute("messaging.batch.message_count", 1); + verify(span).setAttribute("messaging.destination.name", "test-bucket"); + verify(span).setAttribute("messaging.event.type", "ObjectCreated:Put"); + } + + @Test + void shouldEnrichSpanWithBatchCountOnly() { + S3EventNotification.S3EventNotificationRecord record1 = new S3EventNotification.S3EventNotificationRecord( + null, null, null, null, null, null, null, null, null); + S3EventNotification.S3EventNotificationRecord record2 = new S3EventNotification.S3EventNotificationRecord( + null, null, null, null, null, null, null, null, null); + + S3Event event = new S3Event(List.of(record1, record2)); + + extractor.enrichSpan(event, span); + + verify(span).setAttribute("messaging.system", "aws.s3"); + verify(span).setAttribute("messaging.batch.message_count", 2); + verify(span, never()).setAttribute(eq("messaging.destination.name"), anyString()); + } + + @Test + void shouldNotEnrichSpanWithBucketNameWhenBucketIsNull() { + S3EventNotification.S3Entity s3 = new S3EventNotification.S3Entity( + null, null, null, null); + + S3EventNotification.S3EventNotificationRecord record = new S3EventNotification.S3EventNotificationRecord( + null, "ObjectCreated:Put", null, null, null, null, null, s3, null); + + S3Event event = new S3Event(List.of(record)); + + extractor.enrichSpan(event, span); + + verify(span).setAttribute("messaging.system", "aws.s3"); + verify(span).setAttribute("messaging.batch.message_count", 1); + verify(span, never()).setAttribute(eq("messaging.destination.name"), anyString()); + verify(span).setAttribute("messaging.event.type", "ObjectCreated:Put"); + } + + @Test + void shouldNotEnrichSpanWithEventTypeWhenEventNameIsNull() { + S3EventNotification.S3BucketEntity bucket = new S3EventNotification.S3BucketEntity( + "test-bucket", null, null); + S3EventNotification.S3Entity s3 = new S3EventNotification.S3Entity( + null, bucket, null, null); + + S3EventNotification.S3EventNotificationRecord record = new S3EventNotification.S3EventNotificationRecord( + null, null, null, null, null, null, null, s3, null); + + S3Event event = new S3Event(List.of(record)); + + extractor.enrichSpan(event, span); + + verify(span).setAttribute("messaging.system", "aws.s3"); + verify(span).setAttribute("messaging.batch.message_count", 1); + verify(span).setAttribute("messaging.destination.name", "test-bucket"); + verify(span, never()).setAttribute(eq("messaging.event.type"), anyString()); + } +} \ No newline at end of file diff --git a/powertools-tracing-opentelemetry/src/test/java/software/amazon/lambda/powertools/tracing/opentelemetry/context/SnsTraceContextExtractorTest.java b/powertools-tracing-opentelemetry/src/test/java/software/amazon/lambda/powertools/tracing/opentelemetry/context/SnsTraceContextExtractorTest.java new file mode 100644 index 000000000..e372265dc --- /dev/null +++ b/powertools-tracing-opentelemetry/src/test/java/software/amazon/lambda/powertools/tracing/opentelemetry/context/SnsTraceContextExtractorTest.java @@ -0,0 +1,227 @@ +/* + * 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.tracing.opentelemetry.context; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; + +import com.amazonaws.services.lambda.runtime.events.SNSEvent; +import io.opentelemetry.api.trace.Span; +import io.opentelemetry.api.trace.SpanContext; +import io.opentelemetry.api.trace.SpanKind; +import io.opentelemetry.api.trace.propagation.W3CTraceContextPropagator; +import io.opentelemetry.context.Context; +import io.opentelemetry.context.propagation.TextMapPropagator; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class SnsTraceContextExtractorTest { + + @Mock + private Span span; + + @Mock + private TextMapPropagator propagator; + + @InjectMocks + private SnsTraceContextExtractor extractor; + + @Test + void shouldSupportSnsEvent() { + SNSEvent snsEvent = new SNSEvent(); + + boolean result = extractor.supports(snsEvent); + + assertThat(result).isTrue(); + } + + @Test + void shouldNotSupportNonSnsEvent() { + assertThat(extractor.supports("Some non-SNS event")).isFalse(); + assertThat(extractor.supports(new Object())).isFalse(); + assertThat(extractor.supports(null)).isFalse(); + } + + @Test + void shouldExtractTraceContextWithConsumerSpanKindWhenRecordsIsNull() { + SNSEvent event = new SNSEvent(); + event.setRecords(null); + Context parentContext = Context.current(); + + ExtractedTraceContext extractedContext = extractor.extract(event, parentContext, propagator); + + assertThat(extractedContext).isNotNull(); + assertThat(extractedContext.context()).isEqualTo(parentContext); + assertThat(extractedContext.spanContexts()).isEmpty(); + assertThat(extractedContext.spanKind()).isEqualTo(SpanKind.CONSUMER); + } + + @Test + void shouldExtractTraceContextWithConsumerSpanKindWhenRecordsIsEmpty() { + SNSEvent event = new SNSEvent(); + event.setRecords(Collections.emptyList()); + Context parentContext = Context.current(); + + ExtractedTraceContext extractedContext = extractor.extract(event, parentContext, propagator); + + assertThat(extractedContext).isNotNull(); + assertThat(extractedContext.context()).isEqualTo(parentContext); + assertThat(extractedContext.spanContexts()).isEmpty(); + assertThat(extractedContext.spanKind()).isEqualTo(SpanKind.CONSUMER); + } + + @Test + void shouldExtractTraceContextFromSnsEvent() { + String traceId = "4bf92f3577b34da6a3ce929d0e0e4736"; + String spanId = "00f067aa0ba902b7"; + + SNSEvent.MessageAttribute traceparent = new SNSEvent.MessageAttribute(); + traceparent.setType("String"); + traceparent.setValue("00-" + traceId + "-" + spanId + "-01"); + + Map messageAttributes = new HashMap<>(); + messageAttributes.put("traceparent", traceparent); + + SNSEvent.SNS sns = new SNSEvent.SNS(); + sns.setMessageAttributes(messageAttributes); + + SNSEvent.SNSRecord record = new SNSEvent.SNSRecord(); + record.setSns(sns); + + SNSEvent event = new SNSEvent(); + event.setRecords(List.of(record)); + + ExtractedTraceContext extractedContext = extractor.extract( + event, + Context.current(), + W3CTraceContextPropagator.getInstance() + ); + + assertThat(extractedContext).isNotNull(); + assertThat(extractedContext.spanContexts()).hasSize(1); + assertThat(extractedContext.spanKind()).isEqualTo(SpanKind.CONSUMER); + + SpanContext spanContext = extractedContext.spanContexts().get(0); + assertThat(spanContext.isValid()).isTrue(); + assertThat(spanContext.getTraceId()).isEqualTo(traceId); + assertThat(spanContext.getSpanId()).isEqualTo(spanId); + } + + @Test + void shouldNotEnrichSpanWhenRecordsIsNull() { + SNSEvent event = new SNSEvent(); + event.setRecords(null); + + extractor.enrichSpan(event, span); + + verifyNoInteractions(span); + } + + @Test + void shouldNotEnrichSpanWhenRecordsIsEmpty() { + SNSEvent event = new SNSEvent(); + event.setRecords(Collections.emptyList()); + + extractor.enrichSpan(event, span); + + verifyNoInteractions(span); + } + + @Test + void shouldNotEnrichSpanWhenAllRecordsAreNull() { + SNSEvent event = new SNSEvent(); + event.setRecords(Collections.singletonList(null)); + + extractor.enrichSpan(event, span); + + verifyNoInteractions(span); + } + + @Test + void shouldNotEnrichSpanWhenSnsIsNull() { + SNSEvent.SNSRecord record = new SNSEvent.SNSRecord(); + record.setSns(null); + + SNSEvent event = new SNSEvent(); + event.setRecords(List.of(record)); + + extractor.enrichSpan(event, span); + + verifyNoInteractions(span); + } + + @Test + void shouldEnrichSpanWithSnsMetadata() { + SNSEvent.SNS sns = new SNSEvent.SNS(); + sns.setTopicArn("arn:aws:sns:us-east-1:123456789012:test-topic"); + + SNSEvent.SNSRecord record = new SNSEvent.SNSRecord(); + record.setSns(sns); + + SNSEvent event = new SNSEvent(); + event.setRecords(List.of(record)); + + extractor.enrichSpan(event, span); + + verify(span).setAttribute("messaging.system", "aws.sns"); + verify(span).setAttribute("messaging.destination.name", "test-topic"); + } + + @Test + void shouldEnrichSpanWithTopicNameFromArnWithoutColon() { + SNSEvent.SNS sns = new SNSEvent.SNS(); + sns.setTopicArn("topic-name-without-separator"); + + SNSEvent.SNSRecord record = new SNSEvent.SNSRecord(); + record.setSns(sns); + + SNSEvent event = new SNSEvent(); + event.setRecords(List.of(record)); + + extractor.enrichSpan(event, span); + + verify(span).setAttribute("messaging.system", "aws.sns"); + verify(span).setAttribute("messaging.destination.name", "topic-name-without-separator"); + } + + @Test + void shouldNotEnrichSpanWithDestinationNameWhenTopicArnIsNull() { + SNSEvent.SNS sns = new SNSEvent.SNS(); + sns.setTopicArn(null); + + SNSEvent.SNSRecord record = new SNSEvent.SNSRecord(); + record.setSns(sns); + + SNSEvent event = new SNSEvent(); + event.setRecords(List.of(record)); + + extractor.enrichSpan(event, span); + + verify(span).setAttribute("messaging.system", "aws.sns"); + verify(span, never()).setAttribute(eq("messaging.destination.name"), anyString()); + } +} \ No newline at end of file diff --git a/powertools-tracing-opentelemetry/src/test/java/software/amazon/lambda/powertools/tracing/opentelemetry/context/SqsTraceContextExtractorTest.java b/powertools-tracing-opentelemetry/src/test/java/software/amazon/lambda/powertools/tracing/opentelemetry/context/SqsTraceContextExtractorTest.java new file mode 100644 index 000000000..b26a36ca6 --- /dev/null +++ b/powertools-tracing-opentelemetry/src/test/java/software/amazon/lambda/powertools/tracing/opentelemetry/context/SqsTraceContextExtractorTest.java @@ -0,0 +1,212 @@ +/* + * 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.tracing.opentelemetry.context; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; + +import com.amazonaws.services.lambda.runtime.events.SQSEvent; +import io.opentelemetry.api.trace.Span; +import io.opentelemetry.api.trace.SpanContext; +import io.opentelemetry.api.trace.SpanKind; +import io.opentelemetry.api.trace.propagation.W3CTraceContextPropagator; +import io.opentelemetry.context.Context; +import io.opentelemetry.context.propagation.TextMapPropagator; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class SqsTraceContextExtractorTest { + + @Mock + private Span span; + + @Mock + private TextMapPropagator propagator; + + @InjectMocks + private SqsTraceContextExtractor extractor; + + @Test + void shouldSupportSqsEvent() { + SQSEvent sqsEvent = new SQSEvent(); + + boolean result = extractor.supports(sqsEvent); + + assertThat(result).isTrue(); + } + + @Test + void shouldNotSupportNonSqsEvent() { + assertThat(extractor.supports("Some non-SQS event")).isFalse(); + assertThat(extractor.supports(new Object())).isFalse(); + assertThat(extractor.supports(null)).isFalse(); + } + + @Test + void shouldExtractTraceContextWithConsumerSpanKindWhenRecordsIsNull() { + SQSEvent event = new SQSEvent(); + event.setRecords(null); + Context parentContext = Context.current(); + + ExtractedTraceContext extractedContext = extractor.extract(event, parentContext, propagator); + + assertThat(extractedContext).isNotNull(); + assertThat(extractedContext.context()).isEqualTo(parentContext); + assertThat(extractedContext.spanContexts()).isEmpty(); + assertThat(extractedContext.spanKind()).isEqualTo(SpanKind.CONSUMER); + } + + @Test + void shouldExtractTraceContextWithConsumerSpanKindWhenRecordsIsEmpty() { + SQSEvent event = new SQSEvent(); + event.setRecords(Collections.emptyList()); + Context parentContext = Context.current(); + + ExtractedTraceContext extractedContext = extractor.extract(event, parentContext, propagator); + + assertThat(extractedContext).isNotNull(); + assertThat(extractedContext.context()).isEqualTo(parentContext); + assertThat(extractedContext.spanContexts()).isEmpty(); + assertThat(extractedContext.spanKind()).isEqualTo(SpanKind.CONSUMER); + } + + @Test + void shouldExtractTraceContextFromSqsEvent() { + String traceId = "4bf92f3577b34da6a3ce929d0e0e4736"; + String spanId = "00f067aa0ba902b7"; + + SQSEvent.MessageAttribute traceparent = new SQSEvent.MessageAttribute(); + traceparent.setStringValue("00-" + traceId + "-" + spanId + "-01"); + + Map messageAttributes = new HashMap<>(); + messageAttributes.put("traceparent", traceparent); + + SQSEvent.SQSMessage message = new SQSEvent.SQSMessage(); + message.setMessageAttributes(messageAttributes); + + SQSEvent event = new SQSEvent(); + event.setRecords(List.of(message)); + + ExtractedTraceContext extractedContext = extractor.extract( + event, + Context.current(), + W3CTraceContextPropagator.getInstance() + ); + + assertThat(extractedContext).isNotNull(); + assertThat(extractedContext.spanContexts()).hasSize(1); + assertThat(extractedContext.spanKind()).isEqualTo(SpanKind.CONSUMER); + + SpanContext spanContext = extractedContext.spanContexts().get(0); + assertThat(spanContext.isValid()).isTrue(); + assertThat(spanContext.getTraceId()).isEqualTo(traceId); + assertThat(spanContext.getSpanId()).isEqualTo(spanId); + } + + @Test + void shouldNotEnrichSpanWhenRecordsIsNull() { + SQSEvent event = new SQSEvent(); + event.setRecords(null); + + extractor.enrichSpan(event, span); + + verifyNoInteractions(span); + } + + @Test + void shouldNotEnrichSpanWhenRecordsIsEmpty() { + SQSEvent event = new SQSEvent(); + event.setRecords(Collections.emptyList()); + + extractor.enrichSpan(event, span); + + verifyNoInteractions(span); + } + + @Test + void shouldEnrichSpanWithSqsMetadata() { + SQSEvent.SQSMessage message = new SQSEvent.SQSMessage(); + message.setEventSourceArn("arn:aws:sqs:us-east-1:123456789012:test-queue"); + + SQSEvent event = new SQSEvent(); + event.setRecords(List.of(message)); + + extractor.enrichSpan(event, span); + + verify(span).setAttribute("messaging.system", "aws.sqs"); + verify(span).setAttribute("messaging.batch.message_count", 1); + verify(span).setAttribute("messaging.destination.name", "test-queue"); + } + + @Test + void shouldEnrichSpanWithQueueNameFromArnWithoutColon() { + SQSEvent.SQSMessage message = new SQSEvent.SQSMessage(); + message.setEventSourceArn("queue-name-without-separator"); + + SQSEvent event = new SQSEvent(); + event.setRecords(List.of(message)); + + extractor.enrichSpan(event, span); + + verify(span).setAttribute("messaging.system", "aws.sqs"); + verify(span).setAttribute("messaging.batch.message_count", 1); + verify(span).setAttribute("messaging.destination.name", "queue-name-without-separator"); + } + + @Test + void shouldNotEnrichSpanWithDestinationNameWhenEventSourceArnIsNull() { + SQSEvent.SQSMessage message = new SQSEvent.SQSMessage(); + message.setEventSourceArn(null); + + SQSEvent event = new SQSEvent(); + event.setRecords(List.of(message)); + + extractor.enrichSpan(event, span); + + verify(span).setAttribute("messaging.system", "aws.sqs"); + verify(span).setAttribute("messaging.batch.message_count", 1); + verify(span, never()).setAttribute(eq("messaging.destination.name"), anyString()); + } + + @Test + void shouldEnrichSpanWithBatchCountForMultipleMessages() { + SQSEvent.SQSMessage message1 = new SQSEvent.SQSMessage(); + message1.setEventSourceArn("arn:aws:sqs:us-east-1:123456789012:test-queue"); + + SQSEvent.SQSMessage message2 = new SQSEvent.SQSMessage(); + message2.setEventSourceArn("arn:aws:sqs:us-east-1:123456789012:test-queue"); + + SQSEvent event = new SQSEvent(); + event.setRecords(List.of(message1, message2)); + + extractor.enrichSpan(event, span); + + verify(span).setAttribute("messaging.system", "aws.sqs"); + verify(span).setAttribute("messaging.batch.message_count", 2); + verify(span).setAttribute("messaging.destination.name", "test-queue"); + } +} \ No newline at end of file diff --git a/powertools-tracing-opentelemetry/src/test/java/software/amazon/lambda/powertools/tracing/opentelemetry/internal/LambdaResourceTest.java b/powertools-tracing-opentelemetry/src/test/java/software/amazon/lambda/powertools/tracing/opentelemetry/internal/LambdaResourceTest.java new file mode 100644 index 000000000..73d099117 --- /dev/null +++ b/powertools-tracing-opentelemetry/src/test/java/software/amazon/lambda/powertools/tracing/opentelemetry/internal/LambdaResourceTest.java @@ -0,0 +1,298 @@ +/* + * 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.tracing.opentelemetry.internal; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import io.opentelemetry.api.common.Attributes; +import io.opentelemetry.sdk.resources.Resource; +import org.junit.jupiter.api.Test; +import org.junitpioneer.jupiter.SetEnvironmentVariable; + +class LambdaResourceTest { + + @Test + @SetEnvironmentVariable(key = "AWS_LAMBDA_FUNCTION_NAME", value = "my-function") + @SetEnvironmentVariable(key = "AWS_LAMBDA_FUNCTION_VERSION", value = "1") + @SetEnvironmentVariable(key = "AWS_LAMBDA_FUNCTION_MEMORY_SIZE", value = "512") + @SetEnvironmentVariable(key = "AWS_LAMBDA_LOG_STREAM_NAME", value = "2023/01/01/[$LATEST]abcd1234") + @SetEnvironmentVariable(key = "AWS_REGION", value = "us-east-1") + @SetEnvironmentVariable(key = "AWS_LAMBDA_FUNCTION_ARN", value = "arn:aws:lambda:us-east-1:123456789012:function" + + ":my-function") + void testCreateWithAllEnvironmentVariables() { + + Resource resource = LambdaResource.create(); + Attributes attributes = resource.getAttributes(); + + assertThat(attributes.get(io.opentelemetry.api.common.AttributeKey.stringKey("cloud.provider"))) + .isEqualTo("aws"); + assertThat(attributes.get(io.opentelemetry.api.common.AttributeKey.stringKey("cloud.region"))) + .isEqualTo("us-east-1"); + assertThat(attributes.get(io.opentelemetry.api.common.AttributeKey.stringKey("cloud.account.id"))) + .isEqualTo("123456789012"); + assertThat(attributes.get(io.opentelemetry.api.common.AttributeKey.stringKey("service.name"))) + .isEqualTo("my-function"); + assertThat(attributes.get(io.opentelemetry.api.common.AttributeKey.stringKey("service.version"))) + .isEqualTo("1"); + assertThat(attributes.get(io.opentelemetry.api.common.AttributeKey.stringKey("faas.name"))) + .isEqualTo("my-function"); + assertThat(attributes.get(io.opentelemetry.api.common.AttributeKey.stringKey("faas.version"))) + .isEqualTo("1"); + assertThat(attributes.get(io.opentelemetry.api.common.AttributeKey.stringKey("faas.instance"))) + .isEqualTo("2023/01/01/[$LATEST]abcd1234"); + assertThat(attributes.get(io.opentelemetry.api.common.AttributeKey.longKey("faas.max_memory"))) + .isEqualTo(512L); + assertThat(attributes.get(io.opentelemetry.api.common.AttributeKey.stringKey("telemetry.sdk.name"))) + .isEqualTo("opentelemetry"); + assertThat(attributes.get(io.opentelemetry.api.common.AttributeKey.stringKey("telemetry.distro.name"))) + .isEqualTo("powertools-for-aws-lambda"); + assertThat(attributes.get(io.opentelemetry.api.common.AttributeKey.stringKey("telemetry.sdk.language"))) + .isEqualTo("java"); + } + + @Test + void testCreateWithNoEnvironmentVariables() { + + Resource resource = LambdaResource.create(); + Attributes attributes = resource.getAttributes(); + + assertThat(attributes.get(io.opentelemetry.api.common.AttributeKey.stringKey("cloud.provider"))) + .isEqualTo("aws"); + assertThat(attributes.get(io.opentelemetry.api.common.AttributeKey.stringKey("telemetry.sdk.name"))) + .isEqualTo("opentelemetry"); + assertThat(attributes.get(io.opentelemetry.api.common.AttributeKey.stringKey("telemetry.distro.name"))) + .isEqualTo("powertools-for-aws-lambda"); + assertThat(attributes.get(io.opentelemetry.api.common.AttributeKey.stringKey("telemetry.sdk.language"))) + .isEqualTo("java"); + + assertThat(attributes.get(io.opentelemetry.api.common.AttributeKey.stringKey("cloud.region"))).isNull(); + assertThat(attributes.get(io.opentelemetry.api.common.AttributeKey.stringKey("cloud.account.id"))).isNull(); + assertThat(attributes.get(io.opentelemetry.api.common.AttributeKey.stringKey("service.name"))).isNull(); + assertThat(attributes.get(io.opentelemetry.api.common.AttributeKey.stringKey("service.version"))).isNull(); + assertThat(attributes.get(io.opentelemetry.api.common.AttributeKey.stringKey("faas.name"))).isNull(); + assertThat(attributes.get(io.opentelemetry.api.common.AttributeKey.stringKey("faas.version"))).isNull(); + assertThat(attributes.get(io.opentelemetry.api.common.AttributeKey.stringKey("faas.instance"))).isNull(); + assertThat(attributes.get(io.opentelemetry.api.common.AttributeKey.longKey("faas.max_memory"))).isNull(); + } + + @Test + @SetEnvironmentVariable(key = "AWS_LAMBDA_FUNCTION_NAME", value = "") + @SetEnvironmentVariable(key = "AWS_REGION", value = " ") + void testCreateWithEmptyAndBlankEnvironmentVariables() { + + Resource resource = LambdaResource.create(); + Attributes attributes = resource.getAttributes(); + + assertThat(attributes.get(io.opentelemetry.api.common.AttributeKey.stringKey("service.name"))).isNull(); + assertThat(attributes.get(io.opentelemetry.api.common.AttributeKey.stringKey("faas.name"))).isNull(); + assertThat(attributes.get(io.opentelemetry.api.common.AttributeKey.stringKey("cloud.region"))).isNull(); + } + + @Test + @SetEnvironmentVariable(key = "AWS_LAMBDA_FUNCTION_MEMORY_SIZE", value = "1024") + void testCreateWithMemorySize() { + + Resource resource = LambdaResource.create(); + Attributes attributes = resource.getAttributes(); + + assertThat(attributes.get(io.opentelemetry.api.common.AttributeKey.longKey("faas.max_memory"))) + .isEqualTo(1024L); + } + + @Test + @SetEnvironmentVariable(key = "AWS_LAMBDA_FUNCTION_MEMORY_SIZE", value = "invalid") + void testCreateWithInvalidMemorySize() { + assertThatThrownBy(() -> LambdaResource.create()) + .isInstanceOf(NumberFormatException.class); + } + + @Test + @SetEnvironmentVariable(key = "AWS_LAMBDA_FUNCTION_ARN", value = "arn:aws:lambda:us-east-1:123456789012:function" + + ":my-function") + void testExtractAccountIdFromValidArn() { + + Resource resource = LambdaResource.create(); + Attributes attributes = resource.getAttributes(); + + assertThat(attributes.get(io.opentelemetry.api.common.AttributeKey.stringKey("cloud.account.id"))) + .isEqualTo("123456789012"); + } + + @Test + @SetEnvironmentVariable(key = "AWS_LAMBDA_FUNCTION_ARN", value = "arn:aws:lambda:us-west-2:999888777666:function" + + ":another-function:1") + void testExtractAccountIdFromArnWithVersion() { + Resource resource = LambdaResource.create(); + Attributes attributes = resource.getAttributes(); + + assertThat(attributes.get(io.opentelemetry.api.common.AttributeKey.stringKey("cloud.account.id"))) + .isEqualTo("999888777666"); + } + + @Test + @SetEnvironmentVariable(key = "AWS_LAMBDA_FUNCTION_ARN", value = "arn:aws:lambda:eu-central-1:111222333444" + + ":function:test-function:$LATEST") + void testExtractAccountIdFromArnWithLatestAlias() { + + Resource resource = LambdaResource.create(); + Attributes attributes = resource.getAttributes(); + + assertThat(attributes.get(io.opentelemetry.api.common.AttributeKey.stringKey("cloud.account.id"))) + .isEqualTo("111222333444"); + } + + @Test + @SetEnvironmentVariable(key = "AWS_LAMBDA_FUNCTION_ARN", value = "arn:aws:lambda:us-east-1") + void testExtractAccountIdFromIncompleteArn() { + + Resource resource = LambdaResource.create(); + Attributes attributes = resource.getAttributes(); + + assertThat(attributes.get(io.opentelemetry.api.common.AttributeKey.stringKey("cloud.account.id"))) + .isNull(); + } + + @Test + @SetEnvironmentVariable(key = "AWS_LAMBDA_FUNCTION_ARN", value = "arn:aws:lambda") + void testExtractAccountIdFromVeryShortArn() { + + Resource resource = LambdaResource.create(); + Attributes attributes = resource.getAttributes(); + + assertThat(attributes.get(io.opentelemetry.api.common.AttributeKey.stringKey("cloud.account.id"))) + .isNull(); + } + + @Test + @SetEnvironmentVariable(key = "AWS_LAMBDA_FUNCTION_ARN", value = "invalid-arn") + void testExtractAccountIdFromInvalidArn() { + + Resource resource = LambdaResource.create(); + Attributes attributes = resource.getAttributes(); + + assertThat(attributes.get(io.opentelemetry.api.common.AttributeKey.stringKey("cloud.account.id"))) + .isNull(); + } + + @Test + @SetEnvironmentVariable(key = "AWS_LAMBDA_FUNCTION_ARN", value = "") + void testExtractAccountIdFromEmptyArn() { + + Resource resource = LambdaResource.create(); + Attributes attributes = resource.getAttributes(); + + assertThat(attributes.get(io.opentelemetry.api.common.AttributeKey.stringKey("cloud.account.id"))) + .isNull(); + } + + @Test + @SetEnvironmentVariable(key = "AWS_LAMBDA_FUNCTION_NAME", value = "test-function") + @SetEnvironmentVariable(key = "AWS_LAMBDA_FUNCTION_VERSION", value = "$LATEST") + void testCreateWithLatestVersion() { + + Resource resource = LambdaResource.create(); + Attributes attributes = resource.getAttributes(); + + assertThat(attributes.get(io.opentelemetry.api.common.AttributeKey.stringKey("service.version"))) + .isEqualTo("$LATEST"); + assertThat(attributes.get(io.opentelemetry.api.common.AttributeKey.stringKey("faas.version"))) + .isEqualTo("$LATEST"); + } + + @Test + @SetEnvironmentVariable(key = "AWS_LAMBDA_LOG_STREAM_NAME", value = "2023/12/31/[$LATEST]xyz789") + void testCreateWithLogStreamName() { + + Resource resource = LambdaResource.create(); + Attributes attributes = resource.getAttributes(); + + assertThat(attributes.get(io.opentelemetry.api.common.AttributeKey.stringKey("faas.instance"))) + .isEqualTo("2023/12/31/[$LATEST]xyz789"); + } + + @Test + void testCreateReturnsResourceWithAttributes() { + + Resource resource = LambdaResource.create(); + + assertThat(resource).isNotNull(); + assertThat(resource.getAttributes()).isNotNull(); + assertThat(resource.getAttributes().size()).isGreaterThan(0); + } + + @Test + @SetEnvironmentVariable(key = "AWS_REGION", value = "ap-southeast-1") + void testCreateWithDifferentRegion() { + + Resource resource = LambdaResource.create(); + Attributes attributes = resource.getAttributes(); + + assertThat(attributes.get(io.opentelemetry.api.common.AttributeKey.stringKey("cloud.region"))) + .isEqualTo("ap-southeast-1"); + } + + @Test + void testTelemetryAttributesAreAlwaysPresent() { + + Resource resource = LambdaResource.create(); + Attributes attributes = resource.getAttributes(); + + assertThat(attributes.get(io.opentelemetry.api.common.AttributeKey.stringKey("cloud.provider"))) + .isNotNull() + .isEqualTo("aws"); + assertThat(attributes.get(io.opentelemetry.api.common.AttributeKey.stringKey("telemetry.sdk.name"))) + .isNotNull() + .isEqualTo("opentelemetry"); + assertThat(attributes.get(io.opentelemetry.api.common.AttributeKey.stringKey("telemetry.distro.name"))) + .isNotNull() + .isEqualTo("powertools-for-aws-lambda"); + assertThat(attributes.get(io.opentelemetry.api.common.AttributeKey.stringKey("telemetry.sdk.language"))) + .isNotNull() + .isEqualTo("java"); + } + + @Test + @SetEnvironmentVariable(key = "AWS_LAMBDA_FUNCTION_ARN", value = "arn:aws:lambda:us-east-1:123456789012:function" + + ":my-function:alias-name") + void testCreateWithArnContainingAlias() { + + Resource resource = LambdaResource.create(); + Attributes attributes = resource.getAttributes(); + + assertThat(attributes.get(io.opentelemetry.api.common.AttributeKey.stringKey("cloud.account.id"))) + .isEqualTo("123456789012"); + } + + @Test + @SetEnvironmentVariable(key = "AWS_LAMBDA_FUNCTION_MEMORY_SIZE", value = "128") + void testCreateWithMinimumMemorySize() { + Resource resource = LambdaResource.create(); + Attributes attributes = resource.getAttributes(); + + assertThat(attributes.get(io.opentelemetry.api.common.AttributeKey.longKey("faas.max_memory"))) + .isEqualTo(128L); + } + + @Test + @SetEnvironmentVariable(key = "AWS_LAMBDA_FUNCTION_MEMORY_SIZE", value = "10240") + void testCreateWithMaximumMemorySize() { + Resource resource = LambdaResource.create(); + Attributes attributes = resource.getAttributes(); + + assertThat(attributes.get(io.opentelemetry.api.common.AttributeKey.longKey("faas.max_memory"))) + .isEqualTo(10240L); + } +} \ No newline at end of file diff --git a/powertools-tracing-opentelemetry/src/test/java/software/amazon/lambda/powertools/tracing/opentelemetry/internal/SpanScopeTest.java b/powertools-tracing-opentelemetry/src/test/java/software/amazon/lambda/powertools/tracing/opentelemetry/internal/SpanScopeTest.java new file mode 100644 index 000000000..80db83c30 --- /dev/null +++ b/powertools-tracing-opentelemetry/src/test/java/software/amazon/lambda/powertools/tracing/opentelemetry/internal/SpanScopeTest.java @@ -0,0 +1,207 @@ +/* + * 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.tracing.opentelemetry.internal; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import io.opentelemetry.api.common.Attributes; +import io.opentelemetry.api.trace.Span; +import io.opentelemetry.api.trace.StatusCode; +import io.opentelemetry.context.Scope; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class SpanScopeTest { + + @Mock + private Span mockSpan; + + @Mock + private Scope mockScope; + + @BeforeEach + void setUp() { + when(mockSpan.makeCurrent()).thenReturn(mockScope); + } + + @Test + void span_shouldReturnCurrentSpan() { + + SpanScope spanScope = new SpanScope(mockSpan); + + Span result = spanScope.span(); + + assertThat(result).isEqualTo(mockSpan); + } + + @Test + void constructor_shouldMakeSpanCurrent() { + new SpanScope(mockSpan); + + verify(mockSpan).makeCurrent(); + } + + @Test + void setStatus_shouldSetSpanStatus() { + SpanScope spanScope = new SpanScope(mockSpan); + StatusCode status = StatusCode.OK; + + SpanScope result = spanScope.setStatus(status); + + verify(mockSpan).setStatus(status); + assertThat(result).isSameAs(spanScope); + } + + @Test + void setStatus_shouldAllowMethodChaining() { + SpanScope spanScope = new SpanScope(mockSpan); + + SpanScope result = spanScope + .setStatus(StatusCode.OK) + .setStatus(StatusCode.ERROR); + + assertThat(result).isSameAs(spanScope); + verify(mockSpan).setStatus(StatusCode.OK); + verify(mockSpan).setStatus(StatusCode.ERROR); + } + + @Test + void addEvent_shouldAddEventWithName() { + SpanScope spanScope = new SpanScope(mockSpan); + String eventName = "test-event"; + + SpanScope result = spanScope.addEvent(eventName); + + verify(mockSpan).addEvent(eventName); + assertThat(result).isSameAs(spanScope); + } + + @Test + void addEvent_shouldAddEventWithNameAndAttributes() { + + SpanScope spanScope = new SpanScope(mockSpan); + String eventName = "test-event"; + Attributes attributes = Attributes.builder() + .put("key1", "value1") + .put("key2", 42L) + .build(); + + SpanScope result = spanScope.addEvent(eventName, attributes); + + verify(mockSpan).addEvent(eventName, attributes); + assertThat(result).isSameAs(spanScope); + } + + @Test + void addEvent_shouldAllowMethodChaining() { + + SpanScope spanScope = new SpanScope(mockSpan); + Attributes attrs = Attributes.empty(); + + SpanScope result = spanScope + .addEvent("event1") + .addEvent("event2", attrs); + + assertThat(result).isSameAs(spanScope); + verify(mockSpan).addEvent("event1"); + verify(mockSpan).addEvent("event2", attrs); + } + + @Test + void recordException_shouldRecordExceptionAndSetErrorStatus() { + + SpanScope spanScope = new SpanScope(mockSpan); + Throwable exception = new RuntimeException("Test exception"); + + spanScope.recordException(exception); + + verify(mockSpan).recordException(exception); + verify(mockSpan).setStatus(StatusCode.ERROR); + } + + @Test + void recordException_shouldHandleDifferentExceptionTypes() { + + SpanScope spanScope = new SpanScope(mockSpan); + IllegalArgumentException exception = new IllegalArgumentException("Invalid argument"); + + spanScope.recordException(exception); + + verify(mockSpan).recordException(exception); + verify(mockSpan).setStatus(StatusCode.ERROR); + } + + @Test + void close_shouldCloseScopeAndEndSpan() { + + SpanScope spanScope = new SpanScope(mockSpan); + + spanScope.close(); + + verify(mockScope).close(); + verify(mockSpan).end(); + } + + @Test + void spanScope_shouldWorkWithTryWithResources() { + + try (SpanScope spanScope = new SpanScope(mockSpan)) { + spanScope.addEvent("test-event"); + } + + verify(mockSpan).addEvent("test-event"); + verify(mockScope).close(); + verify(mockSpan).end(); + } + + @Test + void spanScope_shouldSupportFluentAPI() { + + SpanScope spanScope = new SpanScope(mockSpan); + Attributes attrs = Attributes.builder() + .put("error.type", "validation") + .build(); + + spanScope + .addEvent("validation-started") + .setStatus(StatusCode.ERROR) + .addEvent("validation-failed", attrs); + + verify(mockSpan).addEvent("validation-started"); + verify(mockSpan).setStatus(StatusCode.ERROR); + verify(mockSpan).addEvent("validation-failed", attrs); + } + + @Test + void spanScope_shouldAllowExceptionRecordingInTryWithResources() { + + RuntimeException exception = new RuntimeException("Test failure"); + + try (SpanScope spanScope = new SpanScope(mockSpan)) { + spanScope.recordException(exception); + } + + verify(mockSpan).recordException(exception); + verify(mockSpan).setStatus(StatusCode.ERROR); + verify(mockScope).close(); + verify(mockSpan).end(); + } +} \ No newline at end of file diff --git a/powertools-tracing-opentelemetry/src/test/java/software/amazon/lambda/powertools/tracing/opentelemetry/internal/TracingOpenTelemetryAspectTest.java b/powertools-tracing-opentelemetry/src/test/java/software/amazon/lambda/powertools/tracing/opentelemetry/internal/TracingOpenTelemetryAspectTest.java new file mode 100644 index 000000000..76364400b --- /dev/null +++ b/powertools-tracing-opentelemetry/src/test/java/software/amazon/lambda/powertools/tracing/opentelemetry/internal/TracingOpenTelemetryAspectTest.java @@ -0,0 +1,517 @@ +/* + * 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.tracing.opentelemetry.internal; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.amazonaws.services.lambda.runtime.Context; +import com.amazonaws.services.lambda.runtime.RequestHandler; +import io.opentelemetry.api.common.Attributes; +import io.opentelemetry.api.trace.Span; +import io.opentelemetry.api.trace.SpanContext; +import io.opentelemetry.api.trace.SpanKind; +import io.opentelemetry.context.propagation.TextMapPropagator; +import java.util.Collections; +import java.util.List; +import org.apache.commons.lang3.reflect.FieldUtils; +import org.aspectj.lang.ProceedingJoinPoint; +import org.aspectj.lang.Signature; +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.Mock; +import org.mockito.MockedStatic; +import org.mockito.junit.jupiter.MockitoExtension; +import software.amazon.lambda.powertools.tracing.opentelemetry.CaptureMode; +import software.amazon.lambda.powertools.tracing.opentelemetry.Tracing; +import software.amazon.lambda.powertools.tracing.opentelemetry.TracingOpenTelemetry; +import software.amazon.lambda.powertools.tracing.opentelemetry.context.ExtractedTraceContext; +import software.amazon.lambda.powertools.tracing.opentelemetry.context.LambdaEventContextExtractorResolver; +import software.amazon.lambda.powertools.tracing.opentelemetry.context.TraceContextPropagationMode; +import software.amazon.lambda.powertools.tracing.opentelemetry.provider.OpenTelemetryProvider; + +@ExtendWith(MockitoExtension.class) +class TracingOpenTelemetryAspectTest { + + @Mock + private ProceedingJoinPoint pjp; + + @Mock + private Tracing tracing; + + @Mock + private TracingOpenTelemetry tracingOpenTelemetry; + + @Mock + private SpanScope spanScope; + + @Mock + private Span span; + + @Mock + private Signature signature; + + @Mock + private Context lambdaContext; + + @Mock + private LambdaEventContextExtractorResolver extractorResolver; + + @Mock + private TextMapPropagator propagator; + + private TracingOpenTelemetryAspect aspect; + private TracingOpenTelemetry originalTracing; + + @BeforeEach + void setUp() throws IllegalAccessException { + aspect = new TracingOpenTelemetryAspect(); + + originalTracing = (TracingOpenTelemetry) FieldUtils + .readStaticField(TracingOpenTelemetryAspect.class, "tracingOtel", true); + + FieldUtils.writeStaticField(TracingOpenTelemetryAspect.class, "tracingOtel", tracingOpenTelemetry, true); + + lenient().when(tracingOpenTelemetry.eventContextExtractorResolver()).thenReturn(extractorResolver); + lenient().when(tracingOpenTelemetry.propagator()).thenReturn(propagator); + lenient().when(spanScope.span()).thenReturn(span); + } + + @AfterEach + void tearDown() throws IllegalAccessException { + FieldUtils.writeStaticField(TracingOpenTelemetryAspect.class, "tracingOtel", originalTracing, true); + } + + @Test + void shouldTraceHandlerMethodSuccessfully() throws Throwable { + + setupHandlerMethod(); + Object event = new Object(); + Object[] args = {event, lambdaContext}; + Object expectedResult = "result"; + + when(pjp.getArgs()).thenReturn(args); + when(tracing.spanName()).thenReturn("testHandler"); + when(tracing.captureMode()).thenReturn(CaptureMode.DISABLED); + + ExtractedTraceContext extractedContext = new ExtractedTraceContext( + io.opentelemetry.context.Context.current(), + Collections.emptyList(), + SpanKind.SERVER + ); + + when(extractorResolver.extract(any(), any(), any())).thenReturn(extractedContext); + when(tracingOpenTelemetry.addSpan(anyString(), any(SpanKind.class), any(Attributes.class), + any(io.opentelemetry.context.Context.class))) + .thenReturn(spanScope); + when(pjp.proceed(args)).thenReturn(expectedResult); + + Object result = aspect.around(pjp, tracing); + + assertThat(result).isEqualTo(expectedResult); + verify(tracingOpenTelemetry).addSpan(eq("testHandler"), eq(SpanKind.SERVER), any(Attributes.class), + any(io.opentelemetry.context.Context.class)); + verify(extractorResolver).enrichSpan(event, span); + verify(tracingOpenTelemetry).flush(); + } + + @Test + void shouldUseMethodNameWhenSpanNameIsEmpty() throws Throwable { + + setupHandlerMethod(); + Object[] args = {new Object(), lambdaContext}; + when(pjp.getArgs()).thenReturn(args); + when(tracing.spanName()).thenReturn(""); + when(tracing.captureMode()).thenReturn(CaptureMode.DISABLED); + + ExtractedTraceContext extractedContext = new ExtractedTraceContext( + io.opentelemetry.context.Context.current(), + Collections.emptyList(), + SpanKind.SERVER + ); + + when(extractorResolver.extract(any(), any(), any())).thenReturn(extractedContext); + when(tracingOpenTelemetry.addSpan(anyString(), any(SpanKind.class), any(Attributes.class), + any(io.opentelemetry.context.Context.class))) + .thenReturn(spanScope); + when(pjp.proceed(args)).thenReturn("result"); + + aspect.around(pjp, tracing); + + verify(tracingOpenTelemetry).addSpan(eq("handleRequest"), any(SpanKind.class), any(Attributes.class), + any(io.opentelemetry.context.Context.class)); + } + + @Test + void shouldCaptureExceptionInHandler() throws Throwable { + + setupHandlerMethod(); + Object[] args = {new Object(), lambdaContext}; + RuntimeException expectedException = new RuntimeException("Test exception"); + + when(pjp.getArgs()).thenReturn(args); + when(tracing.spanName()).thenReturn("testHandler"); + when(tracing.captureMode()).thenReturn(CaptureMode.ERROR); + + ExtractedTraceContext extractedContext = new ExtractedTraceContext( + io.opentelemetry.context.Context.current(), + Collections.emptyList(), + SpanKind.SERVER + ); + + when(extractorResolver.extract(any(), any(), any())).thenReturn(extractedContext); + when(tracingOpenTelemetry.addSpan(anyString(), any(SpanKind.class), any(Attributes.class), + any(io.opentelemetry.context.Context.class))) + .thenReturn(spanScope); + when(pjp.proceed(args)).thenThrow(expectedException); + + assertThatThrownBy(() -> aspect.around(pjp, tracing)) + .isEqualTo(expectedException); + + verify(spanScope).recordException(expectedException); + verify(tracingOpenTelemetry).flush(); + } + + @Test + void shouldCaptureResponseWhenModeIsResponse() throws Throwable { + + setupHandlerMethod(); + Object[] args = {new Object(), lambdaContext}; + String expectedResult = "test-response"; + + when(pjp.getArgs()).thenReturn(args); + when(tracing.spanName()).thenReturn("testHandler"); + when(tracing.captureMode()).thenReturn(CaptureMode.RESPONSE); + + ExtractedTraceContext extractedContext = new ExtractedTraceContext( + io.opentelemetry.context.Context.current(), + Collections.emptyList(), + SpanKind.SERVER + ); + + when(extractorResolver.extract(any(), any(), any())).thenReturn(extractedContext); + when(tracingOpenTelemetry.addSpan(anyString(), any(SpanKind.class), any(Attributes.class), + any(io.opentelemetry.context.Context.class))) + .thenReturn(spanScope); + when(pjp.proceed(args)).thenReturn(expectedResult); + + aspect.around(pjp, tracing); + + verify(span).setAttribute(eq(AttributesConstants.RESPONSE_ATTRIBUTE), anyString()); + } + + @Test + void shouldCaptureResponseAndErrorWhenModeIsResponseAndError() throws Throwable { + + setupHandlerMethod(); + Object[] args = {new Object(), lambdaContext}; + RuntimeException exception = new RuntimeException("error"); + + when(pjp.getArgs()).thenReturn(args); + when(tracing.spanName()).thenReturn("testHandler"); + when(tracing.captureMode()).thenReturn(CaptureMode.RESPONSE_AND_ERROR); + + ExtractedTraceContext extractedContext = new ExtractedTraceContext( + io.opentelemetry.context.Context.current(), + Collections.emptyList(), + SpanKind.SERVER + ); + + when(extractorResolver.extract(any(), any(), any())).thenReturn(extractedContext); + when(tracingOpenTelemetry.addSpan(anyString(), any(SpanKind.class), any(Attributes.class), + any(io.opentelemetry.context.Context.class))) + .thenReturn(spanScope); + when(pjp.proceed(args)).thenThrow(exception); + + assertThatThrownBy(() -> aspect.around(pjp, tracing)) + .isEqualTo(exception); + + verify(spanScope).recordException(exception); + } + + @Test + void shouldNotCaptureWhenModeIsDisabled() throws Throwable { + + setupHandlerMethod(); + Object[] args = {new Object(), lambdaContext}; + RuntimeException exception = new RuntimeException("error"); + + when(pjp.getArgs()).thenReturn(args); + when(tracing.spanName()).thenReturn("testHandler"); + when(tracing.captureMode()).thenReturn(CaptureMode.DISABLED); + + ExtractedTraceContext extractedContext = new ExtractedTraceContext( + io.opentelemetry.context.Context.current(), + Collections.emptyList(), + SpanKind.SERVER + ); + + when(extractorResolver.extract(any(), any(), any())).thenReturn(extractedContext); + when(tracingOpenTelemetry.addSpan(anyString(), any(SpanKind.class), any(Attributes.class), + any(io.opentelemetry.context.Context.class))) + .thenReturn(spanScope); + when(pjp.proceed(args)).thenThrow(exception); + + assertThatThrownBy(() -> aspect.around(pjp, tracing)) + .isEqualTo(exception); + + verify(spanScope, never()).recordException(any()); + verify(span, never()).setAttribute(eq(AttributesConstants.RESPONSE_ATTRIBUTE), anyString()); + } + + @Test + void shouldNotCaptureResponseWhenModeIsError() throws Throwable { + + setupHandlerMethod(); + Object[] args = {new Object(), lambdaContext}; + + when(pjp.getArgs()).thenReturn(args); + when(tracing.spanName()).thenReturn("testHandler"); + when(tracing.captureMode()).thenReturn(CaptureMode.ERROR); + + ExtractedTraceContext extractedContext = new ExtractedTraceContext( + io.opentelemetry.context.Context.current(), + Collections.emptyList(), + SpanKind.SERVER + ); + + when(extractorResolver.extract(any(), any(), any())).thenReturn(extractedContext); + when(tracingOpenTelemetry.addSpan(anyString(), any(SpanKind.class), any(Attributes.class), + any(io.opentelemetry.context.Context.class))) + .thenReturn(spanScope); + when(pjp.proceed(args)).thenReturn("result"); + + aspect.around(pjp, tracing); + + verify(span, never()).setAttribute(eq(AttributesConstants.RESPONSE_ATTRIBUTE), anyString()); + } + + @Test + void shouldTraceNonHandlerMethodSuccessfully() throws Throwable { + + setupNonHandlerMethod(); + Object[] args = {"arg1", "arg2"}; + Object expectedResult = "result"; + + when(pjp.getArgs()).thenReturn(args); + when(tracing.spanName()).thenReturn("testMethod"); + when(tracing.captureMode()).thenReturn(CaptureMode.DISABLED); + when(tracingOpenTelemetry.addSpan(anyString(), eq(SpanKind.INTERNAL), any(Attributes.class), + any(io.opentelemetry.context.Context.class))) + .thenReturn(spanScope); + when(pjp.proceed(args)).thenReturn(expectedResult); + + Object result = aspect.around(pjp, tracing); + + assertThat(result).isEqualTo(expectedResult); + verify(tracingOpenTelemetry).addSpan(eq("testMethod"), eq(SpanKind.INTERNAL), any(Attributes.class), + any(io.opentelemetry.context.Context.class)); + verify(extractorResolver, never()).enrichSpan(any(), any()); + verify(tracingOpenTelemetry, never()).flush(); + } + + @Test + void shouldCaptureExceptionInNonHandlerMethod() throws Throwable { + + setupNonHandlerMethod(); + Object[] args = {"arg1"}; + RuntimeException expectedException = new RuntimeException("Method exception"); + + when(pjp.getArgs()).thenReturn(args); + when(tracing.spanName()).thenReturn("testMethod"); + when(tracing.captureMode()).thenReturn(CaptureMode.ERROR); + when(tracingOpenTelemetry.addSpan(anyString(), eq(SpanKind.INTERNAL), any(Attributes.class), + any(io.opentelemetry.context.Context.class))) + .thenReturn(spanScope); + when(pjp.proceed(args)).thenThrow(expectedException); + + assertThatThrownBy(() -> aspect.around(pjp, tracing)) + .isEqualTo(expectedException); + + verify(spanScope).recordException(expectedException); + } + + @Test + void shouldCaptureResponseInNonHandlerMethod() throws Throwable { + + setupNonHandlerMethod(); + Object[] args = {}; + String expectedResult = "method-result"; + + when(pjp.getArgs()).thenReturn(args); + when(tracing.spanName()).thenReturn("testMethod"); + when(tracing.captureMode()).thenReturn(CaptureMode.RESPONSE); + when(tracingOpenTelemetry.addSpan(anyString(), eq(SpanKind.INTERNAL), any(Attributes.class), + any(io.opentelemetry.context.Context.class))) + .thenReturn(spanScope); + when(pjp.proceed(args)).thenReturn(expectedResult); + + aspect.around(pjp, tracing); + + verify(span).setAttribute(eq(AttributesConstants.RESPONSE_ATTRIBUTE), anyString()); + } + + @Test + void shouldNotCaptureErrorInNonHandlerMethodWhenModeIsResponse() throws Throwable { + + setupNonHandlerMethod(); + Object[] args = {}; + RuntimeException exception = new RuntimeException("error"); + + when(pjp.getArgs()).thenReturn(args); + when(tracing.spanName()).thenReturn("testMethod"); + when(tracing.captureMode()).thenReturn(CaptureMode.RESPONSE); + when(tracingOpenTelemetry.addSpan(anyString(), eq(SpanKind.INTERNAL), any(Attributes.class), + any(io.opentelemetry.context.Context.class))) + .thenReturn(spanScope); + when(pjp.proceed(args)).thenThrow(exception); + + assertThatThrownBy(() -> aspect.around(pjp, tracing)) + .isEqualTo(exception); + + verify(spanScope, never()).recordException(any()); + } + + + @Test + void shouldConfigureCustomTracingOpenTelemetry() throws IllegalAccessException { + + TracingOpenTelemetry customTracing = mock(TracingOpenTelemetry.class); + + TracingOpenTelemetryAspect.configure(customTracing); + + TracingOpenTelemetry configured = (TracingOpenTelemetry) FieldUtils + .readStaticField(TracingOpenTelemetryAspect.class, "tracingOtel", true); + assertThat(configured).isEqualTo(customTracing); + } + + @Test + void shouldThrowNullPointerExceptionWhenConfiguringNull() { + assertThatThrownBy(() -> TracingOpenTelemetryAspect.configure(null)) + .isInstanceOf(NullPointerException.class); + } + + + @Test + void shouldUseSpanLinksWhenPropagationModeIsLink() throws Throwable { + + when(pjp.getSignature()).thenReturn(signature); + when(signature.getDeclaringType()).thenReturn(RequestHandler.class); + + Object[] args = {new Object(), lambdaContext}; + SpanContext spanContext = mock(SpanContext.class); + List spanContexts = List.of(spanContext); + + when(pjp.getArgs()).thenReturn(args); + when(tracing.spanName()).thenReturn("testHandler"); + when(tracing.captureMode()).thenReturn(CaptureMode.DISABLED); + + ExtractedTraceContext extractedContext = new ExtractedTraceContext( + io.opentelemetry.context.Context.current(), + spanContexts, + SpanKind.SERVER + ); + + when(extractorResolver.extract(any(), any(), any())).thenReturn(extractedContext); + + try (MockedStatic mockedProvider = mockStatic(OpenTelemetryProvider.class)) { + mockedProvider.when(OpenTelemetryProvider::traceContextPropagationMode) + .thenReturn(TraceContextPropagationMode.LINK); + + when(tracingOpenTelemetry.addSpan( + anyString(), + any(SpanKind.class), + any(Attributes.class), + any(io.opentelemetry.context.Context.class), + any(List.class))) + .thenReturn(spanScope); + + when(pjp.proceed(args)).thenReturn("result"); + + aspect.around(pjp, tracing); + + verify(tracingOpenTelemetry, times(1)).addSpan( + eq("testHandler"), + eq(SpanKind.SERVER), + any(Attributes.class), + any(io.opentelemetry.context.Context.class), + eq(spanContexts) + ); + } + } + + @Test + void shouldUseParentContextWhenSpanContextsAreEmpty() throws Throwable { + + setupHandlerMethod(); + Object[] args = {new Object(), lambdaContext}; + + when(pjp.getArgs()).thenReturn(args); + when(tracing.spanName()).thenReturn("testHandler"); + when(tracing.captureMode()).thenReturn(CaptureMode.DISABLED); + + ExtractedTraceContext extractedContext = new ExtractedTraceContext( + io.opentelemetry.context.Context.current(), + Collections.emptyList(), + SpanKind.SERVER + ); + + when(extractorResolver.extract(any(), any(), any())).thenReturn(extractedContext); + when(tracingOpenTelemetry.addSpan(anyString(), any(SpanKind.class), any(Attributes.class), + any(io.opentelemetry.context.Context.class))) + .thenReturn(spanScope); + when(pjp.proceed(args)).thenReturn("result"); + + aspect.around(pjp, tracing); + + verify(tracingOpenTelemetry).addSpan( + eq("testHandler"), + eq(SpanKind.SERVER), + any(Attributes.class), + any(io.opentelemetry.context.Context.class) + ); + verify(tracingOpenTelemetry, never()).addSpan( + anyString(), + any(SpanKind.class), + any(Attributes.class), + any(io.opentelemetry.context.Context.class), + any(List.class) + ); + } + + private void setupHandlerMethod() { + lenient().when(pjp.getSignature()).thenReturn(signature); + lenient().when(signature.getDeclaringType()).thenReturn(RequestHandler.class); + lenient().when(signature.getName()).thenReturn("handleRequest"); + } + + private void setupNonHandlerMethod() { + lenient().when(pjp.getSignature()).thenReturn(signature); + lenient().when(signature.getDeclaringType()).thenReturn(TracingOpenTelemetryAspectTest.class); + lenient().when(signature.getName()).thenReturn("someMethod"); + } +} \ No newline at end of file diff --git a/powertools-tracing-opentelemetry/src/test/java/software/amazon/lambda/powertools/tracing/opentelemetry/provider/OpenTelemetryProviderTest.java b/powertools-tracing-opentelemetry/src/test/java/software/amazon/lambda/powertools/tracing/opentelemetry/provider/OpenTelemetryProviderTest.java new file mode 100644 index 000000000..d13ba9c81 --- /dev/null +++ b/powertools-tracing-opentelemetry/src/test/java/software/amazon/lambda/powertools/tracing/opentelemetry/provider/OpenTelemetryProviderTest.java @@ -0,0 +1,361 @@ +/* + * 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.tracing.opentelemetry.provider; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.fasterxml.jackson.databind.ObjectMapper; +import io.opentelemetry.api.trace.Tracer; +import io.opentelemetry.context.propagation.TextMapGetter; +import io.opentelemetry.context.propagation.TextMapPropagator; +import io.opentelemetry.sdk.common.CompletableResultCode; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junitpioneer.jupiter.SetEnvironmentVariable; +import software.amazon.lambda.powertools.tracing.opentelemetry.context.TraceContextPropagationMode; + +class OpenTelemetryProviderTest { + + @BeforeEach + @AfterEach + void cleanup() { + System.clearProperty("otel.traces.exporter"); + } + + @Test + void shouldProvideTextMapGetter() { + TextMapGetter> getter = OpenTelemetryProvider.textMapGetter(); + + assertThat(getter).isNotNull(); + } + + @Test + void shouldGetValueFromCarrierWithTextMapGetter() { + TextMapGetter> getter = OpenTelemetryProvider.textMapGetter(); + + Map carrier = Map.of("key1", "value1", "key2", "value2"); + + assertThat(getter.get(carrier, "key1")).isEqualTo("value1"); + assertThat(getter.get(carrier, "key2")).isEqualTo("value2"); + } + + @Test + void shouldReturnNullWhenKeyNotFoundInCarrier() { + TextMapGetter> getter = OpenTelemetryProvider.textMapGetter(); + + Map carrier = Map.of("key1", "value1"); + + assertThat(getter.get(carrier, "unknown")).isNull(); + assertThat(getter.get(carrier, "")).isNull(); + } + + @Test + void shouldReturnNullWhenCarrierIsNull() { + TextMapGetter> getter = OpenTelemetryProvider.textMapGetter(); + + assertThat(getter.get(null, "key1")).isNull(); + assertThat(getter.get(null, "anyKey")).isNull(); + } + + @Test + void shouldReturnAllKeysFromCarrier() { + TextMapGetter> getter = OpenTelemetryProvider.textMapGetter(); + + Map carrier = Map.of("key1", "value1", "key2", "value2", "key3", "value3"); + + assertThat(getter.keys(carrier)).containsExactlyInAnyOrder("key1", "key2", "key3"); + } + + @Test + void shouldReturnEmptyKeysWhenCarrierIsNull() { + TextMapGetter> getter = OpenTelemetryProvider.textMapGetter(); + + assertThat(getter.keys(null)).isEmpty(); + } + + @Test + void shouldReturnEmptyKeysWhenCarrierIsEmpty() { + TextMapGetter> getter = OpenTelemetryProvider.textMapGetter(); + + assertThat(getter.keys(Collections.emptyMap())).isEmpty(); + } + + @Test + void shouldHandleMutableCarrier() { + TextMapGetter> getter = OpenTelemetryProvider.textMapGetter(); + + Map carrier = new HashMap<>(); + carrier.put("key1", "value1"); + carrier.put("key2", "value2"); + + assertThat(getter.get(carrier, "key1")).isEqualTo("value1"); + assertThat(getter.keys(carrier)).containsExactlyInAnyOrder("key1", "key2"); + } + + @Test + void shouldProvidePropagator() { + TextMapPropagator propagator = OpenTelemetryProvider.propagator(); + + assertThat(propagator).isNotNull(); + } + + @Test + void shouldReturnSamePropagatorInstance() { + TextMapPropagator propagator1 = OpenTelemetryProvider.propagator(); + TextMapPropagator propagator2 = OpenTelemetryProvider.propagator(); + + assertThat(propagator1).isSameAs(propagator2); + } + + @Test + void shouldPropagatorHaveFields() { + TextMapPropagator propagator = OpenTelemetryProvider.propagator(); + + assertThat(propagator.fields()).isNotEmpty(); + assertThat(propagator.fields()).contains("traceparent"); + } + + @Test + void shouldProvideTracer() { + Tracer tracer = OpenTelemetryProvider.tracer(); + + assertThat(tracer).isNotNull(); + } + + @Test + void shouldReturnSameTracerInstance() { + Tracer tracer1 = OpenTelemetryProvider.tracer(); + Tracer tracer2 = OpenTelemetryProvider.tracer(); + + assertThat(tracer1).isSameAs(tracer2); + } + + @Test + void shouldTracerCreateSpans() { + Tracer tracer = OpenTelemetryProvider.tracer(); + + assertThat(tracer.spanBuilder("test-span")).isNotNull(); + } + + @Test + void shouldProvideObjectMapper() { + ObjectMapper objectMapper = OpenTelemetryProvider.objectMapper(); + + assertThat(objectMapper).isNotNull(); + } + + @Test + void shouldReturnSameObjectMapperInstance() { + ObjectMapper mapper1 = OpenTelemetryProvider.objectMapper(); + ObjectMapper mapper2 = OpenTelemetryProvider.objectMapper(); + + assertThat(mapper1).isSameAs(mapper2); + } + + @Test + void shouldObjectMapperBeUsable() throws Exception { + ObjectMapper mapper = OpenTelemetryProvider.objectMapper(); + + String json = mapper.writeValueAsString(Map.of("key", "value")); + assertThat(json).contains("key").contains("value"); + + Map parsed = mapper.readValue(json, Map.class); + assertThat(parsed).containsEntry("key", "value"); + } + + @Test + void shouldProvideDefaultTraceContextPropagationMode() { + TraceContextPropagationMode mode = OpenTelemetryProvider.traceContextPropagationMode(); + + assertThat(mode).isEqualTo(TraceContextPropagationMode.PARENT); + } + + @Test + void shouldReturnSameTraceContextPropagationModeInstance() { + TraceContextPropagationMode mode1 = OpenTelemetryProvider.traceContextPropagationMode(); + TraceContextPropagationMode mode2 = OpenTelemetryProvider.traceContextPropagationMode(); + + assertThat(mode1).isEqualTo(mode2); + } + + @Test + @SetEnvironmentVariable(key = "POWERTOOLS_TRACE_CONTEXT_PROPAGATION_MODE", value = "LINK") + void shouldReadTraceContextPropagationModeFromEnvironment() { + assertThat(TraceContextPropagationMode.LINK).isNotNull(); + } + + @Test + @SetEnvironmentVariable(key = "POWERTOOLS_TRACE_CONTEXT_PROPAGATION_MODE", value = "PARENT") + void shouldHandleParentModeFromEnvironment() { + assertThat(TraceContextPropagationMode.PARENT).isNotNull(); + } + + @Test + @SetEnvironmentVariable(key = "POWERTOOLS_TRACE_CONTEXT_PROPAGATION_MODE", value = "invalid") + void shouldDefaultToParentOnInvalidEnvironmentValue() { + assertThat(TraceContextPropagationMode.PARENT).isNotNull(); + } + + @Test + @SetEnvironmentVariable(key = "POWERTOOLS_TRACE_CONTEXT_PROPAGATION_MODE", value = "") + void shouldDefaultToParentOnEmptyEnvironmentValue() { + assertThat(TraceContextPropagationMode.PARENT).isNotNull(); + } + + @Test + void shouldForceFlush() { + CompletableResultCode result = OpenTelemetryProvider.forceFlush(); + + assertThat(result).isNotNull(); + } + + @Test + void shouldForceFlushComplete() { + CompletableResultCode result = OpenTelemetryProvider.forceFlush(); + + assertThat(result.join(5000, java.util.concurrent.TimeUnit.MILLISECONDS).isSuccess()).isTrue(); + } + + + @Test + @SetEnvironmentVariable(key = "OTEL_EXPORTER_OTLP_TRACES_HEADERS", value = "key1=value1,key2=value2") + void shouldParseHeadersCorrectly() { + assertThat(OpenTelemetryProvider.tracer()).isNotNull(); + } + + @Test + @SetEnvironmentVariable(key = "OTEL_EXPORTER_OTLP_TRACES_HEADERS", value = "") + void shouldHandleEmptyHeaders() { + assertThat(OpenTelemetryProvider.tracer()).isNotNull(); + } + + @Test + @SetEnvironmentVariable(key = "OTEL_EXPORTER_OTLP_TRACES_PROTOCOL", value = "grpc") + void shouldUseGrpcProtocol() { + assertThat(OpenTelemetryProvider.tracer()).isNotNull(); + } + + @Test + @SetEnvironmentVariable(key = "OTEL_EXPORTER_OTLP_TRACES_PROTOCOL", value = "http/protobuf") + void shouldUseHttpProtobufProtocol() { + assertThat(OpenTelemetryProvider.tracer()).isNotNull(); + } + + @Test + @SetEnvironmentVariable(key = "OTEL_EXPORTER_OTLP_TRACES_PROTOCOL", value = "") + void shouldDefaultToGrpcWhenProtocolIsEmpty() { + assertThat(OpenTelemetryProvider.tracer()).isNotNull(); + } + + @Test + @SetEnvironmentVariable(key = "OTEL_EXPORTER_OTLP_TRACES_ENDPOINT", value = "http://localhost:4317") + void shouldUseCustomEndpoint() { + assertThat(OpenTelemetryProvider.tracer()).isNotNull(); + } + + @Test + @SetEnvironmentVariable(key = "OTEL_EXPORTER_OTLP_TRACES_ENDPOINT", value = "") + void shouldHandleEmptyEndpoint() { + assertThat(OpenTelemetryProvider.tracer()).isNotNull(); + } + + + @Test + void shouldAllComponentsWorkTogether() { + assertThat(OpenTelemetryProvider.tracer()).isNotNull(); + assertThat(OpenTelemetryProvider.propagator()).isNotNull(); + assertThat(OpenTelemetryProvider.textMapGetter()).isNotNull(); + assertThat(OpenTelemetryProvider.objectMapper()).isNotNull(); + assertThat(OpenTelemetryProvider.traceContextPropagationMode()).isNotNull(); + assertThat(OpenTelemetryProvider.forceFlush()).isNotNull(); + } + + @Test + void shouldTracerAndPropagatorBeCompatible() { + Tracer tracer = OpenTelemetryProvider.tracer(); + TextMapPropagator propagator = OpenTelemetryProvider.propagator(); + + assertThat(tracer.spanBuilder("test").startSpan()).isNotNull(); + assertThat(propagator.fields()).isNotEmpty(); + } + + @Test + void shouldTextMapGetterWorkWithEmptyMap() { + TextMapGetter> getter = OpenTelemetryProvider.textMapGetter(); + Map emptyMap = Collections.emptyMap(); + + assertThat(getter.keys(emptyMap)).isEmpty(); + assertThat(getter.get(emptyMap, "anyKey")).isNull(); + } + + @Test + void shouldTextMapGetterWorkWithSingleEntry() { + TextMapGetter> getter = OpenTelemetryProvider.textMapGetter(); + Map singleEntry = Map.of("singleKey", "singleValue"); + + assertThat(getter.keys(singleEntry)).containsExactly("singleKey"); + assertThat(getter.get(singleEntry, "singleKey")).isEqualTo("singleValue"); + } + + @Test + void shouldTextMapGetterHandleSpecialCharacters() { + TextMapGetter> getter = OpenTelemetryProvider.textMapGetter(); + Map carrier = Map.of( + "key-with-dash", "value1", + "key_with_underscore", "value2", + "key.with.dot", "value3" + ); + + assertThat(getter.get(carrier, "key-with-dash")).isEqualTo("value1"); + assertThat(getter.get(carrier, "key_with_underscore")).isEqualTo("value2"); + assertThat(getter.get(carrier, "key.with.dot")).isEqualTo("value3"); + } + + @Test + void shouldHandleNullSafely() { + TextMapGetter> getter = OpenTelemetryProvider.textMapGetter(); + + assertThat(getter.get(null, null)).isNull(); + assertThat(getter.get(null, "key")).isNull(); + assertThat(getter.keys(null)).isEmpty(); + } + + @Test + void shouldObjectMapperHandleComplexObjects() throws Exception { + ObjectMapper mapper = OpenTelemetryProvider.objectMapper(); + + Map complex = Map.of( + "string", "value", + "number", 123, + "nested", Map.of("inner", "data") + ); + + String json = mapper.writeValueAsString(complex); + assertThat(json).isNotNull(); + + Map parsed = mapper.readValue(json, Map.class); + assertThat(parsed).containsKeys("string", "number", "nested"); + } + + @Test + void shouldTracerNameBeCorrect() { + Tracer tracer = OpenTelemetryProvider.tracer(); + assertThat(tracer.spanBuilder("test").startSpan().getSpanContext()).isNotNull(); + } +} \ No newline at end of file