Skip to content

[Bug]: OTel ignores the invocation-local X-Ray header on Lambda Managed Instances #951

Closed

Description

@zhongkechen

Expected Behavior

On Node.js LMI, the default OTel integration should consume the header supplied as context.xRayTraceId and preserve its trace ID, parent and explicit sampling decision. It should retain ordinary Lambda compatibility and keep this data invocation-local.

Actual Behavior

The core wrapper does not forward context.xRayTraceId in InvocationInfo, and the default extractor reads only process.env._X_AMZN_TRACE_ID. Node.js LMI explicitly does not support that environment variable.

Using the published wrapper with a valid context-only header containing Sampled=0, the plugin receives no header, chooses a different ARN-derived trace ID, and exports sampled Invocation/Workflow spans when the fallback sampler is AlwaysOn. The same header supplied through the environment is correctly parsed as NOT_SAMPLED. This is a lost upstream-context input, rather than an issue with deterministic fallback IDs themselves.

Steps to Reproduce

Install @aws/[email protected], @aws/[email protected], @opentelemetry/[email protected], and @opentelemetry/[email protected]. Run the following with node repro.cjs. It invokes the real public wrapper using a valid local event, an LMI-shaped Lambda context and a service client that rejects any network operation.

Expected: the context header is available to the default extractor and Sampled=0 suppresses export. Observed: the captured invocation info has no context/header, the handler succeeds, and Invocation/Workflow are exported sampled on a different trace.

Reproduction code
const {withDurableExecution,DurableExecutionInvocationInputWithClient}=require('@aws/durable-execution-sdk-js');
const {ExecutionOtelPlugin}=require('@aws/durable-execution-sdk-js-otel');
const {NodeTracerProvider,InMemorySpanExporter,SimpleSpanProcessor,AlwaysOnSampler}=require('@opentelemetry/sdk-trace-node');
const header='Root=1-6955b900-123456789012345678901234;Parent=1234567890123456;Sampled=0';
const arn='arn:aws:lambda:us-west-2:123456789012:function:probe:1/durable-execution/probe/00000000-0000-4000-8000-000000000001';
const ctx={awsRequestId:'req',functionName:'probe',functionVersion:'1',invokedFunctionArn:'arn:aws:lambda:us-west-2:123456789012:function:probe:1',memoryLimitInMB:'128',logGroupName:'probe',logStreamName:'probe',getRemainingTimeInMillis:()=>30000,xRayTraceId:header,callbackWaitsForEmptyEventLoop:false};
const event={DurableExecutionArn:arn,CheckpointToken:'token',InitialExecutionState:{Operations:[{Id:arn.split('/').at(-1),Type:'EXECUTION',Status:'STARTED',StartTimestamp:new Date('2026-10-02T00:00:00Z'),ExecutionDetails:{InputPayload:'{}'}}]}};
const client=new Proxy({},{get:(_,name)=>()=>{throw new Error('unexpected network method '+String(name))}});
let infoSeen,provider;const exporter=new InMemorySpanExporter();
const plugin=new ExecutionOtelPlugin({tracerProviderFactory:create=>provider=new NodeTracerProvider({idGenerator:create(),sampler:new AlwaysOnSampler(),spanProcessors:[new SimpleSpanProcessor(exporter)]})});
const recorder={onInvocationStart:async info=>{infoSeen={keys:Object.keys(info),hasContext:'context' in info,xRayTraceId:info.xRayTraceId??null};}};
const wrapped=withDurableExecution(async()=> 'ok',{plugins:[recorder,plugin]});
(async()=>{
 const old=process.env._X_AMZN_TRACE_ID;delete process.env._X_AMZN_TRACE_ID;
 try {
 const output=await wrapped(new DurableExecutionInvocationInputWithClient(event,client),ctx);
 await provider.forceFlush();const result={inputContextTraceHeader:header,invocationInfo:infoSeen,output,spans:exporter.getFinishedSpans().map(s=>({name:s.name,traceId:s.spanContext().traceId,sampled:s.spanContext().traceFlags}))};
 console.log(JSON.stringify(result,null,2));await provider.shutdown();
 } finally {if(old===undefined)delete process.env._X_AMZN_TRACE_ID;else process.env._X_AMZN_TRACE_ID=old;}
})().catch(e=>{console.error(e);process.exitCode=1;});

SDK Version

Core SDK 2.6.0; OTel plugin 1.1.0. Main rechecked at 2d285ddb54d07be3dcbdc09f7fd1696aece67659.

Node.js Version

24.21.0

Is this a regression?

Unknown; no last-known-good version has been established.

Additional Context

Sources: LMI request-context contract, core InvocationInfo construction, and default X-Ray extractor. The public Node.js runtime also skips the environment carrier in multi-concurrent mode.

This is reproduced locally against the documented runtime carrier, not a newly deployed LMI test. PR #924, checked at a2c9b2552071ec73ddb14d17d252fc7f56b0ba02, leaves this input path unchanged. Per-invocation plugin factories alone do not supply the missing header.

Acceptance criteria

  • Forward the runtime header to the plugin/extractor and preserve Root, Parent and Sampled=0/1.
  • Cover missing/invalid headers, the ordinary Lambda environment fallback, replay/resume, and overlapping LMI invocations with different headers.
  • Do not copy invocation-local headers into shared environment variables.
  • Add a deployed LMI test confirming trace correlation and upstream sampling.

Related cross-SDK tracking: aws/aws-durable-execution-sdk-java#762

Activity

  1. zhongkechen commented on Oct 22, 2026

    @zhongkechen
    ContributorAuthor

    Fixed on main by #924 (merge commit 6597e0759d07abe792a167a0f31dcd7547eee736). The invocation-local X-Ray carrier implementation from #955 was incorporated into that PR; #955 was closed as incorporated, with no remaining source delta after the Core 3 plugin-factory adaptations.

    The SDK captures context.xRayTraceId in invocation-local metadata and passes it to the plugin/extractor, preserving Root, Parent, and explicit sampling. An available empty, nullish, invalid, or unreadable runtime carrier suppresses stale process-wide fallback; an unavailable carrier retains ordinary Lambda compatibility. No invocation header is written into shared environment variables. The merged implementation includes public-wrapper, carrier precedence, sampling, replay/resume, and compatibility coverage.

    Closing as completed for the implementation fix. The latest published GitHub SDK release, 2.7.1, does not contain this capability; it is part of the coordinated Core 3 / OTel 2 changes. Fresh composed Core 3 LMI lifecycle acceptance remains tracked separately in #1017 / #928 and is not claimed by this closure.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingneeds-triageIssue needs triage

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions