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 9cd8ac33a..09214055c 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
@@ -41,13 +41,15 @@
*
* - Workflow span — one logical span per durable execution, parented onto the execution ancestor so
* it shares the execution trace. Its span ID is derived deterministically from the execution ARN, so every
- * invocation of the same execution produces the same ID. It is ended (and therefore exported) exactly once, on
- * the terminal invocation.
+ * invocation of the same execution produces the same ID. It is started and ended together (and therefore
+ * exported) exactly once, on the terminal invocation; between invocations it is represented only by a
+ * deterministic {@link SpanContext} that operations parent onto, so no open span is left abandoned.
*
- Invocation span — one per Lambda invocation, a child of the ambient Lambda span when available and a
* root otherwise. Created and ended every invocation.
*
- Operation span — parented to its parent operation span (or the Workflow span) and carrying a
* link to the current Invocation span for correlation. Deterministic ID keyed by operation ID, so a
- * suspended-then-resumed operation stitches into a single logical span across invocations.
+ * suspended-then-resumed operation stitches into a single logical span across invocations. Started and ended
+ * together in {@code onOperationEnd}, so a suspended operation never leaves an open span.
*
- Attempt span — one per user-function execution (step attempt, child-context run), child of the operation
* span, linked to the current Invocation span.
*
@@ -77,6 +79,12 @@
* so it is not exported this invocation (effectively {@link StatusCode#UNSET}).
*
*
+ * Deferred operation spans and context operations. An operation span is created only when the operation
+ * completes ({@code onOperationEnd}), so each operation is exported once even across suspend/resume. Before completion
+ * it is represented by a deterministic, non-recording {@link SpanContext}. A CONTEXT operation makes this placeholder
+ * current, so {@code Span.current()} enrichment is not recorded on the final operation span. The placeholder uses the
+ * Invocation span's resolved sampling metadata when available.
+ *
*
Thread-safe: uses {@link ConcurrentHashMap} for span/scope storage since the SDK runs user code on multiple
* threads.
*/
@@ -94,7 +102,6 @@ public class ExecutionOtelPlugin implements DurableExecutionPlugin {
// Per-invocation state
private volatile boolean tracingEnabled;
- private volatile Span workflowSpan;
private volatile Span invocationSpan;
private volatile String durableExecutionArn;
@@ -109,16 +116,25 @@ public class ExecutionOtelPlugin implements DurableExecutionPlugin {
/** Immutable snapshot of the resolved execution trace, read atomically through a single volatile reference. */
private record ExecutionTrace(String traceId, TraceFlags flags) {}
- // Thread-safe storage for operation spans (keyed by operationId) — open spans that need ending
- private final ConcurrentHashMap operationSpans = new ConcurrentHashMap<>();
+ // Between invocations the Workflow span exists only as a deterministic context that operations parent onto; the
+ // recording span is started and ended in a single call on the terminal invocation, so it is never left open. The
+ // execution ancestor and start time are retained so that span can be built at invocation end.
+ private volatile SpanContext workflowSpanContext;
+ private volatile SpanContext executionAncestor;
+ private volatile Instant executionStartTime;
// 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"}.
@@ -221,18 +237,8 @@ public void onInvocationStart(InvocationInfo info) {
var sampled = OtelPluginSupport.isSampled(decision);
var execCtx = ExecutionTraceContext.resolve(extracted, canonicalTraceId, arn(), idGenerator, () -> sampled);
executionTrace = new ExecutionTrace(canonicalTraceId, execCtx.traceFlags());
-
- // Workflow root span — parented onto the execution ancestor so it joins the execution trace, with a
- // deterministic span ID from the ARN. 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 backend execution start time.
- var workflowSpanBuilder = tracer.spanBuilder(workflowSpanName)
- .setSpanKind(SpanKind.INTERNAL)
- .setParent(withDurableDecision(Context.root().with(Span.wrap(execCtx.executionAncestor()))))
- .setAttribute(DURABLE_EXECUTION_ARN, info.durableExecutionArn())
- .setStartTimestamp(info.executionStartTime());
- var workflowSpanId = idGenerator.generateWorkflowSpanId(info.durableExecutionArn());
- // Force the span ID only; the trace ID comes from the parent so the Workflow span joins the execution trace.
- workflowSpan = startDurableSpan(workflowSpanBuilder, null, workflowSpanId);
+ executionAncestor = execCtx.executionAncestor();
+ executionStartTime = info.executionStartTime();
// Invocation span — child of the ambient Lambda span when it is on the execution trace, otherwise a child of
// the execution ancestor so it stays within the same trace.
@@ -249,6 +255,13 @@ public void onInvocationStart(InvocationInfo info) {
invocationSpan = startDurableSpan(spanBuilder);
+ // Defer the recording Workflow span until terminal completion. The placeholder uses the Invocation span's
+ // resolved sampling metadata so operation parents/links match the span that is eventually exported.
+ var workflowSpanId = idGenerator.generateWorkflowSpanId(info.durableExecutionArn());
+ var invocationContext = invocationSpan.getSpanContext();
+ workflowSpanContext = SpanContext.create(
+ canonicalTraceId, workflowSpanId, invocationContext.getTraceFlags(), invocationContext.getTraceState());
+
// Inject MDC on the handler thread so handler-level logs (between steps) have trace context.
if (enableMdc) {
MDC.put(
@@ -270,20 +283,19 @@ 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 placeholder state. Open operations have no recording span to abandon.
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 so no recording span is
+ // abandoned. Attempt spans normally start and end within one 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.
@@ -294,30 +306,36 @@ public void onInvocationEnd(InvocationEndInfo info) {
invocationSpan.end();
invocationSpan = null;
}
- samplingIntent = 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());
- }
+ // Materialize the Workflow span only on terminal status.
+ if (isTerminal(info) && workflowSpanContext != null && executionAncestor != null) {
+ var workflowSpanBuilder = tracer.spanBuilder(workflowSpanName)
+ .setSpanKind(SpanKind.INTERNAL)
+ .setParent(withDurableDecision(Context.root().with(Span.wrap(executionAncestor))))
+ .setAttribute(DURABLE_EXECUTION_ARN, durableExecutionArn)
+ .setAttribute(
+ DURABLE_EXECUTION_STATUS, info.invocationStatus().name())
+ .setStartTimestamp(executionStartTime != null ? executionStartTime : Instant.now());
+ // Force only the deterministic span ID; the parent supplies the execution trace ID.
+ var workflowSpan = startDurableSpan(workflowSpanBuilder, null, workflowSpanContext.getSpanId());
+ 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;
+ executionAncestor = null;
+ executionStartTime = null;
+ samplingIntent = null;
// Flush spans before Lambda freezes
if (sdkTracerProvider != null) {
@@ -335,6 +353,30 @@ public void onOperationStart(OperationInfo info) {
if (!tracingEnabled) return;
if (info.id() == null) return;
+ // Retain only a deterministic placeholder. Its flags/state come from the Invocation span's resolved sampling
+ // decision because CONTEXT operations make this placeholder current.
+ var trace = executionTrace;
+ var spanId = idGenerator.generateSpanIdForOperation(durableExecutionArn, info.id());
+ operationContexts.put(
+ info.id(), SpanContext.create(trace.traceId(), spanId, effectiveTraceFlags(), effectiveTraceState()));
+
+ // Retain the start time for onOperationEnd, which may receive none (virtual FLAT map/parallel operations).
+ 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 single span here, 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, and it is the only place an operation span is created — so none is ever left open.
+ 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()))
@@ -344,8 +386,12 @@ public void onOperationStart(OperationInfo info) {
.setAttribute(DURABLE_OPERATION_TYPE, info.type());
addInvocationLink(spanBuilder);
- if (info.startTimestamp() != null) {
- spanBuilder.setStartTimestamp(info.startTimestamp());
+ // Use the earliest known start so the operation span never starts after its own attempt/child spans (which were
+ // created earlier, at onUserFunctionStart/onOperationStart). onOperationEnd's start timestamp can be a later
+ // re-observed value than the start captured at onOperationStart, so take the minimum of the two.
+ var startTimestamp = earliest(capturedStart, info.startTimestamp());
+ if (startTimestamp != null) {
+ spanBuilder.setStartTimestamp(startTimestamp);
}
if (info.name() != null) {
spanBuilder.setAttribute(DURABLE_OPERATION_NAME, info.name());
@@ -357,86 +403,25 @@ public void onOperationStart(OperationInfo info) {
var operationSpanId = idGenerator.generateSpanIdForOperation(durableExecutionArn, info.id());
var span = startDurableSpan(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 = startDurableSpan(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 ─────────────────────────────────────────────
@@ -446,11 +431,12 @@ public void onUserFunctionStart(UserFunctionStartInfo info) {
if (!tracingEnabled) return;
// 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.
+ // make the operation's context current so auto-instrumented calls become children of the (deferred) operation
+ // span. The context is non-recording until onOperationEnd, which is enough for parent propagation.
if ("CONTEXT".equals(info.type())) {
- var operationSpan = operationSpans.get(info.id());
- if (operationSpan != null) {
- var scope = operationSpan.makeCurrent();
+ 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);
}
@@ -627,13 +613,14 @@ private Context resolveParentContext(String parentId) {
// Parent operation from a prior invocation — non-recording placeholder with its deterministic ID.
var deterministicParentSpanId = idGenerator.generateSpanIdForOperation(durableExecutionArn, parentId);
var placeholderContext = SpanContext.create(
- trace.traceId(), deterministicParentSpanId, trace.flags(), TraceState.getDefault());
+ trace.traceId(), deterministicParentSpanId, effectiveTraceFlags(), effectiveTraceState());
return withDurableDecision(Context.current().with(Span.wrap(placeholderContext)));
}
}
- // No usable parent operation — hang off the Workflow root span.
- if (workflowSpan != null) {
- return withDurableDecision(Context.current().with(workflowSpan));
+ // No usable parent operation — hang off the deferred Workflow span via its deterministic context.
+ var workflowContext = workflowSpanContext;
+ if (workflowContext != null) {
+ return withDurableDecision(Context.current().with(Span.wrap(workflowContext)));
}
return withDurableDecision(Context.current());
}
@@ -648,6 +635,20 @@ private Context withDurableDecision(Context context) {
return intent != null ? DurableSamplingDecision.store(context, intent) : context;
}
+ private TraceFlags effectiveTraceFlags() {
+ var invocation = invocationSpan;
+ if (invocation != null) {
+ return invocation.getSpanContext().getTraceFlags();
+ }
+ var trace = executionTrace;
+ return trace != null ? trace.flags() : TraceFlags.getDefault();
+ }
+
+ private TraceState effectiveTraceState() {
+ var invocation = invocationSpan;
+ return invocation != null ? invocation.getSpanContext().getTraceState() : TraceState.getDefault();
+ }
+
/**
* Starts a durable span with the execution's sampling intent published on the current thread for the duration of
* the sampler call, so {@link DurableSampler} applies it even when the plugin and the agent-installed sampler run
@@ -683,6 +684,17 @@ private static void endSpan(Span span, Instant endTimestamp) {
}
}
+ /** Returns the earlier of two timestamps, ignoring nulls; null only when both are null. */
+ private static Instant earliest(Instant a, Instant b) {
+ if (a == null) {
+ return b;
+ }
+ if (b == null) {
+ return a;
+ }
+ return a.isBefore(b) ? a : b;
+ }
+
private static String spanName(String type, String subType, String name) {
if (name != null) {
return name;
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 307ec84ac..e89804bdc 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
@@ -39,9 +39,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 ended
+ * together on the terminal invocation only, so it is never left open). Between invocations it exists only as a
+ * deterministic context 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)
@@ -88,7 +89,6 @@ public class InvocationOtelPlugin implements DurableExecutionPlugin {
// Per-invocation state
private volatile boolean tracingEnabled;
- private volatile Span workflowSpan;
private volatile Span invocationSpan;
private volatile String durableExecutionArn;
// Trace ID and flags of the execution trace, published together as one snapshot so readers never pair a trace ID
@@ -99,6 +99,11 @@ public class InvocationOtelPlugin implements DurableExecutionPlugin {
// its own delegate) without re-invoking the configured sampler per span.
private volatile DurableSamplingDecision.Intent samplingIntent;
+ // Deferred Workflow placeholder; the recording span is emitted only on terminal invocation.
+ private volatile SpanContext workflowSpanContext;
+ private volatile SpanContext executionAncestor;
+ private volatile Instant executionStartTime;
+
/** Immutable snapshot of the resolved execution trace, read atomically through a single volatile reference. */
private record ExecutionTrace(String traceId, TraceFlags flags) {}
@@ -226,19 +231,8 @@ public void onInvocationStart(InvocationInfo info) {
var execCtx = ExecutionTraceContext.resolve(
extracted, canonicalTraceId, info.durableExecutionArn(), idGenerator, () -> sampled);
executionTrace = new ExecutionTrace(canonicalTraceId, execCtx.traceFlags());
-
- // Workflow span — one logical span per durable execution, parented onto the execution ancestor so it joins the
- // execution trace. 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)
- .setParent(withDurableDecision(Context.root().with(Span.wrap(execCtx.executionAncestor()))))
- .setAttribute(DURABLE_EXECUTION_ARN, info.durableExecutionArn())
- .setStartTimestamp(info.executionStartTime());
- var workflowSpanId = idGenerator.generateWorkflowSpanId(info.durableExecutionArn());
- // Force the span ID only; the trace ID comes from the parent so the Workflow span joins the execution trace.
- workflowSpan = startDurableSpan(workflowSpanBuilder, null, workflowSpanId);
+ executionAncestor = execCtx.executionAncestor();
+ executionStartTime = info.executionStartTime();
// Invocation span parent — the same-trace ambient span when available, then the execution ancestor, so the
// Invocation span stays on the execution trace.
@@ -257,6 +251,13 @@ public void onInvocationStart(InvocationInfo info) {
invocationSpan = startDurableSpan(spanBuilder);
+ // Defer the recording Workflow span until terminal completion. The placeholder uses the Invocation span's
+ // resolved sampling metadata so operation links match the span that is eventually exported.
+ var workflowSpanId = idGenerator.generateWorkflowSpanId(info.durableExecutionArn());
+ var invocationContext = invocationSpan.getSpanContext();
+ workflowSpanContext = SpanContext.create(
+ canonicalTraceId, workflowSpanId, invocationContext.getTraceFlags(), invocationContext.getTraceState());
+
// 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) {
@@ -311,30 +312,36 @@ public void onInvocationEnd(InvocationEndInfo info) {
invocationSpan.end();
invocationSpan = null;
- samplingIntent = 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());
- }
+ // Materialize the Workflow span only on terminal status.
+ if (isTerminal(info) && workflowSpanContext != null && executionAncestor != null) {
+ var workflowSpanBuilder = tracer.spanBuilder(workflowSpanName)
+ .setSpanKind(SpanKind.INTERNAL)
+ .setParent(withDurableDecision(Context.root().with(Span.wrap(executionAncestor))))
+ .setAttribute(DURABLE_EXECUTION_ARN, durableExecutionArn)
+ .setAttribute(
+ DURABLE_EXECUTION_STATUS, info.invocationStatus().name())
+ .setStartTimestamp(executionStartTime != null ? executionStartTime : Instant.now());
+ // Force only the deterministic span ID; the parent supplies the execution trace ID.
+ var workflowSpan = startDurableSpan(workflowSpanBuilder, null, workflowSpanContext.getSpanId());
+ 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;
+ executionAncestor = null;
+ executionStartTime = null;
+ samplingIntent = null;
if (sdkTracerProvider != null) {
// Flush spans before Lambda freezes
@@ -673,11 +680,14 @@ private Span startDurableSpan(SpanBuilder spanBuilder, String traceId, String sp
}
}
- /** Adds a link to the Workflow span, if one exists, for execution-level correlation. */
+ /**
+ * Adds a link to the Workflow span, if one is set, for execution-level correlation. Uses the deterministic Workflow
+ * context (the recording span is deferred to the terminal invocation, but shares this span ID).
+ */
private void addWorkflowLink(SpanBuilder spanBuilder) {
- var currentWorkflowSpan = workflowSpan;
- if (currentWorkflowSpan != null) {
- spanBuilder.addLink(currentWorkflowSpan.getSpanContext());
+ var workflowContext = workflowSpanContext;
+ if (workflowContext != null) {
+ spanBuilder.addLink(workflowContext);
}
}
@@ -694,11 +704,25 @@ private void addInitialOperationLink(SpanBuilder spanBuilder, String operationId
var initial = SpanContext.create(
trace.traceId(),
idGenerator.generateSpanIdForOperation(durableExecutionArn, operationId),
- trace.flags(),
- TraceState.getDefault());
+ effectiveTraceFlags(),
+ effectiveTraceState());
spanBuilder.addLink(initial);
}
+ private TraceFlags effectiveTraceFlags() {
+ var invocation = invocationSpan;
+ if (invocation != null) {
+ return invocation.getSpanContext().getTraceFlags();
+ }
+ var trace = executionTrace;
+ return trace != null ? trace.flags() : TraceFlags.getDefault();
+ }
+
+ private TraceState effectiveTraceState() {
+ var invocation = invocationSpan;
+ return invocation != null ? invocation.getSpanContext().getTraceState() : TraceState.getDefault();
+ }
+
private static boolean isTerminal(InvocationEndInfo info) {
return switch (info.invocationStatus()) {
case SUCCEEDED, FAILED -> true;
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 1f4adcf1c..8b84efc10 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
@@ -17,9 +17,11 @@
import io.opentelemetry.sdk.resources.Resource;
import io.opentelemetry.sdk.testing.exporter.InMemorySpanExporter;
import io.opentelemetry.sdk.trace.SdkTracerProvider;
+import io.opentelemetry.sdk.trace.data.SpanData;
import io.opentelemetry.sdk.trace.export.SimpleSpanProcessor;
import io.opentelemetry.sdk.trace.samplers.Sampler;
import java.time.Instant;
+import java.util.List;
import java.util.ServiceLoader;
import java.util.concurrent.atomic.AtomicInteger;
import org.junit.jupiter.api.AfterEach;
@@ -693,6 +695,64 @@ void childOperation_parentedToParentOperationSpan() {
"Child operation should be parented to its parent operation span");
}
+ @Test
+ void contextOperation_currentContextCarriesResolvedFlags_withAlwaysOffSampler() {
+ // The operation span is deferred to onOperationEnd, so the context made current during a CONTEXT operation is a
+ // non-recording placeholder. Its trace flags must come from the Invocation span (already run through
+ // DurableSampler), so descendants inherit the resolved decision. With always_off the resolved decision is
+ // unsampled, so the current context inside the context body must be unsampled — not a provisional sampled bit.
+ var exporter = InMemorySpanExporter.create();
+ var offPlugin = new ExecutionOtelPlugin(
+ SdkTracerProvider.builder()
+ .setSampler(Sampler.alwaysOff())
+ .addSpanProcessor(SimpleSpanProcessor.create(exporter)),
+ OtelPluginConfig.builder()
+ .contextExtractor(() -> null)
+ .enableMdc(false)
+ .workflowSpanName("Workflow")
+ .build());
+
+ offPlugin.onInvocationStart(new InvocationInfo("req-1", ARN, true, Instant.now()));
+ offPlugin.onOperationStart(
+ new OperationInfo("ctx-1", "my-ctx", "CONTEXT", "Context", null, Instant.now(), null, null, false));
+ offPlugin.onUserFunctionStart(
+ new UserFunctionStartInfo("ctx-1", "my-ctx", "CONTEXT", "Context", null, Instant.now(), false, 1));
+
+ var current = Span.current().getSpanContext();
+ assertTrue(current.isValid(), "A context is made current inside a context operation body");
+ assertFalse(
+ current.getTraceFlags().isSampled(),
+ "The current context must carry the always_off delegate's resolved unsampled flags (from the "
+ + "Invocation span), not a provisional sampled bit that would let descendants bypass the "
+ + "drop policy");
+
+ offPlugin.onUserFunctionEnd(new UserFunctionEndInfo(
+ "ctx-1",
+ "my-ctx",
+ "CONTEXT",
+ "Context",
+ null,
+ Instant.now(),
+ Instant.now(),
+ false,
+ 1,
+ UserFunctionOutcome.SUCCEEDED,
+ null));
+ offPlugin.onOperationEnd(new OperationEndInfo(
+ "ctx-1",
+ "my-ctx",
+ "CONTEXT",
+ "Context",
+ null,
+ Instant.now(),
+ Instant.now(),
+ "SUCCEEDED",
+ null,
+ false,
+ null));
+ offPlugin.onInvocationEnd(new InvocationEndInfo("req-1", ARN, true, InvocationStatus.SUCCEEDED, null));
+ }
+
// ─── Failure propagation ─────────────────────────────────────────────
@Test
@@ -881,8 +941,9 @@ void operationNotCompleted_notEndedAtInvocationEnd() {
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. A still-open operation has no recording span (creation is deferred to
+ // onOperationEnd), so there is nothing to abandon, and the Workflow span is not exported on a non-terminal
+ // invocation.
assertEquals(1, spans.size());
assertEquals("Invocation", spans.get(0).getName());
assertTrue(
@@ -890,6 +951,89 @@ void operationNotCompleted_notEndedAtInvocationEnd() {
"An operation still open at invocation end must not be ended/exported in onInvocationEnd");
}
+ @Test
+ void openAttemptSpan_isEndedAtInvocationEnd_notAbandoned() {
+ // A user function that starts but never ends (e.g. the execution suspends mid-attempt) must not leave a
+ // recording span abandoned: onInvocationEnd force-ends it so it is exported.
+ plugin.onInvocationStart(new InvocationInfo("req-1", ARN, true, Instant.now()));
+ plugin.onUserFunctionStart(
+ new UserFunctionStartInfo("op-1", "stuck", "STEP", "Step", null, Instant.now(), false, 1));
+ // No onUserFunctionEnd — the invocation suspends.
+ plugin.onInvocationEnd(new InvocationEndInfo("req-1", ARN, true, InvocationStatus.PENDING, null));
+
+ var attemptSpan = spanExporter.getFinishedSpanItems().stream()
+ .filter(s -> s.getName().contains("stuck"))
+ .findFirst()
+ .orElseThrow(() -> new AssertionError("Open attempt span must be ended and exported, not abandoned"));
+ assertTrue(attemptSpan.hasEnded(), "Attempt span must be ended");
+ }
+
+ @Test
+ void everyRecordingSpanIsEnded_onNonTerminalInvocation() {
+ // No recording span may be left un-ended when the execution returns a non-terminal status.
+ 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.onUserFunctionStart(
+ new UserFunctionStartInfo("op-1", "step-a", "STEP", "Step", null, Instant.now(), false, 1));
+ // Suspend mid-attempt: neither onUserFunctionEnd nor onOperationEnd fires.
+ plugin.onInvocationEnd(new InvocationEndInfo("req-1", ARN, true, InvocationStatus.PENDING, null));
+
+ // Whatever spans were exported this invocation must all be ended (InMemorySpanExporter only records ended
+ // spans, so an abandoned recording span would simply be missing — assert the attempt span made it out ended).
+ var spans = spanExporter.getFinishedSpanItems();
+ assertTrue(spans.stream().allMatch(SpanData::hasEnded), "All exported spans are ended");
+ assertTrue(
+ spans.stream().anyMatch(s -> s.getName().contains("step-a")),
+ "The open attempt span is force-ended and exported rather than abandoned");
+ }
+
+ @Test
+ void noRecordingSpanIsLeftOpen_onPending_trackedByLifecycleProcessor() {
+ assertNoOpenSpansOnNonTerminal(InvocationStatus.PENDING);
+ }
+
+ @Test
+ void noRecordingSpanIsLeftOpen_onRetrying_trackedByLifecycleProcessor() {
+ assertNoOpenSpansOnNonTerminal(InvocationStatus.RETRYING);
+ }
+
+ /**
+ * Drives an invocation that suspends mid-attempt and ends with the given non-terminal status, using a lifecycle
+ * processor that observes onStart/onEnd. Unlike an exporter (which only receives ended spans), this catches a span
+ * that started but was abandoned un-ended. Asserts nothing is left open, that the attempt span was actually started
+ * (so the check is not vacuous), and that the deferred Workflow span never started.
+ */
+ private void assertNoOpenSpansOnNonTerminal(InvocationStatus status) {
+ var lifecycle = new LifecycleTrackingSpanProcessor();
+ var trackingPlugin = new ExecutionOtelPlugin(
+ SdkTracerProvider.builder().addSpanProcessor(lifecycle),
+ OtelPluginConfig.builder()
+ .contextExtractor(() -> null)
+ .enableMdc(false)
+ .workflowSpanName("Workflow")
+ .build());
+
+ trackingPlugin.onInvocationStart(new InvocationInfo("req-1", ARN, true, Instant.now()));
+ trackingPlugin.onOperationStart(
+ new OperationInfo("op-1", "step-a", "STEP", "Step", null, Instant.now(), null, null, false));
+ trackingPlugin.onUserFunctionStart(
+ new UserFunctionStartInfo("op-1", "step-a", "STEP", "Step", null, Instant.now(), false, 1));
+ // Suspend mid-attempt: neither onUserFunctionEnd nor onOperationEnd fires.
+ trackingPlugin.onInvocationEnd(new InvocationEndInfo("req-1", ARN, true, status, null));
+
+ assertTrue(
+ lifecycle.startedSpanNames().stream().anyMatch(n -> n.contains("step-a")),
+ "The attempt span must have started, so the no-open-spans assertion is meaningful");
+ assertEquals(
+ List.of(),
+ lifecycle.openSpanNames(),
+ "No recording span may be left open on a " + status + " invocation");
+ assertFalse(
+ lifecycle.startedSpanNames().contains("Workflow"),
+ "The deferred Workflow span must not start on a non-terminal invocation");
+ }
+
@Test
void operationOpenedThenCompletedNextInvocation_exportedOnceOnOperationEnd() {
// Invocation 1: operation opens but does not complete.
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 d46073baa..d8d7e7f43 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
@@ -29,6 +29,7 @@
import io.opentelemetry.sdk.trace.SdkTracerProviderBuilder;
import io.opentelemetry.sdk.trace.export.SimpleSpanProcessor;
import java.time.Instant;
+import java.util.List;
import java.util.ServiceLoader;
import java.util.concurrent.TimeUnit;
import java.util.function.BiFunction;
@@ -1900,6 +1901,50 @@ void workflowSpan_notExportedOnNonTerminal() {
"Workflow span must not be exported on a non-terminal invocation");
}
+ @Test
+ void workflowSpan_notExportedOnRetrying() {
+ // RETRYING is non-terminal, so the deferred Workflow span is neither materialized nor abandoned.
+ plugin.onInvocationStart(new InvocationInfo("req-1", "arn:exec1", true, Instant.now()));
+ plugin.onInvocationEnd(new InvocationEndInfo(
+ "req-1", "arn:exec1", true, InvocationStatus.RETRYING, new RuntimeException("transient")));
+
+ assertTrue(
+ spanExporter.getFinishedSpanItems().stream()
+ .noneMatch(s -> s.getName().equals("Workflow")),
+ "Workflow span must not be exported on a RETRYING invocation");
+ }
+
+ @Test
+ void deferredWorkflowSpan_whenExported_isEnded_andMatchesLinkedSpanId() {
+ // The Workflow span is created only at the terminal invocation, but operations that ran earlier linked to its
+ // deterministic context. When it is finally exported it must be ended and carry that same span ID.
+ plugin.onInvocationStart(new InvocationInfo("req-1", "arn:exec-wf", 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",
+ null,
+ false,
+ null,
+ null));
+ plugin.onInvocationEnd(new InvocationEndInfo("req-1", "arn:exec-wf", true, InvocationStatus.SUCCEEDED, null));
+
+ var workflow = spanByName("Workflow");
+ var operation = spanByName("step-a");
+ assertTrue(workflow.hasEnded(), "Deferred Workflow span must be ended when materialized");
+ assertTrue(
+ operation.getLinks().stream()
+ .anyMatch(l -> l.getSpanContext().getSpanId().equals(workflow.getSpanId())),
+ "Operation's Workflow link must resolve to the materialized Workflow span ID");
+ }
+
@Test
void operationAndAttemptSpans_linkToWorkflowSpan() {
plugin.onInvocationStart(new InvocationInfo("req-1", "arn:exec-wf", true, Instant.now()));
@@ -2099,4 +2144,49 @@ public String generateTraceId() {
}
};
}
+
+ @Test
+ void noRecordingSpanIsLeftOpen_onPending_trackedByLifecycleProcessor() {
+ assertNoOpenSpansOnNonTerminal(InvocationStatus.PENDING);
+ }
+
+ @Test
+ void noRecordingSpanIsLeftOpen_onRetrying_trackedByLifecycleProcessor() {
+ assertNoOpenSpansOnNonTerminal(InvocationStatus.RETRYING);
+ }
+
+ /**
+ * Drives an invocation that suspends mid-attempt and ends with the given non-terminal status, using a lifecycle
+ * processor that observes onStart/onEnd. Unlike an exporter (which only receives ended spans), this catches a span
+ * that started but was abandoned un-ended. Asserts nothing is left open, that the attempt span was actually started
+ * (so the check is not vacuous), and that the deferred Workflow span never started.
+ */
+ private void assertNoOpenSpansOnNonTerminal(InvocationStatus status) {
+ var lifecycle = new LifecycleTrackingSpanProcessor();
+ var trackingPlugin = new InvocationOtelPlugin(
+ SdkTracerProvider.builder().addSpanProcessor(lifecycle),
+ OtelPluginConfig.builder()
+ .contextExtractor(() -> null)
+ .enableMdc(false)
+ .build());
+
+ trackingPlugin.onInvocationStart(new InvocationInfo("req-1", "arn:exec1", true, Instant.now()));
+ trackingPlugin.onOperationStart(
+ new OperationInfo("op-1", "step-a", "STEP", "Step", null, Instant.now(), null, null, false));
+ trackingPlugin.onUserFunctionStart(
+ new UserFunctionStartInfo("op-1", "step-a", "STEP", "Step", null, Instant.now(), false, 1));
+ // Suspend mid-attempt: neither onUserFunctionEnd nor onOperationEnd fires.
+ trackingPlugin.onInvocationEnd(new InvocationEndInfo("req-1", "arn:exec1", true, status, null));
+
+ assertTrue(
+ lifecycle.startedSpanNames().stream().anyMatch(n -> n.contains("step-a")),
+ "The attempt span must have started, so the no-open-spans assertion is meaningful");
+ assertEquals(
+ List.of(),
+ lifecycle.openSpanNames(),
+ "No recording span may be left open on a " + status + " invocation");
+ assertFalse(
+ lifecycle.startedSpanNames().contains("Workflow"),
+ "The deferred Workflow span must not start on a non-terminal invocation");
+ }
}
diff --git a/otel-plugin/src/test/java/software/amazon/lambda/durable/otel/LifecycleTrackingSpanProcessor.java b/otel-plugin/src/test/java/software/amazon/lambda/durable/otel/LifecycleTrackingSpanProcessor.java
new file mode 100644
index 000000000..b398b460a
--- /dev/null
+++ b/otel-plugin/src/test/java/software/amazon/lambda/durable/otel/LifecycleTrackingSpanProcessor.java
@@ -0,0 +1,58 @@
+// 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.context.Context;
+import io.opentelemetry.sdk.trace.ReadWriteSpan;
+import io.opentelemetry.sdk.trace.ReadableSpan;
+import io.opentelemetry.sdk.trace.SpanProcessor;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.CopyOnWriteArrayList;
+
+/**
+ * A test {@link SpanProcessor} that records span lifecycle events — both {@code onStart} and {@code onEnd} — keyed by
+ * span ID.
+ *
+ *
{@code InMemorySpanExporter} only receives spans that have been ended, so asserting "span X was not exported" is
+ * satisfied both when X never started and when X started but was abandoned un-ended. This processor closes that gap: it
+ * observes every recording span at start, so a test can assert that every started span also ended (nothing is left
+ * open) or that a deferred span never started at all.
+ */
+final class LifecycleTrackingSpanProcessor implements SpanProcessor {
+
+ private final List startedNames = new CopyOnWriteArrayList<>();
+ private final Map openSpanNamesById = new ConcurrentHashMap<>();
+
+ @Override
+ public void onStart(Context parentContext, ReadWriteSpan span) {
+ startedNames.add(span.getName());
+ openSpanNamesById.put(span.getSpanContext().getSpanId(), span.getName());
+ }
+
+ @Override
+ public boolean isStartRequired() {
+ return true;
+ }
+
+ @Override
+ public void onEnd(ReadableSpan span) {
+ openSpanNamesById.remove(span.getSpanContext().getSpanId());
+ }
+
+ @Override
+ public boolean isEndRequired() {
+ return true;
+ }
+
+ /** Names of all spans that were started (recording), whether or not they were later ended. */
+ List startedSpanNames() {
+ return List.copyOf(startedNames);
+ }
+
+ /** Names of spans that started but were never ended — i.e. abandoned recording spans. */
+ List openSpanNames() {
+ return List.copyOf(openSpanNamesById.values());
+ }
+}