OpenTelemetry instrumentation plugin for the AWS Lambda Durable Execution SDK for Java. Anchors every durable execution on one trace so the Workflow span and its per-invocation spans stay correlated, joining the propagated backend trace when one is present.
- Backend-parented execution trace: The Workflow span parents onto the execution ancestor resolved at invocation start — a propagated remote context, or a synthetic execution root — for one trace ID that is stable across all invocations, plus a stable span ID derived from the ARN
- Ambient Invocation Traces: Invocation spans inherit the active Lambda/X-Ray context, or join the execution ancestor so they stay on the execution trace
- Scoped ID Generation: Unrelated instrumentation scopes retain their provider's normal root trace ID generation
- Span-per-Operation: Each durable operation (step, wait, map, etc.) gets its own span with accurate timing
- Attempt Spans: Each user function execution (step attempt, child context run) gets a span, including retries
- Log Correlation: Injects
traceId,spanId, andotelTraceSampledinto SLF4J MDC for end-to-end observability - ADOT Java Agent Integration:
InvocationOtelPlugin.factory()binds the ADOT Java agent's global provider on first use, with no handler-side OpenTelemetry initialization - Lambda Layer Discovery:
DURABLE_EXECUTION_PLUGINSloads either OTel plugin from a JAR under a layer'sjava/libdirectory
Choose either the invocation or execution factory. Their durable-otel-view exclusive group is validated across
explicit and environment-selected factories before any invocation instance or span is created. Unrelated factories
remain allowed. DurableConfig.toBuilder() and LocalDurableTestRunner preserve the resolved factory registrations
without rediscovering environment plugins; factory identity retains environment-owned provider resources.
With a core that supports the explicit @HandlerScoped capability, user instrumentation in the root handler joins
its canonical execution trace. A valid same-trace ambient Lambda span stays current. If ambient context is absent or
belongs to another trace, invocation view activates Invocation; execution view activates the deterministic
Workflow context, including its unsampled non-recording form. Nested operations retain their existing contexts.
The core opens the explicit scope after invocation startup and closes it on the same handler thread on success, failure, or suspension. The handler owns the entire invocation lifecycle: Start, body and scope unwinding, operation and checkpoint drain, output preparation, then End in reverse plugin registration order. The caller waits for that owner and for End to finish. A zero or expired runtime budget does not move End onto the caller or allow root scope cleanup to continue after End. A compatible ambient span uses a no-op scope with the same lifecycle ordering.
Ordinary cleanup preserves the previously selected durable outcome. Recognized VirtualMachineError or ThreadDeath
from owned scope callbacks propagates after the owner has completed cleanup; started plugins receive one RETRYING End.
The SDK cannot bound arbitrary root code, scope close callbacks, serializers, provider callbacks, or transports. Its
fatal signal still interrupts cooperative peers and rejects queued work, but the invocation joins actual peer tasks,
admitted continuations, and in-flight transport before End. A fatal no longer enables a 500 ms return while those tasks
remain active. Non-cooperative work can therefore delay End and caller return without a hard bound. Operation owners
retain their matching cleanup recipients through that drain, while concurrent invocations create distinct instances. Shared provider resources remain environment-owned.
The JDK-only opener requires explicit @HandlerScoped opt-in; unannotated application methods are not invoked. The
factory migration follows the 3.x artifact boundary in the migration guide. Nonfatal scope-open/close failures remain
contained, and earlier scopes still close in reverse acquisition order. Root-handler context requires the matching
core/plugin pair.
The core observes the handler and manager outcomes before executor dispatch. A later ordinary finally return or
failure does not replace an already selected suspension or retry. Diagnostics during ordinary failure-response
preparation share its existing guard: if diagnostic access fails, started plugins receive one RETRYING End before
that preparation failure propagates.
Root-handler JVM fatals recognized by the existing body classifier are reported before owned scopes close so peer
work can be stopped cooperatively, but the root owner still finishes cleanup before End. The first accepted body fatal
remains primary if close raises another fatal. Earlier selected suspension/retry control still wins over a later body
finally failure; a recognized losing body fatal escapes its actual asynchronous worker after that selected outcome
and End are delivered. A result-serialization fatal is discovered only after normal peer draining, so it cannot shorten
that earlier drain.
After a fatal abort, checkpoint and state-page responses are checked before local state/callback/poller publication. A response that observes the fatal at a guard stops publication; persisted backend state remains available for normal replay. These guards are not atomic cancellation: work already past a check can race with abort and finish, including callbacks and poller completion. If a poller callback aborts the invocation, the remaining detached observers settle with that fatal while earlier delivered outcomes remain unchanged. The transport and poller locks are unchanged; these checks do not interrupt the transport or create a new caller-outcome policy.
Successful-output preparation closes new checkpoint-continuation admission before serialization. It does not revoke user workers already admitted to their executor, and End does not mean all unawaited work has finished. The separate late-worker and mandatory large-result acknowledgment boundaries in #787 remain outside this change.
<dependency>
<groupId>software.amazon.lambda.durable</groupId>
<artifactId>aws-durable-execution-sdk-java-plugin-otel</artifactId>
<version>${durable.sdk.version}</version>
</dependency>For the agent path (InvocationOtelPlugin.factory()), no additional OpenTelemetry dependencies are needed — the ADOT Java agent layer provides them.
If you configure your own SdkTracerProviderBuilder, add the OpenTelemetry SDK and an exporter:
<dependency>
<groupId>io.opentelemetry</groupId>
<artifactId>opentelemetry-sdk</artifactId>
<version>1.65.0</version>
</dependency>
<dependency>
<groupId>io.opentelemetry</groupId>
<artifactId>opentelemetry-exporter-logging</artifactId>
<version>1.65.0</version>
</dependency>Both views provide the calling operation's X-Ray context to the SDK's synchronous collector. New invoke START
checkpoints carry that context in the flat optional ChainedInvokeOptions.XAmznTraceId field. The root POM's
AWS SDK for Java 2.56.0 provides the generated typed setter. Deployed parent/child trace behavior still requires
cloud validation. See the implemented path and validation status.
Execution-view operation spans are deferred until completion. Their placeholders can provide sampling metadata with
a visible provider using the SDK's DurableSampler, or an agent provider verified after SDK autoconfiguration.
The post-build callback checks the immutable provider's final sampler, after all customizers have run. Only that exact
DurableSampler instance can confirm consumption of this invocation's sampling intent; a later filtering wrapper is
rejected even if it delegates Invocation sampling to an inner DurableSampler. Confirmation crosses the application/agent class-loader boundary through
the existing thread-scoped bridge and is cleared after span creation. The propagated parent is the deterministic
invoke span ID, and sampling flags come from the actual Invocation span.
Replacement samplers and opaque providers without post-build verification and confirmation still abstain; the backend's existing
omitted-header fallback applies. Invocation view uses the actual started operation span's flags. Before that span is
available, it requires a visible durable sampler. Neither path changes the customer's sampler, samples again, or
creates a span just to obtain propagation metadata.
When the backend supplies no complete remote parent, both views create and immediately end a DurableExecutionRoot
anchor during per-invocation factory creation, before descendant spans and customer code. Its trace and span IDs are
deterministic; both timestamps are the checkpointed execution start. The anchor has
durable.execution.synthetic_root=true and carries identity, not execution duration or outcome. Workflow remains the
terminal summary. Complete remote parents are externally owned and are never exported by the SDK.
With the managed DurableSampler, anchors use only the sampled decision and stable SDK identity. Custom sampler
attributes and trace state remain on Workflow, Invocation, operation and attempt descendants; they are not copied to
re-exported anchors. RECORD_ONLY still records descendants but produces no anchor, and DROP produces neither.
Visible plain replacement providers retain their documented independent per-span policy described below.
Each invocation may re-export the zero-duration anchor to recover an earlier lost export. Managed sampled re-exports keep identity, timestamps and SDK span fields stable. Provider resources still apply and may differ between execution environments. No export or flush failure can guarantee delivery. Invocation End attempts one flush even if span finalization fails. A factory that fails before returning an instance keeps the major's existing containment/skipping contract; the SDK does not publish a partially constructed plugin just to give it an End callback.
After a nonfatal construction failure, the OTel factory makes a best-effort flush of completed spans through its
already bound, visible SDK provider, using the same five-second result wait as End. It never shuts down the shared
provider or recovers every partially opened span. A recognized original VirtualMachineError or ThreadDeath
(including the supported asynchronous wrappers) propagates before any additional provider flush; completed telemetry
can remain buffered on that fatal path. For nonfatal startup failures, ordinary flush problems retain the original
failure, while a JVM fatal from flushing still propagates. Opaque providers have no added flush guarantee, and the
result wait does not preempt an arbitrary synchronous processor callback.
Configure exactly one of InvocationOtelPlugin.factory(...) or ExecutionOtelPlugin.factory(...) when enabling
tracing. Both emit Workflow and Invocation telemetry and manage log correlation. Configuration rejects multiple
factories in the durable-otel-view exclusive group before creating any invocation-owned plugin, including repeated
explicit factories and conflicting environment/explicit registrations. Either single view and unrelated factories
remain valid. The factory migration uses DurableExecutionPluginFactory.getExclusiveGroup() metadata; it does not
inspect arbitrary methods on the plugin instances or construct them during validation.
config.toBuilder() preserves the resolved factory list without rediscovery. Calling withPlugins(...) replaces
that complete list, and withPlugins() removes every factory. Use a fresh DurableConfig.builder() to read the
current environment selection. The 3.x testing SDK uses this copy path; the 2.x instance-registration/testing SDK
compatibility path is outside this major-version migration.
View exclusivity is declared with inherited @ExclusivePluginGroup("durable-otel-view") metadata.
Configuration reads this explicit opt-in annotation from the entire superclass chain; it does not call application methods
that happen to be named getExclusiveGroup. Existing subclasses retain their own methods while inheriting the bundled
view restriction. A subclass may add another group, but cannot replace a superclass's group; repeated group names in one
class hierarchy are checked once.
The exclusivity annotation does not change the provider registration API; the same-thread core requirement above still applies.
- Add the ADOT Lambda Layer to your function
- Enable X-Ray Active Tracing on the function
- Configure environment variables
- Load
InvocationOtelPlugindynamically or register it in your handler'sDurableConfig - Grant X-Ray write permissions
This plugin uses the AWS Distro for OpenTelemetry (ADOT) Lambda layer for trace export. InvocationOtelPlugin.factory() resolves the global provider initialized by the ADOT Java agent when the first invocation's plugin instance is created, with deterministic span ID generation installed through the plugin's AutoConfigurationCustomizerProvider SPI. If the provider is not ready, that invocation's instance emits no telemetry and the next invocation's instance resolves the provider again.
The layer ARN follows the format:
arn:aws:lambda:<region>:615299751070:layer:AWSOpenTelemetryDistroJava:<version>
Note: The layer is regional — the account ID and version vary by region. Find the current per-region ARN in the ADOT Java instrumentation releases.
CloudFormation / SAM:
MyFunction:
Type: AWS::Serverless::Function
Properties:
Tracing: Active
LoggingConfig:
LogFormat: JSON
Layers:
- !Sub arn:aws:lambda:${AWS::Region}:615299751070:layer:AWSOpenTelemetryDistroJava:16
- <otel-plugin-layer-arn>
Environment:
Variables:
AWS_LAMBDA_EXEC_WRAPPER: /opt/otel-instrument
OTEL_JAVAAGENT_EXTENSIONS: /opt/java/lib/aws-durable-execution-sdk-java-plugin-otel-<version>.jar
DURABLE_EXECUTION_PLUGINS: otel-invocationAWS CLI:
aws lambda update-function-configuration \
--function-name your-function-name \
--layers "arn:aws:lambda:<region>:615299751070:layer:AWSOpenTelemetryDistroJava:16" "<otel-plugin-layer-arn>" \
--environment "Variables={AWS_LAMBDA_EXEC_WRAPPER=/opt/otel-instrument,OTEL_JAVAAGENT_EXTENSIONS=/opt/java/lib/aws-durable-execution-sdk-java-plugin-otel-<version>.jar,DURABLE_EXECUTION_PLUGINS=otel-invocation}"Build the plugin layer ZIP with the OTel plugin JAR at java/lib/aws-durable-execution-sdk-java-plugin-otel-<version>.jar. Lambda adds JARs in this directory to the Java class path. Set OTEL_JAVAAGENT_EXTENSIONS to the deployed JAR so the ADOT Java agent also loads its AutoConfigurationCustomizerProvider, and set DURABLE_EXECUTION_PLUGINS=otel-invocation so the Durable Execution SDK loads its InvocationOtelPluginProvider.
The SDK captures Context.getXrayTraceId() on the runtime thread before dispatching user work when an actual
runtime implementation overrides the accessor. It stores the immutable snapshot in InvocationInfo.xRayTraceId().
The same information object reaches the ordinary createPlugin(info) factory and onInvocationStart(info) hook,
so both built-in and custom factories can read the header before creating spans. Context extraction uses extract(info);
existing no-argument extractor implementations retain their default delegation.
An override returning null/empty is represented by an empty String and is authoritative absence: deterministic fallback is used without reading stale global context. An unavailable accessor, or only Lambda Core1.4's inherited null-returning default, is represented by null and retains ordinary system-property then environment fallback. A malformed captured header also never adopts the global carrier. No global header is modified.
Capture is skipped when no plugin factories are registered. A nonfatal failure from an available runtime accessor
also becomes authoritative absence, so it cannot reuse another invocation's global trace or sampling decision.
Direct JVM-fatal errors propagate with their original identity, as do fatal errors wrapped in a CompletionException,
ExecutionException, InvocationTargetException, or UndeclaredThrowableException.
In 3.x, the header is the eighth InvocationInfo record component. Existing 4-, 5-, 6-
and 7-argument constructors remain and default the optional header to null. Existing compiled constructor/accessor calls
and seven-component record patterns remain compatible; recompiling a seven-component record pattern requires an eighth
binding. Reflection sees eight components, and record equality/hash calculation includes the header. toString() omits it.
Core/plugin artifacts still follow the explicit 3.x factory migration boundary: rebuild providers and layers against 3.x
as described in the migration guide; 2.x and 3.x plugin artifacts are not interchangeable.
Enable active tracing on your Lambda function so the _X_AMZN_TRACE_ID environment variable is populated at invocation time. The plugin uses this header both to parent Invocation spans to the ambient Lambda/X-Ray trace and to anchor the execution trace on the propagated context when it carries a complete parent and an explicit sampling decision.
AWS Console: Lambda > Configuration > Monitoring and operations tools > Active tracing > Enable
CloudFormation / SAM:
MyFunction:
Type: AWS::Serverless::Function
Properties:
Tracing: ActiveWith the layer and DURABLE_EXECUTION_PLUGINS=otel-invocation configured above, no OTel plugin dependency or registration code is required in the function artifact. The function can use its existing DurableConfig.
The OTel plugin JAR exposes two dynamic provider names:
| Provider name | Plugin | Trace model |
|---|---|---|
otel-invocation |
InvocationOtelPlugin |
Invocation-rooted |
otel-execution |
ExecutionOtelPlugin |
Workflow-rooted |
Set DURABLE_EXECUTION_PLUGINS=otel-execution to select the Workflow-rooted plugin instead.
Applications that prefer code-based configuration can continue to register the plugin explicitly:
import software.amazon.lambda.durable.DurableConfig;
import software.amazon.lambda.durable.DurableContext;
import software.amazon.lambda.durable.DurableHandler;
import software.amazon.lambda.durable.otel.InvocationOtelPlugin;
public class MyHandler extends DurableHandler<MyInput, MyOutput> {
@Override
protected DurableConfig createConfiguration() {
// A factory, not a plugin instance: the SDK creates one plugin instance per invocation from it.
return DurableConfig.builder().withPlugins(InvocationOtelPlugin.factory()).build();
}
@Override
public MyOutput handleRequest(MyInput input, DurableContext context) {
var result = context.step("fetch-data", String.class, stepCtx -> {
return fetchData(input.getId());
});
context.wait("cool-down", Duration.ofSeconds(5));
context.step("process", Void.class, stepCtx -> {
process(result);
return null;
});
return new MyOutput(result);
}
}Keep the OpenTelemetry API, context, SDK, and Java agent versions aligned. This plugin is built and tested against
OpenTelemetry 1.66.0. Global-provider binding needs GlobalOpenTelemetry.isSet() and getOrNoop(); when the visible
API lacks either method (for example, API 1.49.0), the plugin logs a compatibility diagnostic and disables its telemetry
for that invocation. It does not install a no-op global that would prevent a provider from being registered later.
The OpenTelemetry dependency guard and hook linkage isolation are inherited from the 2.x fix in
#780. The 3.x factory migration described above still
requires rebuilt SDK plugin providers; it does not provide a cross-major compatibility bridge. Fatal JVM errors and
ThreadDeath propagate. Align incompatible dependencies to restore instrumentation; error isolation does not make
every old agent/API combination capable of exporting telemetry.
The function's execution role needs the AWSXRayDaemonWriteAccess managed policy (or equivalent permissions) to write traces to X-Ray.
The whole execution shares one trace, anchored at the execution ancestor resolved at invocation start. When the backend propagates a valid remote server span (Root and Parent), that span is the ancestor and the Workflow and Invocation spans nest under it, alongside the ambient Lambda spans on the same trace:
Remote backend server span (Root / Parent)
├── Workflow (stable span ID, exported once)
├── Ambient Lambda span 1
│ └── Invocation 1
├── Ambient Lambda span 2
│ └── Invocation 2
└── Invocation N (direct child when no same-trace ambient span exists)
When no valid remote parent can be constructed, a synthetic execution root anchors the trace instead and both spans parent onto it:
DurableExecutionRoot (materialized and re-exported each invocation)
├── Workflow
├── Invocation 1
├── Invocation 2
└── Invocation N
- Execution ancestor — the common parent both the Workflow and Invocation spans resolve onto. A valid remote server span (
RootandParent) is used directly, whether or notSampledis present; only when a valid remote parent cannot be constructed does a synthetic execution root take its place. The remote ancestor is used as a non-recording context; the synthetic ancestor is materialized asDurableExecutionRootand re-exported on each invocation with its stable span ID and execution start time, subject to sampling. - Workflow span — one logical span per durable execution, joining the execution trace with a stable span ID derived from the ARN. Exported only on the terminal invocation (SUCCEEDED/FAILED).
- Invocation span — one per Lambda invocation, parented to the ambient span only when it is on the execution trace, otherwise to the execution ancestor
- Operation span — one per durable operation, named after your step/wait names
- 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.
The SDK-wide sampling guarantees below require DurableSampler itself to be the final installed sampler. The
builder constructors and ADOT extension setup install it automatically;
configure its delegate through these documented paths and retain DurableSampler as the final sampler. An
unrecognized outer wrapper is treated as a plain replacement. The SDK wrapper applies its sampling intent to every durable
span. Workflow, Invocation, operation and attempt spans preserve the full result, including RECORD_ONLY, sampler
attributes and trace state. The re-exported DurableExecutionRoot is the narrow exception described above: it uses only
the sampled decision and stable SDK identity, and is omitted for RECORD_ONLY or DROP.
The supported SDK sampler follows this precedence, highest first:
- Backend decision — with
DurableSamplerinstalled,Sampled=1/Sampled=0in the propagated header is authoritative, regardless of the wrapped delegate's policy. - Same-trace ambient span — without an explicit header decision, a valid ambient span already on the execution
trace supplies its decision: sampled → sampled; unsampled and recording →
RECORD_ONLY; unsampled and non-recording → dropped. - Same-copy durable sampler — the visible SDK sampler is evaluated against root context once per invocation using the canonical trace ID, span name and attributes. Its complete result is carried in that loader's context.
- Foreign or opaque SDK sampler — resolution is deferred to the installed wrapper's actual delegate. Its full
result is cached by execution ARN and canonical trace ID in a 256-entry LRU cache and reused while resident.
Eviction can cause another evaluation. The delegate still receives the canonical trace ID; drop and
RECORD_ONLYdecisions, attributes and updated trace state are retained.
For a visible plain replacement sampler with no explicit upstream or same-trace ambient decision, a fallback anchor
uses the provider's actual root sampling result for its parent context. It does not spend a preliminary decision before
creating that root. Descendants still use the provider's own per-span sampling, so a stateful policy can select them
differently. Explicit and ambient decisions retain their existing context behavior. The legacy complete-remote-parent
path still evaluates root policy before span creation when sampling is undecided; a stateful replacement can observe
that additional query. The query resolves the remote parent's otherwise-unknown sampled flag from the configured
root policy; omitting it can bypass ParentBased(alwaysOff). Supply an explicit upstream decision or retain
DurableSampler when a policy result must be reused authoritatively. A plain replacement receives no SDK sampling carrier and does not provide SDK-wide decision
reuse or full-result overrides. Opaque providers cannot expose a later sampler replacement to the
application, so retain the wrapper through the documented customization setup.
For explicit execution-level control, keep DurableSampler as the final installed sampler and set Sampled upstream,
for example through X-Ray
active tracing. The SDK sampling wrapper preserves that decision.
Parentless fallback-root exports also use the provider's sampler. If the SDK wrapper is replaced by a plain
sampler, those anchors can follow its normal root policy independently of the flags used by their descendants;
full upstream sampling overrides require the installed SDK wrapper. For example, a sampler that selects spans by name
can export only the anchor or only its Invocation child. This behavior is covered for both views by
ExecutionRootParityTest.plainReplacementRetainsIndependentPerSpanSampling; installing DurableSampler restores the
shared decision. Merely delaying anchor creation until terminal completion would not make an arbitrary replacement
sampler consistent across spans.
An explicit operation status of FAILED, TIMED_OUT, or STOPPED sets the OTel span to ERROR, including when the
backend error container is absent or fully empty. No exception event is recorded when no details were supplied.
An explicitly present empty string or stack-trace list still counts as supplied error details.
Virtual (FLAT) contexts can have no terminal status; a non-null failure still sets ERROR, even when its backend
error container supplies no details. That failure has no exception event unless details are available.
An exception's own message (including an empty string), cause, suppressed failures,
or explicit stack trace still supplies diagnostic information and retains ERROR and its exception event.
This telemetry decision does not remove the SDK's failure notification: a present ErrorObject, including {},
remains a DurableOperationException in operation hooks and snapshots. An absent container retains its existing
null metadata behavior. Persisted errors, caller failures, replay, and user-function error handling are unchanged.
Execution-view operation spans with a known, nonzero backend end timestamp record the exception event at that
same timestamp. This path uses the public exception event with standard exception.type, exception.message
(when present), and exception.stacktrace attributes. Java's standard stack-trace text and the provider's ordinary
event attribute count/value limits apply; native recordException resolver behavior is not preserved on this path.
Invocation-view events and operations without a backend end (including the SDK's epoch-zero clock sentinel) keep
native recordException behavior.
When an Execution-view completion has no observed or backend start timestamp, its known end also supplies the start. Existing earliest-known-start selection is unchanged and is not clamped to the backend end, so clock skew can still produce an inverted interval. This correction does not add a broader clock or containment policy.
| Attribute | Description |
|---|---|
durable.execution.arn |
The durable execution ARN |
durable.invocation.status |
SUCCEEDED, FAILED, PENDING, or RETRYING |
durable.invocation.first |
Whether this is the first invocation of the execution |
faas.invocation_id |
Lambda request ID |
| Attribute | Description |
|---|---|
durable.execution.arn |
The durable execution ARN |
durable.operation.id |
Unique operation ID |
durable.operation.type |
STEP, WAIT, CONTEXT, CHAINED_INVOKE, CALLBACK |
durable.operation.name |
Human-readable name (if provided) |
durable.operation.subtype |
Map, Parallel, WaitForCondition, etc. |
durable.operation.status |
Backend status: SUCCEEDED, FAILED, PENDING, TIMED_OUT, etc. |
| Attribute | Description |
|---|---|
durable.execution.arn |
The durable execution ARN |
durable.operation.id |
Parent operation ID |
durable.operation.type |
Parent operation type |
durable.operation.name |
Parent operation name |
durable.attempt.number |
1-based attempt number |
durable.attempt.outcome |
SUCCEEDED (span status OK), FAILED (ERROR), or INCOMPLETE (UNSET) |
When enableMdc is true (default), the plugin injects these fields into SLF4J MDC during user function execution:
| MDC Key | Description |
|---|---|
traceId |
W3C trace ID (32 hex chars) |
spanId |
Current span ID (16 hex chars) |
otelTraceSampled |
Whether the trace is sampled (true/false) |
The traceId is also injected at invocation start so handler-level logs (between steps) include it.
The core preserves worker MDC through the handler-owned End callback, then restores the previous worker map before publishing the return to the caller. A recognized fatal from restoration propagates to the caller and actual worker, including after an ordinary handler failure. End still describes the already selected SDK outcome and is not dispatched a second time to describe a later MDC-restoration failure. An already known fatal remains primary over cleanup errors.
After an ordinary outer restoration failure, the core attempts MDC.clear() once and exposes the cleanup failure on the
asynchronous worker while retaining the selected ordinary caller outcome. This also applies when an earlier handler fatal
remains primary. With a standard ThreadPoolExecutor, the escaped failure retires that worker; a successful clear keeps
invocation MDC from being inherited by its replacement. Clearing can discard ambient MDC that could not be restored.
A caller-owned executor may intercept failures, and an adapter that rejects clear cannot guarantee a clean replacement.
Factory APIs and handler-body classification are unchanged. With no plugins, the outer worker does not capture or clear
MDC; input failure before the durable logger scope leaves application-owned MDC untouched. Once that logger scope starts,
its existing cleanup behavior still applies. Pre-start plugin MDC capture uses bounded standard-wrapper fatal detection,
settles observation before throwing the original fatal, and creates no plugin or hook. Ordinary capture failures retain
their existing classification. Terminal failure payloads are prepared before End; an encoding failure reports RETRYING.
Configure your logging framework (e.g., Log4j2) to include MDC fields in the output. For example, using JsonLayout:
<Console name="Console" target="SYSTEM_OUT">
<JsonLayout compact="true" eventEol="true" properties="true" />
</Console>With Lambda's LoggingConfig: JSON (required for durable functions), CloudWatch parses the JSON and X-Ray correlates logs via requestId (injected by the core SDK's DurableLogger).
Each plugin is registered as a DurableExecutionPluginFactory obtained from its static factory(...) methods, because
a plugin instance serves exactly one invocation: the SDK calls the factory once per invocation and drops the instance
when the invocation returns. The factory holds what belongs to the execution environment — your tracer provider (built
once) or the ADOT global provider binding, plus the deterministic ID generator — while each instance holds only its own
invocation's spans.
The factory(...) overloads take an optional SdkTracerProviderBuilder (your exporter/processor pipeline) plus an
optional OtelPluginConfig built with a named-field builder, which matches the OtelPluginConfig object in the
JavaScript and Python SDKs.
// Default: ADOT Java agent global provider, X-Ray context extraction, MDC enabled
InvocationOtelPlugin.factory();
// Custom tracer provider pipeline, all other options defaulted
InvocationOtelPlugin.factory(tracerProviderBuilder);
// Full configuration via the builder
InvocationOtelPlugin.factory(
tracerProviderBuilder,
OtelPluginConfig.builder()
.contextExtractor(new XRayContextExtractor())
.enableMdc(true)
.workflowSpanName("Workflow")
.instrumentationName("aws-durable-execution-sdk-java")
.build());The ExecutionOtelPlugin renders the Workflow span as the durable trace root with operations beneath it. Invocation
spans remain in the ambient Lambda trace, and operations link to the Invocation that ran them. It exposes the same
factory(SdkTracerProviderBuilder, OtelPluginConfig) methods:
// Default: ADOT Java agent global provider, X-Ray context extraction, MDC enabled
ExecutionOtelPlugin.factory();
// Custom tracer provider pipeline, all other options defaulted
ExecutionOtelPlugin.factory(tracerProviderBuilder);
// Full configuration via the builder
ExecutionOtelPlugin.factory(
tracerProviderBuilder,
OtelPluginConfig.builder()
.enableMdc(false)
.workflowSpanName("Workflow")
.build());| Builder method | Description | Default |
|---|---|---|
contextExtractor(...) |
Extracts parent trace context from the Lambda environment | new XRayContextExtractor() |
enableMdc(...) |
If true, injects traceId/spanId/otelTraceSampled into SLF4J MDC |
true |
workflowSpanName(...) |
Name for the Workflow span | "Workflow" |
instrumentationName(...) |
Instrumentation scope name registered with the tracer | "aws-durable-execution-sdk-java" |
The no-builder
InvocationOtelPlugin.factory()/ExecutionOtelPlugin.factory()forms resolve the ADOT Java agent's global provider instead, when the first invocation's instance needs it. If it is not ready, all telemetry is disabled for that invocation and the next invocation's instance resolves it again. Anullpassed to anyOtelPluginConfigbuilder setter falls back to that option's default.
The plugin's spans do not appear as nested subsegments of the Lambda platform segment in the ungrouped "Segments Timeline" view. This is because the ADOT collector's OTLP-to-X-Ray conversion cannot attach exported spans as subsegments of the Lambda service's native X-Ray segment (created outside the OTLP pipeline). Use the "Group by nodes" view to see the full span hierarchy.
The Workflow span joins the execution trace by parenting onto the execution ancestor: the propagated remote server span when one is valid, otherwise a synthetic execution root. Either way it shares the execution trace ID and keeps its stable, ARN-derived span ID.
After deploying your function with the plugin configured:
-
Invoke your durable function — trigger at least one execution that includes multiple steps or a wait/resume cycle.
-
Check CloudWatch console — Navigate to CloudWatch > Traces. Enable "Group by nodes" to see:
- One execution trace covering the whole execution, with the Workflow span and each Invocation span sharing its trace ID
- One Invocation span per Lambda invocation
- Child spans for each durable operation (named after your step names)
- Links between durable Workflow/operation spans and Invocation spans
-
Check log correlation — Verify that the Logs section at the bottom of the trace view shows both platform logs and application logs correlated with the trace.
| Symptom | Likely Cause |
|---|---|
| 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 |
_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 |
For local testing, use a logging exporter to print spans to stdout:
import io.opentelemetry.exporter.logging.LoggingSpanExporter;
var otelPluginFactory = InvocationOtelPlugin.factory(
SdkTracerProvider.builder()
.addSpanProcessor(SimpleSpanProcessor.create(LoggingSpanExporter.create())));- Java 17+
- AWS Durable Execution SDK for Java 3.x and plugin factory API 2
- OpenTelemetry SDK 1.65.0+ (only for custom TracerProvider path)
- ADOT Lambda Layer
AWSOpenTelemetryDistroJava(for the agent path,factory()without a builder)
Apache-2.0
The retained src/test/compatibility/run_matrix.py is the2.x regression harness for the additive minor fixes. It
verifies released/current2.x artifacts in separate plugin-layer loaders without dependency-floor rejection. It is not
a 3.x compatibility bridge: the factory/provider migration deliberately requires 3.x artifacts and rebuilt provider
layers. The major's provider migration tests validate clear rejection of selected legacy providers; the minor PRs
retain their independently tested2.x compatibility guarantees.
The inherited @HandlerScoped annotation names an explicit JDK Function opener. The core passes the plugin
instance to it without discovering a plugin method by name. Bundled openers call private SDK implementation code.
Custom opener classes need a public no-argument constructor accessible to the core. The factory/provider migration
still requires 3.x artifacts and intentionally final view classes; this is not a cross-major subclass compatibility bridge.