diff --git a/conformance-tests/src/main/java/plugin/PluginFaultyAndHealthy.java b/conformance-tests/src/main/java/plugin/PluginFaultyAndHealthy.java index 744b6aa1f..34ca77378 100644 --- a/conformance-tests/src/main/java/plugin/PluginFaultyAndHealthy.java +++ b/conformance-tests/src/main/java/plugin/PluginFaultyAndHealthy.java @@ -18,9 +18,9 @@ * *

A single greeting step configured with TWO plugins registered together, in order: first a faulty plugin whose * every exercised hook (invocation-start, operation-start, attempt-start, attempt-end, operation-end, invocation-end) - * logs a record then throws, then a healthy plugin that logs the corresponding six records normally. The SDK's {@code - * PluginRunner} isolates each plugin at every hook boundary (swallows the faulty plugin's exceptions), so the healthy - * plugin still receives every hook and the execution result/history are identical to running without the faulty + * logs a record then throws, then a healthy plugin that logs the corresponding six records normally. The SDK's + * {@code PluginRunner} isolates each plugin at every hook boundary (swallows the faulty plugin's exceptions), so the + * healthy plugin still receives every hook and the execution result/history are identical to running without the faulty * plugin. Attempt boundaries are the real user-function hooks ({@code onUserFunctionStart}/{@code onUserFunctionEnd}, * filtered to step attempts); the healthy attempt-end reports the SDK's real success/failure outcome. */ diff --git a/conformance-tests/src/main/java/plugin/PluginParallelBranchHooks.java b/conformance-tests/src/main/java/plugin/PluginParallelBranchHooks.java index f4292c447..4931c2e3b 100644 --- a/conformance-tests/src/main/java/plugin/PluginParallelBranchHooks.java +++ b/conformance-tests/src/main/java/plugin/PluginParallelBranchHooks.java @@ -20,8 +20,8 @@ * *

A parallel operation named "parallel" with two branches (max-concurrency 1, so they run sequentially in index * order); each branch returns a constant directly. The plugin, filtering to parallel-branch operations, logs fn-start - * and fn-end (with outcome) from the real user-function hooks, carrying the branch operation id and the parallel - * parent id. These hooks run on the branch's own thread, so start-before-end order per branch is deterministic. + * and fn-end (with outcome) from the real user-function hooks, carrying the branch operation id and the parallel parent + * id. These hooks run on the branch's own thread, so start-before-end order per branch is deterministic. */ @SuppressWarnings("deprecation") public class PluginParallelBranchHooks extends DurableHandler> { diff --git a/conformance-tests/src/main/java/plugin/PluginReplayFlags.java b/conformance-tests/src/main/java/plugin/PluginReplayFlags.java index 0a061f76a..73e8b49fd 100644 --- a/conformance-tests/src/main/java/plugin/PluginReplayFlags.java +++ b/conformance-tests/src/main/java/plugin/PluginReplayFlags.java @@ -17,10 +17,10 @@ /** * 10-13: Non-terminal operations replay with replay=true; terminal operations are not re-emitted. * - *

Two sequential steps. Step A succeeds on its first attempt (terminal before the retry invocation). Step B fails - * on its first attempt and succeeds on the second, using the SDK's built-in exponential-backoff retry strategy - * (~1s delay). The plugin, filtering to step-type operations, logs operation-start with the SDK's is-replayed - * indicator ({@code OperationInfo#isReplay()}) and operation-end with the terminal status. + *

Two sequential steps. Step A succeeds on its first attempt (terminal before the retry invocation). Step B fails on + * its first attempt and succeeds on the second, using the SDK's built-in exponential-backoff retry strategy (~1s + * delay). The plugin, filtering to step-type operations, logs operation-start with the SDK's is-replayed indicator + * ({@code OperationInfo#isReplay()}) and operation-end with the terminal status. */ @SuppressWarnings("deprecation") public class PluginReplayFlags extends DurableHandler { diff --git a/conformance-tests/src/main/java/plugin/PluginRetryExhaustion.java b/conformance-tests/src/main/java/plugin/PluginRetryExhaustion.java index 642f99a03..782d22474 100644 --- a/conformance-tests/src/main/java/plugin/PluginRetryExhaustion.java +++ b/conformance-tests/src/main/java/plugin/PluginRetryExhaustion.java @@ -18,8 +18,8 @@ /** * 10-15: Attempt hooks fire for every attempt until exhaustion, then operation-end reports FAILED. * - *

A single step that always throws, configured with the SDK's built-in exponential-backoff retry strategy allowing - * 2 total attempts (1 initial + 1 retry, ~1s delay). The plugin, filtering to step-type operations, logs attempt-start + *

A single step that always throws, configured with the SDK's built-in exponential-backoff retry strategy allowing 2 + * total attempts (1 initial + 1 retry, ~1s delay). The plugin, filtering to step-type operations, logs attempt-start * and attempt-end (with outcome) from the real user-function hooks (which carry the 1-based attempt number) and * operation-end when the step reaches its terminal FAILED status. */ diff --git a/conformance-tests/src/main/java/plugin/PluginSupport.java b/conformance-tests/src/main/java/plugin/PluginSupport.java index 7260a0c43..474f94e6c 100644 --- a/conformance-tests/src/main/java/plugin/PluginSupport.java +++ b/conformance-tests/src/main/java/plugin/PluginSupport.java @@ -5,16 +5,19 @@ /** * Shared helpers for the plugin conformance handlers (requirements 10-8..10-18). * - *

Every plugin captures the durable execution ARN from the invocation-start hook's info parameter and stamps it as - * a top-level {@code durableExecutionArn} field on every stdout JSON record, so the runner's execution-scoped - * CloudWatch filter ({@code $.durableExecutionArn = ""}) locates the records. These helpers only format that - * field and classify operation types reported by the real SDK; no behavior is fabricated here. + *

Every plugin captures the durable execution ARN from the invocation-start hook's info parameter and stamps it as a + * top-level {@code durableExecutionArn} field on every stdout JSON record, so the runner's execution-scoped CloudWatch + * filter ({@code $.durableExecutionArn = ""}) locates the records. These helpers only format that field and + * classify operation types reported by the real SDK; no behavior is fabricated here. */ final class PluginSupport { private PluginSupport() {} - /** Operation type token for step operations as reported by {@code OperationInfo#type()} (AWS SDK {@code OperationType}). */ + /** + * Operation type token for step operations as reported by {@code OperationInfo#type()} (AWS SDK + * {@code OperationType}). + */ static boolean isStep(String type) { return "STEP".equals(type); } diff --git a/conformance-tests/src/main/java/plugin/PluginWaitOperationHooks.java b/conformance-tests/src/main/java/plugin/PluginWaitOperationHooks.java index ab0fe671b..94b7ea5c3 100644 --- a/conformance-tests/src/main/java/plugin/PluginWaitOperationHooks.java +++ b/conformance-tests/src/main/java/plugin/PluginWaitOperationHooks.java @@ -16,8 +16,8 @@ * 10-10: Plugin operation-start and operation-end hooks fire for wait-type operations. * *

A single 2-second wait. The plugin, filtering to wait-type operations, logs operation-start when the wait's - * STARTED checkpoint is observed and operation-end with the terminal status. The type token is normalized to - * upper-case (WAIT). + * STARTED checkpoint is observed and operation-end with the terminal status. The type token is normalized to upper-case + * (WAIT). */ @SuppressWarnings("deprecation") public class PluginWaitOperationHooks extends DurableHandler { diff --git a/examples/README.md b/examples/README.md index f10eac11d..3c84477e6 100644 --- a/examples/README.md +++ b/examples/README.md @@ -97,6 +97,8 @@ mvn test -Dtest=CloudBasedIntegrationTest \ | [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 | | [OtelXRayStepExample](src/main/java/software/amazon/lambda/durable/examples/otel/OtelXRayStepExample.java) | Export step spans to X-Ray through the ADOT Lambda Layer | +| [OtelXRayExecutionStepExample](src/main/java/software/amazon/lambda/durable/examples/otel/OtelXRayExecutionStepExample.java) | Export spans to X-Ray with `new ExecutionOtelPlugin()` using workflow-rooted trace structure | +| [OtelXRayExecutionWaitExample](src/main/java/software/amazon/lambda/durable/examples/otel/OtelXRayExecutionWaitExample.java) | Trace a step-wait-step workflow with ExecutionOtelPlugin across Lambda invocations | | [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 | | [OtelXRayParallelExample](src/main/java/software/amazon/lambda/durable/examples/otel/OtelXRayParallelExample.java) | Trace parallel branches and branch steps in X-Ray | 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 deleted file mode 100644 index 9dee0c7bf..000000000 --- a/examples/src/main/java/software/amazon/lambda/durable/examples/otel/OtelXRayDefaultConstructorExample.java +++ /dev/null @@ -1,37 +0,0 @@ -// 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.InvocationOtelPlugin; - -/** - * OTel + X-Ray example that uses the no-arg plugin constructor. - * - *

{@link InvocationOtelPlugin#InvocationOtelPlugin()} 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 InvocationOtelPlugin()).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/OtelXRayExecutionStepExample.java b/examples/src/main/java/software/amazon/lambda/durable/examples/otel/OtelXRayExecutionStepExample.java new file mode 100644 index 000000000..f01bfa122 --- /dev/null +++ b/examples/src/main/java/software/amazon/lambda/durable/examples/otel/OtelXRayExecutionStepExample.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.ExecutionOtelPlugin; + +/** + * OTel + X-Ray example using the ExecutionOtelPlugin with the no-arg constructor. + * + *

{@link ExecutionOtelPlugin#ExecutionOtelPlugin()} uses the global provider initialized by the ADOT Java agent. The + * ExecutionOtelPlugin renders the Workflow span as the trace root with operations as siblings of the invocation span. + */ +@ExampleTemplate(tracing = true, javaAgent = true) +public class OtelXRayExecutionStepExample extends DurableHandler { + + @Override + protected DurableConfig createConfiguration() { + return DurableConfig.builder().withPlugins(new ExecutionOtelPlugin()).build(); + } + + @Override + public String handleRequest(GreetingRequest input, DurableContext context) { + context.getLogger().info("Starting OTel X-Ray execution view example for {}", input.getName()); + + var greeting = context.step("exec-create-greeting", String.class, stepCtx -> "Hello, " + input.getName()); + + var result = context.step("exec-transform", String.class, stepCtx -> greeting.toUpperCase() + "!"); + + context.getLogger().info("OTel X-Ray execution view example complete: {}", result); + return result; + } +} diff --git a/examples/src/main/java/software/amazon/lambda/durable/examples/otel/OtelXRayExecutionWaitExample.java b/examples/src/main/java/software/amazon/lambda/durable/examples/otel/OtelXRayExecutionWaitExample.java new file mode 100644 index 000000000..c44639499 --- /dev/null +++ b/examples/src/main/java/software/amazon/lambda/durable/examples/otel/OtelXRayExecutionWaitExample.java @@ -0,0 +1,40 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +package software.amazon.lambda.durable.examples.otel; + +import java.time.Duration; +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.ExecutionOtelPlugin; + +/** + * OTel + X-Ray example using ExecutionOtelPlugin with a step → wait → step pattern. + * + *

Exercises the multi-invocation tracing scenario with the workflow-rooted trace structure. The Workflow span is + * only exported on the terminal invocation, producing a clean single-execution trace. + */ +@ExampleTemplate(tracing = true, javaAgent = true) +public class OtelXRayExecutionWaitExample extends DurableHandler { + + @Override + protected DurableConfig createConfiguration() { + return DurableConfig.builder().withPlugins(new ExecutionOtelPlugin()).build(); + } + + @Override + public String handleRequest(GreetingRequest input, DurableContext context) { + context.getLogger().info("Starting OTel X-Ray execution view wait example for {}", input.getName()); + + var before = context.step("exec-before-wait", String.class, stepCtx -> "Prepared: " + input.getName()); + + context.wait("exec-pause", Duration.ofSeconds(5)); + + var after = context.step("exec-after-wait", String.class, stepCtx -> before + " | Resumed and completed"); + + context.getLogger().info("OTel X-Ray execution view wait example complete: {}", after); + return after; + } +} 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..8f87e71f5 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 @@ -127,35 +127,8 @@ void simpleSteps_producesUnifiedTraceInXRay() throws Exception { // 2. Wait for X-Ray ingestion Thread.sleep(XRAY_INGESTION_DELAY.toMillis()); - // 3. Query X-Ray for the trace - var traces = queryTracesWithRetry(startTime, Instant.now(), "otel-xray-step-example"); - - assertFalse(traces.isEmpty(), "Expected at least one trace in X-Ray after execution"); - - // Get full trace details (batch in groups of 5 — X-Ray API limit) - var traceIds = traces.stream().map(TraceSummary::id).toList(); - var allTraces = new java.util.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()); - } - - // Find the trace that contains our durable spans - var durableTrace = allTraces.stream() - .filter(trace -> trace.segments().stream().anyMatch(seg -> segmentContains(seg, "create-greeting"))) - .findFirst() - .orElse(null); - - assertNotNull( - durableTrace, - "Expected to find a trace with create-greeting segment. " + "Found " + traces.size() - + " traces in the time window. Segment names: " - + allTraces.stream() - .flatMap(t -> t.segments().stream()) - .map(seg -> getSegmentName(seg)) - .collect(Collectors.joining(", "))); + // 3. Query X-Ray for the trace, retrying until durable spans appear + var durableTrace = queryTraceWithDurableSpans(startTime, "otel-xray-step-example", "create-greeting"); // 5. Verify span structure var segmentDocuments = @@ -198,31 +171,8 @@ void waitAndResume_producesUnifiedTraceAcrossInvocations() throws Exception { // 2. Wait for X-Ray ingestion (extra time since multi-invocation takes longer) Thread.sleep(XRAY_INGESTION_DELAY.plus(Duration.ofSeconds(5)).toMillis()); - // 3. Query X-Ray for the trace - var traces = queryTracesWithRetry(startTime, Instant.now(), "otel-xray-wait-example"); - - assertFalse(traces.isEmpty(), "Expected at least one trace in X-Ray after multi-invocation execution"); - - // Get full trace details (batch in groups of 5 — X-Ray API limit) - var traceIds = traces.stream().map(TraceSummary::id).toList(); - var allTraces = new java.util.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()); - } - - // Find the trace containing our durable spans - var durableTrace = allTraces.stream() - .filter(trace -> trace.segments().stream().anyMatch(seg -> segmentContains(seg, "before-wait"))) - .findFirst() - .orElse(null); - - assertNotNull( - durableTrace, - "Expected to find a trace with before-wait segment. " + "Found " + traces.size() - + " traces in the time window."); + // 3. Query X-Ray for the trace, retrying until durable spans appear + var durableTrace = queryTraceWithDurableSpans(startTime, "otel-xray-wait-example", "before-wait"); // 4. Verify multi-invocation trace structure var segmentDocuments = @@ -330,4 +280,50 @@ private static int countOccurrences(String text, String substring) { private static String summarizeSegments(List segmentDocuments) { return extractSegmentNames(segmentDocuments).stream().collect(Collectors.joining(", ", "[", "]")); } + + /** + * Queries X-Ray for a trace containing durable spans, retrying until the expected span appears or timeout is + * reached. Handles eventual consistency where the trace exists but OTLP-exported spans haven't been ingested yet. + */ + private software.amazon.awssdk.services.xray.model.Trace queryTraceWithDurableSpans( + Instant startTime, String functionName, String expectedSpanName) throws InterruptedException { + var maxAttempts = 5; + for (int attempt = 1; attempt <= maxAttempts; attempt++) { + var traces = queryTracesWithRetry(startTime, Instant.now(), functionName); + if (traces.isEmpty()) { + fail("Expected at least one trace in X-Ray after execution of " + functionName); + } + + var traceIds = traces.stream().map(TraceSummary::id).toList(); + var allTraces = new java.util.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()); + } + + var durableTrace = allTraces.stream() + .filter(trace -> trace.segments().stream().anyMatch(seg -> segmentContains(seg, expectedSpanName))) + .findFirst() + .orElse(null); + + if (durableTrace != null) { + return durableTrace; + } + + if (attempt < maxAttempts) { + var segmentNames = allTraces.stream() + .flatMap(t -> t.segments().stream()) + .map(CloudBasedOtelIntegrationTest::getSegmentName) + .collect(Collectors.joining(", ")); + System.out.println("⏳ Trace found but missing '" + expectedSpanName + "' span (attempt " + attempt + "/" + + maxAttempts + "). Current segments: " + segmentNames + ". Retrying in 10s..."); + Thread.sleep(XRAY_RETRY_DELAY.toMillis()); + } + } + + fail("Expected to find a trace with '" + expectedSpanName + "' span after " + maxAttempts + " attempts"); + return null; // unreachable + } } diff --git a/examples/src/test/java/software/amazon/lambda/durable/examples/otel/OtelXRayExecutionStepExampleTest.java b/examples/src/test/java/software/amazon/lambda/durable/examples/otel/OtelXRayExecutionStepExampleTest.java new file mode 100644 index 000000000..8febaf3f0 --- /dev/null +++ b/examples/src/test/java/software/amazon/lambda/durable/examples/otel/OtelXRayExecutionStepExampleTest.java @@ -0,0 +1,48 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +package software.amazon.lambda.durable.examples.otel; + +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; +import software.amazon.lambda.durable.testing.LocalDurableTestRunner; + +class OtelXRayExecutionStepExampleTest { + + @BeforeEach + void setUp() { + OtelXRayExampleTestSupport.installGlobalOpenTelemetry(); + } + + @AfterEach + void tearDown() { + OtelXRayExampleTestSupport.resetGlobalOpenTelemetry(); + } + + @Test + void testSimpleSteps_succeeds() { + var handler = new OtelXRayExecutionStepExample(); + var runner = LocalDurableTestRunner.create(GreetingRequest.class, handler); + + var result = runner.runUntilComplete(new GreetingRequest("Alice")); + + assertEquals(ExecutionStatus.SUCCEEDED, result.getStatus()); + assertEquals("HELLO, ALICE!", result.getResult(String.class)); + } + + @Test + void testReplay_returnsSameResult() { + var handler = new OtelXRayExecutionStepExample(); + var runner = LocalDurableTestRunner.create(GreetingRequest.class, handler); + + var input = new GreetingRequest("Bob"); + var result1 = runner.runUntilComplete(input); + var result2 = runner.runUntilComplete(input); + + assertEquals(result1.getResult(String.class), result2.getResult(String.class)); + } +} diff --git a/examples/src/test/java/software/amazon/lambda/durable/examples/otel/OtelXRayExecutionWaitExampleTest.java b/examples/src/test/java/software/amazon/lambda/durable/examples/otel/OtelXRayExecutionWaitExampleTest.java new file mode 100644 index 000000000..c314c241f --- /dev/null +++ b/examples/src/test/java/software/amazon/lambda/durable/examples/otel/OtelXRayExecutionWaitExampleTest.java @@ -0,0 +1,48 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +package software.amazon.lambda.durable.examples.otel; + +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; +import software.amazon.lambda.durable.testing.LocalDurableTestRunner; + +class OtelXRayExecutionWaitExampleTest { + + @BeforeEach + void setUp() { + OtelXRayExampleTestSupport.installGlobalOpenTelemetry(); + } + + @AfterEach + void tearDown() { + OtelXRayExampleTestSupport.resetGlobalOpenTelemetry(); + } + + @Test + void testFirstInvocation_suspendsOnWait() { + var handler = new OtelXRayExecutionWaitExample(); + var runner = LocalDurableTestRunner.create(GreetingRequest.class, handler); + + var result = runner.run(new GreetingRequest("Alice")); + + assertEquals(ExecutionStatus.PENDING, result.getStatus()); + } + + @Test + void testFullExecution_completesAfterWait() { + var handler = new OtelXRayExecutionWaitExample(); + var runner = LocalDurableTestRunner.create(GreetingRequest.class, handler); + + var result = runner.runUntilComplete(new GreetingRequest("Alice")); + + assertEquals(ExecutionStatus.SUCCEEDED, result.getStatus()); + assertTrue( + result.getResult(String.class).contains("Resumed and completed"), + "Expected result to contain 'Resumed and completed', got: " + result.getResult(String.class)); + } +} diff --git a/otel-plugin/README.md b/otel-plugin/README.md index 328855dfc..8dfaa8d5d 100644 --- a/otel-plugin/README.md +++ b/otel-plugin/README.md @@ -231,12 +231,53 @@ new InvocationOtelPlugin(tracerProviderBuilder, contextExtractor); new InvocationOtelPlugin(tracerProviderBuilder, contextExtractor, enableMdc); ``` +### InvocationOtelPlugin + +```java +// Default: ADOT Java agent global provider, X-Ray context extraction, MDC enabled +new InvocationOtelPlugin(); + +// Custom tracer provider pipeline +new InvocationOtelPlugin(tracerProviderBuilder); + +// Custom context extractor, MDC enabled +new InvocationOtelPlugin(tracerProviderBuilder, contextExtractor); + +// Full configuration +new InvocationOtelPlugin(tracerProviderBuilder, contextExtractor, enableMdc); +``` + | Parameter | Description | Default | |-----------|-------------|---------| | `tracerProviderBuilder` | `SdkTracerProviderBuilder` with your exporter/processor configured | Not used by `new InvocationOtelPlugin()`; the default constructor uses the ADOT Java agent provider | | `contextExtractor` | Extracts parent trace context from the Lambda environment | `XRayContextExtractor` | | `enableMdc` | If true, injects `trace_id`/`span_id`/`traceSampled` into SLF4J MDC | `true` | +### ExecutionOtelPlugin + +The `ExecutionOtelPlugin` renders the Workflow span as the trace root with operations as siblings of the invocation span. It supports the same constructor options: + +```java +// Default: ADOT Java agent global provider, X-Ray context extraction, MDC enabled +new ExecutionOtelPlugin(); + +// Custom tracer provider pipeline +new ExecutionOtelPlugin(tracerProviderBuilder); + +// Custom context extractor, MDC enabled +new ExecutionOtelPlugin(tracerProviderBuilder, contextExtractor); + +// Full configuration +new ExecutionOtelPlugin(tracerProviderBuilder, contextExtractor, enableMdc, workflowSpanName); +``` + +| Parameter | Description | Default | +|-----------|-------------|---------| +| `tracerProviderBuilder` | `SdkTracerProviderBuilder` with your exporter/processor configured | Not used by `new ExecutionOtelPlugin()`; the default constructor uses the ADOT Java agent provider | +| `contextExtractor` | Extracts parent trace context from the Lambda environment | `XRayContextExtractor` | +| `enableMdc` | If true, injects `trace_id`/`span_id`/`traceSampled` into SLF4J MDC | `true` | +| `workflowSpanName` | Name for the Workflow root span | `"Workflow"` | + ## Known Limitations ### X-Ray Segments Timeline (ungrouped view) 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 fa1794eeb..9f16720ca 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 @@ -34,6 +34,7 @@ public class DeterministicIdGenerator implements IdGenerator { 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 static final String PENDING_RAW_SPAN_ID_PROPERTY_PREFIX = PROPERTY_PREFIX + "pendingRawSpanId."; private final AtomicReference extractedTraceId = new AtomicReference<>(null); private final AtomicReference arnDerivedTraceId = new AtomicReference<>(null); @@ -83,6 +84,7 @@ public void setNextSpanOperationId(String operationId) { */ public void setNextSpanId(String spanId) { this.pendingRawSpanId.set(spanId); + setOrClearProperty(pendingRawSpanIdProperty(), spanId); } /** @@ -139,8 +141,12 @@ public String generateTraceId() { @Override public String generateSpanId() { var raw = pendingRawSpanId.get(); + if (raw == null) { + raw = System.getProperty(pendingRawSpanIdProperty()); + } if (raw != null) { pendingRawSpanId.remove(); + System.clearProperty(pendingRawSpanIdProperty()); return raw; } var operationId = pendingSpanOperationId.get(); @@ -192,7 +198,8 @@ 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)) + .filter(name -> name.startsWith(PENDING_SPAN_OPERATION_ID_PROPERTY_PREFIX) + || name.startsWith(PENDING_RAW_SPAN_ID_PROPERTY_PREFIX)) .toList() .forEach(System::clearProperty); } @@ -202,6 +209,10 @@ private static String pendingSpanOperationIdProperty() { + Thread.currentThread().getId(); } + private static String pendingRawSpanIdProperty() { + return PENDING_RAW_SPAN_ID_PROPERTY_PREFIX + Thread.currentThread().getId(); + } + private static void setOrClearProperty(String key, String value) { if (value == null) { System.clearProperty(key); diff --git a/otel-plugin/src/main/java/software/amazon/lambda/durable/otel/ExecutionOtelPlugin.java b/otel-plugin/src/main/java/software/amazon/lambda/durable/otel/ExecutionOtelPlugin.java index 6071bdbc9..c6d95ba74 100644 --- a/otel-plugin/src/main/java/software/amazon/lambda/durable/otel/ExecutionOtelPlugin.java +++ b/otel-plugin/src/main/java/software/amazon/lambda/durable/otel/ExecutionOtelPlugin.java @@ -14,6 +14,7 @@ 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; @@ -22,8 +23,10 @@ import io.opentelemetry.semconv.ServiceAttributes; import java.time.Instant; import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.TimeUnit; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import org.slf4j.MDC; import software.amazon.lambda.durable.plugin.DurableExecutionPlugin; import software.amazon.lambda.durable.plugin.InvocationEndInfo; import software.amazon.lambda.durable.plugin.InvocationInfo; @@ -86,7 +89,7 @@ public class ExecutionOtelPlugin implements DurableExecutionPlugin { private static final String DEFAULT_WORKFLOW_SPAN_NAME = "Workflow"; private static final String SERVICE_NAME = "workflow"; - private final SdkTracerProvider tracerProvider; + private final SdkTracerProvider sdkTracerProvider; private final Tracer tracer; private final DeterministicIdGenerator idGenerator; private final ContextExtractor contextExtractor; @@ -112,12 +115,25 @@ public class ExecutionOtelPlugin implements DurableExecutionPlugin { * Creates a Workflow-rooted OTel plugin with default settings: X-Ray context extraction, MDC enabled, root span * named {@code "Workflow"}. * + *

Uses the provided tracer provider builder. For ADOT Java agent usage, prefer {@link #ExecutionOtelPlugin()} + * with the plugin jar configured through {@code OTEL_JAVAAGENT_EXTENSIONS}. + * * @param tracerProviderBuilder the tracer provider builder (ID generator will be overridden) */ public ExecutionOtelPlugin(SdkTracerProviderBuilder tracerProviderBuilder) { this(tracerProviderBuilder, new XRayContextExtractor(), true, DEFAULT_WORKFLOW_SPAN_NAME); } + /** + * Creates a Workflow-rooted 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 ExecutionOtelPlugin() { + this(getDefaultTracerProvider(), createDefaultIdGenerator()); + } + /** * Creates a Workflow-rooted OTel plugin with a custom context extractor, MDC enabled, root span named * {@code "Workflow"}. @@ -144,18 +160,28 @@ public ExecutionOtelPlugin( String workflowSpanName) { this.idGenerator = new DeterministicIdGenerator(); - // Set service.name so this plugin's spans group under a distinct "workflow" node in X-Ray/OTLP backends - // (parity with InvocationOtelPlugin, which sets "invocation"). Applies to all spans from this provider. + // Set service.name so this plugin's spans group under a distinct "workflow" node in X-Ray/OTLP backends. var resource = Resource.create(Attributes.of(ServiceAttributes.SERVICE_NAME, SERVICE_NAME)); 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; this.workflowSpanName = workflowSpanName != null ? workflowSpanName : DEFAULT_WORKFLOW_SPAN_NAME; } + private ExecutionOtelPlugin(TracerProvider tracerProvider, DeterministicIdGenerator idGenerator) { + this.idGenerator = idGenerator; + this.sdkTracerProvider = OtelPluginSupport.getSdkTracerProviderForFlush(tracerProvider, "ExecutionOtelPlugin"); + this.tracer = tracerProvider.get(INSTRUMENTATION_NAME); + + this.contextExtractor = new XRayContextExtractor(); + this.enableMdc = true; + this.workflowSpanName = DEFAULT_WORKFLOW_SPAN_NAME; + } + // ─── Invocation hooks ──────────────────────────────────────────────── @Override @@ -168,8 +194,13 @@ public void onInvocationStart(InvocationInfo info) { // Extract trace context from environment (X-Ray header). Only the trace ID is used — the Workflow span is a // true root, so the X-Ray parent span ID is intentionally not used for parenting (unlike InvocationOtelPlugin). var extractedContext = contextExtractor.extract(); + if (extractedContext == null) { + extractedContext = extractCurrentSpanContext(); + } if (extractedContext != null) { idGenerator.setExtractedTraceId(extractedContext.traceId()); + } else { + idGenerator.setExtractedTraceId(null); } // Workflow root span — deterministic span ID from the ARN, no parent. Recreated every invocation with the @@ -195,10 +226,21 @@ public void onInvocationStart(InvocationInfo info) { } invocationSpan = spanBuilder.startSpan(); + + // Inject MDC on the handler thread so handler-level logs (between steps) have trace context. + if (enableMdc) { + var traceId = idGenerator.generateTraceId(); + MDC.put(MdcSpanEnricher.MDC_TRACE_ID, traceId); + } } @Override public void onInvocationEnd(InvocationEndInfo info) { + // Clear invocation-level MDC + if (enableMdc) { + MdcSpanEnricher.clear(); + } + // Reset per-invocation operation state WITHOUT ending open operation spans. Matching the JS/Python // ExecutionOtelPlugin, an operation span is only ended in onOperationEnd. An operation still open when the // invocation suspends is left un-exported here and is re-materialized once (with its deterministic span ID, @@ -248,9 +290,11 @@ public void onInvocationEnd(InvocationEndInfo info) { } // 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) { + 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"); + } } } @@ -420,10 +464,6 @@ public void onUserFunctionEnd(UserFunctionEndInfo info) { scope.close(); } - if (enableMdc) { - MdcSpanEnricher.clear(); - } - // CONTEXT operations don't have attempt spans — scope cleanup is all we need if ("CONTEXT".equals(info.type())) { return; @@ -530,4 +570,16 @@ 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() { + return OtelPluginSupport.extractCurrentSpanContext(); + } + + private static TracerProvider getDefaultTracerProvider() { + return OtelPluginSupport.getDefaultTracerProvider("ExecutionOtelPlugin"); + } + + private static DeterministicIdGenerator createDefaultIdGenerator() { + return OtelPluginSupport.createDefaultIdGenerator(); + } } diff --git a/otel-plugin/src/main/java/software/amazon/lambda/durable/otel/InvocationOtelPlugin.java b/otel-plugin/src/main/java/software/amazon/lambda/durable/otel/InvocationOtelPlugin.java index e8224e77b..3aa446275 100644 --- a/otel-plugin/src/main/java/software/amazon/lambda/durable/otel/InvocationOtelPlugin.java +++ b/otel-plugin/src/main/java/software/amazon/lambda/durable/otel/InvocationOtelPlugin.java @@ -4,7 +4,6 @@ import static software.amazon.lambda.durable.otel.SpanAttributes.*; -import io.opentelemetry.api.GlobalOpenTelemetry; import io.opentelemetry.api.common.AttributeKey; import io.opentelemetry.api.trace.Span; import io.opentelemetry.api.trace.SpanBuilder; @@ -19,8 +18,6 @@ import io.opentelemetry.context.Scope; import io.opentelemetry.sdk.trace.SdkTracerProvider; import io.opentelemetry.sdk.trace.SdkTracerProviderBuilder; -import java.nio.file.Files; -import java.nio.file.Path; import java.time.Instant; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.TimeUnit; @@ -200,7 +197,7 @@ public InvocationOtelPlugin( private InvocationOtelPlugin(TracerProvider tracerProvider, DeterministicIdGenerator idGenerator) { this.idGenerator = idGenerator; - this.sdkTracerProvider = getSdkTracerProviderForFlush(tracerProvider); + this.sdkTracerProvider = OtelPluginSupport.getSdkTracerProviderForFlush(tracerProvider, "InvocationOtelPlugin"); this.tracer = tracerProvider.get(INSTRUMENTATION_NAME); this.contextExtractor = new XRayContextExtractor(); @@ -634,80 +631,14 @@ private static String attemptKey(String operationId, Integer attempt) { } private static ExtractedContext extractCurrentSpanContext() { - var spanContext = Span.current().getSpanContext(); - if (!spanContext.isValid()) { - return null; - } - return new ExtractedContext(spanContext.getTraceId(), spanContext.getSpanId()); + return OtelPluginSupport.extractCurrentSpanContext(); } private static TracerProvider getDefaultTracerProvider() { - validateAutoConfigurationCustomizerProviderInstalled(); - - var globalTracerProvider = GlobalOpenTelemetry.getTracerProvider(); - if (globalTracerProvider == TracerProvider.noop()) { - throw new IllegalStateException("InvocationOtelPlugin() requires GlobalOpenTelemetry to be initialized by " - + "OtelPluginAutoConfigurationCustomizerProvider through the OpenTelemetry Java agent."); - } - logger.info( - "InvocationOtelPlugin initialized from existing GlobalOpenTelemetry tracer provider {}; assuming " - + "deterministic span IDs were installed through AutoConfigurationCustomizerProvider", - globalTracerProvider.getClass().getName()); - return globalTracerProvider; + return OtelPluginSupport.getDefaultTracerProvider("InvocationOtelPlugin"); } 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( - "InvocationOtelPlugin() 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 " - + "InvocationOtelPlugin(). " - + 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( - "InvocationOtelPlugin 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; + return OtelPluginSupport.createDefaultIdGenerator(); } } diff --git a/otel-plugin/src/main/java/software/amazon/lambda/durable/otel/OtelPluginSupport.java b/otel-plugin/src/main/java/software/amazon/lambda/durable/otel/OtelPluginSupport.java new file mode 100644 index 000000000..1408b28cf --- /dev/null +++ b/otel-plugin/src/main/java/software/amazon/lambda/durable/otel/OtelPluginSupport.java @@ -0,0 +1,102 @@ +// 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.api.GlobalOpenTelemetry; +import io.opentelemetry.api.trace.Span; +import io.opentelemetry.api.trace.TracerProvider; +import io.opentelemetry.sdk.trace.SdkTracerProvider; +import java.nio.file.Files; +import java.nio.file.Path; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Shared utilities for OTel plugin default constructor support (ADOT Java agent SPI path). + * + * @deprecated This is a preview API that is experimental and may be changed or removed in future releases. + */ +@Deprecated +final class OtelPluginSupport { + + private static final Logger logger = LoggerFactory.getLogger(OtelPluginSupport.class); + + private OtelPluginSupport() {} + + /** Gets the global TracerProvider after validating the SPI was installed. */ + static TracerProvider getDefaultTracerProvider(String pluginName) { + validateAutoConfigurationCustomizerProviderInstalled(pluginName); + + var globalTracerProvider = GlobalOpenTelemetry.getTracerProvider(); + if (globalTracerProvider == TracerProvider.noop()) { + throw new IllegalStateException(pluginName + "() requires GlobalOpenTelemetry to be initialized by " + + "OtelPluginAutoConfigurationCustomizerProvider through the OpenTelemetry Java agent."); + } + logger.info( + "{} initialized from existing GlobalOpenTelemetry tracer provider {}; assuming " + + "deterministic span IDs were installed through AutoConfigurationCustomizerProvider", + pluginName, + globalTracerProvider.getClass().getName()); + return globalTracerProvider; + } + + /** Creates a new DeterministicIdGenerator for the application-side state bridge. */ + static DeterministicIdGenerator createDefaultIdGenerator() { + return new DeterministicIdGenerator(); + } + + /** Extracts trace context from the current OTel span (fallback when X-Ray header is unavailable). */ + static ExtractedContext extractCurrentSpanContext() { + var spanContext = Span.current().getSpanContext(); + if (!spanContext.isValid()) { + return null; + } + return new ExtractedContext(spanContext.getTraceId(), spanContext.getSpanId()); + } + + /** Returns the SdkTracerProvider for flushing, or null if the provider is wrapped by the agent classloader. */ + static SdkTracerProvider getSdkTracerProviderForFlush(TracerProvider tracerProvider, String pluginName) { + if (tracerProvider instanceof SdkTracerProvider sdkTracerProvider) { + return sdkTracerProvider; + } + logger.info( + "{} 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.", + pluginName, + tracerProvider.getClass().getName()); + return null; + } + + private static void validateAutoConfigurationCustomizerProviderInstalled(String pluginName) { + if (OtelPluginAutoConfigurationState.isInstalled()) { + return; + } + throw new IllegalStateException( + pluginName + "() 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 " + + pluginName + "(). " + + 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=" + + (propertyValue != null ? propertyValue : "") + + ", OTEL_JAVAAGENT_EXTENSIONS=" + + (environmentValue != null ? environmentValue : "") + + ", configured extension path exists=" + + extensionPathExists(configuredPath); + } + + 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)); + } +} 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 6133a3ddb..2b9389fc7 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 @@ -139,6 +139,18 @@ void generatedIds_areSharedAcrossGeneratorInstances() { assertEquals(pluginGenerator.generateSpanIdForOperation("op-1"), agentGenerator.generateSpanId()); } + @Test + void rawSpanId_isSharedAcrossGeneratorInstances() { + var pluginGenerator = new DeterministicIdGenerator(); + var agentGenerator = new DeterministicIdGenerator(); + + pluginGenerator.setDurableExecutionArn("arn:exec1"); + var workflowSpanId = pluginGenerator.generateWorkflowSpanId(); + pluginGenerator.setNextSpanId(workflowSpanId); + + assertEquals(workflowSpanId, agentGenerator.generateSpanId()); + } + @Test void traceId_isValidHex() { generator.setDurableExecutionArn("arn:exec1"); diff --git a/otel-plugin/src/test/java/software/amazon/lambda/durable/otel/ExecutionOtelPluginTest.java b/otel-plugin/src/test/java/software/amazon/lambda/durable/otel/ExecutionOtelPluginTest.java index df0326641..15dda75b3 100644 --- a/otel-plugin/src/test/java/software/amazon/lambda/durable/otel/ExecutionOtelPluginTest.java +++ b/otel-plugin/src/test/java/software/amazon/lambda/durable/otel/ExecutionOtelPluginTest.java @@ -4,12 +4,15 @@ import static org.junit.jupiter.api.Assertions.*; +import io.opentelemetry.api.GlobalOpenTelemetry; import io.opentelemetry.api.trace.SpanKind; import io.opentelemetry.api.trace.StatusCode; +import io.opentelemetry.sdk.OpenTelemetrySdk; import io.opentelemetry.sdk.testing.exporter.InMemorySpanExporter; import io.opentelemetry.sdk.trace.SdkTracerProvider; import io.opentelemetry.sdk.trace.export.SimpleSpanProcessor; import java.time.Instant; +import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import software.amazon.lambda.durable.plugin.*; @@ -23,6 +26,8 @@ class ExecutionOtelPluginTest { @BeforeEach void setUp() { + DeterministicIdGenerator.clearSharedStateForTest(); + OtelPluginAutoConfigurationState.resetInstalledForTest(); spanExporter = InMemorySpanExporter.create(); plugin = new ExecutionOtelPlugin( SdkTracerProvider.builder().addSpanProcessor(SimpleSpanProcessor.create(spanExporter)), @@ -31,6 +36,49 @@ void setUp() { "Workflow"); } + @AfterEach + void tearDown() { + GlobalOpenTelemetry.resetForTest(); + DeterministicIdGenerator.clearSharedStateForTest(); + OtelPluginAutoConfigurationState.resetInstalledForTest(); + } + + // ─── Default constructor ───────────────────────────────────────────── + + @Test + void defaultConstructor_throwsWhenAutoConfigurationCustomizerProviderIsNotInstalled() { + GlobalOpenTelemetry.resetForTest(); + var error = assertThrows(IllegalStateException.class, ExecutionOtelPlugin::new); + 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 ExecutionOtelPlugin(); + defaultPlugin.onInvocationStart(new InvocationInfo("req-1", "arn:exec1", true, Instant.now())); + 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", null, false, null)); + defaultPlugin.onInvocationEnd( + new InvocationEndInfo("req-1", "arn:exec1", true, InvocationStatus.SUCCEEDED, null)); + + var spans = globalExporter.getFinishedSpanItems(); + // Workflow + Invocation + operation = 3 + assertEquals(3, spans.size()); + assertTrue(spans.stream().anyMatch(span -> span.getName().equals("Workflow"))); + assertTrue(spans.stream().anyMatch(span -> span.getName().equals("Invocation"))); + assertTrue(spans.stream().anyMatch(span -> span.getName().equals("step"))); + } + // ─── Workflow root span lifecycle ──────────────────────────────────── @Test