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