diff --git a/conformance-tests/src/main/java/plugin/ConformanceLoggingPlugin.java b/conformance-tests/src/main/java/plugin/ConformanceLoggingPlugin.java
index 0315128de..54ea51660 100644
--- a/conformance-tests/src/main/java/plugin/ConformanceLoggingPlugin.java
+++ b/conformance-tests/src/main/java/plugin/ConformanceLoggingPlugin.java
@@ -13,10 +13,10 @@
/**
* Shared instrumentation plugin for the plugin conformance suite.
*
- *
Emits lifecycle log lines with a configurable prefix (e.g. {@code CONFPLUGIN}, {@code CONFPLUGIN-A}) so one
- * plugin — or two, for the multiple-plugins case — can be registered on a handler. Operation- and attempt-level hooks
- * are filtered to step-type operations to match the requirement vocabulary. All lines are emitted from the real SDK
- * plugin hooks; nothing is hand-rolled.
+ *
Emits lifecycle log lines with a configurable prefix (e.g. {@code CONFPLUGIN}, {@code CONFPLUGIN-A}) so one plugin
+ * — or two, for the multiple-plugins case — can be registered on a handler. Operation- and attempt-level hooks are
+ * filtered to step-type operations to match the requirement vocabulary. All lines are emitted from the real SDK plugin
+ * hooks; nothing is hand-rolled.
*/
@SuppressWarnings("deprecation")
public class ConformanceLoggingPlugin implements DurableExecutionPlugin {
diff --git a/conformance-tests/src/main/java/plugin/FaultyConformancePlugin.java b/conformance-tests/src/main/java/plugin/FaultyConformancePlugin.java
index 2b5e2306c..59811aae3 100644
--- a/conformance-tests/src/main/java/plugin/FaultyConformancePlugin.java
+++ b/conformance-tests/src/main/java/plugin/FaultyConformancePlugin.java
@@ -35,23 +35,23 @@ private String arnField() {
@Override
public void onInvocationStart(InvocationInfo info) {
this.executionArn = info.durableExecutionArn();
- System.out.println(String.format(
- "{\"plugin\": \"CONFPLUGIN-FAULTY\", \"hook\": \"invocation-start\"%s}", arnField()));
+ System.out.println(
+ String.format("{\"plugin\": \"CONFPLUGIN-FAULTY\", \"hook\": \"invocation-start\"%s}", arnField()));
throw new RuntimeException("faulty invocation-start");
}
@Override
public void onInvocationEnd(InvocationEndInfo info) {
- System.out.println(String.format(
- "{\"plugin\": \"CONFPLUGIN-FAULTY\", \"hook\": \"invocation-end\"%s}", arnField()));
+ System.out.println(
+ String.format("{\"plugin\": \"CONFPLUGIN-FAULTY\", \"hook\": \"invocation-end\"%s}", arnField()));
throw new RuntimeException("faulty invocation-end");
}
@Override
public void onOperationStart(OperationInfo info) {
if (isStep(info.type())) {
- System.out.println(String.format(
- "{\"plugin\": \"CONFPLUGIN-FAULTY\", \"hook\": \"operation-start\"%s}", arnField()));
+ System.out.println(
+ String.format("{\"plugin\": \"CONFPLUGIN-FAULTY\", \"hook\": \"operation-start\"%s}", arnField()));
throw new RuntimeException("faulty operation-start");
}
}
@@ -59,8 +59,8 @@ public void onOperationStart(OperationInfo info) {
@Override
public void onOperationEnd(OperationEndInfo info) {
if (isStep(info.type())) {
- System.out.println(String.format(
- "{\"plugin\": \"CONFPLUGIN-FAULTY\", \"hook\": \"operation-end\"%s}", arnField()));
+ System.out.println(
+ String.format("{\"plugin\": \"CONFPLUGIN-FAULTY\", \"hook\": \"operation-end\"%s}", arnField()));
throw new RuntimeException("faulty operation-end");
}
}
@@ -68,8 +68,8 @@ public void onOperationEnd(OperationEndInfo info) {
@Override
public void onUserFunctionStart(UserFunctionStartInfo info) {
if (isStep(info.type())) {
- System.out.println(String.format(
- "{\"plugin\": \"CONFPLUGIN-FAULTY\", \"hook\": \"attempt-start\"%s}", arnField()));
+ System.out.println(
+ String.format("{\"plugin\": \"CONFPLUGIN-FAULTY\", \"hook\": \"attempt-start\"%s}", arnField()));
throw new RuntimeException("faulty attempt-start");
}
}
@@ -77,8 +77,8 @@ public void onUserFunctionStart(UserFunctionStartInfo info) {
@Override
public void onUserFunctionEnd(UserFunctionEndInfo info) {
if (isStep(info.type())) {
- System.out.println(String.format(
- "{\"plugin\": \"CONFPLUGIN-FAULTY\", \"hook\": \"attempt-end\"%s}", arnField()));
+ System.out.println(
+ String.format("{\"plugin\": \"CONFPLUGIN-FAULTY\", \"hook\": \"attempt-end\"%s}", arnField()));
throw new RuntimeException("faulty attempt-end");
}
}
diff --git a/conformance-tests/src/main/java/plugin/PluginErrorIsolation.java b/conformance-tests/src/main/java/plugin/PluginErrorIsolation.java
index 59aeb80ce..0ff7aea7d 100644
--- a/conformance-tests/src/main/java/plugin/PluginErrorIsolation.java
+++ b/conformance-tests/src/main/java/plugin/PluginErrorIsolation.java
@@ -18,7 +18,9 @@ public class PluginErrorIsolation extends DurableHandler {
@Override
protected DurableConfig createConfiguration() {
- return DurableConfig.builder().withPlugins(new FaultyConformancePlugin()).build();
+ return DurableConfig.builder()
+ .withPlugins(new FaultyConformancePlugin())
+ .build();
}
@Override
diff --git a/conformance-tests/src/main/java/plugin/PluginInvocationLifecycle.java b/conformance-tests/src/main/java/plugin/PluginInvocationLifecycle.java
index a407f2674..fc59beffa 100644
--- a/conformance-tests/src/main/java/plugin/PluginInvocationLifecycle.java
+++ b/conformance-tests/src/main/java/plugin/PluginInvocationLifecycle.java
@@ -10,8 +10,8 @@
* 10-1: Plugin invocation lifecycle hooks (start and end on a single invocation).
*
* A single greeting step configured with {@link ConformanceLoggingPlugin}. The plugin's invocation-start hook logs
- * {@code first=true} before the handler runs and its invocation-end hook logs {@code status=SUCCEEDED} after the
- * result is finalized. The step body logs its running line via the context logger.
+ * {@code first=true} before the handler runs and its invocation-end hook logs {@code status=SUCCEEDED} after the result
+ * is finalized. The step body logs its running line via the context logger.
*/
@SuppressWarnings("deprecation")
public class PluginInvocationLifecycle extends DurableHandler {
diff --git a/conformance-tests/src/main/java/plugin/PluginMultiplePlugins.java b/conformance-tests/src/main/java/plugin/PluginMultiplePlugins.java
index 4dde9f6b3..619d34b55 100644
--- a/conformance-tests/src/main/java/plugin/PluginMultiplePlugins.java
+++ b/conformance-tests/src/main/java/plugin/PluginMultiplePlugins.java
@@ -38,8 +38,8 @@ private String arnField() {
@Override
public void onInvocationStart(InvocationInfo info) {
this.executionArn = info.durableExecutionArn();
- System.out.println(String.format(
- "{\"plugin\": \"%s\", \"hook\": \"invocation-start\"%s}", prefix, arnField()));
+ System.out.println(
+ String.format("{\"plugin\": \"%s\", \"hook\": \"invocation-start\"%s}", prefix, arnField()));
}
@Override
@@ -53,8 +53,7 @@ public void onInvocationEnd(InvocationEndInfo info) {
@Override
protected DurableConfig createConfiguration() {
return DurableConfig.builder()
- .withPlugins(
- new InvocationLoggingPlugin("CONFPLUGIN-A"), new InvocationLoggingPlugin("CONFPLUGIN-B"))
+ .withPlugins(new InvocationLoggingPlugin("CONFPLUGIN-A"), new InvocationLoggingPlugin("CONFPLUGIN-B"))
.build();
}
diff --git a/conformance-tests/src/main/java/plugin/PluginOperationLifecycle.java b/conformance-tests/src/main/java/plugin/PluginOperationLifecycle.java
index ecbb39bb9..4de3f93b0 100644
--- a/conformance-tests/src/main/java/plugin/PluginOperationLifecycle.java
+++ b/conformance-tests/src/main/java/plugin/PluginOperationLifecycle.java
@@ -10,8 +10,8 @@
* 10-2: Plugin operation lifecycle hooks (step start and terminal end).
*
* A single greeting step configured with {@link ConformanceLoggingPlugin}. The plugin, filtering to step-type
- * operations, logs {@code operation-start} when the step's STARTED checkpoint is observed and
- * {@code operation-end status=SUCCEEDED} when the step reaches its terminal status.
+ * operations, logs {@code operation-start} when the step's STARTED checkpoint is observed and {@code operation-end
+ * status=SUCCEEDED} when the step reaches its terminal status.
*/
@SuppressWarnings("deprecation")
public class PluginOperationLifecycle extends DurableHandler {
diff --git a/conformance-tests/src/main/java/plugin/PluginTerminalFailure.java b/conformance-tests/src/main/java/plugin/PluginTerminalFailure.java
index 9ba9cf7da..8b6db8e5f 100644
--- a/conformance-tests/src/main/java/plugin/PluginTerminalFailure.java
+++ b/conformance-tests/src/main/java/plugin/PluginTerminalFailure.java
@@ -11,9 +11,9 @@
/**
* 10-7: Plugin invocation-end hook receives FAILED status when execution fails.
*
- * A single step that always throws, configured with no retries and {@link ConformanceLoggingPlugin}. The plugin
- * logs {@code invocation-start first=true}; the step throws, no retry is attempted, and the plugin's invocation-end
- * hook fires with {@code status=FAILED}.
+ *
A single step that always throws, configured with no retries and {@link ConformanceLoggingPlugin}. The plugin logs
+ * {@code invocation-start first=true}; the step throws, no retry is attempted, and the plugin's invocation-end hook
+ * fires with {@code status=FAILED}.
*/
@SuppressWarnings("deprecation")
public class PluginTerminalFailure extends DurableHandler {
diff --git a/examples/generate-template.py b/examples/generate-template.py
index 75e6cce09..d223d37d4 100755
--- a/examples/generate-template.py
+++ b/examples/generate-template.py
@@ -4,6 +4,7 @@
import argparse
import re
+import xml.etree.ElementTree as ET
from dataclasses import dataclass
from pathlib import Path
@@ -13,6 +14,17 @@
EXAMPLE_PACKAGE_ROOT = SOURCE_ROOT / "software/amazon/lambda/durable/examples"
DEFAULT_OUTPUT = EXAMPLES_DIR / "template.yaml"
TEMPLATE_ANNOTATION = "ExampleTemplate"
+POM_NAMESPACE = {"m": "http://maven.apache.org/POM/4.0.0"}
+
+
+def read_otel_plugin_jar_path() -> str:
+ root = ET.parse(EXAMPLES_DIR / "pom.xml").getroot()
+ version = root.findtext("m:version", namespaces=POM_NAMESPACE)
+ if version is None:
+ version = root.findtext("m:parent/m:version", namespaces=POM_NAMESPACE)
+ if version is None:
+ raise ValueError("Unable to read examples version from pom.xml")
+ return f"/var/task/lib/aws-durable-execution-sdk-java-plugin-otel-{version}.jar"
@dataclass(frozen=True)
@@ -22,6 +34,7 @@ class ExampleFunction:
suffix: str
condition: str | None
tracing: bool
+ java_agent: bool
@property
def logical_id(self) -> str:
@@ -58,24 +71,26 @@ def is_top_level_durable_handler(source: str, class_name: str) -> bool:
return bool(match and "extends DurableHandler" in match.group("header"))
-def read_template_annotation(source: str, class_name: str) -> tuple[str | None, bool]:
+def read_template_annotation(source: str, class_name: str) -> tuple[str | None, bool, bool]:
class_match = re.search(rf"public\s+(?:final\s+)?class\s+{class_name}\b", source)
if not class_match:
- return None, False
+ return None, False, False
prefix = source[: class_match.start()]
matches = list(
re.finditer(rf"@(?:[A-Za-z_][\w.]*\.)?{TEMPLATE_ANNOTATION}\s*(?:\((?P.*?)\))?", prefix, re.DOTALL)
)
if not matches:
- return None, False
+ return None, False, False
body = matches[-1].group("body") or ""
condition_match = re.search(r'condition\s*=\s*"([^"]+)"', body)
tracing_match = re.search(r"tracing\s*=\s*(true|false)", body)
+ java_agent_match = re.search(r"javaAgent\s*=\s*(true|false)", body)
condition = condition_match.group(1) if condition_match else None
tracing = tracing_match.group(1) == "true" if tracing_match else False
- return condition, tracing
+ java_agent = java_agent_match.group(1) == "true" if java_agent_match else False
+ return condition, tracing, java_agent
def discover_examples() -> list[ExampleFunction]:
@@ -86,7 +101,7 @@ def discover_examples() -> list[ExampleFunction]:
if not is_top_level_durable_handler(source, class_name):
continue
- condition, tracing = read_template_annotation(source, class_name)
+ condition, tracing, java_agent = read_template_annotation(source, class_name)
package_name = read_package(source, path)
examples.append(
ExampleFunction(
@@ -95,12 +110,13 @@ def discover_examples() -> list[ExampleFunction]:
suffix=kebab_case(class_name),
condition=condition,
tracing=tracing,
+ java_agent=java_agent,
)
)
return examples
-def emit_function(lines: list[str], example: ExampleFunction) -> None:
+def emit_function(lines: list[str], example: ExampleFunction, java_agent_extension_path: str) -> None:
lines.extend(
[
f" {example.logical_id}:",
@@ -123,9 +139,19 @@ def emit_function(lines: list[str], example: ExampleFunction) -> None:
[
" Tracing: Active",
" Layers:",
- " - !Sub",
- " - arn:aws:lambda:${AWS::Region}:901920570463:layer:aws-otel-java-agent-${AdotArch}-ver-1-32-0:6",
- " - AdotArch: amd64",
+ " - !Ref AdotLayerArn",
+ ]
+ )
+ if example.java_agent:
+ lines.extend(
+ [
+ " LoggingConfig:",
+ " LogFormat: JSON",
+ " Environment:",
+ " Variables:",
+ " AWS_LAMBDA_EXEC_WRAPPER: /opt/otel-instrument",
+ f' JAVA_TOOL_OPTIONS: "-Dotel.javaagent.extensions={java_agent_extension_path}"',
+ f" OTEL_JAVAAGENT_EXTENSIONS: {java_agent_extension_path}",
]
)
lines.append("")
@@ -151,6 +177,7 @@ def emit_log_group(lines: list[str], example: ExampleFunction) -> None:
def render_template(examples: list[ExampleFunction]) -> str:
+ java_agent_extension_path = read_otel_plugin_jar_path()
lines = [
"# This file is generated by examples/generate-template.py. Do not edit it by hand.",
'AWSTemplateFormatVersion: "2010-09-09"',
@@ -176,6 +203,14 @@ def render_template(examples: list[ExampleFunction]) -> str:
" RoleArn:",
" Type: String",
" Description: IAM Role ARN for Lambda function execution",
+ " AdotLayerArn:",
+ " Type: String",
+ " Default: arn:aws:lambda:us-west-2:615299751070:layer:AWSOpenTelemetryDistroJava:16",
+ " Description: >-",
+ " ARN of the ADOT (AWS Distro for OpenTelemetry) Lambda layer used by the tracing examples.",
+ " The layer is regional: its account ID and version vary by region, so override this per",
+ " deployment region. The default targets us-west-2 (the region used by e2e tests); CI",
+ " resolves the latest ARN for its region.",
"",
"Conditions:",
" IsJava21OrLater:",
@@ -202,7 +237,7 @@ def render_template(examples: list[ExampleFunction]) -> str:
for example in examples:
emit_log_group(lines, example)
- emit_function(lines, example)
+ emit_function(lines, example, java_agent_extension_path)
lines.append("Outputs:")
for index, example in enumerate(examples):
diff --git a/examples/pom.xml b/examples/pom.xml
index 7c3530cb2..51566f3e1 100644
--- a/examples/pom.xml
+++ b/examples/pom.xml
@@ -50,18 +50,6 @@
1.64.0
-
-
- io.opentelemetry
- opentelemetry-exporter-otlp
- 1.64.0
-
-
- io.grpc
- grpc-netty-shaded
- 1.82.2
-
-
com.amazonaws
@@ -154,6 +142,30 @@
+
+ org.apache.maven.plugins
+ maven-dependency-plugin
+ 3.9.0
+
+
+ copy-otel-javaagent-extension
+ prepare-package
+
+ copy
+
+
+
+
+ ${project.groupId}
+ aws-durable-execution-sdk-java-plugin-otel
+ ${project.version}
+ ${project.build.outputDirectory}/lib
+
+
+
+
+
+
org.apache.maven.plugins
maven-source-plugin
diff --git a/examples/src/main/java/software/amazon/lambda/durable/examples/ExampleTemplate.java b/examples/src/main/java/software/amazon/lambda/durable/examples/ExampleTemplate.java
index 898634884..bd7d92bc0 100644
--- a/examples/src/main/java/software/amazon/lambda/durable/examples/ExampleTemplate.java
+++ b/examples/src/main/java/software/amazon/lambda/durable/examples/ExampleTemplate.java
@@ -14,4 +14,6 @@
String condition() default "";
boolean tracing() default false;
+
+ boolean javaAgent() default false;
}
diff --git a/examples/src/main/java/software/amazon/lambda/durable/examples/otel/OtelXRayDefaultConstructorExample.java b/examples/src/main/java/software/amazon/lambda/durable/examples/otel/OtelXRayDefaultConstructorExample.java
new file mode 100644
index 000000000..9dee0c7bf
--- /dev/null
+++ b/examples/src/main/java/software/amazon/lambda/durable/examples/otel/OtelXRayDefaultConstructorExample.java
@@ -0,0 +1,37 @@
+// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: Apache-2.0
+package software.amazon.lambda.durable.examples.otel;
+
+import software.amazon.lambda.durable.DurableConfig;
+import software.amazon.lambda.durable.DurableContext;
+import software.amazon.lambda.durable.DurableHandler;
+import software.amazon.lambda.durable.examples.ExampleTemplate;
+import software.amazon.lambda.durable.examples.types.GreetingRequest;
+import software.amazon.lambda.durable.otel.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/OtelXRayExamples.java b/examples/src/main/java/software/amazon/lambda/durable/examples/otel/OtelXRayExamples.java
index dcd6a875b..625fb0aad 100644
--- a/examples/src/main/java/software/amazon/lambda/durable/examples/otel/OtelXRayExamples.java
+++ b/examples/src/main/java/software/amazon/lambda/durable/examples/otel/OtelXRayExamples.java
@@ -2,7 +2,7 @@
// SPDX-License-Identifier: Apache-2.0
package software.amazon.lambda.durable.examples.otel;
-import io.opentelemetry.exporter.otlp.trace.OtlpGrpcSpanExporter;
+import io.opentelemetry.exporter.logging.LoggingSpanExporter;
import io.opentelemetry.sdk.trace.SdkTracerProvider;
import io.opentelemetry.sdk.trace.export.SimpleSpanProcessor;
import java.util.List;
@@ -20,10 +20,9 @@ public final class OtelXRayExamples {
private OtelXRayExamples() {}
- private static DurableConfig otelConfig() {
- var otlpExporter = OtlpGrpcSpanExporter.getDefault();
+ private static DurableConfig localOtelConfig() {
var otelPlugin = new InvocationOtelPlugin(
- SdkTracerProvider.builder().addSpanProcessor(SimpleSpanProcessor.create(otlpExporter)));
+ SdkTracerProvider.builder().addSpanProcessor(SimpleSpanProcessor.create(LoggingSpanExporter.create())));
return DurableConfig.builder().withPlugins(otelPlugin).build();
}
@@ -32,7 +31,7 @@ public static class MapExample extends DurableHandler {
@Override
protected DurableConfig createConfiguration() {
- return otelConfig();
+ return localOtelConfig();
}
@Override
@@ -56,7 +55,7 @@ public static class ParallelExample extends DurableHandlerExports spans via OTLP to the ADOT Lambda Layer collector, which forwards them to X-Ray. Requires:
+ * Exports spans through the ADOT Java agent global OpenTelemetry provider. Requires:
*
*
* {@code Tracing: Active} on the Lambda function
- * ADOT Lambda Layer added to the function (for the OTLP collector)
+ * ADOT Lambda Layer added to the function
+ * {@code OTEL_JAVAAGENT_EXTENSIONS} pointing at the OTel plugin jar
*
*
* Expected trace structure in X-Ray:
@@ -32,18 +30,12 @@
* └── transform attempt 1
*
*/
-@ExampleTemplate(tracing = true)
+@ExampleTemplate(tracing = true, javaAgent = true)
public class OtelXRayStepExample extends DurableHandler {
@Override
protected DurableConfig createConfiguration() {
- // OTLP exporter sends spans to the ADOT collector (localhost:4317 by default)
- var otlpExporter = OtlpGrpcSpanExporter.getDefault();
-
- var otelPlugin = new InvocationOtelPlugin(
- SdkTracerProvider.builder().addSpanProcessor(SimpleSpanProcessor.create(otlpExporter)));
-
- return DurableConfig.builder().withPlugins(otelPlugin).build();
+ return DurableConfig.builder().withPlugins(new InvocationOtelPlugin()).build();
}
@Override
diff --git a/examples/src/main/java/software/amazon/lambda/durable/examples/otel/OtelXRayWaitExample.java b/examples/src/main/java/software/amazon/lambda/durable/examples/otel/OtelXRayWaitExample.java
index f58fea837..aac7c95d8 100644
--- a/examples/src/main/java/software/amazon/lambda/durable/examples/otel/OtelXRayWaitExample.java
+++ b/examples/src/main/java/software/amazon/lambda/durable/examples/otel/OtelXRayWaitExample.java
@@ -2,9 +2,6 @@
// SPDX-License-Identifier: Apache-2.0
package software.amazon.lambda.durable.examples.otel;
-import io.opentelemetry.exporter.otlp.trace.OtlpGrpcSpanExporter;
-import io.opentelemetry.sdk.trace.SdkTracerProvider;
-import io.opentelemetry.sdk.trace.export.SimpleSpanProcessor;
import java.time.Duration;
import software.amazon.lambda.durable.DurableConfig;
import software.amazon.lambda.durable.DurableContext;
@@ -23,39 +20,34 @@
* Invocation 2: replays "before-wait" (no-op) → wait completes → "after-wait" step runs
*
*
- * Exports spans via OTLP to the ADOT Lambda Layer collector. Requires:
+ *
Exports spans through the ADOT Java agent global OpenTelemetry provider. Requires:
*
*
* {@code Tracing: Active} on the Lambda function
- * ADOT Lambda Layer added to the function (for the OTLP collector)
+ * ADOT Lambda Layer added to the function
+ * {@code OTEL_JAVAAGENT_EXTENSIONS} pointing at the OTel plugin jar
*
*
* Expected trace structure in X-Ray (all under one trace ID — backend propagates same Root):
*
*
* Trace (single trace ID across both invocations)
- * ├── durable.invocation (invocation 1)
- * │ ├── durable.step:before-wait
- * │ │ └── durable.step:before-wait [attempt 1]
- * │ └── durable.wait:pause (ended as PENDING)
- * └── durable.invocation (invocation 2)
- * ├── durable.wait:pause (completed)
- * └── durable.step:after-wait
- * └── durable.step:after-wait [attempt 1]
+ * ├── invocation (invocation 1)
+ * │ ├── before-wait
+ * │ │ └── before-wait attempt 1
+ * │ └── pause (ended as PENDING)
+ * └── invocation (invocation 2)
+ * ├── pause (completed)
+ * └── after-wait
+ * └── after-wait attempt 1
*
*/
-@ExampleTemplate(tracing = true)
+@ExampleTemplate(tracing = true, javaAgent = true)
public class OtelXRayWaitExample extends DurableHandler {
@Override
protected DurableConfig createConfiguration() {
- // OTLP exporter sends spans to the ADOT collector (localhost:4317 by default)
- var otlpExporter = OtlpGrpcSpanExporter.getDefault();
-
- var otelPlugin = new InvocationOtelPlugin(
- SdkTracerProvider.builder().addSpanProcessor(SimpleSpanProcessor.create(otlpExporter)));
-
- return DurableConfig.builder().withPlugins(otelPlugin).build();
+ return DurableConfig.builder().withPlugins(new InvocationOtelPlugin()).build();
}
@Override
diff --git a/examples/src/main/resources/log4j2.xml b/examples/src/main/resources/log4j2.xml
index 827132a4c..7bf78fae6 100644
--- a/examples/src/main/resources/log4j2.xml
+++ b/examples/src/main/resources/log4j2.xml
@@ -2,7 +2,7 @@
-
+
diff --git a/examples/src/test/java/software/amazon/lambda/durable/examples/otel/OtelXRayExampleTestSupport.java b/examples/src/test/java/software/amazon/lambda/durable/examples/otel/OtelXRayExampleTestSupport.java
new file mode 100644
index 000000000..b1f1a5744
--- /dev/null
+++ b/examples/src/test/java/software/amazon/lambda/durable/examples/otel/OtelXRayExampleTestSupport.java
@@ -0,0 +1,30 @@
+// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: Apache-2.0
+package software.amazon.lambda.durable.examples.otel;
+
+import io.opentelemetry.api.GlobalOpenTelemetry;
+import io.opentelemetry.exporter.logging.LoggingSpanExporter;
+import io.opentelemetry.sdk.OpenTelemetrySdk;
+import io.opentelemetry.sdk.trace.SdkTracerProvider;
+import io.opentelemetry.sdk.trace.export.SimpleSpanProcessor;
+
+final class OtelXRayExampleTestSupport {
+
+ private static final String SPI_INSTALLED_PROPERTY =
+ "software.amazon.lambda.durable.otel.autoConfigurationCustomizerProviderInstalled";
+
+ private OtelXRayExampleTestSupport() {}
+
+ static void installGlobalOpenTelemetry() {
+ System.setProperty(SPI_INSTALLED_PROPERTY, Boolean.TRUE.toString());
+ var tracerProvider = SdkTracerProvider.builder()
+ .addSpanProcessor(SimpleSpanProcessor.create(LoggingSpanExporter.create()))
+ .build();
+ OpenTelemetrySdk.builder().setTracerProvider(tracerProvider).buildAndRegisterGlobal();
+ }
+
+ static void resetGlobalOpenTelemetry() {
+ GlobalOpenTelemetry.resetForTest();
+ System.clearProperty(SPI_INSTALLED_PROPERTY);
+ }
+}
diff --git a/examples/src/test/java/software/amazon/lambda/durable/examples/otel/OtelXRayStepExampleTest.java b/examples/src/test/java/software/amazon/lambda/durable/examples/otel/OtelXRayStepExampleTest.java
index 1bae61686..dffe99080 100644
--- a/examples/src/test/java/software/amazon/lambda/durable/examples/otel/OtelXRayStepExampleTest.java
+++ b/examples/src/test/java/software/amazon/lambda/durable/examples/otel/OtelXRayStepExampleTest.java
@@ -4,6 +4,8 @@
import static org.junit.jupiter.api.Assertions.*;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import software.amazon.lambda.durable.examples.types.GreetingRequest;
import software.amazon.lambda.durable.model.ExecutionStatus;
@@ -11,6 +13,16 @@
class OtelXRayStepExampleTest {
+ @BeforeEach
+ void setUp() {
+ OtelXRayExampleTestSupport.installGlobalOpenTelemetry();
+ }
+
+ @AfterEach
+ void tearDown() {
+ OtelXRayExampleTestSupport.resetGlobalOpenTelemetry();
+ }
+
@Test
void testSimpleSteps_succeeds() {
var handler = new OtelXRayStepExample();
diff --git a/examples/src/test/java/software/amazon/lambda/durable/examples/otel/OtelXRayWaitExampleTest.java b/examples/src/test/java/software/amazon/lambda/durable/examples/otel/OtelXRayWaitExampleTest.java
index 4d46dc2bb..ac42d31d9 100644
--- a/examples/src/test/java/software/amazon/lambda/durable/examples/otel/OtelXRayWaitExampleTest.java
+++ b/examples/src/test/java/software/amazon/lambda/durable/examples/otel/OtelXRayWaitExampleTest.java
@@ -4,6 +4,8 @@
import static org.junit.jupiter.api.Assertions.*;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import software.amazon.lambda.durable.examples.types.GreetingRequest;
import software.amazon.lambda.durable.model.ExecutionStatus;
@@ -11,6 +13,16 @@
class OtelXRayWaitExampleTest {
+ @BeforeEach
+ void setUp() {
+ OtelXRayExampleTestSupport.installGlobalOpenTelemetry();
+ }
+
+ @AfterEach
+ void tearDown() {
+ OtelXRayExampleTestSupport.resetGlobalOpenTelemetry();
+ }
+
@Test
void testFirstInvocation_suspendsOnWait() {
var handler = new OtelXRayWaitExample();
diff --git a/otel-plugin/README.md b/otel-plugin/README.md
index 8b2a06ea5..328855dfc 100644
--- a/otel-plugin/README.md
+++ b/otel-plugin/README.md
@@ -9,8 +9,8 @@ OpenTelemetry instrumentation plugin for the AWS Lambda Durable Execution SDK fo
- **Deterministic Trace IDs**: All invocations of the same durable execution share a single trace, derived from the X-Ray trace header or execution ARN
- **Span-per-Operation**: Each durable operation (step, wait, map, etc.) gets its own span with accurate timing
- **Attempt Spans**: Each user function execution (step attempt, child context run) gets a span, including retries
-- **Log Correlation**: Injects `traceId`, `spanId`, and `traceSampled` into SLF4J MDC for end-to-end observability
-- **Self-Contained Setup**: No manual TracerProvider configuration required beyond the exporter
+- **Log Correlation**: Injects `trace_id`, `span_id`, and `traceSampled` into SLF4J MDC for end-to-end observability
+- **ADOT Java Agent Integration**: `new InvocationOtelPlugin()` uses the ADOT Java agent's global provider with no handler-side OpenTelemetry initialization
## Installation
@@ -22,40 +22,43 @@ OpenTelemetry instrumentation plugin for the AWS Lambda Durable Execution SDK fo
```
-You also need the OpenTelemetry SDK and an exporter:
+For the no-arg constructor (`new InvocationOtelPlugin()`), no additional OpenTelemetry dependencies are needed — the ADOT Java agent layer provides them.
+
+If you configure your own `SdkTracerProviderBuilder`, add the OpenTelemetry SDK and an exporter:
```xml
io.opentelemetry
opentelemetry-sdk
- 1.63.0
+ 1.64.0
io.opentelemetry
- opentelemetry-exporter-otlp
- 1.63.0
+ opentelemetry-exporter-logging
+ 1.64.0
```
-## Quick Start using X-Ray/CloudWatch Tracing
+## Quick Start using X-Ray/CloudWatch Tracing (ADOT Java Agent)
1. Add the ADOT Lambda Layer to your function
2. Enable X-Ray Active Tracing on the function
-3. Register `InvocationOtelPlugin` in your handler's `DurableConfig`
-4. Grant X-Ray write permissions
+3. Configure environment variables
+4. Register `InvocationOtelPlugin` in your handler's `DurableConfig`
+5. Grant X-Ray write permissions
### 1. ADOT Lambda Layer
-This plugin uses the [AWS Distro for OpenTelemetry (ADOT) Lambda layer](https://aws-otel.github.io/docs/getting-started/lambda) for trace export. The layer provides an OTLP collector that receives spans from the plugin and exports them to X-Ray.
-
-> **Note:** Do NOT set `AWS_LAMBDA_EXEC_WRAPPER`. The ADOT layer's collector extension runs independently as a Lambda External Extension. The wrapper would attach the auto-instrumentation agent which creates a competing TracerProvider, causing disconnected service nodes in the X-Ray trace map.
+This plugin uses the [AWS Distro for OpenTelemetry (ADOT) Lambda layer](https://aws-otel.github.io/docs/getting-started/lambda) for trace export. The `new InvocationOtelPlugin()` constructor uses the global provider initialized by the ADOT Java agent with deterministic span ID generation installed through the plugin's `AutoConfigurationCustomizerProvider` SPI.
The layer ARN follows the format:
```
-arn:aws:lambda::901920570463:layer:aws-otel-java-agent--ver-1-32-0:6
+arn:aws:lambda::615299751070:layer:AWSOpenTelemetryDistroJava:
```
+> **Note:** The layer is regional — the account ID and version vary by region. Find the current per-region ARN in the [ADOT Java instrumentation releases](https://github.com/aws-observability/aws-otel-java-instrumentation/releases/latest).
+
**CloudFormation / SAM:**
```yaml
@@ -63,10 +66,14 @@ MyFunction:
Type: AWS::Serverless::Function
Properties:
Tracing: Active
+ LoggingConfig:
+ LogFormat: JSON
Layers:
- - !Sub
- - arn:aws:lambda:${AWS::Region}:901920570463:layer:aws-otel-java-agent-${Arch}-ver-1-32-0:6
- - Arch: amd64
+ - !Sub arn:aws:lambda:${AWS::Region}:615299751070:layer:AWSOpenTelemetryDistroJava:16
+ Environment:
+ Variables:
+ AWS_LAMBDA_EXEC_WRAPPER: /opt/otel-instrument
+ OTEL_JAVAAGENT_EXTENSIONS: /var/task/lib/aws-durable-execution-sdk-java-plugin-otel-.jar
```
**AWS CLI:**
@@ -74,39 +81,30 @@ MyFunction:
```bash
aws lambda update-function-configuration \
--function-name your-function-name \
- --layers "arn:aws:lambda::901920570463:layer:aws-otel-java-agent-amd64-ver-1-32-0:6"
+ --layers "arn:aws:lambda::615299751070:layer:AWSOpenTelemetryDistroJava:16" \
+ --environment "Variables={AWS_LAMBDA_EXEC_WRAPPER=/opt/otel-instrument,OTEL_JAVAAGENT_EXTENSIONS=/var/task/lib/aws-durable-execution-sdk-java-plugin-otel-.jar}"
```
+Set `OTEL_JAVAAGENT_EXTENSIONS` to the deployed OTel plugin jar that contains this plugin's `META-INF/services/io.opentelemetry.sdk.autoconfigure.spi.AutoConfigurationCustomizerProvider` entry.
+
### 2. AWS X-Ray Active Tracing
Enable active tracing on your Lambda function so the `_X_AMZN_TRACE_ID` environment variable is populated at invocation time. The plugin uses this header to derive deterministic trace IDs that remain consistent across all invocations of the same durable execution.
**AWS Console:** Lambda > Configuration > Monitoring and operations tools > Active tracing > Enable
-**AWS CLI:**
-
-```bash
-aws lambda update-function-configuration \
- --function-name your-function-name \
- --tracing-config Mode=Active
-```
-
**CloudFormation / SAM:**
```yaml
MyFunction:
- Type: AWS::Lambda::Function
+ Type: AWS::Serverless::Function
Properties:
- TracingConfig:
- Mode: Active
+ Tracing: Active
```
### 3. In Your Lambda Handler
```java
-import io.opentelemetry.exporter.otlp.trace.OtlpGrpcSpanExporter;
-import io.opentelemetry.sdk.trace.SdkTracerProvider;
-import io.opentelemetry.sdk.trace.export.SimpleSpanProcessor;
import software.amazon.lambda.durable.DurableConfig;
import software.amazon.lambda.durable.DurableContext;
import software.amazon.lambda.durable.DurableHandler;
@@ -116,13 +114,7 @@ public class MyHandler extends DurableHandler {
@Override
protected DurableConfig createConfiguration() {
- var otlpExporter = OtlpGrpcSpanExporter.getDefault();
-
- var otelPlugin = new InvocationOtelPlugin(
- SdkTracerProvider.builder()
- .addSpanProcessor(SimpleSpanProcessor.create(otlpExporter)));
-
- return DurableConfig.builder().withPlugins(otelPlugin).build();
+ return DurableConfig.builder().withPlugins(new InvocationOtelPlugin()).build();
}
@Override
@@ -149,10 +141,11 @@ The function's execution role needs the `AWSXRayDaemonWriteAccess` managed polic
## Trace Structure
-The plugin creates spans at three levels:
+The plugin creates spans at four levels:
```
-invocation
+Workflow (deterministic ID, exported once on terminal invocation)
+Invocation
├── fetch-data
│ └── fetch-data attempt 1
├── cool-down
@@ -160,7 +153,8 @@ invocation
└── process attempt 1
```
-- **Invocation span** (`SpanKind.SERVER`) — one per Lambda invocation, creates a distinct X-Ray service node
+- **Workflow span** — one logical span per durable execution with a deterministic ID derived from the ARN. Exported only on the terminal invocation (SUCCEEDED/FAILED). Serves as a correlation anchor across invocations.
+- **Invocation span** — one per Lambda invocation
- **Operation span** — one per durable operation, named after your step/wait names
- **Attempt span** — one per user function execution (retries produce additional attempt spans)
@@ -203,18 +197,31 @@ When `enableMdc` is true (default), the plugin injects these fields into SLF4J M
| MDC Key | Description |
|---------|-------------|
-| `traceId` | W3C trace ID (32 hex chars) |
-| `spanId` | Current span ID (16 hex chars) |
+| `trace_id` | W3C trace ID (32 hex chars) |
+| `span_id` | Current span ID (16 hex chars) |
| `traceSampled` | Whether the trace is sampled (true/false) |
-These appear automatically in structured log output (Log4j2 JSON, Logback JSON) for log-trace correlation.
+The `trace_id` is also injected at invocation start so handler-level logs (between steps) include it.
+
+Configure your logging framework (e.g., Log4j2) to include MDC fields in the output. For example, using `JsonLayout`:
+
+```xml
+
+
+
+```
+
+With Lambda's `LoggingConfig: JSON` (required for durable functions), CloudWatch parses the JSON and X-Ray correlates logs via `requestId` (injected by the core SDK's `DurableLogger`).
## Configuration
### Constructor Options
```java
-// Default: X-Ray context extraction, MDC enabled
+// 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
@@ -226,9 +233,19 @@ new InvocationOtelPlugin(tracerProviderBuilder, contextExtractor, enableMdc);
| Parameter | Description | Default |
|-----------|-------------|---------|
-| `tracerProviderBuilder` | `SdkTracerProviderBuilder` with your exporter/processor configured | Required |
+| `tracerProviderBuilder` | `SdkTracerProviderBuilder` with your exporter/processor configured | Not used by `new InvocationOtelPlugin()`; the default constructor uses the ADOT Java agent provider |
| `contextExtractor` | Extracts parent trace context from the Lambda environment | `XRayContextExtractor` |
-| `enableMdc` | If true, injects `traceId`/`spanId`/`traceSampled` into SLF4J MDC | `true` |
+| `enableMdc` | If true, injects `trace_id`/`span_id`/`traceSampled` into SLF4J MDC | `true` |
+
+## Known Limitations
+
+### X-Ray Segments Timeline (ungrouped view)
+
+The plugin's spans do not appear as nested subsegments of the Lambda platform segment in the ungrouped "Segments Timeline" view. This is because the ADOT collector's OTLP-to-X-Ray conversion cannot attach exported spans as subsegments of the Lambda service's native X-Ray segment (created outside the OTLP pipeline). Use the **"Group by nodes"** view to see the full span hierarchy.
+
+### Workflow Span
+
+The Workflow span appears as a separate root segment in the X-Ray trace because it uses `setNoParent()` with a deterministic span ID. This is expected — it serves as a correlation anchor across invocations.
## Verification
@@ -236,24 +253,24 @@ After deploying your function with the plugin configured:
1. **Invoke your durable function** — trigger at least one execution that includes multiple steps or a wait/resume cycle.
-2. **Check CloudWatch console** — Navigate to CloudWatch > Traces. You should see a trace with:
- - An invocation span per Lambda invocation
+2. **Check CloudWatch console** — Navigate to CloudWatch > Traces. Enable "Group by nodes" to see:
+ - A Workflow span covering the entire execution
+ - An Invocation span per Lambda invocation
- Child spans for each durable operation (named after your step names)
- All invocations of the same execution grouped under one trace ID
-3. **Check log correlation** — Verify that your logs include `traceId` and `spanId` fields matching the spans in the trace view.
+3. **Check log correlation** — Verify that the Logs section at the bottom of the trace view shows both platform logs and application logs correlated with the trace.
### Troubleshooting
| Symptom | Likely Cause |
|---------|-------------|
-| No traces appear | ADOT layer not added to the function |
+| No traces appear | ADOT layer not added, or `AWS_LAMBDA_EXEC_WRAPPER` not set |
| Traces appear but are fragmented | X-Ray active tracing not enabled on the Lambda function |
| Missing spans for some operations | Sampling is configured below 1.0 |
| `_X_AMZN_TRACE_ID` not populated | X-Ray active tracing not enabled |
-| Two service nodes in trace map | `AWS_LAMBDA_EXEC_WRAPPER` is set — remove it (see note above) |
-
-> **Note on ADOT wrapper:** Do not set `AWS_LAMBDA_EXEC_WRAPPER`. The wrapper attaches the auto-instrumentation agent which creates a separate TracerProvider. Since the plugin needs its own TracerProvider (for deterministic ID generation), having two providers causes X-Ray to render disconnected service nodes.
+| Plugin spans missing but Lambda/runtime spans appear | Plugin jar not configured in `OTEL_JAVAAGENT_EXTENSIONS` |
+| Logs not correlated | Ensure `LoggingConfig: JSON` is set and logging framework outputs MDC fields |
## Local Development
@@ -271,7 +288,8 @@ var otelPlugin = new InvocationOtelPlugin(
- Java 17+
- AWS Durable Execution SDK for Java 2.0.0+
-- OpenTelemetry SDK 1.30.0+
+- OpenTelemetry SDK 1.64.0+ (only for custom TracerProvider path)
+- ADOT Lambda Layer `AWSOpenTelemetryDistroJava` (for the no-arg constructor path)
## License
diff --git a/otel-plugin/pom.xml b/otel-plugin/pom.xml
index 3c759ab20..f145e1cc1 100644
--- a/otel-plugin/pom.xml
+++ b/otel-plugin/pom.xml
@@ -40,6 +40,13 @@
${opentelemetry.version}
provided
+
+
+ io.opentelemetry
+ opentelemetry-sdk-extension-autoconfigure-spi
+ ${opentelemetry.version}
+ provided
+
diff --git a/otel-plugin/src/main/java/software/amazon/lambda/durable/otel/DeterministicIdGenerator.java b/otel-plugin/src/main/java/software/amazon/lambda/durable/otel/DeterministicIdGenerator.java
index b74ef845b..fa1794eeb 100644
--- a/otel-plugin/src/main/java/software/amazon/lambda/durable/otel/DeterministicIdGenerator.java
+++ b/otel-plugin/src/main/java/software/amazon/lambda/durable/otel/DeterministicIdGenerator.java
@@ -30,6 +30,10 @@
public class DeterministicIdGenerator implements IdGenerator {
private static final IdGenerator RANDOM = IdGenerator.random();
+ private static final String PROPERTY_PREFIX = "software.amazon.lambda.durable.otel.";
+ private static final String EXTRACTED_TRACE_ID_PROPERTY = PROPERTY_PREFIX + "extractedTraceId";
+ private static final String DURABLE_EXECUTION_ARN_PROPERTY = PROPERTY_PREFIX + "durableExecutionArn";
+ private static final String PENDING_SPAN_OPERATION_ID_PROPERTY_PREFIX = PROPERTY_PREFIX + "pendingSpanOperationId.";
private final AtomicReference extractedTraceId = new AtomicReference<>(null);
private final AtomicReference arnDerivedTraceId = new AtomicReference<>(null);
@@ -45,6 +49,7 @@ public class DeterministicIdGenerator implements IdGenerator {
*/
public void setExtractedTraceId(String traceId) {
this.extractedTraceId.set(traceId);
+ setOrClearProperty(EXTRACTED_TRACE_ID_PROPERTY, traceId);
}
/**
@@ -55,7 +60,8 @@ public void setExtractedTraceId(String traceId) {
*/
public void setDurableExecutionArn(String arn) {
this.durableExecutionArn.set(arn);
- this.arnDerivedTraceId.set(generateTraceIdFromArn(arn));
+ this.arnDerivedTraceId.set(arn != null ? generateTraceIdFromArn(arn) : null);
+ setOrClearProperty(DURABLE_EXECUTION_ARN_PROPERTY, arn);
}
/**
@@ -65,6 +71,7 @@ public void setDurableExecutionArn(String arn) {
*/
public void setNextSpanOperationId(String operationId) {
this.pendingSpanOperationId.set(operationId);
+ setOrClearProperty(pendingSpanOperationIdProperty(), operationId);
}
/**
@@ -110,11 +117,18 @@ public String generateWorkflowSpanId() {
public String generateTraceId() {
// Priority 1: extracted from X-Ray header (backend propagates same Root across invocations)
var extracted = extractedTraceId.get();
+ if (extracted == null) {
+ extracted = System.getProperty(EXTRACTED_TRACE_ID_PROPERTY);
+ }
if (extracted != null) {
return extracted;
}
// Priority 2: deterministic from execution ARN (local tests, non-Lambda)
var arnDerived = arnDerivedTraceId.get();
+ if (arnDerived == null) {
+ var arn = System.getProperty(DURABLE_EXECUTION_ARN_PROPERTY);
+ arnDerived = arn != null ? generateTraceIdFromArn(arn) : null;
+ }
if (arnDerived != null) {
return arnDerived;
}
@@ -130,8 +144,12 @@ public String generateSpanId() {
return raw;
}
var operationId = pendingSpanOperationId.get();
+ if (operationId == null) {
+ operationId = System.getProperty(pendingSpanOperationIdProperty());
+ }
if (operationId != null) {
pendingSpanOperationId.remove();
+ System.clearProperty(pendingSpanOperationIdProperty());
return generateSpanIdFromOperation(operationId);
}
return RANDOM.generateSpanId();
@@ -148,6 +166,9 @@ private String generateTraceIdFromArn(String arn) {
*/
private String generateSpanIdFromOperation(String operationId) {
var arn = durableExecutionArn.get();
+ if (arn == null) {
+ arn = System.getProperty(DURABLE_EXECUTION_ARN_PROPERTY);
+ }
var input = arn != null ? arn + ":" + operationId : operationId;
var hash = sha256(input);
return hash.substring(0, 16);
@@ -166,4 +187,26 @@ private static String sha256(String input) {
throw new IllegalStateException("SHA-256 not available", e);
}
}
+
+ static void clearSharedStateForTest() {
+ System.clearProperty(EXTRACTED_TRACE_ID_PROPERTY);
+ System.clearProperty(DURABLE_EXECUTION_ARN_PROPERTY);
+ System.getProperties().stringPropertyNames().stream()
+ .filter(name -> name.startsWith(PENDING_SPAN_OPERATION_ID_PROPERTY_PREFIX))
+ .toList()
+ .forEach(System::clearProperty);
+ }
+
+ private static String pendingSpanOperationIdProperty() {
+ return PENDING_SPAN_OPERATION_ID_PROPERTY_PREFIX
+ + Thread.currentThread().getId();
+ }
+
+ private static void setOrClearProperty(String key, String value) {
+ if (value == null) {
+ System.clearProperty(key);
+ } else {
+ System.setProperty(key, value);
+ }
+ }
}
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 9111b80d2..e8224e77b 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,8 +4,8 @@
import static software.amazon.lambda.durable.otel.SpanAttributes.*;
+import io.opentelemetry.api.GlobalOpenTelemetry;
import io.opentelemetry.api.common.AttributeKey;
-import io.opentelemetry.api.common.Attributes;
import io.opentelemetry.api.trace.Span;
import io.opentelemetry.api.trace.SpanBuilder;
import io.opentelemetry.api.trace.SpanContext;
@@ -14,16 +14,19 @@
import io.opentelemetry.api.trace.TraceFlags;
import io.opentelemetry.api.trace.TraceState;
import io.opentelemetry.api.trace.Tracer;
+import io.opentelemetry.api.trace.TracerProvider;
import io.opentelemetry.context.Context;
import io.opentelemetry.context.Scope;
-import io.opentelemetry.sdk.resources.Resource;
import io.opentelemetry.sdk.trace.SdkTracerProvider;
import io.opentelemetry.sdk.trace.SdkTracerProviderBuilder;
-import io.opentelemetry.semconv.ServiceAttributes;
+import java.nio.file.Files;
+import java.nio.file.Path;
import java.time.Instant;
import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.TimeUnit;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
+import org.slf4j.MDC;
import software.amazon.lambda.durable.plugin.DurableExecutionPlugin;
import software.amazon.lambda.durable.plugin.InvocationEndInfo;
import software.amazon.lambda.durable.plugin.InvocationInfo;
@@ -46,6 +49,18 @@
* Attempt span — one per user function execution (step attempt, child context run)
*
*
+ * Workflow span behavior by provider:
+ *
+ *
+ * When using the ADOT Java agent ({@link #InvocationOtelPlugin()}), the Workflow span appears as a separate root
+ * segment in the X-Ray trace because it uses {@code setNoParent()} with a deterministic span ID. This is expected
+ * — it serves as a correlation anchor across invocations. The Invocation span and its children nest under the
+ * ADOT agent's Lambda segment as subsegments.
+ * When using a custom {@link io.opentelemetry.sdk.trace.SdkTracerProviderBuilder} (no ADOT agent), the Workflow
+ * span is similarly unparented but all spans share the same trace ID. Operation and attempt spans link to the
+ * Workflow span for execution-level correlation.
+ *
+ *
* Trace ID resolution:
*
*
@@ -58,12 +73,20 @@
* Requires the ADOT Lambda Layer for trace export. Configure with:
*
*
- * Lambda Layer: {@code aws-otel-java-agent} (provides the OTLP collector extension)
+ * Lambda Layer: {@code AWSOpenTelemetryDistroJava} (provides the ADOT Java agent and export pipeline)
* Tracing: Active (to populate {@code _X_AMZN_TRACE_ID})
*
*
- * Note: Do NOT set {@code AWS_LAMBDA_EXEC_WRAPPER}. The collector extension runs independently. The wrapper would
- * attach the auto-instrumentation agent which creates a competing TracerProvider.
+ *
When using {@link #InvocationOtelPlugin()}, the plugin requires
+ * {@code OtelPluginAutoConfigurationCustomizerProvider} to have been installed by the OpenTelemetry Java agent and uses
+ * the global provider directly.
+ *
+ *
X-Ray console limitation: In the X-Ray "Segments Timeline" ungrouped view, the plugin's spans (Invocation,
+ * operation, attempt) do not appear as nested subsegments of the Lambda platform segment. This is a known limitation of
+ * the OTLP-to-X-Ray conversion: the ADOT collector cannot attach OTLP-exported spans as subsegments of the Lambda
+ * service's native X-Ray segment because that segment is created outside the OTLP pipeline. Use the "Group by nodes"
+ * view to see the full span hierarchy correctly — it stitches all spans together by trace ID and parent-child
+ * relationships regardless of segment boundaries.
*
*
Thread-safe: uses {@link ConcurrentHashMap} for span/scope storage since the SDK runs user code on multiple
* threads.
@@ -77,7 +100,7 @@ public class InvocationOtelPlugin implements DurableExecutionPlugin {
private static final String INSTRUMENTATION_NAME = "aws-durable-execution-sdk-java";
private static final String DEFAULT_WORKFLOW_SPAN_NAME = "Workflow";
- private final SdkTracerProvider tracerProvider;
+ private final SdkTracerProvider sdkTracerProvider;
private final Tracer tracer;
private final DeterministicIdGenerator idGenerator;
private final ContextExtractor contextExtractor;
@@ -105,12 +128,13 @@ public class InvocationOtelPlugin implements DurableExecutionPlugin {
*
Uses the provided tracer provider builder. Customers configure exporters and span processors on the builder —
* the plugin handles ID generation.
*
- *
For ADOT layer usage, configure with an OTLP exporter:
+ *
For ADOT Java agent usage, prefer {@link #InvocationOtelPlugin()} with the plugin jar configured through
+ * {@code OTEL_JAVAAGENT_EXTENSIONS}. Use this builder constructor when you want to own the exporter pipeline:
*
*
{@code
- * var otlpExporter = OtlpGrpcSpanExporter.getDefault(); // sends to localhost:4317
+ * var exporter = LoggingSpanExporter.create();
* var plugin = new InvocationOtelPlugin(
- * SdkTracerProvider.builder().addSpanProcessor(SimpleSpanProcessor.create(otlpExporter)));
+ * SdkTracerProvider.builder().addSpanProcessor(SimpleSpanProcessor.create(exporter)));
* }
*
* @param tracerProviderBuilder the tracer provider builder (ID generator will be overridden)
@@ -119,6 +143,16 @@ public InvocationOtelPlugin(SdkTracerProviderBuilder tracerProviderBuilder) {
this(tracerProviderBuilder, new XRayContextExtractor(), true);
}
+ /**
+ * Creates an OTel plugin with default settings: X-Ray context extraction and MDC enabled.
+ *
+ * Uses {@code GlobalOpenTelemetry} directly and assumes deterministic ID generation was installed by
+ * {@code OtelPluginAutoConfigurationCustomizerProvider}.
+ */
+ public InvocationOtelPlugin() {
+ this(getDefaultTracerProvider(), createDefaultIdGenerator());
+ }
+
/**
* Creates an OTel plugin with a custom context extractor, MDC enabled.
*
@@ -156,17 +190,24 @@ public InvocationOtelPlugin(
String workflowSpanName) {
this.idGenerator = new DeterministicIdGenerator();
- // Set service.name to "invocation" for the exported spans' resource.
- var resource = Resource.create(Attributes.of(ServiceAttributes.SERVICE_NAME, "invocation"));
- tracerProviderBuilder.addResource(resource);
-
- this.tracerProvider = tracerProviderBuilder.setIdGenerator(idGenerator).build();
- this.tracer = tracerProvider.get(INSTRUMENTATION_NAME);
+ this.sdkTracerProvider =
+ tracerProviderBuilder.setIdGenerator(idGenerator).build();
+ this.tracer = sdkTracerProvider.get(INSTRUMENTATION_NAME);
this.contextExtractor = contextExtractor;
this.enableMdc = enableMdc;
this.workflowSpanName = workflowSpanName != null ? workflowSpanName : DEFAULT_WORKFLOW_SPAN_NAME;
}
+ private InvocationOtelPlugin(TracerProvider tracerProvider, DeterministicIdGenerator idGenerator) {
+ this.idGenerator = idGenerator;
+ this.sdkTracerProvider = getSdkTracerProviderForFlush(tracerProvider);
+ this.tracer = tracerProvider.get(INSTRUMENTATION_NAME);
+
+ this.contextExtractor = new XRayContextExtractor();
+ this.enableMdc = true;
+ this.workflowSpanName = DEFAULT_WORKFLOW_SPAN_NAME;
+ }
+
// ─── Invocation hooks ────────────────────────────────────────────────
@Override
@@ -178,10 +219,15 @@ public void onInvocationStart(InvocationInfo info) {
// Extract trace context from environment (X-Ray header)
var extractedContext = contextExtractor.extract();
+ if (extractedContext == null) {
+ extractedContext = extractCurrentSpanContext();
+ }
if (extractedContext != null) {
// Use the X-Ray trace ID — backend propagates same Root across all invocations
idGenerator.setExtractedTraceId(extractedContext.traceId());
+ } else {
+ idGenerator.setExtractedTraceId(null);
}
// If no extracted context, idGenerator falls back to ARN-derived trace ID
@@ -200,8 +246,8 @@ public void onInvocationStart(InvocationInfo info) {
// Determine parent context for the invocation span.
Context parentContext;
if (extractedContext != null && extractedContext.parentSpanId() != null) {
- // X-Ray header has parent — create the invocation span as a child of that segment.
- // This connects our OTLP-exported spans to the Lambda service's X-Ray segments.
+ // Reconstruct a remote parent from the extracted trace context (X-Ray header or current span).
+ // This connects plugin spans to the Lambda service's X-Ray segments.
var parentSpanContext = SpanContext.createFromRemoteParent(
extractedContext.traceId(),
extractedContext.parentSpanId(),
@@ -224,10 +270,22 @@ public void onInvocationStart(InvocationInfo info) {
}
invocationSpan = spanBuilder.startSpan();
+
+ // Inject MDC on the handler thread so handler-level logs (between steps) have trace context.
+ // This runs on the same thread as context.getLogger() calls in the handler.
+ if (enableMdc) {
+ var traceId = idGenerator.generateTraceId();
+ MDC.put(MdcSpanEnricher.MDC_TRACE_ID, traceId);
+ }
}
@Override
public void onInvocationEnd(InvocationEndInfo info) {
+ // Clear invocation-level MDC (set in onInvocationStart on the handler thread)
+ if (enableMdc) {
+ MdcSpanEnricher.clear();
+ }
+
if (invocationSpan == null) return;
// End still-open operation spans without stamping a status — no terminal
@@ -301,10 +359,12 @@ public void onInvocationEnd(InvocationEndInfo info) {
workflowSpan = null;
}
- // Flush spans before Lambda freezes
- var flushResult = tracerProvider.forceFlush().join(5, java.util.concurrent.TimeUnit.SECONDS);
- if (!flushResult.isSuccess()) {
- logger.warn("OTel span flush failed or timed out — some spans may be lost");
+ if (sdkTracerProvider != null) {
+ // Flush spans before Lambda freezes
+ var flushResult = sdkTracerProvider.forceFlush().join(5, TimeUnit.SECONDS);
+ if (!flushResult.isSuccess()) {
+ logger.warn("OTel span flush failed or timed out — some spans may be lost");
+ }
}
}
@@ -489,9 +549,9 @@ public void onUserFunctionEnd(UserFunctionEndInfo info) {
scope.close();
}
- // Clear MDC after user function completes
+ // Clear span-level MDC after user function completes (keep trace_id for handler-level logs between steps)
if (enableMdc) {
- MdcSpanEnricher.clear();
+ MDC.remove(MdcSpanEnricher.MDC_SPAN_ID);
}
// CONTEXT operations don't have attempt spans — scope cleanup is all we need
@@ -572,4 +632,82 @@ private static String attemptSpanName(String type, String subType, String name,
private static String attemptKey(String operationId, Integer attempt) {
return operationId + "-" + (attempt != null ? attempt : "ctx");
}
+
+ private static ExtractedContext extractCurrentSpanContext() {
+ var spanContext = Span.current().getSpanContext();
+ if (!spanContext.isValid()) {
+ return null;
+ }
+ return new ExtractedContext(spanContext.getTraceId(), spanContext.getSpanId());
+ }
+
+ private static TracerProvider getDefaultTracerProvider() {
+ validateAutoConfigurationCustomizerProviderInstalled();
+
+ var globalTracerProvider = GlobalOpenTelemetry.getTracerProvider();
+ if (globalTracerProvider == TracerProvider.noop()) {
+ throw new IllegalStateException("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;
+ }
+
+ 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;
+ }
}
diff --git a/otel-plugin/src/main/java/software/amazon/lambda/durable/otel/MdcSpanEnricher.java b/otel-plugin/src/main/java/software/amazon/lambda/durable/otel/MdcSpanEnricher.java
index 2b769e308..e5a49e1cc 100644
--- a/otel-plugin/src/main/java/software/amazon/lambda/durable/otel/MdcSpanEnricher.java
+++ b/otel-plugin/src/main/java/software/amazon/lambda/durable/otel/MdcSpanEnricher.java
@@ -9,13 +9,13 @@
* Injects OTel trace/span IDs into SLF4J MDC for log-trace correlation.
*
* When used with structured logging (Log4j2 JSON, Logback JSON), these MDC fields appear in every log line, enabling
- * tools like CloudWatch Logs Insights and Datadog to correlate logs with traces.
+ * tools like CloudWatch Application Signals and Datadog to correlate logs with traces.
*
*
MDC keys injected:
*
*
- * {@code traceId} — the W3C trace ID (32 hex chars)
- * {@code spanId} — the current span ID (16 hex chars)
+ * {@code trace_id} — the W3C trace ID (32 hex chars)
+ * {@code span_id} — the current span ID (16 hex chars)
* {@code traceSampled} — whether the trace is sampled (true/false)
*
*
@@ -28,8 +28,8 @@
@Deprecated
public final class MdcSpanEnricher {
- public static final String MDC_TRACE_ID = "traceId";
- public static final String MDC_SPAN_ID = "spanId";
+ public static final String MDC_TRACE_ID = "trace_id";
+ public static final String MDC_SPAN_ID = "span_id";
public static final String MDC_TRACE_SAMPLED = "traceSampled";
private MdcSpanEnricher() {}
diff --git a/otel-plugin/src/main/java/software/amazon/lambda/durable/otel/OtelPluginAutoConfigurationCustomizerProvider.java b/otel-plugin/src/main/java/software/amazon/lambda/durable/otel/OtelPluginAutoConfigurationCustomizerProvider.java
new file mode 100644
index 000000000..23ac19ecd
--- /dev/null
+++ b/otel-plugin/src/main/java/software/amazon/lambda/durable/otel/OtelPluginAutoConfigurationCustomizerProvider.java
@@ -0,0 +1,23 @@
+// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: Apache-2.0
+package software.amazon.lambda.durable.otel;
+
+import io.opentelemetry.sdk.autoconfigure.spi.AutoConfigurationCustomizer;
+import io.opentelemetry.sdk.autoconfigure.spi.AutoConfigurationCustomizerProvider;
+
+/**
+ * Installs the durable-execution ID generator when the OpenTelemetry Java agent auto-configures the SDK.
+ *
+ * @deprecated This is a preview API that is experimental and may be changed or removed in future releases.
+ */
+@Deprecated
+public final class OtelPluginAutoConfigurationCustomizerProvider implements AutoConfigurationCustomizerProvider {
+
+ private static final DeterministicIdGenerator ID_GENERATOR = new DeterministicIdGenerator();
+
+ @Override
+ public void customize(AutoConfigurationCustomizer autoConfiguration) {
+ OtelPluginAutoConfigurationState.markInstalled();
+ autoConfiguration.addTracerProviderCustomizer((builder, config) -> builder.setIdGenerator(ID_GENERATOR));
+ }
+}
diff --git a/otel-plugin/src/main/java/software/amazon/lambda/durable/otel/OtelPluginAutoConfigurationState.java b/otel-plugin/src/main/java/software/amazon/lambda/durable/otel/OtelPluginAutoConfigurationState.java
new file mode 100644
index 000000000..765489b69
--- /dev/null
+++ b/otel-plugin/src/main/java/software/amazon/lambda/durable/otel/OtelPluginAutoConfigurationState.java
@@ -0,0 +1,23 @@
+// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: Apache-2.0
+package software.amazon.lambda.durable.otel;
+
+final class OtelPluginAutoConfigurationState {
+
+ private static final String INSTALLED_PROPERTY =
+ "software.amazon.lambda.durable.otel.autoConfigurationCustomizerProviderInstalled";
+
+ private OtelPluginAutoConfigurationState() {}
+
+ static boolean isInstalled() {
+ return Boolean.getBoolean(INSTALLED_PROPERTY);
+ }
+
+ static void markInstalled() {
+ System.setProperty(INSTALLED_PROPERTY, Boolean.TRUE.toString());
+ }
+
+ static void resetInstalledForTest() {
+ System.clearProperty(INSTALLED_PROPERTY);
+ }
+}
diff --git a/otel-plugin/src/main/resources/META-INF/services/io.opentelemetry.sdk.autoconfigure.spi.AutoConfigurationCustomizerProvider b/otel-plugin/src/main/resources/META-INF/services/io.opentelemetry.sdk.autoconfigure.spi.AutoConfigurationCustomizerProvider
new file mode 100644
index 000000000..3aa2fb591
--- /dev/null
+++ b/otel-plugin/src/main/resources/META-INF/services/io.opentelemetry.sdk.autoconfigure.spi.AutoConfigurationCustomizerProvider
@@ -0,0 +1 @@
+software.amazon.lambda.durable.otel.OtelPluginAutoConfigurationCustomizerProvider
diff --git a/otel-plugin/src/test/java/io/opentelemetry/javaagent/testing/FakeJavaAgentTracerProvider.java b/otel-plugin/src/test/java/io/opentelemetry/javaagent/testing/FakeJavaAgentTracerProvider.java
new file mode 100644
index 000000000..db99b5bdb
--- /dev/null
+++ b/otel-plugin/src/test/java/io/opentelemetry/javaagent/testing/FakeJavaAgentTracerProvider.java
@@ -0,0 +1,31 @@
+// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: Apache-2.0
+package io.opentelemetry.javaagent.testing;
+
+import io.opentelemetry.api.trace.Tracer;
+import io.opentelemetry.api.trace.TracerBuilder;
+import io.opentelemetry.api.trace.TracerProvider;
+
+public final class FakeJavaAgentTracerProvider implements TracerProvider {
+
+ private final TracerProvider delegate;
+
+ public FakeJavaAgentTracerProvider(TracerProvider agentTracerProvider) {
+ this.delegate = agentTracerProvider;
+ }
+
+ @Override
+ public Tracer get(String instrumentationName) {
+ return delegate.get(instrumentationName);
+ }
+
+ @Override
+ public Tracer get(String instrumentationName, String instrumentationVersion) {
+ return delegate.get(instrumentationName, instrumentationVersion);
+ }
+
+ @Override
+ public TracerBuilder tracerBuilder(String instrumentationScopeName) {
+ return delegate.tracerBuilder(instrumentationScopeName);
+ }
+}
diff --git a/otel-plugin/src/test/java/software/amazon/lambda/durable/otel/DeterministicIdGeneratorTest.java b/otel-plugin/src/test/java/software/amazon/lambda/durable/otel/DeterministicIdGeneratorTest.java
index bc1a5a90d..6133a3ddb 100644
--- a/otel-plugin/src/test/java/software/amazon/lambda/durable/otel/DeterministicIdGeneratorTest.java
+++ b/otel-plugin/src/test/java/software/amazon/lambda/durable/otel/DeterministicIdGeneratorTest.java
@@ -4,6 +4,7 @@
import static org.junit.jupiter.api.Assertions.*;
+import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
@@ -13,9 +14,15 @@ class DeterministicIdGeneratorTest {
@BeforeEach
void setUp() {
+ DeterministicIdGenerator.clearSharedStateForTest();
generator = new DeterministicIdGenerator();
}
+ @AfterEach
+ void tearDown() {
+ DeterministicIdGenerator.clearSharedStateForTest();
+ }
+
@Test
void generateTraceId_withoutArn_returnsRandom() {
var id1 = generator.generateTraceId();
@@ -120,6 +127,18 @@ void generateSpanIdForOperation_isDeterministic() {
assertEquals(id1, id2);
}
+ @Test
+ void generatedIds_areSharedAcrossGeneratorInstances() {
+ var pluginGenerator = new DeterministicIdGenerator();
+ var agentGenerator = new DeterministicIdGenerator();
+
+ pluginGenerator.setDurableExecutionArn("arn:exec1");
+ pluginGenerator.setNextSpanOperationId("op-1");
+
+ assertEquals(pluginGenerator.generateTraceId(), agentGenerator.generateTraceId());
+ assertEquals(pluginGenerator.generateSpanIdForOperation("op-1"), agentGenerator.generateSpanId());
+ }
+
@Test
void traceId_isValidHex() {
generator.setDurableExecutionArn("arn:exec1");
diff --git a/otel-plugin/src/test/java/software/amazon/lambda/durable/otel/InvocationOtelPluginIntegrationTest.java b/otel-plugin/src/test/java/software/amazon/lambda/durable/otel/InvocationOtelPluginIntegrationTest.java
index c6961a827..d4e85c241 100644
--- a/otel-plugin/src/test/java/software/amazon/lambda/durable/otel/InvocationOtelPluginIntegrationTest.java
+++ b/otel-plugin/src/test/java/software/amazon/lambda/durable/otel/InvocationOtelPluginIntegrationTest.java
@@ -4,7 +4,12 @@
import static org.junit.jupiter.api.Assertions.*;
+import io.opentelemetry.api.GlobalOpenTelemetry;
+import io.opentelemetry.api.OpenTelemetry;
import io.opentelemetry.api.trace.StatusCode;
+import io.opentelemetry.context.propagation.ContextPropagators;
+import io.opentelemetry.javaagent.testing.FakeJavaAgentTracerProvider;
+import io.opentelemetry.sdk.OpenTelemetrySdk;
import io.opentelemetry.sdk.testing.exporter.InMemorySpanExporter;
import io.opentelemetry.sdk.trace.SdkTracerProvider;
import io.opentelemetry.sdk.trace.data.SpanData;
@@ -13,6 +18,7 @@
import java.time.Duration;
import java.util.List;
import java.util.concurrent.atomic.AtomicInteger;
+import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import software.amazon.awssdk.services.lambda.model.ErrorObject;
@@ -34,6 +40,8 @@ class InvocationOtelPluginIntegrationTest {
@BeforeEach
void setUp() {
+ DeterministicIdGenerator.clearSharedStateForTest();
+ OtelPluginAutoConfigurationState.resetInstalledForTest();
spanExporter = InMemorySpanExporter.create();
var plugin = new InvocationOtelPlugin(
@@ -44,6 +52,13 @@ void setUp() {
otelConfig = DurableConfig.builder().withPlugins(plugin).build();
}
+ @AfterEach
+ void tearDown() {
+ GlobalOpenTelemetry.resetForTest();
+ DeterministicIdGenerator.clearSharedStateForTest();
+ OtelPluginAutoConfigurationState.resetInstalledForTest();
+ }
+
@Test
void simpleStep_producesInvocationAndOperationAndAttemptSpans() {
var runner = LocalDurableTestRunner.create(
@@ -511,4 +526,71 @@ void waitForCondition_producesSpansWithAttempts() {
"Should have waitForCondition span. Got: "
+ spans.stream().map(SpanData::getName).toList());
}
+
+ @Test
+ void defaultConstructor_usesGlobalSdkTracerProviderDirectly() {
+ OtelPluginAutoConfigurationState.markInstalled();
+ GlobalOpenTelemetry.resetForTest();
+ var globalExporter = InMemorySpanExporter.create();
+ var globalTracerProvider = SdkTracerProvider.builder()
+ .addSpanProcessor(SimpleSpanProcessor.create(globalExporter))
+ .build();
+ OpenTelemetrySdk.builder().setTracerProvider(globalTracerProvider).buildAndRegisterGlobal();
+
+ var defaultConfig =
+ DurableConfig.builder().withPlugins(new InvocationOtelPlugin()).build();
+ var runner = LocalDurableTestRunner.create(
+ String.class,
+ (input, ctx) -> ctx.step("global-step", String.class, stepCtx -> "Hello " + input),
+ defaultConfig);
+
+ var result = runner.runUntilComplete("World");
+ assertEquals(ExecutionStatus.SUCCEEDED, result.getStatus());
+
+ var spans = globalExporter.getFinishedSpanItems();
+ assertTrue(spans.size() >= 4, "Expected at least 4 spans, got " + spans.size());
+ assertSpanExists(spans, "Invocation");
+ assertSpanExists(spans, "global-step");
+ assertSpanExists(spans, "global-step attempt 1");
+ }
+
+ @Test
+ void defaultConstructor_usesJavaAgentGlobalTracerProviderDirectly_withSeparateAutoConfiguredIdGenerator() {
+ OtelPluginAutoConfigurationState.markInstalled();
+ GlobalOpenTelemetry.resetForTest();
+ var globalExporter = InMemorySpanExporter.create();
+ var javaAgentIdGenerator = new DeterministicIdGenerator();
+ var sdkTracerProvider = SdkTracerProvider.builder()
+ .setIdGenerator(javaAgentIdGenerator)
+ .addSpanProcessor(SimpleSpanProcessor.create(globalExporter))
+ .build();
+ var javaAgentTracerProvider = new FakeJavaAgentTracerProvider(sdkTracerProvider);
+ GlobalOpenTelemetry.set(new OpenTelemetry() {
+ @Override
+ public io.opentelemetry.api.trace.TracerProvider getTracerProvider() {
+ return javaAgentTracerProvider;
+ }
+
+ @Override
+ public ContextPropagators getPropagators() {
+ return ContextPropagators.noop();
+ }
+ });
+
+ var defaultConfig =
+ DurableConfig.builder().withPlugins(new InvocationOtelPlugin()).build();
+ var runner = LocalDurableTestRunner.create(
+ String.class,
+ (input, ctx) -> ctx.step("javaagent-step", String.class, stepCtx -> "Hello " + input),
+ defaultConfig);
+
+ var result = runner.runUntilComplete("World");
+ assertEquals(ExecutionStatus.SUCCEEDED, result.getStatus());
+
+ var spans = globalExporter.getFinishedSpanItems();
+ assertTrue(spans.size() >= 4, "Expected at least 4 spans, got " + spans.size());
+ assertSpanExists(spans, "Invocation");
+ assertSpanExists(spans, "javaagent-step");
+ assertSpanExists(spans, "javaagent-step attempt 1");
+ }
}
diff --git a/otel-plugin/src/test/java/software/amazon/lambda/durable/otel/InvocationOtelPluginTest.java b/otel-plugin/src/test/java/software/amazon/lambda/durable/otel/InvocationOtelPluginTest.java
index 2536d0ad3..39971f667 100644
--- a/otel-plugin/src/test/java/software/amazon/lambda/durable/otel/InvocationOtelPluginTest.java
+++ b/otel-plugin/src/test/java/software/amazon/lambda/durable/otel/InvocationOtelPluginTest.java
@@ -3,16 +3,38 @@
package software.amazon.lambda.durable.otel;
import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+import io.opentelemetry.api.GlobalOpenTelemetry;
+import io.opentelemetry.api.OpenTelemetry;
import io.opentelemetry.api.common.AttributeKey;
+import io.opentelemetry.api.trace.Span;
+import io.opentelemetry.api.trace.SpanContext;
import io.opentelemetry.api.trace.SpanKind;
import io.opentelemetry.api.trace.StatusCode;
+import io.opentelemetry.api.trace.TraceFlags;
+import io.opentelemetry.api.trace.TraceState;
+import io.opentelemetry.context.propagation.ContextPropagators;
+import io.opentelemetry.javaagent.testing.FakeJavaAgentTracerProvider;
+import io.opentelemetry.sdk.OpenTelemetrySdk;
+import io.opentelemetry.sdk.autoconfigure.spi.AutoConfigurationCustomizer;
+import io.opentelemetry.sdk.autoconfigure.spi.AutoConfigurationCustomizerProvider;
+import io.opentelemetry.sdk.autoconfigure.spi.ConfigProperties;
import io.opentelemetry.sdk.testing.exporter.InMemorySpanExporter;
import io.opentelemetry.sdk.trace.SdkTracerProvider;
+import io.opentelemetry.sdk.trace.SdkTracerProviderBuilder;
import io.opentelemetry.sdk.trace.export.SimpleSpanProcessor;
import java.time.Instant;
+import java.util.ServiceLoader;
+import java.util.concurrent.TimeUnit;
+import java.util.function.BiFunction;
+import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
import software.amazon.lambda.durable.execution.SuspendExecutionException;
import software.amazon.lambda.durable.plugin.*;
@@ -23,6 +45,8 @@ class InvocationOtelPluginTest {
@BeforeEach
void setUp() {
+ DeterministicIdGenerator.clearSharedStateForTest();
+ OtelPluginAutoConfigurationState.resetInstalledForTest();
spanExporter = InMemorySpanExporter.create();
plugin = new InvocationOtelPlugin(
@@ -31,6 +55,167 @@ void setUp() {
false);
}
+ @AfterEach
+ void tearDown() {
+ GlobalOpenTelemetry.resetForTest();
+ DeterministicIdGenerator.clearSharedStateForTest();
+ OtelPluginAutoConfigurationState.resetInstalledForTest();
+ }
+
+ @Test
+ void defaultConstructor_throwsWhenAutoConfigurationCustomizerProviderIsNotInstalled() {
+ GlobalOpenTelemetry.resetForTest();
+
+ var error = assertThrows(IllegalStateException.class, InvocationOtelPlugin::new);
+
+ assertTrue(error.getMessage().contains("OtelPluginAutoConfigurationCustomizerProvider"));
+ assertTrue(error.getMessage().contains("OTEL_JAVAAGENT_EXTENSIONS"));
+ }
+
+ @Test
+ void defaultConstructor_throwsWhenGlobalOpenTelemetryIsNotInitializedBySpi() {
+ OtelPluginAutoConfigurationState.markInstalled();
+ GlobalOpenTelemetry.resetForTest();
+
+ var error = assertThrows(IllegalStateException.class, InvocationOtelPlugin::new);
+
+ assertTrue(error.getMessage().contains("GlobalOpenTelemetry"));
+ assertTrue(error.getMessage().contains("OtelPluginAutoConfigurationCustomizerProvider"));
+ }
+
+ @Test
+ void defaultConstructor_usesGlobalSdkTracerProviderDirectly() {
+ OtelPluginAutoConfigurationState.markInstalled();
+ GlobalOpenTelemetry.resetForTest();
+ var globalExporter = InMemorySpanExporter.create();
+ var globalTracerProvider = SdkTracerProvider.builder()
+ .addSpanProcessor(SimpleSpanProcessor.create(globalExporter))
+ .build();
+ OpenTelemetrySdk.builder().setTracerProvider(globalTracerProvider).buildAndRegisterGlobal();
+
+ var defaultPlugin = new InvocationOtelPlugin();
+ 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();
+ // Plugin creates Workflow + Invocation + operation spans
+ assertEquals(3, spans.size());
+ assertTrue(spans.stream().anyMatch(span -> span.getName().equals("step")));
+ }
+
+ @Test
+ void defaultConstructor_usesJavaAgentGlobalTracerProviderDirectly_withSeparateAutoConfiguredIdGenerator() {
+ OtelPluginAutoConfigurationState.markInstalled();
+ GlobalOpenTelemetry.resetForTest();
+ var globalExporter = InMemorySpanExporter.create();
+ var javaAgentIdGenerator = new DeterministicIdGenerator();
+ var sdkTracerProvider = SdkTracerProvider.builder()
+ .setIdGenerator(javaAgentIdGenerator)
+ .addSpanProcessor(SimpleSpanProcessor.create(globalExporter))
+ .build();
+ var javaAgentTracerProvider = new FakeJavaAgentTracerProvider(sdkTracerProvider);
+ GlobalOpenTelemetry.set(new OpenTelemetry() {
+ @Override
+ public io.opentelemetry.api.trace.TracerProvider getTracerProvider() {
+ return javaAgentTracerProvider;
+ }
+
+ @Override
+ public ContextPropagators getPropagators() {
+ return ContextPropagators.noop();
+ }
+ });
+
+ var defaultPlugin = new InvocationOtelPlugin();
+ 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();
+ // Plugin creates Workflow + Invocation + operation spans
+ assertEquals(3, spans.size());
+ assertTrue(spans.stream().anyMatch(span -> span.getName().equals("step")));
+ var expectedIds = new DeterministicIdGenerator();
+ expectedIds.setDurableExecutionArn("arn:exec1");
+ var stepSpan = spans.stream()
+ .filter(span -> span.getName().equals("step"))
+ .findFirst()
+ .orElseThrow();
+ assertEquals(expectedIds.generateSpanIdForOperation("op-1"), stepSpan.getSpanId());
+ }
+
+ @Test
+ void autoConfigurationCustomizerProvider_installsSharedDeterministicIdGenerator() {
+ OtelPluginAutoConfigurationState.resetInstalledForTest();
+ var exporter = InMemorySpanExporter.create();
+ var autoConfiguration = mock(AutoConfigurationCustomizer.class);
+ when(autoConfiguration.addTracerProviderCustomizer(any())).thenReturn(autoConfiguration);
+
+ new OtelPluginAutoConfigurationCustomizerProvider().customize(autoConfiguration);
+ assertTrue(OtelPluginAutoConfigurationState.isInstalled());
+
+ @SuppressWarnings("unchecked")
+ var customizer = ArgumentCaptor.forClass(BiFunction.class);
+ verify(autoConfiguration).addTracerProviderCustomizer(customizer.capture());
+
+ var pluginGenerator = new DeterministicIdGenerator();
+ pluginGenerator.setDurableExecutionArn("arn:spi");
+ pluginGenerator.setNextSpanOperationId("op-spi");
+
+ @SuppressWarnings("unchecked")
+ var tracerProviderCustomizer =
+ (BiFunction)
+ customizer.getValue();
+ var tracerProvider = tracerProviderCustomizer
+ .apply(SdkTracerProvider.builder().addSpanProcessor(SimpleSpanProcessor.create(exporter)), null)
+ .build();
+
+ var span = tracerProvider.get("test").spanBuilder("step").startSpan();
+ span.end();
+ tracerProvider.forceFlush().join(5, TimeUnit.SECONDS);
+
+ var spans = exporter.getFinishedSpanItems();
+ assertEquals(1, spans.size());
+ assertEquals(
+ pluginGenerator.generateSpanIdForOperation("op-spi"),
+ spans.get(0).getSpanId());
+ }
+
+ @Test
+ void autoConfigurationCustomizerProvider_isRegisteredAsServiceProvider() {
+ assertTrue(ServiceLoader.load(AutoConfigurationCustomizerProvider.class).stream()
+ .anyMatch(provider -> provider.type().equals(OtelPluginAutoConfigurationCustomizerProvider.class)));
+ }
+
+ @Test
+ void invocationStart_usesCurrentSpanContext_whenExtractorReturnsNull() {
+ var traceId = "5759e988bd862e3fe1be46a994272793";
+ var parentSpanId = "53995c3f42cd8ad8";
+ var parentSpanContext =
+ SpanContext.create(traceId, parentSpanId, TraceFlags.getSampled(), TraceState.getDefault());
+
+ try (var ignored = Span.wrap(parentSpanContext).makeCurrent()) {
+ plugin.onInvocationStart(new InvocationInfo("req-1", "arn:exec1", true, Instant.now()));
+ }
+ plugin.onInvocationEnd(new InvocationEndInfo("req-1", "arn:exec1", true, InvocationStatus.SUCCEEDED, null));
+
+ var invocationSpan = spanExporter.getFinishedSpanItems().stream()
+ .filter(span -> span.getName().equals("Invocation"))
+ .findFirst()
+ .orElseThrow();
+ assertEquals(traceId, invocationSpan.getTraceId());
+ assertEquals(parentSpanId, invocationSpan.getParentSpanId());
+ }
+
@Test
void invocationStart_and_end_createsSpan() {
plugin.onInvocationStart(new InvocationInfo(
diff --git a/otel-plugin/src/test/java/software/amazon/lambda/durable/otel/MdcSpanEnricherTest.java b/otel-plugin/src/test/java/software/amazon/lambda/durable/otel/MdcSpanEnricherTest.java
index 0c896e141..f7e7174a3 100644
--- a/otel-plugin/src/test/java/software/amazon/lambda/durable/otel/MdcSpanEnricherTest.java
+++ b/otel-plugin/src/test/java/software/amazon/lambda/durable/otel/MdcSpanEnricherTest.java
@@ -64,12 +64,17 @@ void plugin_withMdcEnabled_setsFieldsInMdc() {
plugin.onUserFunctionEnd(new UserFunctionEndInfo(
"op-1", "step", "STEP", "Step", null, Instant.now(), Instant.now(), false, 1, true, null));
- // MDC should be cleared after onUserFunctionEnd
- assertNull(MDC.get(MdcSpanEnricher.MDC_TRACE_ID));
- assertNull(MDC.get(MdcSpanEnricher.MDC_SPAN_ID));
- assertNull(MDC.get(MdcSpanEnricher.MDC_TRACE_SAMPLED));
+ // After onUserFunctionEnd: span_id is cleared, but trace_id remains for handler-level logs between steps
+ assertNotNull(MDC.get(MdcSpanEnricher.MDC_TRACE_ID), "trace_id should persist between steps");
+ assertNull(MDC.get(MdcSpanEnricher.MDC_SPAN_ID), "span_id should be cleared after step");
+ assertNotNull(MDC.get(MdcSpanEnricher.MDC_TRACE_SAMPLED), "trace_flags should persist between steps");
plugin.onInvocationEnd(
new InvocationEndInfo("req-1", "arn:exec-mdc-test", true, InvocationStatus.SUCCEEDED, null));
+
+ // After onInvocationEnd: all MDC fields are cleared
+ assertNull(MDC.get(MdcSpanEnricher.MDC_TRACE_ID));
+ assertNull(MDC.get(MdcSpanEnricher.MDC_SPAN_ID));
+ assertNull(MDC.get(MdcSpanEnricher.MDC_TRACE_SAMPLED));
}
}