diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml index 4a4bd63fb..dcc1eae27 100644 --- a/.github/workflows/e2e-tests.yml +++ b/.github/workflows/e2e-tests.yml @@ -71,6 +71,34 @@ jobs: - name: Generate SAM template run: python3 generate-template.py working-directory: ./examples + - name: Resolve latest ADOT Java layer ARN + env: + GH_TOKEN: ${{ github.token }} + run: | + # The ADOT Java layer is regional: account ID and version vary per region. + # Resolve the current ARN for AWS_REGION from the release notes table (region | ARN). + ADOT_LAYER_ARN=$( + gh api repos/aws-observability/aws-otel-java-instrumentation/releases/latest \ + --jq .body | + awk -F '|' -v region="$AWS_REGION" ' + $2 ~ "^[[:space:]]*" region "[[:space:]]*$" { + gsub(/^[[:space:]]+|[[:space:]]+$/, "", $3) + print $3 + exit + } + ' + ) + if [ -z "$ADOT_LAYER_ARN" ]; then + echo "Could not resolve the latest ADOT Java layer for $AWS_REGION" + exit 1 + fi + aws lambda get-layer-version-by-arn \ + --arn "$ADOT_LAYER_ARN" \ + --region "$AWS_REGION" \ + --query LayerVersionArn \ + --output text > /dev/null + echo "Using ADOT Java layer $ADOT_LAYER_ARN" + echo "ADOT_LAYER_ARN=$ADOT_LAYER_ARN" >> "$GITHUB_ENV" - name: sam build env: MAVEN_OPTS: -DskipTests=true -Dmaven.test.skip=true @@ -87,7 +115,7 @@ jobs: run: | sam deploy --stack-name Java${{ matrix.java }}-JavaSDKCloudBasedIntegrationTestStack \ --resolve-image-repos --resolve-s3 --parameter-overrides \ - 'ParameterKey=Architecture,ParameterValue=x86_64 ParameterKey=JavaVersion,ParameterValue=java${{ matrix.java }} ParameterKey=FunctionNamePrefix,ParameterValue=Java${{ matrix.java }}- ParameterKey=RoleArn,ParameterValue=${{ secrets.TEST_LAMBDA_EXECUTION_ROLE_ARN }}' + 'ParameterKey=Architecture,ParameterValue=x86_64 ParameterKey=JavaVersion,ParameterValue=java${{ matrix.java }} ParameterKey=FunctionNamePrefix,ParameterValue=Java${{ matrix.java }}- ParameterKey=RoleArn,ParameterValue=${{ secrets.TEST_LAMBDA_EXECUTION_ROLE_ARN }} ParameterKey=AdotLayerArn,ParameterValue='"$ADOT_LAYER_ARN" working-directory: ./examples - name: Cloud Based Integration Tests run: | diff --git a/examples/README.md b/examples/README.md index f10eac11d..fa24c9cc3 100644 --- a/examples/README.md +++ b/examples/README.md @@ -48,9 +48,26 @@ The SAM template configures: - `DurableConfig` with `ExecutionTimeout` and `RetentionPeriodInDays` - CloudWatch log groups for Lambda functions with 7 days of retention - IAM permissions for `lambda:CheckpointDurableExecutions` and `lambda:GetDurableExecutionState` +- ADOT tracing examples with active X-Ray tracing and the ADOT Lambda layer +- OTel examples that use `new OtelPlugin()` with `AWS_LAMBDA_EXEC_WRAPPER` and `OTEL_JAVAAGENT_EXTENSIONS` so the OTel plugin SPI is loaded by the Java agent `template.yaml` is generated from the Java example handlers and is intentionally not checked in. Re-run `python3 generate-template.py` after adding or removing a deployable example handler. +The examples package copies the OTel plugin jar into `lib/` so the ADOT Java agent can load it as an extension for examples that enable `OTEL_JAVAAGENT_EXTENSIONS`. + +### ADOT layer region + +The ADOT layer is regional — its account ID and version vary by region — so the template exposes it as the `AdotLayerArn` parameter. The default targets `us-west-2` (the region used by the e2e tests), and the e2e workflow resolves the current ARN for its region automatically. + +If you deploy the tracing examples to any other region, override the parameter with that region's ARN, otherwise the deploy fails CloudFormation's `ResourceExistenceCheck` on a cross-region layer: + +```bash +sam deploy --parameter-overrides \ + AdotLayerArn=arn:aws:lambda:us-east-1:615299751070:layer:AWSOpenTelemetryDistroJava:16 +``` + +Find the current ARN for your region in the [ADOT Java instrumentation releases](https://github.com/aws-observability/aws-otel-java-instrumentation/releases/latest). You can also persist the override in your (git-ignored) `samconfig.toml` under `parameter_overrides`. + ## Invoke Deployed Functions ```bash @@ -96,6 +113,7 @@ mvn test -Dtest=CloudBasedIntegrationTest \ | [CustomShouldCompleteMapExample](src/main/java/software/amazon/lambda/durable/examples/map/CustomShouldCompleteMapExample.java) | Custom map completion with `shouldComplete` decisions | | [WaitForConditionExample](src/main/java/software/amazon/lambda/durable/examples/wait/WaitForConditionExample.java) | Poll a condition until met with `waitForCondition()` | | [OtelExample](src/main/java/software/amazon/lambda/durable/examples/general/OtelExample.java) | OpenTelemetry instrumentation with logging span export | +| [OtelXRayDefaultConstructorExample](src/main/java/software/amazon/lambda/durable/examples/otel/OtelXRayDefaultConstructorExample.java) | Export spans to X-Ray with `new OtelPlugin()` and no handler-side OpenTelemetry initialization | | [OtelXRayStepExample](src/main/java/software/amazon/lambda/durable/examples/otel/OtelXRayStepExample.java) | Export step spans to X-Ray through the ADOT Lambda Layer | | [OtelXRayWaitExample](src/main/java/software/amazon/lambda/durable/examples/otel/OtelXRayWaitExample.java) | Trace a step-wait-step workflow across Lambda invocations | | [OtelXRayMapExample](src/main/java/software/amazon/lambda/durable/examples/otel/OtelXRayMapExample.java) | Trace concurrent map operations and item steps in X-Ray | diff --git a/examples/generate-template.py b/examples/generate-template.py index 75e6cce09..6b1607fe6 100755 --- a/examples/generate-template.py +++ b/examples/generate-template.py @@ -4,6 +4,7 @@ import argparse import re +import xml.etree.ElementTree as ET from dataclasses import dataclass from pathlib import Path @@ -13,6 +14,17 @@ EXAMPLE_PACKAGE_ROOT = SOURCE_ROOT / "software/amazon/lambda/durable/examples" DEFAULT_OUTPUT = EXAMPLES_DIR / "template.yaml" TEMPLATE_ANNOTATION = "ExampleTemplate" +POM_NAMESPACE = {"m": "http://maven.apache.org/POM/4.0.0"} + + +def read_otel_plugin_jar_path() -> str: + root = ET.parse(EXAMPLES_DIR / "pom.xml").getroot() + version = root.findtext("m:version", namespaces=POM_NAMESPACE) + if version is None: + version = root.findtext("m:parent/m:version", namespaces=POM_NAMESPACE) + if version is None: + raise ValueError("Unable to read examples version from pom.xml") + return f"/var/task/lib/aws-durable-execution-sdk-java-plugin-otel-{version}.jar" @dataclass(frozen=True) @@ -22,6 +34,7 @@ class ExampleFunction: suffix: str condition: str | None tracing: bool + java_agent: bool @property def logical_id(self) -> str: @@ -58,24 +71,26 @@ def is_top_level_durable_handler(source: str, class_name: str) -> bool: return bool(match and "extends DurableHandler" in match.group("header")) -def read_template_annotation(source: str, class_name: str) -> tuple[str | None, bool]: +def read_template_annotation(source: str, class_name: str) -> tuple[str | None, bool, bool]: class_match = re.search(rf"public\s+(?:final\s+)?class\s+{class_name}\b", source) if not class_match: - return None, False + return None, False, False prefix = source[: class_match.start()] matches = list( re.finditer(rf"@(?:[A-Za-z_][\w.]*\.)?{TEMPLATE_ANNOTATION}\s*(?:\((?P.*?)\))?", prefix, re.DOTALL) ) if not matches: - return None, False + return None, False, False body = matches[-1].group("body") or "" condition_match = re.search(r'condition\s*=\s*"([^"]+)"', body) tracing_match = re.search(r"tracing\s*=\s*(true|false)", body) + java_agent_match = re.search(r"javaAgent\s*=\s*(true|false)", body) condition = condition_match.group(1) if condition_match else None tracing = tracing_match.group(1) == "true" if tracing_match else False - return condition, tracing + java_agent = java_agent_match.group(1) == "true" if java_agent_match else False + return condition, tracing, java_agent def discover_examples() -> list[ExampleFunction]: @@ -86,7 +101,7 @@ def discover_examples() -> list[ExampleFunction]: if not is_top_level_durable_handler(source, class_name): continue - condition, tracing = read_template_annotation(source, class_name) + condition, tracing, java_agent = read_template_annotation(source, class_name) package_name = read_package(source, path) examples.append( ExampleFunction( @@ -95,12 +110,13 @@ def discover_examples() -> list[ExampleFunction]: suffix=kebab_case(class_name), condition=condition, tracing=tracing, + java_agent=java_agent, ) ) return examples -def emit_function(lines: list[str], example: ExampleFunction) -> None: +def emit_function(lines: list[str], example: ExampleFunction, java_agent_extension_path: str) -> None: lines.extend( [ f" {example.logical_id}:", @@ -123,9 +139,17 @@ def emit_function(lines: list[str], example: ExampleFunction) -> None: [ " Tracing: Active", " Layers:", - " - !Sub", - " - arn:aws:lambda:${AWS::Region}:901920570463:layer:aws-otel-java-agent-${AdotArch}-ver-1-32-0:6", - " - AdotArch: amd64", + " - !Ref AdotLayerArn", + ] + ) + if example.java_agent: + lines.extend( + [ + " Environment:", + " Variables:", + " AWS_LAMBDA_EXEC_WRAPPER: /opt/otel-instrument", + f' JAVA_TOOL_OPTIONS: "-Dotel.javaagent.extensions={java_agent_extension_path}"', + f" OTEL_JAVAAGENT_EXTENSIONS: {java_agent_extension_path}", ] ) lines.append("") @@ -151,6 +175,7 @@ def emit_log_group(lines: list[str], example: ExampleFunction) -> None: def render_template(examples: list[ExampleFunction]) -> str: + java_agent_extension_path = read_otel_plugin_jar_path() lines = [ "# This file is generated by examples/generate-template.py. Do not edit it by hand.", 'AWSTemplateFormatVersion: "2010-09-09"', @@ -176,6 +201,14 @@ def render_template(examples: list[ExampleFunction]) -> str: " RoleArn:", " Type: String", " Description: IAM Role ARN for Lambda function execution", + " AdotLayerArn:", + " Type: String", + " Default: arn:aws:lambda:us-west-2:615299751070:layer:AWSOpenTelemetryDistroJava:16", + " Description: >-", + " ARN of the ADOT (AWS Distro for OpenTelemetry) Lambda layer used by the tracing examples.", + " The layer is regional: its account ID and version vary by region, so override this per", + " deployment region. The default targets us-west-2 (the region used by e2e tests); CI", + " resolves the latest ARN for its region.", "", "Conditions:", " IsJava21OrLater:", @@ -202,7 +235,7 @@ def render_template(examples: list[ExampleFunction]) -> str: for example in examples: emit_log_group(lines, example) - emit_function(lines, example) + emit_function(lines, example, java_agent_extension_path) lines.append("Outputs:") for index, example in enumerate(examples): diff --git a/examples/pom.xml b/examples/pom.xml index 7c3530cb2..da063dbd1 100644 --- a/examples/pom.xml +++ b/examples/pom.xml @@ -50,18 +50,6 @@ 1.64.0 - - - io.opentelemetry - opentelemetry-exporter-otlp - 1.64.0 - - - io.grpc - grpc-netty-shaded - 1.82.2 - - com.amazonaws @@ -154,6 +142,30 @@ + + org.apache.maven.plugins + maven-dependency-plugin + 3.9.0 + + + copy-otel-javaagent-extension + process-resources + + copy + + + + + ${project.groupId} + aws-durable-execution-sdk-java-plugin-otel + ${project.version} + ${project.build.outputDirectory}/lib + + + + + + org.apache.maven.plugins maven-source-plugin diff --git a/examples/src/main/java/software/amazon/lambda/durable/examples/ExampleTemplate.java b/examples/src/main/java/software/amazon/lambda/durable/examples/ExampleTemplate.java index 898634884..bd7d92bc0 100644 --- a/examples/src/main/java/software/amazon/lambda/durable/examples/ExampleTemplate.java +++ b/examples/src/main/java/software/amazon/lambda/durable/examples/ExampleTemplate.java @@ -14,4 +14,6 @@ String condition() default ""; boolean tracing() default false; + + boolean javaAgent() default false; } diff --git a/examples/src/main/java/software/amazon/lambda/durable/examples/general/OtelExample.java b/examples/src/main/java/software/amazon/lambda/durable/examples/general/OtelExample.java index 94b2be751..a50931e8e 100644 --- a/examples/src/main/java/software/amazon/lambda/durable/examples/general/OtelExample.java +++ b/examples/src/main/java/software/amazon/lambda/durable/examples/general/OtelExample.java @@ -22,17 +22,16 @@ *
  • Logging exporter (spans printed to stdout → CloudWatch Logs) * * - *

    In production, replace {@code LoggingSpanExporter} with {@code OtlpGrpcSpanExporter} to send spans to an OTLP - * collector (X-Ray, Datadog, etc.). + *

    In production, replace {@code LoggingSpanExporter} with the exporter for your observability backend. * *

    Expected trace structure: * *

    - * durable.invocation
    - * ├── durable.step:create-greeting [attempt 1]
    - * ├── durable.step:create-greeting (operation, backfilled)
    - * ├── durable.step:transform [attempt 1]
    - * └── durable.step:transform (operation, backfilled)
    + * invocation
    + * ├── create-greeting
    + * │   └── create-greeting attempt 1
    + * └── transform
    + *     └── transform attempt 1
      * 
    */ public class OtelExample extends DurableHandler { diff --git a/examples/src/main/java/software/amazon/lambda/durable/examples/otel/OtelXRayDefaultConstructorExample.java b/examples/src/main/java/software/amazon/lambda/durable/examples/otel/OtelXRayDefaultConstructorExample.java new file mode 100644 index 000000000..f6e4efec1 --- /dev/null +++ b/examples/src/main/java/software/amazon/lambda/durable/examples/otel/OtelXRayDefaultConstructorExample.java @@ -0,0 +1,37 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +package software.amazon.lambda.durable.examples.otel; + +import software.amazon.lambda.durable.DurableConfig; +import software.amazon.lambda.durable.DurableContext; +import software.amazon.lambda.durable.DurableHandler; +import software.amazon.lambda.durable.examples.ExampleTemplate; +import software.amazon.lambda.durable.examples.types.GreetingRequest; +import software.amazon.lambda.durable.otel.OtelPlugin; + +/** + * OTel + X-Ray example that uses the no-arg plugin constructor. + * + *

    {@link OtelPlugin#OtelPlugin()} uses the global provider initialized by the ADOT Java agent with deterministic + * span ID generation installed by the plugin's OpenTelemetry autoconfigure SPI. + */ +@ExampleTemplate(tracing = true, javaAgent = true) +public class OtelXRayDefaultConstructorExample extends DurableHandler { + + @Override + protected DurableConfig createConfiguration() { + return DurableConfig.builder().withPlugins(new OtelPlugin()).build(); + } + + @Override + public String handleRequest(GreetingRequest input, DurableContext context) { + context.getLogger().info("Starting OTel X-Ray default constructor example for {}", input.getName()); + + var greeting = context.step("default-create-greeting", String.class, stepCtx -> "Hello, " + input.getName()); + + var result = context.step("default-transform", String.class, stepCtx -> greeting.toUpperCase() + "!"); + + context.getLogger().info("OTel X-Ray default constructor example complete: {}", result); + return result; + } +} diff --git a/examples/src/main/java/software/amazon/lambda/durable/examples/otel/OtelXRayExamples.java b/examples/src/main/java/software/amazon/lambda/durable/examples/otel/OtelXRayExamples.java index e1e58b431..55a9188d8 100644 --- a/examples/src/main/java/software/amazon/lambda/durable/examples/otel/OtelXRayExamples.java +++ b/examples/src/main/java/software/amazon/lambda/durable/examples/otel/OtelXRayExamples.java @@ -2,7 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 package software.amazon.lambda.durable.examples.otel; -import io.opentelemetry.exporter.otlp.trace.OtlpGrpcSpanExporter; +import io.opentelemetry.exporter.logging.LoggingSpanExporter; import io.opentelemetry.sdk.trace.SdkTracerProvider; import io.opentelemetry.sdk.trace.export.SimpleSpanProcessor; import java.util.List; @@ -20,10 +20,9 @@ public final class OtelXRayExamples { private OtelXRayExamples() {} - private static DurableConfig otelConfig() { - var otlpExporter = OtlpGrpcSpanExporter.getDefault(); - var otelPlugin = - new OtelPlugin(SdkTracerProvider.builder().addSpanProcessor(SimpleSpanProcessor.create(otlpExporter))); + private static DurableConfig localOtelConfig() { + var otelPlugin = new OtelPlugin( + SdkTracerProvider.builder().addSpanProcessor(SimpleSpanProcessor.create(LoggingSpanExporter.create()))); return DurableConfig.builder().withPlugins(otelPlugin).build(); } @@ -32,7 +31,7 @@ public static class MapExample extends DurableHandler { @Override protected DurableConfig createConfiguration() { - return otelConfig(); + return localOtelConfig(); } @Override @@ -56,7 +55,7 @@ public static class ParallelExample extends DurableHandlerExports spans via OTLP to the ADOT Lambda Layer collector, which forwards them to X-Ray. Requires: + *

    Exports spans through the ADOT Java agent global OpenTelemetry provider. Requires: * *

      *
    • {@code Tracing: Active} on the Lambda function - *
    • ADOT Lambda Layer added to the function (for the OTLP collector) + *
    • ADOT Lambda Layer added to the function + *
    • {@code OTEL_JAVAAGENT_EXTENSIONS} pointing at the OTel plugin jar *
    * *

    Expected trace structure in X-Ray: @@ -32,18 +30,12 @@ * └── transform attempt 1 * */ -@ExampleTemplate(tracing = true) +@ExampleTemplate(tracing = true, javaAgent = true) public class OtelXRayStepExample extends DurableHandler { @Override protected DurableConfig createConfiguration() { - // OTLP exporter sends spans to the ADOT collector (localhost:4317 by default) - var otlpExporter = OtlpGrpcSpanExporter.getDefault(); - - var otelPlugin = - new OtelPlugin(SdkTracerProvider.builder().addSpanProcessor(SimpleSpanProcessor.create(otlpExporter))); - - return DurableConfig.builder().withPlugins(otelPlugin).build(); + return DurableConfig.builder().withPlugins(new OtelPlugin()).build(); } @Override diff --git a/examples/src/main/java/software/amazon/lambda/durable/examples/otel/OtelXRayWaitExample.java b/examples/src/main/java/software/amazon/lambda/durable/examples/otel/OtelXRayWaitExample.java index 4ee8e6551..55fa5b659 100644 --- a/examples/src/main/java/software/amazon/lambda/durable/examples/otel/OtelXRayWaitExample.java +++ b/examples/src/main/java/software/amazon/lambda/durable/examples/otel/OtelXRayWaitExample.java @@ -2,9 +2,6 @@ // SPDX-License-Identifier: Apache-2.0 package software.amazon.lambda.durable.examples.otel; -import io.opentelemetry.exporter.otlp.trace.OtlpGrpcSpanExporter; -import io.opentelemetry.sdk.trace.SdkTracerProvider; -import io.opentelemetry.sdk.trace.export.SimpleSpanProcessor; import java.time.Duration; import software.amazon.lambda.durable.DurableConfig; import software.amazon.lambda.durable.DurableContext; @@ -23,39 +20,34 @@ *

  • Invocation 2: replays "before-wait" (no-op) → wait completes → "after-wait" step runs * * - *

    Exports spans via OTLP to the ADOT Lambda Layer collector. Requires: + *

    Exports spans through the ADOT Java agent global OpenTelemetry provider. Requires: * *

      *
    • {@code Tracing: Active} on the Lambda function - *
    • ADOT Lambda Layer added to the function (for the OTLP collector) + *
    • ADOT Lambda Layer added to the function + *
    • {@code OTEL_JAVAAGENT_EXTENSIONS} pointing at the OTel plugin jar *
    * *

    Expected trace structure in X-Ray (all under one trace ID — backend propagates same Root): * *

      * Trace (single trace ID across both invocations)
    - * ├── durable.invocation (invocation 1)
    - * │   ├── durable.step:before-wait
    - * │   │   └── durable.step:before-wait [attempt 1]
    - * │   └── durable.wait:pause (ended as PENDING)
    - * └── durable.invocation (invocation 2)
    - *     ├── durable.wait:pause (completed)
    - *     └── durable.step:after-wait
    - *         └── durable.step:after-wait [attempt 1]
    + * ├── invocation (invocation 1)
    + * │   ├── before-wait
    + * │   │   └── before-wait attempt 1
    + * │   └── pause (ended as PENDING)
    + * └── invocation (invocation 2)
    + *     ├── pause (completed)
    + *     └── after-wait
    + *         └── after-wait attempt 1
      * 
    */ -@ExampleTemplate(tracing = true) +@ExampleTemplate(tracing = true, javaAgent = true) public class OtelXRayWaitExample extends DurableHandler { @Override protected DurableConfig createConfiguration() { - // OTLP exporter sends spans to the ADOT collector (localhost:4317 by default) - var otlpExporter = OtlpGrpcSpanExporter.getDefault(); - - var otelPlugin = - new OtelPlugin(SdkTracerProvider.builder().addSpanProcessor(SimpleSpanProcessor.create(otlpExporter))); - - return DurableConfig.builder().withPlugins(otelPlugin).build(); + return DurableConfig.builder().withPlugins(new OtelPlugin()).build(); } @Override diff --git a/examples/src/test/java/software/amazon/lambda/durable/examples/CloudBasedOtelIntegrationTest.java b/examples/src/test/java/software/amazon/lambda/durable/examples/CloudBasedOtelIntegrationTest.java index 7c0c47564..8455b43b0 100644 --- a/examples/src/test/java/software/amazon/lambda/durable/examples/CloudBasedOtelIntegrationTest.java +++ b/examples/src/test/java/software/amazon/lambda/durable/examples/CloudBasedOtelIntegrationTest.java @@ -7,6 +7,7 @@ import com.fasterxml.jackson.databind.ObjectMapper; import java.time.Duration; import java.time.Instant; +import java.util.ArrayList; import java.util.List; import java.util.Set; import java.util.stream.Collectors; @@ -16,16 +17,20 @@ import software.amazon.awssdk.auth.credentials.DefaultCredentialsProvider; import software.amazon.awssdk.regions.Region; import software.amazon.awssdk.services.lambda.LambdaClient; +import software.amazon.awssdk.services.lambda.model.ErrorObject; +import software.amazon.awssdk.services.lambda.model.Event; +import software.amazon.awssdk.services.lambda.model.EventType; import software.amazon.awssdk.services.sts.StsClient; import software.amazon.awssdk.services.xray.XRayClient; import software.amazon.awssdk.services.xray.model.BatchGetTracesRequest; import software.amazon.awssdk.services.xray.model.GetTraceSummariesRequest; import software.amazon.awssdk.services.xray.model.Segment; import software.amazon.awssdk.services.xray.model.TimeRangeType; +import software.amazon.awssdk.services.xray.model.Trace; import software.amazon.awssdk.services.xray.model.TraceSummary; import software.amazon.lambda.durable.examples.types.GreetingRequest; -import software.amazon.lambda.durable.model.ExecutionStatus; import software.amazon.lambda.durable.testing.CloudDurableTestRunner; +import software.amazon.lambda.durable.testing.TestResult; /** * Integration tests that verify OTel spans exported via ADOT appear correctly in AWS X-Ray. @@ -33,8 +38,8 @@ *

    These tests deploy Lambda functions configured with: * *

      - *
    • OpenTelemetry Durable Plugin with OTLP gRPC exporter - *
    • ADOT collector layer (OTLP receiver → X-Ray exporter) + *
    • OpenTelemetry Durable Plugin using the ADOT Java agent global provider + *
    • ADOT Java agent layer with the plugin jar loaded through {@code OTEL_JAVAAGENT_EXTENSIONS} *
    • Active X-Ray tracing *
    * @@ -121,7 +126,7 @@ void simpleSteps_producesUnifiedTraceInXRay() throws Exception { var uniqueInput = "XRay-" + System.currentTimeMillis(); var result = runner.run(new GreetingRequest(uniqueInput)); - assertEquals(ExecutionStatus.SUCCEEDED, result.getStatus(), "Execution failed: " + result); + assertExecutionSucceeded(result); assertEquals("HELLO, " + uniqueInput.toUpperCase() + "!", result.getResult()); // 2. Wait for X-Ray ingestion @@ -190,7 +195,7 @@ void waitAndResume_producesUnifiedTraceAcrossInvocations() throws Exception { var uniqueInput = "Wait-" + System.currentTimeMillis(); var result = runner.run(new GreetingRequest(uniqueInput)); - assertEquals(ExecutionStatus.SUCCEEDED, result.getStatus(), "Execution failed: " + result); + assertExecutionSucceeded(result); assertTrue( result.getResult().contains("Resumed and completed"), "Expected result to contain 'Resumed and completed', got: " + result.getResult()); @@ -251,8 +256,136 @@ void waitAndResume_producesUnifiedTraceAcrossInvocations() throws Exception { + invocationCount + " invocations in trace " + durableTrace.id()); } + @Test + void defaultConstructorWithAdotJavaAgent_producesDurableSpansInXRay() throws Exception { + var startTime = Instant.now(); + + var runner = CloudDurableTestRunner.create( + arn("otel-xray-default-constructor-example"), GreetingRequest.class, String.class, lambdaClient); + var uniqueInput = "Default-" + System.currentTimeMillis(); + var execution = runner.startAsync(new GreetingRequest(uniqueInput)); + var result = execution.pollUntilComplete(); + + assertExecutionSucceeded(result); + assertEquals("HELLO, " + uniqueInput.toUpperCase() + "!", result.getResult()); + + Thread.sleep(XRAY_INGESTION_DELAY.toMillis()); + + var traces = queryTracesWithRetry(startTime, Instant.now(), "otel-xray-default-constructor-example"); + assertFalse(traces.isEmpty(), "Expected at least one trace in X-Ray after execution"); + + var allTraces = getFullTraces(traces); + var durableTrace = allTraces.stream() + .filter(trace -> + trace.segments().stream().anyMatch(seg -> segmentContains(seg, "default-create-greeting"))) + .findFirst() + .orElse(null); + + assertNotNull( + durableTrace, + "Expected to find a trace with default-create-greeting segment. Segment names: " + + allTraces.stream() + .flatMap(t -> t.segments().stream()) + .map(CloudBasedOtelIntegrationTest::getSegmentName) + .collect(Collectors.joining(", "))); + + var segmentDocuments = + durableTrace.segments().stream().map(Segment::document).toList(); + var allSegmentText = String.join("\n", segmentDocuments); + + assertTrue(allSegmentText.contains("invocation"), "Expected invocation span in trace"); + assertTrue( + allSegmentText.contains("default-create-greeting"), "Expected default-create-greeting span in trace"); + assertTrue(allSegmentText.contains("default-transform"), "Expected default-transform span in trace"); + } + // ─── Helpers ───────────────────────────────────────────────────────── + private static void assertExecutionSucceeded(TestResult result) { + assertTrue( + result.isSucceeded() && result.getError().isEmpty(), + () -> "Execution failed:\n" + summarizeExecutionHistory(result)); + } + + private static String summarizeExecutionHistory(TestResult result) { + var failureSummary = result.getHistoryEvents().stream() + .map(CloudBasedOtelIntegrationTest::formatFailureEvent) + .filter(summary -> !summary.isBlank()) + .collect(Collectors.joining("\n")); + if (!failureSummary.isBlank()) { + return failureSummary; + } + + return result.getError() + .map(error -> "Execution error: " + formatError(error)) + .orElseGet(() -> "Events: " + + result.getHistoryEvents().stream() + .map(event -> event.eventId() + ":" + event.eventType()) + .collect(Collectors.joining(", "))); + } + + private static String formatFailureEvent(Event event) { + if (EventType.INVOCATION_COMPLETED.equals(event.eventType())) { + var details = event.invocationCompletedDetails(); + if (details != null && details.error() != null && details.error().payload() != null) { + return formatEventError(event, details.error().payload()); + } + } + + if (EventType.EXECUTION_FAILED.equals(event.eventType())) { + var details = event.executionFailedDetails(); + if (details != null && details.error() != null && details.error().payload() != null) { + return formatEventError(event, details.error().payload()); + } + } + + if (EventType.EXECUTION_TIMED_OUT.equals(event.eventType())) { + var details = event.executionTimedOutDetails(); + if (details != null && details.error() != null && details.error().payload() != null) { + return formatEventError(event, details.error().payload()); + } + } + + if (EventType.EXECUTION_STOPPED.equals(event.eventType())) { + var details = event.executionStoppedDetails(); + if (details != null && details.error() != null && details.error().payload() != null) { + return formatEventError(event, details.error().payload()); + } + } + + return ""; + } + + private static String formatEventError(Event event, ErrorObject error) { + return event.eventType() + " event " + event.eventId() + ": " + formatError(error); + } + + private static String formatError(ErrorObject error) { + var parts = new ArrayList(); + if (error.errorType() != null) { + parts.add("type=" + error.errorType()); + } + if (error.errorMessage() != null) { + parts.add("message=" + error.errorMessage()); + } + if (error.errorData() != null) { + parts.add("data=" + error.errorData()); + } + return String.join(", ", parts); + } + + private List getFullTraces(List traces) { + var traceIds = traces.stream().map(TraceSummary::id).toList(); + var allTraces = new ArrayList(); + for (int i = 0; i < traceIds.size(); i += 5) { + var batch = traceIds.subList(i, Math.min(i + 5, traceIds.size())); + var batchResult = xrayClient.batchGetTraces( + BatchGetTracesRequest.builder().traceIds(batch).build()); + allTraces.addAll(batchResult.traces()); + } + return allTraces; + } + /** Queries X-Ray for traces with retry logic to handle eventual consistency. */ private List queryTracesWithRetry(Instant startTime, Instant endTime, String functionName) throws InterruptedException { diff --git a/examples/src/test/java/software/amazon/lambda/durable/examples/otel/OtelXRayExampleTestSupport.java b/examples/src/test/java/software/amazon/lambda/durable/examples/otel/OtelXRayExampleTestSupport.java new file mode 100644 index 000000000..b1f1a5744 --- /dev/null +++ b/examples/src/test/java/software/amazon/lambda/durable/examples/otel/OtelXRayExampleTestSupport.java @@ -0,0 +1,30 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +package software.amazon.lambda.durable.examples.otel; + +import io.opentelemetry.api.GlobalOpenTelemetry; +import io.opentelemetry.exporter.logging.LoggingSpanExporter; +import io.opentelemetry.sdk.OpenTelemetrySdk; +import io.opentelemetry.sdk.trace.SdkTracerProvider; +import io.opentelemetry.sdk.trace.export.SimpleSpanProcessor; + +final class OtelXRayExampleTestSupport { + + private static final String SPI_INSTALLED_PROPERTY = + "software.amazon.lambda.durable.otel.autoConfigurationCustomizerProviderInstalled"; + + private OtelXRayExampleTestSupport() {} + + static void installGlobalOpenTelemetry() { + System.setProperty(SPI_INSTALLED_PROPERTY, Boolean.TRUE.toString()); + var tracerProvider = SdkTracerProvider.builder() + .addSpanProcessor(SimpleSpanProcessor.create(LoggingSpanExporter.create())) + .build(); + OpenTelemetrySdk.builder().setTracerProvider(tracerProvider).buildAndRegisterGlobal(); + } + + static void resetGlobalOpenTelemetry() { + GlobalOpenTelemetry.resetForTest(); + System.clearProperty(SPI_INSTALLED_PROPERTY); + } +} diff --git a/examples/src/test/java/software/amazon/lambda/durable/examples/otel/OtelXRayStepExampleTest.java b/examples/src/test/java/software/amazon/lambda/durable/examples/otel/OtelXRayStepExampleTest.java index 1bae61686..dffe99080 100644 --- a/examples/src/test/java/software/amazon/lambda/durable/examples/otel/OtelXRayStepExampleTest.java +++ b/examples/src/test/java/software/amazon/lambda/durable/examples/otel/OtelXRayStepExampleTest.java @@ -4,6 +4,8 @@ import static org.junit.jupiter.api.Assertions.*; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import software.amazon.lambda.durable.examples.types.GreetingRequest; import software.amazon.lambda.durable.model.ExecutionStatus; @@ -11,6 +13,16 @@ class OtelXRayStepExampleTest { + @BeforeEach + void setUp() { + OtelXRayExampleTestSupport.installGlobalOpenTelemetry(); + } + + @AfterEach + void tearDown() { + OtelXRayExampleTestSupport.resetGlobalOpenTelemetry(); + } + @Test void testSimpleSteps_succeeds() { var handler = new OtelXRayStepExample(); diff --git a/examples/src/test/java/software/amazon/lambda/durable/examples/otel/OtelXRayWaitExampleTest.java b/examples/src/test/java/software/amazon/lambda/durable/examples/otel/OtelXRayWaitExampleTest.java index 4d46dc2bb..ac42d31d9 100644 --- a/examples/src/test/java/software/amazon/lambda/durable/examples/otel/OtelXRayWaitExampleTest.java +++ b/examples/src/test/java/software/amazon/lambda/durable/examples/otel/OtelXRayWaitExampleTest.java @@ -4,6 +4,8 @@ import static org.junit.jupiter.api.Assertions.*; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import software.amazon.lambda.durable.examples.types.GreetingRequest; import software.amazon.lambda.durable.model.ExecutionStatus; @@ -11,6 +13,16 @@ class OtelXRayWaitExampleTest { + @BeforeEach + void setUp() { + OtelXRayExampleTestSupport.installGlobalOpenTelemetry(); + } + + @AfterEach + void tearDown() { + OtelXRayExampleTestSupport.resetGlobalOpenTelemetry(); + } + @Test void testFirstInvocation_suspendsOnWait() { var handler = new OtelXRayWaitExample(); diff --git a/otel-plugin/README.md b/otel-plugin/README.md index fb77f9a12..141218916 100644 --- a/otel-plugin/README.md +++ b/otel-plugin/README.md @@ -10,7 +10,7 @@ OpenTelemetry instrumentation plugin for the AWS Lambda Durable Execution SDK fo - **Span-per-Operation**: Each durable operation (step, wait, map, etc.) gets its own span with accurate timing - **Attempt Spans**: Each user function execution (step attempt, child context run) gets a span, including retries - **Log Correlation**: Injects `traceId`, `spanId`, and `traceSampled` into SLF4J MDC for end-to-end observability -- **Self-Contained Setup**: No manual TracerProvider configuration required beyond the exporter +- **ADOT Java Agent Setup**: `new OtelPlugin()` uses the ADOT Java agent global OpenTelemetry provider without handler-side initialization ## Installation @@ -22,7 +22,7 @@ OpenTelemetry instrumentation plugin for the AWS Lambda Durable Execution SDK fo ``` -You also need the OpenTelemetry SDK and an exporter: +If you configure your own `SdkTracerProviderBuilder`, add the OpenTelemetry SDK and an exporter: ```xml @@ -32,11 +32,14 @@ You also need the OpenTelemetry SDK and an exporter: io.opentelemetry - opentelemetry-exporter-otlp + opentelemetry-exporter-logging 1.63.0 ``` +For `new OtelPlugin()` on Lambda, use the ADOT Java agent setup below instead of initializing an exporter in your +handler. + ## Quick Start using X-Ray/CloudWatch Tracing 1. Add the ADOT Lambda Layer to your function @@ -46,16 +49,16 @@ You also need the OpenTelemetry SDK and an exporter: ### 1. ADOT Lambda Layer -This plugin uses the [AWS Distro for OpenTelemetry (ADOT) Lambda layer](https://aws-otel.github.io/docs/getting-started/lambda) for trace export. The layer provides an OTLP collector that receives spans from the plugin and exports them to X-Ray. - -> **Note:** Do NOT set `AWS_LAMBDA_EXEC_WRAPPER`. The ADOT layer's collector extension runs independently as a Lambda External Extension. The wrapper would attach the auto-instrumentation agent which creates a competing TracerProvider, causing disconnected service nodes in the X-Ray trace map. +This plugin uses the [AWS Distro for OpenTelemetry (ADOT) Lambda layer](https://aws-otel.github.io/docs/getting-started/lambda) for trace export. `OtelPlugin()` uses the global provider initialized by the ADOT Java agent and requires deterministic span ID generation to be installed through the OpenTelemetry `AutoConfigurationCustomizerProvider` SPI. The layer ARN follows the format: ``` -arn:aws:lambda::901920570463:layer:aws-otel-java-agent--ver-1-32-0:6 +arn:aws:lambda::615299751070:layer:AWSOpenTelemetryDistroJava:16 ``` +> **Note:** This layer is regional — the account ID and version vary by region. Use the ARN for your deployment region; find the current per-region ARN in the [ADOT Java instrumentation releases](https://github.com/aws-observability/aws-otel-java-instrumentation/releases/latest). + **CloudFormation / SAM:** ```yaml @@ -64,9 +67,11 @@ MyFunction: Properties: Tracing: Active Layers: - - !Sub - - arn:aws:lambda:${AWS::Region}:901920570463:layer:aws-otel-java-agent-${Arch}-ver-1-32-0:6 - - Arch: amd64 + - !Sub arn:aws:lambda:${AWS::Region}:615299751070:layer:AWSOpenTelemetryDistroJava:16 + Environment: + Variables: + AWS_LAMBDA_EXEC_WRAPPER: /opt/otel-instrument + OTEL_JAVAAGENT_EXTENSIONS: /var/task/lib/aws-durable-execution-sdk-java-plugin-otel-.jar ``` **AWS CLI:** @@ -74,9 +79,12 @@ MyFunction: ```bash aws lambda update-function-configuration \ --function-name your-function-name \ - --layers "arn:aws:lambda::901920570463:layer:aws-otel-java-agent-amd64-ver-1-32-0:6" + --layers "arn:aws:lambda::615299751070:layer:AWSOpenTelemetryDistroJava:16" \ + --environment "Variables={AWS_LAMBDA_EXEC_WRAPPER=/opt/otel-instrument,OTEL_JAVAAGENT_EXTENSIONS=/var/task/lib/aws-durable-execution-sdk-java-plugin-otel-.jar}" ``` +Set `OTEL_JAVAAGENT_EXTENSIONS` to the deployed OTel plugin jar that contains this plugin's `META-INF/services/io.opentelemetry.sdk.autoconfigure.spi.AutoConfigurationCustomizerProvider` entry. Prefer the standalone plugin dependency jar under `/var/task/lib` so the Java agent loads the extension against its own OpenTelemetry SPI classes. + ### 2. AWS X-Ray Active Tracing Enable active tracing on your Lambda function so the `_X_AMZN_TRACE_ID` environment variable is populated at invocation time. The plugin uses this header to derive deterministic trace IDs that remain consistent across all invocations of the same durable execution. @@ -104,9 +112,6 @@ MyFunction: ### 3. In Your Lambda Handler ```java -import io.opentelemetry.exporter.otlp.trace.OtlpGrpcSpanExporter; -import io.opentelemetry.sdk.trace.SdkTracerProvider; -import io.opentelemetry.sdk.trace.export.SimpleSpanProcessor; import software.amazon.lambda.durable.DurableConfig; import software.amazon.lambda.durable.DurableContext; import software.amazon.lambda.durable.DurableHandler; @@ -116,13 +121,7 @@ public class MyHandler extends DurableHandler { @Override protected DurableConfig createConfiguration() { - var otlpExporter = OtlpGrpcSpanExporter.getDefault(); - - var otelPlugin = new OtelPlugin( - SdkTracerProvider.builder() - .addSpanProcessor(SimpleSpanProcessor.create(otlpExporter))); - - return DurableConfig.builder().withPlugins(otelPlugin).build(); + return DurableConfig.builder().withPlugins(new OtelPlugin()).build(); } @Override @@ -214,7 +213,10 @@ These appear automatically in structured log output (Log4j2 JSON, Logback JSON) ### Constructor Options ```java -// Default: X-Ray context extraction, MDC enabled +// Default: X-Ray context extraction, MDC enabled, ADOT Java agent global provider +new OtelPlugin(); + +// Custom tracer provider pipeline new OtelPlugin(tracerProviderBuilder); // Custom context extractor, MDC enabled @@ -226,7 +228,7 @@ new OtelPlugin(tracerProviderBuilder, contextExtractor, enableMdc); | Parameter | Description | Default | |-----------|-------------|---------| -| `tracerProviderBuilder` | `SdkTracerProviderBuilder` with your exporter/processor configured | Required | +| `tracerProviderBuilder` | `SdkTracerProviderBuilder` with your exporter/processor configured | Not used by `new OtelPlugin()`; the default constructor uses the ADOT Java agent provider configured by the plugin SPI | | `contextExtractor` | Extracts parent trace context from the Lambda environment | `XRayContextExtractor` | | `enableMdc` | If true, injects `traceId`/`spanId`/`traceSampled` into SLF4J MDC | `true` | @@ -251,9 +253,9 @@ After deploying your function with the plugin configured: | Traces appear but are fragmented | X-Ray active tracing not enabled on the Lambda function | | Missing spans for some operations | Sampling is configured below 1.0 | | `_X_AMZN_TRACE_ID` not populated | X-Ray active tracing not enabled | -| Two service nodes in trace map | `AWS_LAMBDA_EXEC_WRAPPER` is set — remove it (see note above) | +| Plugin spans missing but Lambda/runtime spans appear | The ADOT wrapper did not initialize `GlobalOpenTelemetry`, or the plugin jar was not configured in `OTEL_JAVAAGENT_EXTENSIONS` | -> **Note on ADOT wrapper:** Do not set `AWS_LAMBDA_EXEC_WRAPPER`. The wrapper attaches the auto-instrumentation agent which creates a separate TracerProvider. Since the plugin needs its own TracerProvider (for deterministic ID generation), having two providers causes X-Ray to render disconnected service nodes. +> **Note on ADOT wrapper:** Use `AWS_LAMBDA_EXEC_WRAPPER=/opt/otel-instrument` with the `AWSOpenTelemetryDistroJava` layer. The older `/opt/otel-handler` path is not valid for this layer and can fail before the Java handler starts. ## Local Development diff --git a/otel-plugin/pom.xml b/otel-plugin/pom.xml index 949122af3..bf79192ab 100644 --- a/otel-plugin/pom.xml +++ b/otel-plugin/pom.xml @@ -40,6 +40,13 @@ ${opentelemetry.version} provided + + + io.opentelemetry + opentelemetry-sdk-extension-autoconfigure-spi + ${opentelemetry.version} + provided + diff --git a/otel-plugin/src/main/java/software/amazon/lambda/durable/otel/DeterministicIdGenerator.java b/otel-plugin/src/main/java/software/amazon/lambda/durable/otel/DeterministicIdGenerator.java index 255af2d5e..19cb4d6e0 100644 --- a/otel-plugin/src/main/java/software/amazon/lambda/durable/otel/DeterministicIdGenerator.java +++ b/otel-plugin/src/main/java/software/amazon/lambda/durable/otel/DeterministicIdGenerator.java @@ -30,6 +30,10 @@ public class DeterministicIdGenerator implements IdGenerator { private static final IdGenerator RANDOM = IdGenerator.random(); + private static final String PROPERTY_PREFIX = "software.amazon.lambda.durable.otel."; + private static final String EXTRACTED_TRACE_ID_PROPERTY = PROPERTY_PREFIX + "extractedTraceId"; + private static final String DURABLE_EXECUTION_ARN_PROPERTY = PROPERTY_PREFIX + "durableExecutionArn"; + private static final String PENDING_SPAN_OPERATION_ID_PROPERTY_PREFIX = PROPERTY_PREFIX + "pendingSpanOperationId."; private final AtomicReference extractedTraceId = new AtomicReference<>(null); private final AtomicReference arnDerivedTraceId = new AtomicReference<>(null); @@ -44,6 +48,7 @@ public class DeterministicIdGenerator implements IdGenerator { */ public void setExtractedTraceId(String traceId) { this.extractedTraceId.set(traceId); + setOrClearProperty(EXTRACTED_TRACE_ID_PROPERTY, traceId); } /** @@ -54,7 +59,8 @@ public void setExtractedTraceId(String traceId) { */ public void setDurableExecutionArn(String arn) { this.durableExecutionArn.set(arn); - this.arnDerivedTraceId.set(generateTraceIdFromArn(arn)); + this.arnDerivedTraceId.set(arn != null ? generateTraceIdFromArn(arn) : null); + setOrClearProperty(DURABLE_EXECUTION_ARN_PROPERTY, arn); } /** @@ -64,6 +70,7 @@ public void setDurableExecutionArn(String arn) { */ public void setNextSpanOperationId(String operationId) { this.pendingSpanOperationId.set(operationId); + setOrClearProperty(pendingSpanOperationIdProperty(), operationId); } /** @@ -80,11 +87,18 @@ public String generateSpanIdForOperation(String operationId) { public String generateTraceId() { // Priority 1: extracted from X-Ray header (backend propagates same Root across invocations) var extracted = extractedTraceId.get(); + if (extracted == null) { + extracted = System.getProperty(EXTRACTED_TRACE_ID_PROPERTY); + } if (extracted != null) { return extracted; } // Priority 2: deterministic from execution ARN (local tests, non-Lambda) var arnDerived = arnDerivedTraceId.get(); + if (arnDerived == null) { + var arn = System.getProperty(DURABLE_EXECUTION_ARN_PROPERTY); + arnDerived = arn != null ? generateTraceIdFromArn(arn) : null; + } if (arnDerived != null) { return arnDerived; } @@ -95,8 +109,12 @@ public String generateTraceId() { @Override public String generateSpanId() { var operationId = pendingSpanOperationId.get(); + if (operationId == null) { + operationId = System.getProperty(pendingSpanOperationIdProperty()); + } if (operationId != null) { pendingSpanOperationId.remove(); + System.clearProperty(pendingSpanOperationIdProperty()); return generateSpanIdFromOperation(operationId); } return RANDOM.generateSpanId(); @@ -113,11 +131,36 @@ private String generateTraceIdFromArn(String arn) { */ private String generateSpanIdFromOperation(String operationId) { var arn = durableExecutionArn.get(); + if (arn == null) { + arn = System.getProperty(DURABLE_EXECUTION_ARN_PROPERTY); + } var input = arn != null ? arn + ":" + operationId : operationId; var hash = sha256(input); return hash.substring(0, 16); } + static void clearSharedStateForTest() { + System.clearProperty(EXTRACTED_TRACE_ID_PROPERTY); + System.clearProperty(DURABLE_EXECUTION_ARN_PROPERTY); + System.getProperties().stringPropertyNames().stream() + .filter(name -> name.startsWith(PENDING_SPAN_OPERATION_ID_PROPERTY_PREFIX)) + .toList() + .forEach(System::clearProperty); + } + + private static String pendingSpanOperationIdProperty() { + return PENDING_SPAN_OPERATION_ID_PROPERTY_PREFIX + + Thread.currentThread().getId(); + } + + private static void setOrClearProperty(String key, String value) { + if (value == null) { + System.clearProperty(key); + } else { + System.setProperty(key, value); + } + } + private static String sha256(String input) { try { var digest = MessageDigest.getInstance("SHA-256"); diff --git a/otel-plugin/src/main/java/software/amazon/lambda/durable/otel/OtelPlugin.java b/otel-plugin/src/main/java/software/amazon/lambda/durable/otel/OtelPlugin.java index 25fde5cde..01d05410d 100644 --- a/otel-plugin/src/main/java/software/amazon/lambda/durable/otel/OtelPlugin.java +++ b/otel-plugin/src/main/java/software/amazon/lambda/durable/otel/OtelPlugin.java @@ -4,6 +4,7 @@ import static software.amazon.lambda.durable.otel.SpanAttributes.*; +import io.opentelemetry.api.GlobalOpenTelemetry; import io.opentelemetry.api.common.AttributeKey; import io.opentelemetry.api.common.Attributes; import io.opentelemetry.api.trace.Span; @@ -13,14 +14,18 @@ import io.opentelemetry.api.trace.TraceFlags; import io.opentelemetry.api.trace.TraceState; import io.opentelemetry.api.trace.Tracer; +import io.opentelemetry.api.trace.TracerProvider; import io.opentelemetry.context.Context; import io.opentelemetry.context.Scope; import io.opentelemetry.sdk.resources.Resource; import io.opentelemetry.sdk.trace.SdkTracerProvider; import io.opentelemetry.sdk.trace.SdkTracerProviderBuilder; import io.opentelemetry.semconv.ServiceAttributes; +import java.nio.file.Files; +import java.nio.file.Path; import java.time.Instant; import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.TimeUnit; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import software.amazon.lambda.durable.plugin.DurableExecutionPlugin; @@ -55,12 +60,12 @@ *

    Requires the ADOT Lambda Layer for trace export. Configure with: * *

      - *
    • Lambda Layer: {@code aws-otel-java-agent} (provides the OTLP collector extension) + *
    • Lambda Layer: {@code AWSOpenTelemetryDistroJava} (provides the ADOT Java agent and export pipeline) *
    • Tracing: Active (to populate {@code _X_AMZN_TRACE_ID}) *
    * - *

    Note: Do NOT set {@code AWS_LAMBDA_EXEC_WRAPPER}. The collector extension runs independently. The wrapper would - * attach the auto-instrumentation agent which creates a competing TracerProvider. + *

    When using {@link #OtelPlugin()}, the plugin requires {@code OtelPluginAutoConfigurationCustomizerProvider} to + * have been installed by the OpenTelemetry Java agent and uses the global provider directly. * *

    Thread-safe: uses {@link ConcurrentHashMap} for span/scope storage since the SDK runs user code on multiple * threads. @@ -73,7 +78,7 @@ public class OtelPlugin implements DurableExecutionPlugin { private static final Logger logger = LoggerFactory.getLogger(OtelPlugin.class); private static final String INSTRUMENTATION_NAME = "aws-durable-execution-sdk-java"; - private final SdkTracerProvider tracerProvider; + private final SdkTracerProvider sdkTracerProvider; private final Tracer tracer; private final DeterministicIdGenerator idGenerator; private final ContextExtractor contextExtractor; @@ -99,12 +104,13 @@ public class OtelPlugin implements DurableExecutionPlugin { *

    Uses the provided tracer provider builder. Customers configure exporters and span processors on the builder — * the plugin handles ID generation. * - *

    For ADOT layer usage, configure with an OTLP exporter: + *

    For ADOT Java agent usage, prefer {@link #OtelPlugin()} with the plugin jar configured through + * {@code OTEL_JAVAAGENT_EXTENSIONS}. Use this builder constructor when you want to own the exporter pipeline: * *

    {@code
    -     * var otlpExporter = OtlpGrpcSpanExporter.getDefault(); // sends to localhost:4317
    +     * var exporter = LoggingSpanExporter.create();
          * var plugin = new OtelPlugin(
    -     *     SdkTracerProvider.builder().addSpanProcessor(SimpleSpanProcessor.create(otlpExporter)));
    +     *     SdkTracerProvider.builder().addSpanProcessor(SimpleSpanProcessor.create(exporter)));
          * }
    * * @param tracerProviderBuilder the tracer provider builder (ID generator will be overridden) @@ -113,6 +119,16 @@ public OtelPlugin(SdkTracerProviderBuilder tracerProviderBuilder) { this(tracerProviderBuilder, new XRayContextExtractor(), true); } + /** + * Creates an OTel plugin with default settings: X-Ray context extraction and MDC enabled. + * + *

    Uses {@code GlobalOpenTelemetry} directly and assumes deterministic ID generation was installed by + * {@code OtelPluginAutoConfigurationCustomizerProvider}. + */ + public OtelPlugin() { + this(getDefaultTracerProvider(), createDefaultIdGenerator()); + } + /** * Creates an OTel plugin with a custom context extractor, MDC enabled. * @@ -139,12 +155,22 @@ public OtelPlugin( var resource = Resource.create(Attributes.of(ServiceAttributes.SERVICE_NAME, "invocation")); tracerProviderBuilder.addResource(resource); - this.tracerProvider = tracerProviderBuilder.setIdGenerator(idGenerator).build(); - this.tracer = tracerProvider.get(INSTRUMENTATION_NAME); + this.sdkTracerProvider = + tracerProviderBuilder.setIdGenerator(idGenerator).build(); + this.tracer = sdkTracerProvider.get(INSTRUMENTATION_NAME); this.contextExtractor = contextExtractor; this.enableMdc = enableMdc; } + private OtelPlugin(TracerProvider tracerProvider, DeterministicIdGenerator idGenerator) { + this.idGenerator = idGenerator; + this.sdkTracerProvider = getSdkTracerProviderForFlush(tracerProvider); + this.tracer = tracerProvider.get(INSTRUMENTATION_NAME); + + this.contextExtractor = new XRayContextExtractor(); + this.enableMdc = true; + } + // ─── Invocation hooks ──────────────────────────────────────────────── @Override @@ -156,10 +182,15 @@ public void onInvocationStart(InvocationInfo info) { // Extract trace context from environment (X-Ray header) var extractedContext = contextExtractor.extract(); + if (extractedContext == null) { + extractedContext = extractCurrentSpanContext(); + } if (extractedContext != null) { // Use the X-Ray trace ID — backend propagates same Root across all invocations idGenerator.setExtractedTraceId(extractedContext.traceId()); + } else { + idGenerator.setExtractedTraceId(null); } // If no extracted context, idGenerator falls back to ARN-derived trace ID @@ -167,7 +198,7 @@ public void onInvocationStart(InvocationInfo info) { Context parentContext; if (extractedContext != null && extractedContext.parentSpanId() != null) { // X-Ray header has parent — create the invocation span as a child of that segment. - // This connects our OTLP-exported spans to the Lambda service's X-Ray segments. + // This connects plugin spans to the Lambda service's X-Ray segments. var parentSpanContext = SpanContext.createFromRemoteParent( extractedContext.traceId(), extractedContext.parentSpanId(), @@ -228,10 +259,12 @@ public void onInvocationEnd(InvocationEndInfo info) { invocationSpan.end(); invocationSpan = null; - // Flush spans before Lambda freezes - var flushResult = tracerProvider.forceFlush().join(5, java.util.concurrent.TimeUnit.SECONDS); - if (!flushResult.isSuccess()) { - logger.warn("OTel span flush failed or timed out — some spans may be lost"); + if (sdkTracerProvider != null) { + // Flush spans before Lambda freezes + var flushResult = sdkTracerProvider.forceFlush().join(5, TimeUnit.SECONDS); + if (!flushResult.isSuccess()) { + logger.warn("OTel span flush failed or timed out — some spans may be lost"); + } } } @@ -465,4 +498,82 @@ private static String attemptSpanName(String type, String subType, String name, private static String attemptKey(String operationId, Integer attempt) { return operationId + "-" + (attempt != null ? attempt : "ctx"); } + + private static ExtractedContext extractCurrentSpanContext() { + var spanContext = Span.current().getSpanContext(); + if (!spanContext.isValid()) { + return null; + } + return new ExtractedContext(spanContext.getTraceId(), spanContext.getSpanId()); + } + + private static TracerProvider getDefaultTracerProvider() { + validateAutoConfigurationCustomizerProviderInstalled(); + + var globalTracerProvider = GlobalOpenTelemetry.getTracerProvider(); + if (globalTracerProvider == TracerProvider.noop()) { + throw new IllegalStateException("OtelPlugin() requires GlobalOpenTelemetry to be initialized by " + + "OtelPluginAutoConfigurationCustomizerProvider through the OpenTelemetry Java agent."); + } + logger.info( + "OtelPlugin initialized from existing GlobalOpenTelemetry tracer provider {}; assuming deterministic " + + "span IDs were installed through AutoConfigurationCustomizerProvider", + globalTracerProvider.getClass().getName()); + return globalTracerProvider; + } + + private static DeterministicIdGenerator createDefaultIdGenerator() { + // This is intentionally a separate instance from the SPI provider's generator. The Java agent extension and + // application may load this plugin in different class loaders, so DeterministicIdGenerator bridges invocation + // state through system properties that the SPI-installed generator can read when spans are started. + return new DeterministicIdGenerator(); + } + + private static void validateAutoConfigurationCustomizerProviderInstalled() { + if (OtelPluginAutoConfigurationState.isInstalled()) { + return; + } + throw new IllegalStateException( + "OtelPlugin() requires OtelPluginAutoConfigurationCustomizerProvider to be installed by the " + + "OpenTelemetry Java agent. Package this plugin jar as an agent extension and set " + + "OTEL_JAVAAGENT_EXTENSIONS or -Dotel.javaagent.extensions to that jar before constructing " + + "OtelPlugin(). " + + javaAgentExtensionsDiagnostic()); + } + + private static String javaAgentExtensionsDiagnostic() { + var propertyValue = System.getProperty("otel.javaagent.extensions"); + var environmentValue = System.getenv("OTEL_JAVAAGENT_EXTENSIONS"); + var configuredPath = propertyValue != null ? propertyValue : environmentValue; + return "otel.javaagent.extensions=" + + valueOrUnset(propertyValue) + + ", OTEL_JAVAAGENT_EXTENSIONS=" + + valueOrUnset(environmentValue) + + ", configured extension path exists=" + + extensionPathExists(configuredPath); + } + + private static String valueOrUnset(String value) { + return value != null ? value : ""; + } + + private static boolean extensionPathExists(String configuredPath) { + if (configuredPath == null || configuredPath.isBlank()) { + return false; + } + var firstPath = configuredPath.split(",", 2)[0]; + return Files.exists(Path.of(firstPath)); + } + + private static SdkTracerProvider getSdkTracerProviderForFlush(TracerProvider tracerProvider) { + if (tracerProvider instanceof SdkTracerProvider sdkTracerProvider) { + return sdkTracerProvider; + } + logger.info( + "OtelPlugin forceFlush is not available because GlobalOpenTelemetry provider {} is not an " + + "SdkTracerProvider visible to the application class loader; spans will rely on the " + + "provider's own flushing.", + tracerProvider.getClass().getName()); + return null; + } } diff --git a/otel-plugin/src/main/java/software/amazon/lambda/durable/otel/OtelPluginAutoConfigurationCustomizerProvider.java b/otel-plugin/src/main/java/software/amazon/lambda/durable/otel/OtelPluginAutoConfigurationCustomizerProvider.java new file mode 100644 index 000000000..23ac19ecd --- /dev/null +++ b/otel-plugin/src/main/java/software/amazon/lambda/durable/otel/OtelPluginAutoConfigurationCustomizerProvider.java @@ -0,0 +1,23 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +package software.amazon.lambda.durable.otel; + +import io.opentelemetry.sdk.autoconfigure.spi.AutoConfigurationCustomizer; +import io.opentelemetry.sdk.autoconfigure.spi.AutoConfigurationCustomizerProvider; + +/** + * Installs the durable-execution ID generator when the OpenTelemetry Java agent auto-configures the SDK. + * + * @deprecated This is a preview API that is experimental and may be changed or removed in future releases. + */ +@Deprecated +public final class OtelPluginAutoConfigurationCustomizerProvider implements AutoConfigurationCustomizerProvider { + + private static final DeterministicIdGenerator ID_GENERATOR = new DeterministicIdGenerator(); + + @Override + public void customize(AutoConfigurationCustomizer autoConfiguration) { + OtelPluginAutoConfigurationState.markInstalled(); + autoConfiguration.addTracerProviderCustomizer((builder, config) -> builder.setIdGenerator(ID_GENERATOR)); + } +} diff --git a/otel-plugin/src/main/java/software/amazon/lambda/durable/otel/OtelPluginAutoConfigurationState.java b/otel-plugin/src/main/java/software/amazon/lambda/durable/otel/OtelPluginAutoConfigurationState.java new file mode 100644 index 000000000..765489b69 --- /dev/null +++ b/otel-plugin/src/main/java/software/amazon/lambda/durable/otel/OtelPluginAutoConfigurationState.java @@ -0,0 +1,23 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +package software.amazon.lambda.durable.otel; + +final class OtelPluginAutoConfigurationState { + + private static final String INSTALLED_PROPERTY = + "software.amazon.lambda.durable.otel.autoConfigurationCustomizerProviderInstalled"; + + private OtelPluginAutoConfigurationState() {} + + static boolean isInstalled() { + return Boolean.getBoolean(INSTALLED_PROPERTY); + } + + static void markInstalled() { + System.setProperty(INSTALLED_PROPERTY, Boolean.TRUE.toString()); + } + + static void resetInstalledForTest() { + System.clearProperty(INSTALLED_PROPERTY); + } +} diff --git a/otel-plugin/src/main/resources/META-INF/services/io.opentelemetry.sdk.autoconfigure.spi.AutoConfigurationCustomizerProvider b/otel-plugin/src/main/resources/META-INF/services/io.opentelemetry.sdk.autoconfigure.spi.AutoConfigurationCustomizerProvider new file mode 100644 index 000000000..3aa2fb591 --- /dev/null +++ b/otel-plugin/src/main/resources/META-INF/services/io.opentelemetry.sdk.autoconfigure.spi.AutoConfigurationCustomizerProvider @@ -0,0 +1 @@ +software.amazon.lambda.durable.otel.OtelPluginAutoConfigurationCustomizerProvider diff --git a/otel-plugin/src/test/java/io/opentelemetry/javaagent/testing/FakeJavaAgentTracerProvider.java b/otel-plugin/src/test/java/io/opentelemetry/javaagent/testing/FakeJavaAgentTracerProvider.java new file mode 100644 index 000000000..db99b5bdb --- /dev/null +++ b/otel-plugin/src/test/java/io/opentelemetry/javaagent/testing/FakeJavaAgentTracerProvider.java @@ -0,0 +1,31 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +package io.opentelemetry.javaagent.testing; + +import io.opentelemetry.api.trace.Tracer; +import io.opentelemetry.api.trace.TracerBuilder; +import io.opentelemetry.api.trace.TracerProvider; + +public final class FakeJavaAgentTracerProvider implements TracerProvider { + + private final TracerProvider delegate; + + public FakeJavaAgentTracerProvider(TracerProvider agentTracerProvider) { + this.delegate = agentTracerProvider; + } + + @Override + public Tracer get(String instrumentationName) { + return delegate.get(instrumentationName); + } + + @Override + public Tracer get(String instrumentationName, String instrumentationVersion) { + return delegate.get(instrumentationName, instrumentationVersion); + } + + @Override + public TracerBuilder tracerBuilder(String instrumentationScopeName) { + return delegate.tracerBuilder(instrumentationScopeName); + } +} diff --git a/otel-plugin/src/test/java/software/amazon/lambda/durable/otel/DeterministicIdGeneratorTest.java b/otel-plugin/src/test/java/software/amazon/lambda/durable/otel/DeterministicIdGeneratorTest.java index bc1a5a90d..6133a3ddb 100644 --- a/otel-plugin/src/test/java/software/amazon/lambda/durable/otel/DeterministicIdGeneratorTest.java +++ b/otel-plugin/src/test/java/software/amazon/lambda/durable/otel/DeterministicIdGeneratorTest.java @@ -4,6 +4,7 @@ import static org.junit.jupiter.api.Assertions.*; +import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -13,9 +14,15 @@ class DeterministicIdGeneratorTest { @BeforeEach void setUp() { + DeterministicIdGenerator.clearSharedStateForTest(); generator = new DeterministicIdGenerator(); } + @AfterEach + void tearDown() { + DeterministicIdGenerator.clearSharedStateForTest(); + } + @Test void generateTraceId_withoutArn_returnsRandom() { var id1 = generator.generateTraceId(); @@ -120,6 +127,18 @@ void generateSpanIdForOperation_isDeterministic() { assertEquals(id1, id2); } + @Test + void generatedIds_areSharedAcrossGeneratorInstances() { + var pluginGenerator = new DeterministicIdGenerator(); + var agentGenerator = new DeterministicIdGenerator(); + + pluginGenerator.setDurableExecutionArn("arn:exec1"); + pluginGenerator.setNextSpanOperationId("op-1"); + + assertEquals(pluginGenerator.generateTraceId(), agentGenerator.generateTraceId()); + assertEquals(pluginGenerator.generateSpanIdForOperation("op-1"), agentGenerator.generateSpanId()); + } + @Test void traceId_isValidHex() { generator.setDurableExecutionArn("arn:exec1"); diff --git a/otel-plugin/src/test/java/software/amazon/lambda/durable/otel/OtelPluginIntegrationTest.java b/otel-plugin/src/test/java/software/amazon/lambda/durable/otel/OtelPluginIntegrationTest.java index d30555ecd..200660024 100644 --- a/otel-plugin/src/test/java/software/amazon/lambda/durable/otel/OtelPluginIntegrationTest.java +++ b/otel-plugin/src/test/java/software/amazon/lambda/durable/otel/OtelPluginIntegrationTest.java @@ -4,7 +4,12 @@ import static org.junit.jupiter.api.Assertions.*; +import io.opentelemetry.api.GlobalOpenTelemetry; +import io.opentelemetry.api.OpenTelemetry; import io.opentelemetry.api.trace.StatusCode; +import io.opentelemetry.context.propagation.ContextPropagators; +import io.opentelemetry.javaagent.testing.FakeJavaAgentTracerProvider; +import io.opentelemetry.sdk.OpenTelemetrySdk; import io.opentelemetry.sdk.testing.exporter.InMemorySpanExporter; import io.opentelemetry.sdk.trace.SdkTracerProvider; import io.opentelemetry.sdk.trace.data.SpanData; @@ -13,6 +18,7 @@ import java.time.Duration; import java.util.List; import java.util.concurrent.atomic.AtomicInteger; +import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import software.amazon.awssdk.services.lambda.model.ErrorObject; @@ -34,6 +40,8 @@ class OtelPluginIntegrationTest { @BeforeEach void setUp() { + DeterministicIdGenerator.clearSharedStateForTest(); + OtelPluginAutoConfigurationState.resetInstalledForTest(); spanExporter = InMemorySpanExporter.create(); var plugin = new OtelPlugin( @@ -44,6 +52,13 @@ void setUp() { otelConfig = DurableConfig.builder().withPlugins(plugin).build(); } + @AfterEach + void tearDown() { + GlobalOpenTelemetry.resetForTest(); + DeterministicIdGenerator.clearSharedStateForTest(); + OtelPluginAutoConfigurationState.resetInstalledForTest(); + } + @Test void simpleStep_producesInvocationAndOperationAndAttemptSpans() { var runner = LocalDurableTestRunner.create( @@ -67,6 +82,76 @@ void simpleStep_producesInvocationAndOperationAndAttemptSpans() { assertTrue(spans.stream().allMatch(s -> s.getTraceId().equals(traceId))); } + @Test + void defaultConstructor_usesGlobalSdkTracerProviderDirectly() { + OtelPluginAutoConfigurationState.markInstalled(); + GlobalOpenTelemetry.resetForTest(); + var globalExporter = InMemorySpanExporter.create(); + var globalTracerProvider = SdkTracerProvider.builder() + .addSpanProcessor(SimpleSpanProcessor.create(globalExporter)) + .build(); + OpenTelemetrySdk.builder().setTracerProvider(globalTracerProvider).buildAndRegisterGlobal(); + + var defaultConfig = + DurableConfig.builder().withPlugins(new OtelPlugin()).build(); + var runner = LocalDurableTestRunner.create( + String.class, + (input, ctx) -> ctx.step("global-step", String.class, stepCtx -> "Hello " + input), + defaultConfig); + + var result = runner.runUntilComplete("World"); + assertEquals(ExecutionStatus.SUCCEEDED, result.getStatus()); + + var spans = globalExporter.getFinishedSpanItems(); + assertTrue(spans.size() >= 3, "Expected at least 3 spans, got " + spans.size()); + assertSpanExists(spans, "invocation"); + assertSpanExists(spans, "global-step"); + assertSpanExists(spans, "global-step attempt 1"); + + var traceId = spans.get(0).getTraceId(); + assertTrue(spans.stream().allMatch(span -> span.getTraceId().equals(traceId))); + } + + @Test + void defaultConstructor_usesJavaAgentGlobalTracerProviderDirectly_withSeparateAutoConfiguredIdGenerator() { + OtelPluginAutoConfigurationState.markInstalled(); + GlobalOpenTelemetry.resetForTest(); + var globalExporter = InMemorySpanExporter.create(); + var javaAgentIdGenerator = new DeterministicIdGenerator(); + var sdkTracerProvider = SdkTracerProvider.builder() + .setIdGenerator(javaAgentIdGenerator) + .addSpanProcessor(SimpleSpanProcessor.create(globalExporter)) + .build(); + var javaAgentTracerProvider = new FakeJavaAgentTracerProvider(sdkTracerProvider); + GlobalOpenTelemetry.set(new OpenTelemetry() { + @Override + public io.opentelemetry.api.trace.TracerProvider getTracerProvider() { + return javaAgentTracerProvider; + } + + @Override + public ContextPropagators getPropagators() { + return ContextPropagators.noop(); + } + }); + + var defaultConfig = + DurableConfig.builder().withPlugins(new OtelPlugin()).build(); + var runner = LocalDurableTestRunner.create( + String.class, + (input, ctx) -> ctx.step("javaagent-step", String.class, stepCtx -> "Hello " + input), + defaultConfig); + + var result = runner.runUntilComplete("World"); + assertEquals(ExecutionStatus.SUCCEEDED, result.getStatus()); + + var spans = globalExporter.getFinishedSpanItems(); + assertTrue(spans.size() >= 3, "Expected at least 3 spans, got " + spans.size()); + assertSpanExists(spans, "invocation"); + assertSpanExists(spans, "javaagent-step"); + assertSpanExists(spans, "javaagent-step attempt 1"); + } + @Test void multipleSteps_producesSpansForEach() { var runner = LocalDurableTestRunner.create( diff --git a/otel-plugin/src/test/java/software/amazon/lambda/durable/otel/OtelPluginTest.java b/otel-plugin/src/test/java/software/amazon/lambda/durable/otel/OtelPluginTest.java index 4945c9a34..3c4e2c9e9 100644 --- a/otel-plugin/src/test/java/software/amazon/lambda/durable/otel/OtelPluginTest.java +++ b/otel-plugin/src/test/java/software/amazon/lambda/durable/otel/OtelPluginTest.java @@ -3,15 +3,39 @@ package software.amazon.lambda.durable.otel; import static org.junit.jupiter.api.Assertions.*; - +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import io.opentelemetry.api.GlobalOpenTelemetry; +import io.opentelemetry.api.OpenTelemetry; +import io.opentelemetry.api.trace.Span; +import io.opentelemetry.api.trace.SpanContext; import io.opentelemetry.api.trace.SpanKind; import io.opentelemetry.api.trace.StatusCode; +import io.opentelemetry.api.trace.TraceFlags; +import io.opentelemetry.api.trace.TraceState; +import io.opentelemetry.context.propagation.ContextPropagators; +import io.opentelemetry.javaagent.testing.FakeJavaAgentTracerProvider; +import io.opentelemetry.sdk.OpenTelemetrySdk; +import io.opentelemetry.sdk.autoconfigure.spi.AutoConfigurationCustomizer; +import io.opentelemetry.sdk.autoconfigure.spi.AutoConfigurationCustomizerProvider; +import io.opentelemetry.sdk.autoconfigure.spi.ConfigProperties; import io.opentelemetry.sdk.testing.exporter.InMemorySpanExporter; +import io.opentelemetry.sdk.trace.IdGenerator; import io.opentelemetry.sdk.trace.SdkTracerProvider; +import io.opentelemetry.sdk.trace.SdkTracerProviderBuilder; import io.opentelemetry.sdk.trace.export.SimpleSpanProcessor; import java.time.Instant; +import java.util.ServiceLoader; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.BiFunction; +import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; import software.amazon.lambda.durable.execution.SuspendExecutionException; import software.amazon.lambda.durable.plugin.*; @@ -22,6 +46,8 @@ class OtelPluginTest { @BeforeEach void setUp() { + DeterministicIdGenerator.clearSharedStateForTest(); + OtelPluginAutoConfigurationState.resetInstalledForTest(); spanExporter = InMemorySpanExporter.create(); plugin = new OtelPlugin( @@ -30,6 +56,213 @@ void setUp() { false); } + @AfterEach + void tearDown() { + GlobalOpenTelemetry.resetForTest(); + DeterministicIdGenerator.clearSharedStateForTest(); + OtelPluginAutoConfigurationState.resetInstalledForTest(); + } + + @Test + void defaultConstructor_throwsWhenAutoConfigurationCustomizerProviderIsNotInstalled() { + GlobalOpenTelemetry.resetForTest(); + + var error = assertThrows(IllegalStateException.class, OtelPlugin::new); + + assertTrue(error.getMessage().contains("OtelPluginAutoConfigurationCustomizerProvider")); + assertTrue(error.getMessage().contains("OTEL_JAVAAGENT_EXTENSIONS")); + } + + @Test + void defaultConstructor_throwsWhenGlobalOpenTelemetryIsNotInitializedBySpi() { + OtelPluginAutoConfigurationState.markInstalled(); + GlobalOpenTelemetry.resetForTest(); + + var error = assertThrows(IllegalStateException.class, OtelPlugin::new); + + assertTrue(error.getMessage().contains("GlobalOpenTelemetry")); + assertTrue(error.getMessage().contains("OtelPluginAutoConfigurationCustomizerProvider")); + } + + @Test + void defaultConstructor_usesGlobalSdkTracerProviderDirectly() { + OtelPluginAutoConfigurationState.markInstalled(); + GlobalOpenTelemetry.resetForTest(); + var globalExporter = InMemorySpanExporter.create(); + var globalTracerProvider = SdkTracerProvider.builder() + .addSpanProcessor(SimpleSpanProcessor.create(globalExporter)) + .build(); + OpenTelemetrySdk.builder().setTracerProvider(globalTracerProvider).buildAndRegisterGlobal(); + + var defaultPlugin = new OtelPlugin(); + defaultPlugin.onInvocationStart(new InvocationInfo("req-1", "arn:exec1", true)); + defaultPlugin.onOperationStart( + new OperationInfo("op-1", "step", "STEP", "Step", null, Instant.now(), null, false)); + defaultPlugin.onOperationEnd(new OperationEndInfo( + "op-1", "step", "STEP", "Step", null, Instant.now(), Instant.now(), "SUCCEEDED", false, null)); + defaultPlugin.onInvocationEnd( + new InvocationEndInfo("req-1", "arn:exec1", true, InvocationStatus.SUCCEEDED, null)); + + var spans = globalExporter.getFinishedSpanItems(); + assertEquals(2, spans.size()); + assertTrue(spans.stream().anyMatch(span -> span.getName().equals("invocation"))); + assertTrue(spans.stream().anyMatch(span -> span.getName().equals("step"))); + var traceId = spans.get(0).getTraceId(); + assertTrue(spans.stream().allMatch(span -> span.getTraceId().equals(traceId))); + } + + @Test + void defaultConstructor_doesNotReplaceGlobalSdkTracerProviderIdGenerator() { + OtelPluginAutoConfigurationState.markInstalled(); + GlobalOpenTelemetry.resetForTest(); + var globalExporter = InMemorySpanExporter.create(); + var idGenerator = new CountingIdGenerator(); + var globalTracerProvider = SdkTracerProvider.builder() + .setIdGenerator(idGenerator) + .addSpanProcessor(SimpleSpanProcessor.create(globalExporter)) + .build(); + OpenTelemetrySdk.builder().setTracerProvider(globalTracerProvider).buildAndRegisterGlobal(); + + var defaultPlugin = new OtelPlugin(); + defaultPlugin.onInvocationStart(new InvocationInfo("req-1", "arn:exec1", true)); + defaultPlugin.onOperationStart( + new OperationInfo("op-1", "step", "STEP", "Step", null, Instant.now(), null, false)); + defaultPlugin.onOperationEnd(new OperationEndInfo( + "op-1", "step", "STEP", "Step", null, Instant.now(), Instant.now(), "SUCCEEDED", false, null)); + defaultPlugin.onInvocationEnd( + new InvocationEndInfo("req-1", "arn:exec1", true, InvocationStatus.SUCCEEDED, null)); + + var stepSpan = globalExporter.getFinishedSpanItems().stream() + .filter(span -> span.getName().equals("step")) + .findFirst() + .orElseThrow(); + assertEquals("0000000000000002", stepSpan.getSpanId()); + } + + @Test + void defaultConstructor_usesJavaAgentGlobalTracerProviderDirectly_withSeparateAutoConfiguredIdGenerator() { + OtelPluginAutoConfigurationState.markInstalled(); + GlobalOpenTelemetry.resetForTest(); + var globalExporter = InMemorySpanExporter.create(); + var javaAgentIdGenerator = new DeterministicIdGenerator(); + var sdkTracerProvider = SdkTracerProvider.builder() + .setIdGenerator(javaAgentIdGenerator) + .addSpanProcessor(SimpleSpanProcessor.create(globalExporter)) + .build(); + var javaAgentTracerProvider = new FakeJavaAgentTracerProvider(sdkTracerProvider); + GlobalOpenTelemetry.set(new OpenTelemetry() { + @Override + public io.opentelemetry.api.trace.TracerProvider getTracerProvider() { + return javaAgentTracerProvider; + } + + @Override + public ContextPropagators getPropagators() { + return ContextPropagators.noop(); + } + }); + + var defaultPlugin = new OtelPlugin(); + defaultPlugin.onInvocationStart(new InvocationInfo("req-1", "arn:exec1", true)); + defaultPlugin.onOperationStart( + new OperationInfo("op-1", "step", "STEP", "Step", null, Instant.now(), null, false)); + defaultPlugin.onOperationEnd(new OperationEndInfo( + "op-1", "step", "STEP", "Step", null, Instant.now(), Instant.now(), "SUCCEEDED", false, null)); + defaultPlugin.onInvocationEnd( + new InvocationEndInfo("req-1", "arn:exec1", true, InvocationStatus.SUCCEEDED, null)); + + var spans = globalExporter.getFinishedSpanItems(); + assertEquals(2, spans.size()); + assertTrue(spans.stream().anyMatch(span -> span.getName().equals("invocation"))); + assertTrue(spans.stream().anyMatch(span -> span.getName().equals("step"))); + var expectedIds = new DeterministicIdGenerator(); + expectedIds.setDurableExecutionArn("arn:exec1"); + var stepSpan = spans.stream() + .filter(span -> span.getName().equals("step")) + .findFirst() + .orElseThrow(); + assertEquals(expectedIds.generateSpanIdForOperation("op-1"), stepSpan.getSpanId()); + } + + @Test + void autoConfigurationCustomizerProvider_installsSharedDeterministicIdGenerator() { + OtelPluginAutoConfigurationState.resetInstalledForTest(); + var exporter = InMemorySpanExporter.create(); + var autoConfiguration = mock(AutoConfigurationCustomizer.class); + when(autoConfiguration.addTracerProviderCustomizer(any())).thenReturn(autoConfiguration); + + new OtelPluginAutoConfigurationCustomizerProvider().customize(autoConfiguration); + assertTrue(OtelPluginAutoConfigurationState.isInstalled()); + + @SuppressWarnings("unchecked") + var customizer = ArgumentCaptor.forClass(BiFunction.class); + verify(autoConfiguration).addTracerProviderCustomizer(customizer.capture()); + + var pluginGenerator = new DeterministicIdGenerator(); + pluginGenerator.setDurableExecutionArn("arn:spi"); + pluginGenerator.setNextSpanOperationId("op-spi"); + + @SuppressWarnings("unchecked") + var tracerProviderCustomizer = + (BiFunction) + customizer.getValue(); + var tracerProvider = tracerProviderCustomizer + .apply(SdkTracerProvider.builder().addSpanProcessor(SimpleSpanProcessor.create(exporter)), null) + .build(); + + var span = tracerProvider.get("test").spanBuilder("step").startSpan(); + span.end(); + tracerProvider.forceFlush().join(5, TimeUnit.SECONDS); + + var spans = exporter.getFinishedSpanItems(); + assertEquals(1, spans.size()); + assertEquals( + pluginGenerator.generateSpanIdForOperation("op-spi"), + spans.get(0).getSpanId()); + } + + private static final class CountingIdGenerator implements IdGenerator { + + private final AtomicInteger traceIds = new AtomicInteger(); + private final AtomicInteger spanIds = new AtomicInteger(); + + @Override + public String generateTraceId() { + return String.format("%032x", traceIds.incrementAndGet()); + } + + @Override + public String generateSpanId() { + return String.format("%016x", spanIds.incrementAndGet()); + } + } + + @Test + void autoConfigurationCustomizerProvider_isRegisteredAsServiceProvider() { + assertTrue(ServiceLoader.load(AutoConfigurationCustomizerProvider.class).stream() + .anyMatch(provider -> provider.type().equals(OtelPluginAutoConfigurationCustomizerProvider.class))); + } + + @Test + void invocationStart_usesCurrentSpanContext_whenExtractorReturnsNull() { + var traceId = "5759e988bd862e3fe1be46a994272793"; + var parentSpanId = "53995c3f42cd8ad8"; + var parentSpanContext = + SpanContext.create(traceId, parentSpanId, TraceFlags.getSampled(), TraceState.getDefault()); + + try (var ignored = Span.wrap(parentSpanContext).makeCurrent()) { + plugin.onInvocationStart(new InvocationInfo("req-1", "arn:exec1", true)); + } + plugin.onInvocationEnd(new InvocationEndInfo("req-1", "arn:exec1", true, InvocationStatus.SUCCEEDED, null)); + + var invocationSpan = spanExporter.getFinishedSpanItems().stream() + .filter(span -> span.getName().equals("invocation")) + .findFirst() + .orElseThrow(); + assertEquals(traceId, invocationSpan.getTraceId()); + assertEquals(parentSpanId, invocationSpan.getParentSpanId()); + } + @Test void invocationStart_and_end_createsSpan() { plugin.onInvocationStart(new InvocationInfo(