diff --git a/otel-plugin/README.md b/otel-plugin/README.md
index dcf5d1b4d..87d36a197 100644
--- a/otel-plugin/README.md
+++ b/otel-plugin/README.md
@@ -173,9 +173,9 @@ Lambda/X-Ray parent
└── process attempt 1
```
-- **Workflow span** — one logical root per durable execution with a deterministic, X-Ray-compatible trace ID derived from the execution start time and ARN, plus a stable span ID derived from the ARN. Exported only on the terminal invocation (SUCCEEDED/FAILED).
+- **Workflow span** — one logical root per durable execution with a deterministic, X-Ray-compatible trace ID derived from the execution start time and ARN, plus a stable span ID derived from the ARN. Started and ended on the terminal invocation (SUCCEEDED/FAILED), so it is exported exactly once. On non-terminal invocations (PENDING/RETRYING) it is represented only by its deterministic span context, which operations parent onto or link to.
- **Invocation span** — one per Lambda invocation, parented to ambient context when available
-- **Operation span** — one per durable operation, named after your step/wait names
+- **Operation span** — one per durable operation, named after your step/wait names. In `ExecutionOtelPlugin` an operation span is emitted when the operation completes, so an operation that suspends and resumes in a later invocation is exported once, by the invocation that completes it.
- **Attempt span** — one per user function execution (retries produce additional attempt spans)
Operation and attempt spans link to the Workflow span. `ExecutionOtelPlugin` reverses that relationship: operations are children of Workflow and link to the current Invocation span.
@@ -307,6 +307,19 @@ The plugin's spans do not appear as nested subsegments of the Lambda platform se
The Workflow span appears in a separate deterministic trace because it uses `setNoParent()`. Invocation spans remain in the ambient Lambda/X-Ray trace. Links correlate durable operations with the other trace.
+### Sampling
+
+Because the Workflow span is a root (`setNoParent()`), its sampling is decided by the configured sampler's **root** behavior, not by the ambient Lambda/X-Ray parent. The Invocation span, which is parented to the ambient context, follows that parent instead. With a parent-based sampler these two can differ — for example `parentBased(alwaysOff())` with a sampled ambient parent keeps the Invocation span but drops the Workflow span.
+
+Each plugin keeps its own subtree internally consistent:
+
+- `ExecutionOtelPlugin` parents operations to the Workflow span, so it resolves the Workflow root's sampling decision from the provider's sampler and applies it to operation and attempt spans. The Workflow trace is therefore exported as a whole or dropped as a whole — a dropped Workflow span never leaves orphaned operations behind.
+- `InvocationOtelPlugin` parents operations to the Invocation span, so operations and attempts follow the Invocation span's decision. The Workflow span is only a link target, and a link to a dropped Workflow span does not affect what is recorded.
+
+One consequence for `ExecutionOtelPlugin`: an operation can be recorded while the invocation it links to was not (or vice versa), leaving that link unresolved in the trace viewer.
+
+When the global provider is not an `SdkTracerProvider` visible to the application class loader (the same condition that disables `forceFlush`, logged at startup), the sampler cannot be queried and the Workflow trace is assumed to be sampled. Spans are still created and exported by the agent's provider using its real sampler. If that hidden sampler drops root spans, operation spans can be exported while the Workflow span is dropped. ADOT's default sampler keeps root spans, so this combination is not expected in a default deployment.
+
## Verification
After deploying your function with the plugin configured:
@@ -328,6 +341,7 @@ After deploying your function with the plugin configured:
| No traces appear | ADOT layer not added, or `AWS_LAMBDA_EXEC_WRAPPER` not set |
| Invocation spans are not parented to Lambda | X-Ray active tracing not enabled on the Lambda function |
| Missing spans for some operations | Sampling is configured below 1.0 |
+| Workflow span (and its operations) missing while Invocation spans appear | The sampler drops root spans, e.g. `parentBased(alwaysOff())`; the Workflow span is a root. See [Sampling](#sampling) |
| `_X_AMZN_TRACE_ID` not populated | X-Ray active tracing not enabled |
| 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 |
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 23ebda029..be324468c 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
@@ -40,12 +40,14 @@
*
Workflow span (terminal only): {@code SUCCEEDED} → {@link StatusCode#OK}; {@code FAILED} →
- * {@link StatusCode#ERROR}. Non-terminal statuses ({@code PENDING}/{@code RETRYING}) never end the Workflow span,
- * so it is not exported this invocation (effectively {@link StatusCode#UNSET}).
+ * {@link StatusCode#ERROR}. Non-terminal statuses ({@code PENDING}/{@code RETRYING}) never materialize the
+ * Workflow span, so it is not exported this invocation (effectively {@link StatusCode#UNSET}).
*
*
* Thread-safe: uses {@link ConcurrentHashMap} for span/scope storage since the SDK runs user code on multiple
@@ -91,21 +93,31 @@ public class ExecutionOtelPlugin implements DurableExecutionPlugin {
// Per-invocation state
private volatile boolean tracingEnabled;
- private volatile Span workflowSpan;
+
+ // Between invocations the Workflow span exists only as this deterministic context, which operations parent onto.
+ // The recording span is started and ended on the terminal invocation, beginning at executionStartTime.
+ private volatile SpanContext workflowSpanContext;
+ private volatile Instant executionStartTime;
private volatile Span invocationSpan;
private volatile String durableExecutionArn;
private volatile String workflowTraceId;
- // Thread-safe storage for operation spans (keyed by operationId) — open spans that need ending
- private final ConcurrentHashMap operationSpans = new ConcurrentHashMap<>();
+ // The Workflow root's sampling decision. Operation and attempt contexts carry these flags so the whole Workflow
+ // trace is sampled or dropped together.
+ private volatile TraceFlags workflowTraceFlags = TraceFlags.getSampled();
// Thread-safe storage for attempt spans/scopes (keyed by operationId + "-" + attempt)
private final ConcurrentHashMap attemptSpans = new ConcurrentHashMap<>();
private final ConcurrentHashMap attemptScopes = new ConcurrentHashMap<>();
- // Store operation span contexts for parent resolution (keyed by operationId)
+ // Deterministic operation contexts (keyed by operationId), held between start and end so children and attempts can
+ // parent onto an operation whose recording span is not created until onOperationEnd.
private final ConcurrentHashMap operationContexts = new ConcurrentHashMap<>();
+ // Start timestamps captured at onOperationStart (keyed by operationId), used when onOperationEnd carries none.
+ // Virtual map/parallel child contexts report null timestamps at end.
+ private final ConcurrentHashMap operationStartTimes = new ConcurrentHashMap<>();
+
/**
* Creates a Workflow-rooted OTel plugin with default settings: X-Ray context extraction, MDC enabled, root span
* named {@code "Workflow"}.
@@ -189,19 +201,6 @@ public void onInvocationStart(InvocationInfo info) {
invocationParent = contextExtractor.extract();
}
- // Workflow root span — deterministic span ID from the ARN, no parent. Recreated every invocation with the
- // same ID so it is exported once as a single logical span (on the terminal invocation only). Its start time
- // is the execution start time from the backend.
- var workflowSpanBuilder = tracer.spanBuilder(workflowSpanName)
- .setSpanKind(SpanKind.INTERNAL)
- .setNoParent()
- .setAttribute(DURABLE_EXECUTION_ARN, info.durableExecutionArn())
- .setStartTimestamp(info.executionStartTime());
- workflowTraceId =
- idGenerator.generateTraceIdForExecution(info.durableExecutionArn(), info.executionStartTime());
- var workflowSpanId = idGenerator.generateWorkflowSpanId(info.durableExecutionArn());
- workflowSpan = idGenerator.startSpan(workflowSpanBuilder, workflowTraceId, workflowSpanId);
-
Context parentContext;
if (invocationParent != null && invocationParent.parentSpanId() != null) {
var parentSpanContext = SpanContext.createFromRemoteParent(
@@ -227,6 +226,18 @@ public void onInvocationStart(InvocationInfo info) {
invocationSpan = spanBuilder.startSpan();
+ // Compute the Workflow root's deterministic IDs and sampling decision so operations can parent onto it before
+ // the recording span exists. The Workflow trace is independent of the ambient trace, so operations follow the
+ // Workflow root rather than the invocation span; a link to an unsampled invocation may be left unresolved.
+ this.executionStartTime = info.executionStartTime();
+ workflowTraceId =
+ idGenerator.generateTraceIdForExecution(info.durableExecutionArn(), info.executionStartTime());
+ var workflowSpanId = idGenerator.generateWorkflowSpanId(info.durableExecutionArn());
+ workflowTraceFlags =
+ OtelPluginSupport.resolveWorkflowTraceFlags(sdkTracerProvider, workflowTraceId, workflowSpanName);
+ workflowSpanContext =
+ SpanContext.create(workflowTraceId, workflowSpanId, workflowTraceFlags, TraceState.getDefault());
+
// Inject MDC on the handler thread so handler-level logs (between steps) have trace context.
if (enableMdc) {
MDC.put(
@@ -248,20 +259,20 @@ public void onInvocationEnd(InvocationEndInfo info) {
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,
- // plus a link to the invocation that completes it) when onOperationEnd fires in a later invocation.
- operationSpans.clear();
+ // Drop per-invocation operation state. An operation still open here carries over and is emitted once by the
+ // invocation that completes it.
operationContexts.clear();
+ operationStartTimes.clear();
- // Defensively close any lingering attempt scopes so OTel context is not leaked on worker threads (normally
- // every onUserFunctionStart is paired with onUserFunctionEnd within the invocation). The attempt spans
- // themselves are left un-ended rather than force-ended, consistent with not ending open spans here.
+ // Release OTel context on worker threads, then end any attempt spans still open. Attempt spans normally start
+ // and end within a single user-function call, so this is a safeguard.
for (var scope : attemptScopes.values()) {
scope.close();
}
attemptScopes.clear();
+ for (var span : attemptSpans.values()) {
+ span.end();
+ }
attemptSpans.clear();
// End the invocation span every invocation.
@@ -273,28 +284,34 @@ public void onInvocationEnd(InvocationEndInfo info) {
invocationSpan = null;
}
- // End the Workflow span only on a terminal status, so it is exported exactly once per execution.
- if (workflowSpan != null) {
- if (isTerminal(info)) {
- workflowSpan.setAttribute(
- DURABLE_EXECUTION_STATUS, info.invocationStatus().name());
- switch (info.invocationStatus()) {
- case FAILED -> {
- var message = info.executionError() != null
- ? info.executionError().getMessage()
- : null;
- workflowSpan.setStatus(StatusCode.ERROR, message);
- if (info.executionError() != null) {
- workflowSpan.recordException(info.executionError());
- }
+ // The Workflow span is materialized on a terminal status only: started and ended in the same call so it is
+ // exported once per execution. Non-terminal statuses (PENDING/RETRYING) export no Workflow span.
+ if (isTerminal(info) && workflowSpanContext != null) {
+ var workflowSpanBuilder = tracer.spanBuilder(workflowSpanName)
+ .setSpanKind(SpanKind.INTERNAL)
+ .setNoParent()
+ .setAttribute(DURABLE_EXECUTION_ARN, durableExecutionArn)
+ .setStartTimestamp(executionStartTime != null ? executionStartTime : Instant.now());
+ var workflowSpan = idGenerator.startSpan(
+ workflowSpanBuilder, workflowSpanContext.getTraceId(), workflowSpanContext.getSpanId());
+ workflowSpan.setAttribute(
+ DURABLE_EXECUTION_STATUS, info.invocationStatus().name());
+ switch (info.invocationStatus()) {
+ case FAILED -> {
+ var message = info.executionError() != null
+ ? info.executionError().getMessage()
+ : null;
+ workflowSpan.setStatus(StatusCode.ERROR, message);
+ if (info.executionError() != null) {
+ workflowSpan.recordException(info.executionError());
}
- default -> workflowSpan.setStatus(StatusCode.OK); // SUCCEEDED
}
- workflowSpan.end();
+ default -> workflowSpan.setStatus(StatusCode.OK); // SUCCEEDED
}
- // Non-terminal (PENDING/RETRYING): leave the Workflow span un-ended (not exported this invocation).
- workflowSpan = null;
+ workflowSpan.end();
}
+ workflowSpanContext = null;
+ executionStartTime = null;
// Flush spans before Lambda freezes
if (sdkTracerProvider != null) {
@@ -312,6 +329,29 @@ public void onOperationStart(OperationInfo info) {
if (!tracingEnabled) return;
if (info.id() == null) return;
+ // Retain the deterministic context so children and attempts can parent onto the operation. The recording span
+ // is created in onOperationEnd, keeping a suspended-then-resumed operation a single logical span.
+ var spanId = idGenerator.generateSpanIdForOperation(durableExecutionArn, info.id());
+ operationContexts.put(
+ info.id(), SpanContext.create(workflowTraceId, spanId, workflowTraceFlags, TraceState.getDefault()));
+
+ // Retain the start time for onOperationEnd, which may receive none.
+ if (info.startTimestamp() != null) {
+ operationStartTimes.put(info.id(), info.startTimestamp());
+ }
+ }
+
+ @Override
+ public void onOperationEnd(OperationEndInfo info) {
+ if (!tracingEnabled) return;
+ if (info.id() == null) return;
+
+ // Start and end the operation's span in the same call, using its deterministic span ID and linking to the
+ // invocation that completed it. This covers operations that ran in this invocation and ones resumed from an
+ // earlier one.
+ operationContexts.remove(info.id());
+ var capturedStart = operationStartTimes.remove(info.id());
+
var parentContext = resolveParentContext(info.parentId());
var spanBuilder = tracer.spanBuilder(spanName(info.type(), info.subType(), info.name()))
@@ -321,8 +361,10 @@ public void onOperationStart(OperationInfo info) {
.setAttribute(DURABLE_OPERATION_TYPE, info.type());
addInvocationLink(spanBuilder);
- if (info.startTimestamp() != null) {
- spanBuilder.setStartTimestamp(info.startTimestamp());
+ // Prefer the end info's start timestamp, falling back to the one captured at operation start.
+ var startTimestamp = info.startTimestamp() != null ? info.startTimestamp() : capturedStart;
+ if (startTimestamp != null) {
+ spanBuilder.setStartTimestamp(startTimestamp);
}
if (info.name() != null) {
spanBuilder.setAttribute(DURABLE_OPERATION_NAME, info.name());
@@ -334,86 +376,24 @@ public void onOperationStart(OperationInfo info) {
var operationSpanId = idGenerator.generateSpanIdForOperation(durableExecutionArn, info.id());
var span = idGenerator.startSpan(spanBuilder, null, operationSpanId);
- // Store the open span — will be ended in onOperationEnd or onInvocationEnd
- operationSpans.put(info.id(), span);
- operationContexts.put(info.id(), span.getSpanContext());
- }
-
- @Override
- public void onOperationEnd(OperationEndInfo info) {
- if (!tracingEnabled) return;
- if (info.id() == null) return;
-
- var span = operationSpans.remove(info.id());
-
- if (span != null) {
- // Operation was started in this invocation — end normally
- if (info.status() != null) {
- span.setAttribute(DURABLE_OPERATION_STATUS, info.status());
- }
- // Total attempts for retriable operations (STEP, WAIT_FOR_CONDITION) — emitted only at end.
- if (info.attempt() != null) {
- span.setAttribute(DURABLE_ATTEMPT_NUMBER, info.attempt().longValue());
- }
- if (info.error() != null) {
- span.setStatus(StatusCode.ERROR, info.error().getMessage());
- span.recordException(info.error());
- } else if ("SUCCEEDED".equals(info.status()) || info.status() == null) {
- // Only stamp OK on genuine success. onOperationEnd fires for every terminal status, and
- // extractErrorFromOperation returns null for CANCELLED (always) and for FAILED/TIMED_OUT/STOPPED
- // with no attached error object — those carry a non-null, non-SUCCEEDED status and must stay UNSET.
- // A null status is a successful statusless virtual (FLAT CONTEXT) operation, which is OK.
- span.setStatus(StatusCode.OK);
- }
- endSpan(span, info.endTimestamp());
- } else {
- // Operation completed between invocations: its onOperationStart ran in a prior invocation, whose
- // in-memory span was dropped un-exported at that invocation's end. Emit the operation's single span
- // now, using its deterministic span ID (stable across the execution), plus a link to the invocation
- // that completed it.
- operationContexts.remove(info.id());
-
- var parentContext = resolveParentContext(info.parentId());
-
- var spanBuilder = tracer.spanBuilder(spanName(info.type(), info.subType(), info.name()))
- .setParent(parentContext)
- .setAttribute(DURABLE_EXECUTION_ARN, durableExecutionArn)
- .setAttribute(DURABLE_OPERATION_ID, info.id())
- .setAttribute(DURABLE_OPERATION_TYPE, info.type());
- addInvocationLink(spanBuilder);
-
- if (info.startTimestamp() != null) {
- spanBuilder.setStartTimestamp(info.startTimestamp());
- }
- if (info.name() != null) {
- spanBuilder.setAttribute(DURABLE_OPERATION_NAME, info.name());
- }
- if (info.subType() != null) {
- spanBuilder.setAttribute(DURABLE_OPERATION_SUBTYPE, info.subType());
- }
-
- var operationSpanId = idGenerator.generateSpanIdForOperation(durableExecutionArn, info.id());
- var continuationSpan = idGenerator.startSpan(spanBuilder, null, operationSpanId);
-
- if (info.status() != null) {
- continuationSpan.setAttribute(DURABLE_OPERATION_STATUS, info.status());
- }
- // Total attempts for retriable operations (STEP, WAIT_FOR_CONDITION) — emitted only at end.
- if (info.attempt() != null) {
- continuationSpan.setAttribute(
- DURABLE_ATTEMPT_NUMBER, info.attempt().longValue());
- }
- if (info.error() != null) {
- continuationSpan.setStatus(StatusCode.ERROR, info.error().getMessage());
- continuationSpan.recordException(info.error());
- } else if ("SUCCEEDED".equals(info.status()) || info.status() == null) {
- // See onOperationEnd (this-invocation branch): only genuine success (or a successful statusless
- // virtual operation) is OK; error-less non-success statuses stay UNSET.
- continuationSpan.setStatus(StatusCode.OK);
- }
-
- endSpan(continuationSpan, info.endTimestamp());
+ if (info.status() != null) {
+ span.setAttribute(DURABLE_OPERATION_STATUS, info.status());
}
+ // Total attempts for retriable operations (STEP, WAIT_FOR_CONDITION) — emitted only at end.
+ if (info.attempt() != null) {
+ span.setAttribute(DURABLE_ATTEMPT_NUMBER, info.attempt().longValue());
+ }
+ if (info.error() != null) {
+ span.setStatus(StatusCode.ERROR, info.error().getMessage());
+ span.recordException(info.error());
+ } else if ("SUCCEEDED".equals(info.status()) || info.status() == null) {
+ // Only stamp OK on genuine success. onOperationEnd fires for every terminal status, and
+ // extractErrorFromOperation returns null for CANCELLED (always) and for FAILED/TIMED_OUT/STOPPED
+ // with no attached error object — those carry a non-null, non-SUCCEEDED status and must stay UNSET.
+ // A null status is a successful statusless virtual (FLAT CONTEXT) operation, which is OK.
+ span.setStatus(StatusCode.OK);
+ }
+ endSpan(span, info.endTimestamp());
}
// ─── User function hooks ─────────────────────────────────────────────
@@ -425,9 +405,10 @@ public void onUserFunctionStart(UserFunctionStartInfo info) {
// Skip attempt spans for CONTEXT operations — they are a scoping construct, not a retriable unit of work. Still
// make the operation span current so auto-instrumented calls become children.
if ("CONTEXT".equals(info.type())) {
- var operationSpan = operationSpans.get(info.id());
- if (operationSpan != null) {
- var scope = operationSpan.makeCurrent();
+ // Make the operation's context current so auto-instrumented calls become its children.
+ var operationContext = operationContexts.get(info.id());
+ if (operationContext != null) {
+ var scope = Span.wrap(operationContext).makeCurrent();
var key = attemptKey(info.id(), info.attempt());
attemptScopes.put(key, scope);
}
@@ -576,12 +557,13 @@ private Context resolveParentContext(String parentId) {
// Parent operation from a prior invocation — create a non-recording placeholder with its deterministic ID.
var deterministicParentSpanId = idGenerator.generateSpanIdForOperation(durableExecutionArn, parentId);
var placeholderContext = SpanContext.create(
- workflowTraceId, deterministicParentSpanId, TraceFlags.getSampled(), TraceState.getDefault());
+ workflowTraceId, deterministicParentSpanId, workflowTraceFlags, TraceState.getDefault());
return Context.current().with(Span.wrap(placeholderContext));
}
- // No parent operation — hang off the Workflow root span.
- if (workflowSpan != null) {
- return Context.current().with(workflowSpan);
+ // No parent operation — hang off the Workflow root span via its deterministic (non-recording) context, whose
+ // span ID matches the Workflow span materialized on the terminal invocation.
+ if (workflowSpanContext != null) {
+ return Context.current().with(Span.wrap(workflowSpanContext));
}
return Context.current();
}
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 83d962306..ae5b20650 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
@@ -38,9 +38,10 @@
* Creates spans at these levels:
*
*
- * - Workflow span — one logical span per durable execution (deterministic ID from the ARN, exported once on
- * the terminal invocation). Operation and attempt spans carry a link to it for execution-level
- * correlation; they remain parented to the per-invocation span (this plugin is invocation-rooted).
+ *
- Workflow span — one logical span per durable execution (deterministic ID from the ARN, started and
+ * exported once on the terminal invocation). Between invocations it is represented by a deterministic
+ * {@link SpanContext} that operation and attempt spans link to for execution-level correlation; they
+ * remain parented to the per-invocation span (this plugin is invocation-rooted).
*
- Invocation span — one per Lambda invocation
*
- Operation span — created when an operation starts, ended when it completes or when the invocation ends
*
- Attempt span — one per user function execution (step attempt, child context run)
@@ -96,7 +97,12 @@ public class InvocationOtelPlugin implements DurableExecutionPlugin {
// Per-invocation state
private volatile boolean tracingEnabled;
- private volatile Span workflowSpan;
+
+ // Between invocations the Workflow span exists only as this deterministic context, which operation and attempt
+ // spans link to. The recording span is started and ended on the terminal invocation, beginning at
+ // executionStartTime.
+ private volatile SpanContext workflowSpanContext;
+ private volatile Instant executionStartTime;
private volatile Span invocationSpan;
private volatile String durableExecutionArn;
@@ -204,19 +210,17 @@ public void onInvocationStart(InvocationInfo info) {
invocationParent = contextExtractor.extract();
}
- // Workflow root span — one logical span per durable execution, created unconditionally (independent of the
- // X-Ray parent below). Deterministic span ID from the ARN so it is the same across invocations; exported once,
- // on the terminal invocation. Operation and attempt spans link to it for execution-level correlation while
- // remaining parented to the per-invocation span (this plugin stays invocation-rooted).
- var workflowSpanBuilder = tracer.spanBuilder(workflowSpanName)
- .setSpanKind(SpanKind.INTERNAL)
- .setNoParent()
- .setAttribute(DURABLE_EXECUTION_ARN, info.durableExecutionArn())
- .setStartTimestamp(info.executionStartTime());
+ // Compute the Workflow root's deterministic IDs and sampling decision so operation and attempt spans can link
+ // to it before the recording span exists, and so those links describe the linked span's actual fate. Operation
+ // sampling is unaffected: operations are parented to the invocation span, and links do not feed the sampler.
+ this.executionStartTime = info.executionStartTime();
var workflowTraceId =
idGenerator.generateTraceIdForExecution(info.durableExecutionArn(), info.executionStartTime());
var workflowSpanId = idGenerator.generateWorkflowSpanId(info.durableExecutionArn());
- workflowSpan = idGenerator.startSpan(workflowSpanBuilder, workflowTraceId, workflowSpanId);
+ var workflowTraceFlags =
+ OtelPluginSupport.resolveWorkflowTraceFlags(sdkTracerProvider, workflowTraceId, workflowSpanName);
+ workflowSpanContext =
+ SpanContext.create(workflowTraceId, workflowSpanId, workflowTraceFlags, TraceState.getDefault());
// Determine parent context for the invocation span.
Context parentContext;
@@ -301,28 +305,35 @@ public void onInvocationEnd(InvocationEndInfo info) {
invocationSpan.end();
invocationSpan = null;
- // End the Workflow span only on a terminal status, so it is exported exactly once per execution
- // (SUCCEEDED -> OK, FAILED -> ERROR; non-terminal statuses leave it un-ended / not exported this invocation).
- if (workflowSpan != null) {
- if (isTerminal(info)) {
- workflowSpan.setAttribute(
- DURABLE_EXECUTION_STATUS, info.invocationStatus().name());
- switch (info.invocationStatus()) {
- case FAILED -> {
- var message = info.executionError() != null
- ? info.executionError().getMessage()
- : null;
- workflowSpan.setStatus(StatusCode.ERROR, message);
- if (info.executionError() != null) {
- workflowSpan.recordException(info.executionError());
- }
+ // The Workflow span is materialized on a terminal status only: started and ended in the same call so it is
+ // exported once per execution (SUCCEEDED -> OK, FAILED -> ERROR). Non-terminal statuses (PENDING/RETRYING)
+ // export no Workflow span.
+ if (isTerminal(info) && workflowSpanContext != null) {
+ var workflowSpanBuilder = tracer.spanBuilder(workflowSpanName)
+ .setSpanKind(SpanKind.INTERNAL)
+ .setNoParent()
+ .setAttribute(DURABLE_EXECUTION_ARN, durableExecutionArn)
+ .setStartTimestamp(executionStartTime != null ? executionStartTime : Instant.now());
+ var workflowSpan = idGenerator.startSpan(
+ workflowSpanBuilder, workflowSpanContext.getTraceId(), workflowSpanContext.getSpanId());
+ workflowSpan.setAttribute(
+ DURABLE_EXECUTION_STATUS, info.invocationStatus().name());
+ switch (info.invocationStatus()) {
+ case FAILED -> {
+ var message = info.executionError() != null
+ ? info.executionError().getMessage()
+ : null;
+ workflowSpan.setStatus(StatusCode.ERROR, message);
+ if (info.executionError() != null) {
+ workflowSpan.recordException(info.executionError());
}
- default -> workflowSpan.setStatus(StatusCode.OK); // SUCCEEDED
}
- workflowSpan.end();
+ default -> workflowSpan.setStatus(StatusCode.OK); // SUCCEEDED
}
- workflowSpan = null;
+ workflowSpan.end();
}
+ workflowSpanContext = null;
+ executionStartTime = null;
if (sdkTracerProvider != null) {
// Flush spans before Lambda freezes
@@ -398,6 +409,8 @@ public void onOperationEnd(OperationEndInfo info) {
}
span.end();
} else {
+ // Operation completed between invocations: its onOperationStart ran in a prior invocation, whose open span
+ // was force-ended at that invocation's end. Emit a continuation span now, linked to the Workflow span.
var parentContext = resolveParentContext(info.parentId());
var spanBuilder = tracer.spanBuilder(spanName(info.type(), info.subType(), info.name()))
@@ -598,11 +611,11 @@ private Context resolveParentContext(String parentId) {
return Context.current();
}
- /** Adds a link to the Workflow span, if one exists, for execution-level correlation. */
+ /** Adds a link to the Workflow span's deterministic context, if one exists, for execution-level correlation. */
private void addWorkflowLink(SpanBuilder spanBuilder) {
- var currentWorkflowSpan = workflowSpan;
- if (currentWorkflowSpan != null) {
- spanBuilder.addLink(currentWorkflowSpan.getSpanContext());
+ var currentWorkflowSpanContext = workflowSpanContext;
+ if (currentWorkflowSpanContext != null) {
+ spanBuilder.addLink(currentWorkflowSpanContext);
}
}
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
index cac13d10f..3f8449308 100644
--- 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
@@ -3,12 +3,18 @@
package software.amazon.lambda.durable.otel;
import io.opentelemetry.api.GlobalOpenTelemetry;
+import io.opentelemetry.api.common.Attributes;
import io.opentelemetry.api.trace.Span;
+import io.opentelemetry.api.trace.SpanKind;
+import io.opentelemetry.api.trace.TraceFlags;
import io.opentelemetry.api.trace.Tracer;
import io.opentelemetry.api.trace.TracerProvider;
+import io.opentelemetry.context.Context;
import io.opentelemetry.sdk.trace.SdkTracerProvider;
+import io.opentelemetry.sdk.trace.samplers.SamplingDecision;
import java.nio.file.Files;
import java.nio.file.Path;
+import java.util.Collections;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -71,6 +77,37 @@ static ProviderSetup tryResolveGlobalProvider(String instrumentationName, String
getSdkTracerProviderForFlush(tracerProvider, pluginName), tracerProvider.get(instrumentationName));
}
+ /**
+ * Resolves the Workflow root's sampling decision by querying the provider's sampler with the inputs the Workflow
+ * span uses: no parent and the deterministic Workflow trace ID. No span is started, and the result matches what the
+ * Workflow span itself will be assigned.
+ *
+ *
When the provider is null the sampler cannot be queried and the Workflow trace is treated as sampled, matching
+ * the default provider configuration.
+ *
+ * @param sdkTracerProvider the resolved provider, or null when it is not visible to the application
+ * @param workflowTraceId the deterministic Workflow trace ID
+ * @param workflowSpanName the Workflow span name passed to the sampler
+ * @return the trace flags for Workflow and operation contexts
+ */
+ static TraceFlags resolveWorkflowTraceFlags(
+ SdkTracerProvider sdkTracerProvider, String workflowTraceId, String workflowSpanName) {
+ if (sdkTracerProvider == null) {
+ return TraceFlags.getSampled();
+ }
+ var decision = sdkTracerProvider
+ .getSampler()
+ .shouldSample(
+ Context.root(),
+ workflowTraceId,
+ workflowSpanName,
+ SpanKind.INTERNAL,
+ Attributes.empty(),
+ Collections.emptyList())
+ .getDecision();
+ return decision == SamplingDecision.RECORD_AND_SAMPLE ? TraceFlags.getSampled() : TraceFlags.getDefault();
+ }
+
/** Extracts trace context from the current OTel span (fallback when X-Ray header is unavailable). */
static ExtractedContext extractCurrentSpanContext() {
var spanContext = Span.current().getSpanContext();
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 0c2a1d3f1..6f1e3056b 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
@@ -231,7 +231,7 @@ void workflowSpan_startsAtExecutionStartTime() {
assertEquals(
start.toEpochMilli(),
workflowSpan.getStartEpochNanos() / 1_000_000,
- "Workflow span should start at the execution start time from InvocationInfo");
+ "Workflow span should start at the execution start time captured from InvocationInfo");
}
@Test
@@ -284,7 +284,8 @@ void nonTerminalInvocation_doesNotExportWorkflowSpan() {
plugin.onInvocationEnd(new InvocationEndInfo("req-1", ARN, true, InvocationStatus.PENDING, null));
var spans = spanExporter.getFinishedSpanItems();
- // Only the invocation span is exported; the Workflow span is not ended on non-terminal status.
+ // Only the invocation span is exported. On a non-terminal status the Workflow span is never materialized (no
+ // recording span is created until the terminal invocation), so there is nothing to export or abandon.
assertEquals(1, spans.size());
assertEquals("Invocation", spans.get(0).getName());
assertEquals(StatusCode.OK, spans.get(0).getStatus().getStatusCode(), "PENDING invocation span maps to OK");
@@ -416,6 +417,32 @@ void operationSpan_startsAtOperationStartTimestamp() {
"Operation span should start at OperationInfo.startTimestamp()");
}
+ @Test
+ void virtualOperation_spanStartsAtOnOperationStartTimestamp() {
+ // FLAT map/parallel child contexts fire onOperationStart with a real start time, but onOperationEnd with null
+ // start/end timestamps (the SDK converter maps a null Operation to null end timestamps). Because the span is
+ // materialized at onOperationEnd, it must fall back to the start captured at onOperationStart — otherwise it
+ // would start at materialization time (after the child code ran), giving a near-zero or inverted duration.
+ var opStart = Instant.parse("2026-03-01T12:00:00Z");
+ plugin.onInvocationStart(new InvocationInfo("req-1", ARN, true, Instant.now()));
+ plugin.onOperationStart(
+ new OperationInfo("op-flat", "my-map", "CONTEXT", "Map", null, opStart, null, null, false));
+ // Virtual end: null start, null end, null status (operation == null in PluginInfoConverter).
+ plugin.onOperationEnd(new OperationEndInfo(
+ "op-flat", "my-map", "CONTEXT", "Map", null, null, null, null, null, false, null, null));
+ plugin.onInvocationEnd(new InvocationEndInfo("req-1", ARN, true, InvocationStatus.SUCCEEDED, null));
+
+ var span = spanByName(spanExporter.getFinishedSpanItems(), "my-map");
+ assertEquals(
+ opStart.toEpochMilli(),
+ span.getStartEpochNanos() / 1_000_000,
+ "Virtual operation span must start at the timestamp captured in onOperationStart, not at "
+ + "span materialization time");
+ assertTrue(
+ span.getEndEpochNanos() >= span.getStartEpochNanos(),
+ "Virtual operation span must not end before it starts");
+ }
+
@Test
void operationSpan_parentedToWorkflow_linkedToInvocation() {
plugin.onInvocationStart(new InvocationInfo("req-1", ARN, true, Instant.now()));
@@ -703,20 +730,22 @@ void operationEnd_withNullStatusAndNoError_setsOkOnOperationSpan() {
}
@Test
- void operationNotCompleted_notEndedAtInvocationEnd() {
+ void operationNotCompleted_notMaterializedAtInvocationEnd() {
plugin.onInvocationStart(new InvocationInfo("req-1", ARN, true, Instant.now()));
plugin.onOperationStart(
new OperationInfo("op-1", "my-wait", "WAIT", "Wait", null, Instant.now(), null, null, false));
plugin.onInvocationEnd(new InvocationEndInfo("req-1", ARN, true, InvocationStatus.PENDING, null));
var spans = spanExporter.getFinishedSpanItems();
- // Only the invocation span is exported. The still-open operation span is NOT force-ended (no PENDING
- // span here), and the Workflow span is not exported on a non-terminal invocation.
+ // Only the invocation span is exported. An operation that suspends before completing is never materialized as
+ // a recording span (onOperationStart retains only its deterministic context); it is created and ended once,
+ // later, in onOperationEnd. So there is no open operation span to abandon here, and the Workflow span is not
+ // materialized on a non-terminal invocation either.
assertEquals(1, spans.size());
assertEquals("Invocation", spans.get(0).getName());
assertTrue(
spans.stream().noneMatch(s -> s.getName().equals("my-wait")),
- "An operation still open at invocation end must not be ended/exported in onInvocationEnd");
+ "An operation still open at invocation end must not be materialized/exported in onInvocationEnd");
}
@Test
@@ -875,6 +904,114 @@ void sampling_disabled_producesNoSpans() {
assertTrue(exporter.getFinishedSpanItems().isEmpty(), "No spans should be exported with 0% sampling");
}
+ @Test
+ void operationSampling_followsWorkflowRoot_notTheInvocationSpan() {
+ // Operations and attempts belong to the Workflow trace, so their sampling follows the Workflow root rather than
+ // the per-invocation ambient decision. Here the ambient parent is dropped by a parent-based sampler (so the
+ // Invocation span is not recorded), yet the operation still belongs to the Workflow trace and is exported.
+ // The operation's link to that unsampled invocation is left unresolved, which is the accepted trade-off.
+ var xrayTraceId = "aabbccddee112233445566778899aabb";
+ var parentSpanId = "53995c3f42cd8ad8";
+ var exporter = InMemorySpanExporter.create();
+ var plugin = new ExecutionOtelPlugin(
+ SdkTracerProvider.builder()
+ .setSampler(io.opentelemetry.sdk.trace.samplers.Sampler.parentBased(
+ io.opentelemetry.sdk.trace.samplers.Sampler.alwaysOn()))
+ // An unsampled ambient parent: parentBased copies the parent's decision, so the Invocation span
+ // (and only it) is dropped.
+ .addSpanProcessor(SimpleSpanProcessor.create(exporter)),
+ OtelPluginConfig.builder()
+ .contextExtractor(() -> new ExtractedContext(xrayTraceId, parentSpanId))
+ .enableMdc(false)
+ .workflowSpanName("Workflow")
+ .build());
+
+ plugin.onInvocationStart(new InvocationInfo("req-1", ARN, true, Instant.now()));
+ plugin.onOperationStart(
+ new OperationInfo("op-1", "step-a", "STEP", "Step", null, Instant.now(), null, null, false));
+ plugin.onOperationEnd(new OperationEndInfo(
+ "op-1",
+ "step-a",
+ "STEP",
+ "Step",
+ null,
+ Instant.now(),
+ Instant.now(),
+ "SUCCEEDED",
+ 1,
+ false,
+ null,
+ null));
+ plugin.onInvocationEnd(new InvocationEndInfo("req-1", ARN, true, InvocationStatus.SUCCEEDED, null));
+
+ var spans = exporter.getFinishedSpanItems();
+ var operationSpan = spanByName(spans, "step-a");
+ var workflowSpan = spanByName(spans, "Workflow");
+ assertEquals(
+ workflowSpan.getTraceId(),
+ operationSpan.getTraceId(),
+ "The operation must live in the Workflow trace, independent of the invocation's ambient trace");
+ assertEquals(
+ workflowSpan.getSpanId(),
+ operationSpan.getParentSpanId(),
+ "The operation must be parented to the Workflow root, so it shares the Workflow root's fate");
+ }
+
+ @Test
+ void rootRejectingSampler_dropsWorkflowTreeWithoutOrphans() {
+ // The Workflow root is created with no parent, so a parent-based sampler applies its root rule to it. With a
+ // rejecting root rule the Workflow span is dropped, and the operation/attempt spans parented to the Workflow
+ // context must be dropped with it — otherwise they would export as orphans referencing a Workflow span that
+ // never shipped. The ambient parent is sampled here, so this also proves the decision is taken from the
+ // Workflow root rather than the invocation span.
+ var xrayTraceId = "aabbccddee112233445566778899aabb";
+ var parentSpanId = "53995c3f42cd8ad8";
+ var exporter = InMemorySpanExporter.create();
+ var rejectingPlugin = new ExecutionOtelPlugin(
+ SdkTracerProvider.builder()
+ .setSampler(io.opentelemetry.sdk.trace.samplers.Sampler.parentBased(
+ io.opentelemetry.sdk.trace.samplers.Sampler.alwaysOff()))
+ .addSpanProcessor(SimpleSpanProcessor.create(exporter)),
+ OtelPluginConfig.builder()
+ .contextExtractor(() -> new ExtractedContext(xrayTraceId, parentSpanId))
+ .enableMdc(false)
+ .workflowSpanName("Workflow")
+ .build());
+
+ rejectingPlugin.onInvocationStart(new InvocationInfo("req-1", ARN, true, Instant.now()));
+ rejectingPlugin.onOperationStart(
+ new OperationInfo("op-1", "step-a", "STEP", "Step", null, Instant.now(), null, null, false));
+ rejectingPlugin.onUserFunctionStart(
+ new UserFunctionStartInfo("op-1", "step-a", "STEP", "Step", null, Instant.now(), false, 1));
+ rejectingPlugin.onUserFunctionEnd(new UserFunctionEndInfo(
+ "op-1", "step-a", "STEP", "Step", null, Instant.now(), Instant.now(), false, 1, true, null));
+ rejectingPlugin.onOperationEnd(new OperationEndInfo(
+ "op-1",
+ "step-a",
+ "STEP",
+ "Step",
+ null,
+ Instant.now(),
+ Instant.now(),
+ "SUCCEEDED",
+ 1,
+ false,
+ null,
+ null));
+ rejectingPlugin.onInvocationEnd(new InvocationEndInfo("req-1", ARN, true, InvocationStatus.SUCCEEDED, null));
+
+ var workflowTreeSpans = exporter.getFinishedSpanItems().stream()
+ .filter(s -> !s.getName().equals("Invocation"))
+ .toList();
+ assertTrue(
+ workflowTreeSpans.isEmpty(),
+ "A rejecting root sampler must drop the Workflow span and every operation/attempt parented to it, "
+ + "leaving no orphans; got "
+ + workflowTreeSpans.stream()
+ .map(io.opentelemetry.sdk.trace.data.SpanData::getName)
+ .toList());
+ }
+
// ─── X-Ray trace ID ──────────────────────────────────────────────────
@Test
@@ -942,6 +1079,115 @@ void xrayExtraction_withParentSpanId_invocationSpanHasCorrectParent() {
assertEquals(parentSpanId, invocationSpan.getParentSpanId());
}
+ // ─── Span lifecycle: every recording span is ended exactly once ──────
+
+ @Test
+ void terminalSuccess_endsEveryRecordingSpanExactlyOnce() {
+ assertEveryRecordingSpanEndedOnce(InvocationStatus.SUCCEEDED, null);
+ }
+
+ @Test
+ void terminalFailure_endsEveryRecordingSpanExactlyOnce() {
+ assertEveryRecordingSpanEndedOnce(InvocationStatus.FAILED, new RuntimeException("boom"));
+ }
+
+ @Test
+ void pendingInvocation_endsEveryRecordingSpanExactlyOnce() {
+ assertEveryRecordingSpanEndedOnce(InvocationStatus.PENDING, null);
+ }
+
+ @Test
+ void retryingInvocation_endsEveryRecordingSpanExactlyOnce() {
+ assertEveryRecordingSpanEndedOnce(InvocationStatus.RETRYING, new RuntimeException("transient"));
+ }
+
+ /**
+ * Runs an invocation with a step operation and attempt, ending with the given status, and asserts every started
+ * span was ended exactly once and none is left recording. Non-terminal statuses leave the operation open at
+ * suspend.
+ */
+ private void assertEveryRecordingSpanEndedOnce(InvocationStatus status, Throwable error) {
+ var counting = new CountingSpanProcessor();
+ var lifecyclePlugin = new ExecutionOtelPlugin(
+ SdkTracerProvider.builder().addSpanProcessor(counting),
+ OtelPluginConfig.builder()
+ .contextExtractor(() -> null)
+ .enableMdc(false)
+ .workflowSpanName("Workflow")
+ .build());
+
+ var terminal = status == InvocationStatus.SUCCEEDED || status == InvocationStatus.FAILED;
+
+ lifecyclePlugin.onInvocationStart(new InvocationInfo("req-1", ARN, true, Instant.now()));
+ lifecyclePlugin.onOperationStart(
+ new OperationInfo("op-1", "step-a", "STEP", "Step", null, Instant.now(), null, null, false));
+ lifecyclePlugin.onUserFunctionStart(
+ new UserFunctionStartInfo("op-1", "step-a", "STEP", "Step", null, Instant.now(), false, 1));
+ lifecyclePlugin.onUserFunctionEnd(new UserFunctionEndInfo(
+ "op-1", "step-a", "STEP", "Step", null, Instant.now(), Instant.now(), false, 1, true, null));
+ if (terminal) {
+ // On a terminal invocation the operation completes; on a non-terminal one it stays open (suspends).
+ lifecyclePlugin.onOperationEnd(new OperationEndInfo(
+ "op-1",
+ "step-a",
+ "STEP",
+ "Step",
+ null,
+ Instant.now(),
+ Instant.now(),
+ "SUCCEEDED",
+ 1,
+ false,
+ null,
+ null));
+ }
+ lifecyclePlugin.onInvocationEnd(new InvocationEndInfo("req-1", ARN, true, status, error));
+
+ counting.assertBalancedAndNotRecording();
+ }
+
+ /**
+ * A {@link io.opentelemetry.sdk.trace.SpanProcessor} that records every started span and counts ends, so a test can
+ * prove that every recording span is ended exactly once and none is left recording.
+ */
+ private static final class CountingSpanProcessor implements io.opentelemetry.sdk.trace.SpanProcessor {
+ private final java.util.List started =
+ new java.util.concurrent.CopyOnWriteArrayList<>();
+ private final java.util.concurrent.atomic.AtomicInteger ended = new java.util.concurrent.atomic.AtomicInteger();
+
+ @Override
+ public void onStart(
+ io.opentelemetry.context.Context parentContext, io.opentelemetry.sdk.trace.ReadWriteSpan span) {
+ started.add(span);
+ }
+
+ @Override
+ public boolean isStartRequired() {
+ return true;
+ }
+
+ @Override
+ public void onEnd(io.opentelemetry.sdk.trace.ReadableSpan span) {
+ ended.incrementAndGet();
+ }
+
+ @Override
+ public boolean isEndRequired() {
+ return true;
+ }
+
+ void assertBalancedAndNotRecording() {
+ assertFalse(started.isEmpty(), "Expected the plugin to start at least one span");
+ assertEquals(
+ started.size(), ended.get(), "Every recording span the plugin started must be ended exactly once");
+ for (var span : started) {
+ assertFalse(
+ span.isRecording(),
+ "No span may still be recording after onInvocationEnd (span '" + span.getName() + "')");
+ }
+ }
+ }
+
// ─── Helpers ─────────────────────────────────────────────────────────
private static io.opentelemetry.sdk.trace.data.SpanData spanByName(
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 be68c9716..7b6735379 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
@@ -983,6 +983,47 @@ void sampling_disabled_producesNoSpans() {
assertTrue(spanExporter.getFinishedSpanItems().isEmpty(), "No spans should be exported with 0% sampling");
}
+ @Test
+ void parentBasedRejectingSampler_producesNoOrphanContinuationSpan() {
+ // An operation whose parent completed in a prior invocation parents onto a synthetic (non-recording)
+ // placeholder. With a parent-based rejecting sampler, that placeholder must carry the trace's "drop" decision
+ // so the continuation span is not forced to record and export as an orphan.
+ spanExporter = InMemorySpanExporter.create();
+ var rejectingPlugin = new InvocationOtelPlugin(
+ SdkTracerProvider.builder()
+ .setSampler(io.opentelemetry.sdk.trace.samplers.Sampler.parentBased(
+ io.opentelemetry.sdk.trace.samplers.Sampler.alwaysOff()))
+ .addSpanProcessor(SimpleSpanProcessor.create(spanExporter)),
+ OtelPluginConfig.builder()
+ .contextExtractor(() -> null)
+ .enableMdc(false)
+ .build());
+
+ rejectingPlugin.onInvocationStart(new InvocationInfo("req-2", "arn:exec1", false, Instant.now()));
+ // No matching onOperationStart this invocation, and parentId refers to an operation from a prior invocation →
+ // the continuation span parents onto the deterministic placeholder context.
+ rejectingPlugin.onOperationEnd(new OperationEndInfo(
+ "op-child",
+ "inner",
+ "STEP",
+ "Step",
+ "op-parent",
+ Instant.now(),
+ Instant.now(),
+ "SUCCEEDED",
+ 1,
+ false,
+ null,
+ null));
+ rejectingPlugin.onInvocationEnd(
+ new InvocationEndInfo("req-2", "arn:exec1", false, InvocationStatus.SUCCEEDED, null));
+
+ assertTrue(
+ spanExporter.getFinishedSpanItems().isEmpty(),
+ "A parent-based rejecting sampler must drop the continuation span parented to the deterministic "
+ + "placeholder context");
+ }
+
// ─── X-Ray trace ID extraction integration tests ─────────────────────
@Test
@@ -1699,6 +1740,8 @@ void workflowSpan_notExportedOnNonTerminal() {
plugin.onInvocationStart(new InvocationInfo("req-1", "arn:exec1", true, Instant.now()));
plugin.onInvocationEnd(new InvocationEndInfo("req-1", "arn:exec1", true, InvocationStatus.PENDING, null));
+ // On a non-terminal status the Workflow span is never materialized (no recording span is created until the
+ // terminal invocation), so it is neither exported nor abandoned.
assertTrue(
spanExporter.getFinishedSpanItems().stream()
.noneMatch(s -> s.getName().equals("Workflow")),
@@ -1830,6 +1873,114 @@ void failedInvocation_setsErrorOnBothWorkflowAndInvocationSpans() {
"Workflow span should record the execution exception on FAILED");
}
+ // ─── Span lifecycle: every recording span is ended exactly once ──────
+
+ @Test
+ void terminalSuccess_endsEveryRecordingSpanExactlyOnce() {
+ assertEveryRecordingSpanEndedOnce(InvocationStatus.SUCCEEDED, null);
+ }
+
+ @Test
+ void terminalFailure_endsEveryRecordingSpanExactlyOnce() {
+ assertEveryRecordingSpanEndedOnce(InvocationStatus.FAILED, new RuntimeException("boom"));
+ }
+
+ @Test
+ void pendingInvocation_endsEveryRecordingSpanExactlyOnce() {
+ assertEveryRecordingSpanEndedOnce(InvocationStatus.PENDING, null);
+ }
+
+ @Test
+ void retryingInvocation_endsEveryRecordingSpanExactlyOnce() {
+ assertEveryRecordingSpanEndedOnce(InvocationStatus.RETRYING, new RuntimeException("transient"));
+ }
+
+ /**
+ * Runs an invocation with a step operation and attempt, ending with the given status, and asserts every started
+ * span was ended exactly once and none is left recording. Non-terminal statuses leave the operation open at
+ * suspend.
+ */
+ private void assertEveryRecordingSpanEndedOnce(InvocationStatus status, Throwable error) {
+ var counting = new CountingSpanProcessor();
+ var lifecyclePlugin = new InvocationOtelPlugin(
+ SdkTracerProvider.builder().addSpanProcessor(counting),
+ OtelPluginConfig.builder()
+ .contextExtractor(() -> null)
+ .enableMdc(false)
+ .build());
+
+ var terminal = status == InvocationStatus.SUCCEEDED || status == InvocationStatus.FAILED;
+
+ lifecyclePlugin.onInvocationStart(new InvocationInfo("req-1", "arn:exec1", true, Instant.now()));
+ lifecyclePlugin.onOperationStart(
+ new OperationInfo("op-1", "step-a", "STEP", "Step", null, Instant.now(), null, null, false));
+ lifecyclePlugin.onUserFunctionStart(
+ new UserFunctionStartInfo("op-1", "step-a", "STEP", "Step", null, Instant.now(), false, 1));
+ lifecyclePlugin.onUserFunctionEnd(new UserFunctionEndInfo(
+ "op-1", "step-a", "STEP", "Step", null, Instant.now(), Instant.now(), false, 1, true, null));
+ if (terminal) {
+ // On a terminal invocation the operation completes; on a non-terminal one it stays open (suspends).
+ lifecyclePlugin.onOperationEnd(new OperationEndInfo(
+ "op-1",
+ "step-a",
+ "STEP",
+ "Step",
+ null,
+ Instant.now(),
+ Instant.now(),
+ "SUCCEEDED",
+ 1,
+ false,
+ null,
+ null));
+ }
+ lifecyclePlugin.onInvocationEnd(new InvocationEndInfo("req-1", "arn:exec1", true, status, error));
+
+ counting.assertBalancedAndNotRecording();
+ }
+
+ /**
+ * A {@link io.opentelemetry.sdk.trace.SpanProcessor} that records every started span and counts ends, so a test can
+ * prove that every recording span is ended exactly once and none is left recording.
+ */
+ private static final class CountingSpanProcessor implements io.opentelemetry.sdk.trace.SpanProcessor {
+ private final java.util.List started =
+ new java.util.concurrent.CopyOnWriteArrayList<>();
+ private final java.util.concurrent.atomic.AtomicInteger ended = new java.util.concurrent.atomic.AtomicInteger();
+
+ @Override
+ public void onStart(
+ io.opentelemetry.context.Context parentContext, io.opentelemetry.sdk.trace.ReadWriteSpan span) {
+ started.add(span);
+ }
+
+ @Override
+ public boolean isStartRequired() {
+ return true;
+ }
+
+ @Override
+ public void onEnd(io.opentelemetry.sdk.trace.ReadableSpan span) {
+ ended.incrementAndGet();
+ }
+
+ @Override
+ public boolean isEndRequired() {
+ return true;
+ }
+
+ void assertBalancedAndNotRecording() {
+ assertFalse(started.isEmpty(), "Expected the plugin to start at least one span");
+ assertEquals(
+ started.size(), ended.get(), "Every recording span the plugin started must be ended exactly once");
+ for (var span : started) {
+ assertFalse(
+ span.isRecording(),
+ "No span may still be recording after onInvocationEnd (span '" + span.getName() + "')");
+ }
+ }
+ }
+
// ─── Helpers ─────────────────────────────────────────────────────────
private io.opentelemetry.sdk.trace.data.SpanData spanByName(String name) {