diff --git a/docs/core/tracing-opentelemetry.md b/docs/core/tracing-opentelemetry.md new file mode 100644 index 000000000..df6a302dd --- /dev/null +++ b/docs/core/tracing-opentelemetry.md @@ -0,0 +1,498 @@ +--- +title: Tracing OpenTelemetry +description: Core utility +--- + +The Tracing OpenTelemetry utility provides an opinionated integration with [OpenTelemetry](https://opentelemetry.io/) for AWS Lambda. It reduces the overhead of performing distributed tracing tasks by automatically creating spans, extracting trace context from various AWS Lambda event sources, capturing cold starts, responses, and errors, and exporting traces to OpenTelemetry Protocol (OTLP) collectors or AWS Distro for OpenTelemetry (ADOT). + +![Tracing showcase](../media/tracing_utility_showcase.png) + + **Key Features** + + * Automatic span lifecycle management for Lambda handlers and arbitrary methods using the `@Tracing` annotation or programmatic API. + * Capture cold start as attribute (`faas.coldstart`), Lambda invocation ID (`faas.invocation_id`), responses, and full exceptions. + * Built-in trace context extraction and span enrichment for major AWS Lambda event sources (API Gateway, SQS, SNS, Kinesis, DynamoDB Streams, S3). + * Flexible context propagation modes (`PARENT` and `LINK`) supporting W3C Trace Context and AWS X-Ray formats. + * Automatic configuration for OTLP exporters (gRPC and HTTP/Protobuf) and seamless compatibility with Global OpenTelemetry (e.g. ADOT Java agent / Lambda layer). + * Programmatic tracing API (`TracingOpenTelemetry`) for custom spans, scopes, and manual context injection/extraction. + * Automatic User-Agent tagging for AWS SDK for Java 2.x clients. + * GraalVM support. + +## Install + +=== "Maven" + + ```xml hl_lines="3-7 25-28" + + ... + + software.amazon.lambda + powertools-tracing-opentelemetry + {{ powertools.version }} + + ... + + ... + + + + + ... + + dev.aspectj + aspectj-maven-plugin + 1.14 + + 17 + 17 + 17 + + + software.amazon.lambda + powertools-tracing-opentelemetry + + + + + + org.aspectj + aspectjtools + + 1.9.22 + + + + + + compile + + + + + ... + + + ``` + +=== "Gradle" + + ```groovy hl_lines="3 11 12" + plugins { + id 'java' + id 'io.freefair.aspectj.post-compile-weaving' version '8.1.0' // Not needed when using the programmatic approach + } + + repositories { + mavenCentral() + } + + dependencies { + aspect 'software.amazon.lambda:powertools-tracing-opentelemetry:{{ powertools.version }}' // Not needed when using the programmatic approach + implementation 'software.amazon.lambda:powertools-tracing-opentelemetry:{{ powertools.version }}' // Use this instead of 'aspect' when using the programmatic approach + } + + sourceCompatibility = 17 // or higher + targetCompatibility = 17 // or higher + ``` + +## Initialization + +Before using this utility, ensure your environment is configured to export OpenTelemetry traces (such as using an OpenTelemetry Collector, AWS Distro for OpenTelemetry (ADOT), or a generic OTLP backend). + +> Example using AWS Serverless Application Model (SAM) + +=== "template.yaml" + + ```yaml hl_lines="8 10-14" + Resources: + HelloWorldFunction: + Type: AWS::Serverless::Function + Properties: + ... + Runtime: java17 + + Tracing: Active + Environment: + Variables: + POWERTOOLS_SERVICE_NAME: example + POWERTOOLS_TRACE_CONTEXT_PROPAGATION_MODE: PARENT + OTEL_EXPORTER_OTLP_TRACES_PROTOCOL: grpc + OTEL_EXPORTER_OTLP_TRACES_ENDPOINT: http://localhost:4317 + ``` + +The following environment variables can be configured: + +| Environment Variable | Description | Default | +| --- | --- | --- | +| **`POWERTOOLS_SERVICE_NAME`** | Service name associated with the Lambda function resource | `""` | +| **`POWERTOOLS_TRACE_CONTEXT_PROPAGATION_MODE`** | Propagation strategy: `PARENT` or `LINK` | `PARENT` | +| **`POWERTOOLS_TRACER_CAPTURE_RESPONSE`** | Capture method response in span attribute | `false` | +| **`POWERTOOLS_TRACER_CAPTURE_ERROR`** | Capture thrown exception in span | `false` | +| **`OTEL_EXPORTER_OTLP_TRACES_PROTOCOL`** | Exporter protocol: `grpc` or `http/protobuf` | `grpc` | +| **`OTEL_EXPORTER_OTLP_TRACES_ENDPOINT`** | Target OTLP endpoint (e.g., `http://localhost:4317`) | Collector default | +| **`OTEL_EXPORTER_OTLP_TRACES_HEADERS`** | Comma-separated list of headers (`key=value,key2=value2`) | `""` | + +### Lambda handler + +You can enable tracing using either the `@Tracing` annotation or the programmatic API. + +**With the `@Tracing` annotation**, add it to your `handleRequest` method or any internal method to capture it as a separate span automatically. You can optionally customize the span name and namespace. + +**With the programmatic API**, use `TracingOpenTelemetry.create()` to manually manage spans without AspectJ configuration. + +=== "@Tracing annotation" + + ```java hl_lines="3 10 15" + public class App implements RequestHandler { + + @Tracing + public APIGatewayProxyResponseEvent handleRequest(APIGatewayProxyRequestEvent input, Context context) { + businessLogic1(); + + businessLogic2(); + } + + @Tracing + public void businessLogic1(){ + + } + + @Tracing + public void businessLogic2(){ + + } + } + ``` + +=== "Programmatic API" + + ```java hl_lines="1 6 7 8 10 11 12" + import software.amazon.lambda.powertools.tracing.opentelemetry.TracingOpenTelemetry; + + public class App implements RequestHandler { + + public APIGatewayProxyResponseEvent handleRequest(APIGatewayProxyRequestEvent input, Context context) { + TracingOpenTelemetry.create().withSpan("businessLogic1", span -> { + // Business logic 1 + return null; + }); + + TracingOpenTelemetry.create().withSpan("businessLogic2", span -> { + // Business logic 2 + return null; + }); + } + } + ``` + +=== "Custom Span names" + + ```java hl_lines="3" + public class App implements RequestHandler { + + @Tracing(spanName="yourCustomSpanName") + public APIGatewayProxyResponseEvent handleRequest(APIGatewayProxyRequestEvent input, Context context) { + ... + } + ``` + +When using the `@Tracing` annotation on a Lambda handler, the utility performs these additional tasks: + + * Creates a `faas.coldstart` attribute to easily identify cold starts. + * Adds the `faas.invocation_id` attribute containing the AWS Request ID. + * Automatically extracts incoming trace context from supported Lambda event sources (API Gateway, SQS, SNS, Kinesis, DynamoDB, S3) and enriches the span with event details. + * Captures responses and full exceptions based on the configured capture mode. + * Automatically flushes telemetry data when handler execution finishes. + +By default, the `@Tracing` annotation uses `captureMode = CaptureMode.ENVIRONMENT_VAR`, which means it will record method responses and exceptions only if you set the environment variables `POWERTOOLS_TRACER_CAPTURE_RESPONSE` and `POWERTOOLS_TRACER_CAPTURE_ERROR` to `true`. You can override this behavior by specifying a different `captureMode`: + +* `CaptureMode.ENVIRONMENT_VAR` (default): Uses environment variables. +* `CaptureMode.RESPONSE`: Always captures the response. +* `CaptureMode.ERROR`: Always captures exceptions. +* `CaptureMode.RESPONSE_AND_ERROR`: Captures both response and exceptions. +* `CaptureMode.DISABLED`: Disables response and exception capture. + +!!! note + When using the programmatic API with `TracingOpenTelemetry`, response capture is manual, while `withSpan` automatically records thrown exceptions on the span before propagating them. + +!!! warning "Returning sensitive information from your Lambda handler or functions?" + When using the `@Tracing` annotation, you can disable response and exception capture with **`captureMode = CaptureMode.DISABLED`** + or globally by setting the environment variables **`POWERTOOLS_TRACER_CAPTURE_RESPONSE`** and **`POWERTOOLS_TRACER_CAPTURE_ERROR`** to **`false`**. + +=== "@Tracing annotation - Disable on method" + + ```java hl_lines="3" + public class App implements RequestHandler { + + @Tracing(captureMode = CaptureMode.DISABLED) + public APIGatewayProxyResponseEvent handleRequest(APIGatewayProxyRequestEvent input, Context context) { + ... + } + ``` + +=== "@Tracing annotation - Disable Globally" + + ```yaml hl_lines="11 12" + Resources: + HelloWorldFunction: + Type: AWS::Serverless::Function + Properties: + ... + Runtime: java17 + + Tracing: Active + Environment: + Variables: + POWERTOOLS_TRACER_CAPTURE_RESPONSE: false + POWERTOOLS_TRACER_CAPTURE_ERROR: false + ``` + +=== "Programmatic API" + + ```java hl_lines="6 7 8" + import software.amazon.lambda.powertools.tracing.opentelemetry.TracingOpenTelemetry; + + public class App implements RequestHandler { + + public APIGatewayProxyResponseEvent handleRequest(APIGatewayProxyRequestEvent input, Context context) { + TracingOpenTelemetry.create().withSpan("businessLogic", span -> { + // You have full control over what attributes are set on the span + return null; + }); + } + ``` + +### Attributes & Events + +In OpenTelemetry, **Attributes** are key-value pairs associated with spans used for querying and filtering traces. **Events** are timestamped annotations within a span representing specific moments during execution. + +=== "Attributes" + + You can add attributes directly to the active span using OpenTelemetry API: + ```java hl_lines="2 8" + import io.opentelemetry.api.trace.Span; + import software.amazon.lambda.powertools.tracing.opentelemetry.Tracing; + + public class App implements RequestHandler { + + @Tracing + public APIGatewayProxyResponseEvent handleRequest(APIGatewayProxyRequestEvent input, Context context) { + Span.current().setAttribute("customer.id", "12345"); + Span.current().setAttribute("order.amount", 99.99); + } + } + ``` + +=== "Events" + + You can record events on the active span using `addEvent()`: + ```java hl_lines="2 8 9" + import io.opentelemetry.api.trace.Span; + import software.amazon.lambda.powertools.tracing.opentelemetry.Tracing; + + public class App implements RequestHandler { + + @Tracing + public APIGatewayProxyResponseEvent handleRequest(APIGatewayProxyRequestEvent input, Context context) { + Span.current().addEvent("processing_started"); + // Perform operations... + Span.current().addEvent("processing_finished"); + } + } + ``` + +## Event Source Context Extraction + +Powertools for AWS Lambda OpenTelemetry Tracing automatically inspects incoming Lambda events and extracts trace context across distributed boundaries using W3C Trace Context and AWS X-Ray headers. + +Supported event sources include: + +* **API Gateway** (`APIGatewayProxyRequestEvent`, `APIGatewayV2HTTPEvent`): Extracts `traceparent`, `tracestate`, or `X-Amzn-Trace-Id` headers and enriches span with HTTP method, path, and status code. +* **Amazon SQS** (`SQSEvent`): Extracts trace headers from message attributes and system attributes. +* **Amazon SNS** (`SNSEvent`): Extracts trace headers from message attributes. +* **Amazon Kinesis** (`KinesisEvent`): Extracts trace context from record data. +* **Amazon DynamoDB Streams** (`DynamodbEvent`): Extracts trace context from stream records. +* **Amazon S3** (`S3Event`): Extracts and enriches span with bucket and object metadata. + +### Propagation Modes + +You can control how trace contexts are connected using the `POWERTOOLS_TRACE_CONTEXT_PROPAGATION_MODE` environment variable: + +* **`PARENT` (Default)**: Creates the handler span as a child of the extracted trace context. +* **`LINK`**: Creates the handler span in the current context and links it to extracted trace contexts. This is particularly useful for batch event sources (such as SQS or Kinesis) where a single batch contains messages from multiple independent traces. + +=== "PARENT Mode" + + ```yaml hl_lines="4" + Environment: + Variables: + POWERTOOLS_TRACE_CONTEXT_PROPAGATION_MODE: PARENT + ``` + +=== "LINK Mode" + + ```yaml hl_lines="4" + Environment: + Variables: + POWERTOOLS_TRACE_CONTEXT_PROPAGATION_MODE: LINK + ``` + +## Advanced usage + +### Programmatic Tracing with TracingOpenTelemetry + +You can use `TracingOpenTelemetry` to manually create and manage spans with full control over span lifecycle, kinds, attributes, and context scoping. + +=== "Span with SpanScope" + + ```java hl_lines="1 6 7 11" + import software.amazon.lambda.powertools.tracing.opentelemetry.TracingOpenTelemetry; + import software.amazon.lambda.powertools.tracing.opentelemetry.internal.SpanScope; + + public class OrderService { + public void processOrder(String orderId) { + TracingOpenTelemetry tracing = TracingOpenTelemetry.create(); + try (SpanScope scope = tracing.addSpan("processOrder")) { + scope.span().setAttribute("order.id", orderId); + scope.addEvent("validating_order"); + // Business logic + } + } + } + ``` + +=== "Custom Builder Configuration" + + ```java hl_lines="1 4-8" + import software.amazon.lambda.powertools.tracing.opentelemetry.TracingOpenTelemetry; + + TracingOpenTelemetry customTracing = TracingOpenTelemetry.builder() + .tracer(customTracer) + .propagator(customPropagator) + .eventContextExtractorResolver(customResolver) + .build(); + ``` + +### Context Injection & Extraction + +When communicating across distributed systems or custom protocols, you can inject and extract trace context directly: + +=== "Context Propagation" + + ```java hl_lines="1 2 7 13" + import io.opentelemetry.context.propagation.TextMapSetter; + import software.amazon.lambda.powertools.tracing.opentelemetry.TracingOpenTelemetry; + + public class MessageSender { + public void sendMessage(Map carrier) { + TracingOpenTelemetry tracing = TracingOpenTelemetry.create(); + tracing.injectContext(carrier, (map, key, value) -> map.put(key, value)); + } + + public void receiveMessage(Map carrier) { + TracingOpenTelemetry tracing = TracingOpenTelemetry.create(); + io.opentelemetry.context.Context context = tracing.extractContext(carrier, (map, key) -> map.get(key)); + } + } + ``` + +### Multi-threaded programming + +When spawning multiple threads, ensure the OpenTelemetry context is propagated to the new thread using `Context.makeCurrent()`: + +=== "Multi-threaded example" + + ```java hl_lines="1 2 8 10 11" + import io.opentelemetry.context.Context; + import io.opentelemetry.context.Scope; + import software.amazon.lambda.powertools.tracing.opentelemetry.TracingOpenTelemetry; + + public class App implements RequestHandler { + + public APIGatewayProxyResponseEvent handleRequest(APIGatewayProxyRequestEvent input, com.amazonaws.services.lambda.runtime.Context lambdaContext) { + Context parentContext = Context.current(); + + Thread anotherThread = new Thread(() -> { + try (Scope scope = parentContext.makeCurrent()) { + TracingOpenTelemetry.create().withSpan("asyncTask", span -> { + // Business logic in separate thread with preserved context + return null; + }); + } + }); + anotherThread.start(); + } + } + ``` + +## Instrumenting SDK clients and HTTP calls + +### AWS SDK for Java 2.x + +Powertools for AWS Lambda (Java) OpenTelemetry module includes `TracingUserAgentInterceptor`, which automatically tags AWS SDK v2 client requests with the Powertools user-agent header without any additional code required. + +For tracing outgoing AWS SDK calls, you can use the OpenTelemetry AWS SDK Instrumentation library or the [AWS Distro for OpenTelemetry (ADOT)](https://aws-otel.github.io/) Java agent / Lambda layer, which automatically instruments AWS SDK clients and outgoing HTTP calls. + +## Testing your code + +When testing code instrumented with `@Tracing` or `TracingOpenTelemetry`, you can configure unit tests using OpenTelemetry testing utilities like `InMemorySpanExporter` or configure custom `TracingOpenTelemetry` instances. + +=== "JUnit 5 Test (InMemorySpanExporter)" + + ```java hl_lines="1 2 11 12 13 14 15 22 23" + import io.opentelemetry.sdk.testing.exporter.InMemorySpanExporter; + import io.opentelemetry.sdk.trace.SdkTracerProvider; + import io.opentelemetry.sdk.trace.export.SimpleSpanProcessor; + import org.junit.jupiter.api.AfterEach; + import org.junit.jupiter.api.BeforeEach; + import org.junit.jupiter.api.Test; + + public class AppTest { + private InMemorySpanExporter exporter; + private SdkTracerProvider tracerProvider; + + @BeforeEach + public void setup() { + exporter = InMemorySpanExporter.create(); + tracerProvider = SdkTracerProvider.builder() + .addSpanProcessor(SimpleSpanProcessor.create(exporter)) + .build(); + } + + @AfterEach + public void tearDown() { + tracerProvider.close(); + } + + @Test + public void successfulResponse() { + // Execute handler and assert captured spans from exporter.getFinishedSpanItems() + } + } + ``` + +=== "Maven (pom.xml)" + + ```xml + + + + org.apache.maven.plugins + maven-surefire-plugin + + + on-demand + + + + + + ``` + +=== "Gradle (build.gradle)" + + ```groovy + test { + environment "AWS_LAMBDA_INITIALIZATION_TYPE", "on-demand" + } + ``` diff --git a/pom.xml b/pom.xml index 5b3d31fd2..69af54867 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 @@ -120,6 +122,8 @@ 2.3.0 1.5.0 0.11.5 + 1.65.0 + 1.59.0-alpha @@ -314,6 +318,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} + @@ -407,6 +431,12 @@ ${wiremock.version} test + + io.opentelemetry + opentelemetry-sdk-testing + ${opentelemetry-api.version} + test + @@ -485,7 +515,8 @@ true true - true + true + @@ -736,7 +767,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/internal/TracingUserAgentInterceptor.java b/powertools-tracing-opentelemetry/src/main/java/software/amazon/lambda/powertools/tracing/opentelemetry/internal/TracingUserAgentInterceptor.java new file mode 100644 index 000000000..b2f58a699 --- /dev/null +++ b/powertools-tracing-opentelemetry/src/main/java/software/amazon/lambda/powertools/tracing/opentelemetry/internal/TracingUserAgentInterceptor.java @@ -0,0 +1,38 @@ +/* + * 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 software.amazon.awssdk.core.SdkRequest; +import software.amazon.awssdk.core.interceptor.Context; +import software.amazon.awssdk.core.interceptor.ExecutionAttributes; +import software.amazon.awssdk.core.interceptor.ExecutionInterceptor; +import software.amazon.lambda.powertools.common.internal.UserAgentConfigurator; + +/** + * Global interceptor that configures the User-Agent for all AWS SDK clients + * when the powertools-tracing module is on the classpath. + */ +public final class TracingUserAgentInterceptor implements ExecutionInterceptor { + static { + UserAgentConfigurator.configureUserAgent("tracing-opentelemetry"); + } + + @Override + public SdkRequest modifyRequest(Context.ModifyRequest context, ExecutionAttributes executionAttributes) { + // This is a no-op interceptor. We use this class to configure the PT User-Agent in the static block. It is + // loaded by AWS SDK Global Interceptors. + return context.request(); + } +} 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/main/resources/software.amazon.awssdk.global.handlers/execution.interceptors b/powertools-tracing-opentelemetry/src/main/resources/software.amazon.awssdk.global.handlers/execution.interceptors new file mode 100644 index 000000000..58f3df538 --- /dev/null +++ b/powertools-tracing-opentelemetry/src/main/resources/software.amazon.awssdk.global.handlers/execution.interceptors @@ -0,0 +1 @@ +software.amazon.lambda.powertools.tracing.opentelemetry.internal.TracingUserAgentInterceptor 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