Skip to content

Commit 5c269c3

Browse files
committed
fix(otel): Fix X-Ray trace integration and cross-invocation retry spans
1 parent 0097f4a commit 5c269c3

10 files changed

Lines changed: 287 additions & 89 deletions

File tree

examples/src/test/java/software/amazon/lambda/durable/examples/OtelXRayIntegrationTest.java

Lines changed: 15 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -144,14 +144,13 @@ void simpleSteps_producesUnifiedTraceInXRay() throws Exception {
144144

145145
// Find the trace that contains our durable spans
146146
var durableTrace = allTraces.stream()
147-
.filter(trace ->
148-
trace.segments().stream().anyMatch(seg -> segmentContains(seg, "durable.step:create-greeting")))
147+
.filter(trace -> trace.segments().stream().anyMatch(seg -> segmentContains(seg, "create-greeting")))
149148
.findFirst()
150149
.orElse(null);
151150

152151
assertNotNull(
153152
durableTrace,
154-
"Expected to find a trace with durable.step:create-greeting segment. " + "Found " + traces.size()
153+
"Expected to find a trace with create-greeting segment. " + "Found " + traces.size()
155154
+ " traces in the time window. Segment names: "
156155
+ allTraces.stream()
157156
.flatMap(t -> t.segments().stream())
@@ -165,12 +164,10 @@ void simpleSteps_producesUnifiedTraceInXRay() throws Exception {
165164

166165
// Verify expected span names appear in the trace
167166
assertTrue(
168-
allSegmentText.contains("durable.invocation"),
169-
"Expected durable.invocation span in trace. Segments: " + summarizeSegments(segmentDocuments));
170-
assertTrue(
171-
allSegmentText.contains("durable.step:create-greeting"),
172-
"Expected durable.step:create-greeting span in trace");
173-
assertTrue(allSegmentText.contains("durable.step:transform"), "Expected durable.step:transform span in trace");
167+
allSegmentText.contains("invocation"),
168+
"Expected invocation span in trace. Segments: " + summarizeSegments(segmentDocuments));
169+
assertTrue(allSegmentText.contains("create-greeting"), "Expected create-greeting span in trace");
170+
assertTrue(allSegmentText.contains("transform"), "Expected transform span in trace");
174171

175172
// Verify all segments share the same trace ID (single unified trace)
176173
var uniqueTraceIds =
@@ -218,14 +215,13 @@ void waitAndResume_producesUnifiedTraceAcrossInvocations() throws Exception {
218215

219216
// Find the trace containing our durable spans
220217
var durableTrace = allTraces.stream()
221-
.filter(trace ->
222-
trace.segments().stream().anyMatch(seg -> segmentContains(seg, "durable.step:before-wait")))
218+
.filter(trace -> trace.segments().stream().anyMatch(seg -> segmentContains(seg, "before-wait")))
223219
.findFirst()
224220
.orElse(null);
225221

226222
assertNotNull(
227223
durableTrace,
228-
"Expected to find a trace with durable.step:before-wait segment. " + "Found " + traces.size()
224+
"Expected to find a trace with before-wait segment. " + "Found " + traces.size()
229225
+ " traces in the time window.");
230226

231227
// 4. Verify multi-invocation trace structure
@@ -234,14 +230,12 @@ void waitAndResume_producesUnifiedTraceAcrossInvocations() throws Exception {
234230
var allSegmentText = String.join("\n", segmentDocuments);
235231

236232
// Verify spans from BOTH invocations appear in the same trace
237-
assertTrue(
238-
allSegmentText.contains("durable.step:before-wait"), "Expected before-wait span from first invocation");
239-
assertTrue(
240-
allSegmentText.contains("durable.step:after-wait"), "Expected after-wait span from second invocation");
241-
assertTrue(allSegmentText.contains("durable.wait:pause"), "Expected wait:pause span in trace");
233+
assertTrue(allSegmentText.contains("before-wait"), "Expected before-wait span from first invocation");
234+
assertTrue(allSegmentText.contains("after-wait"), "Expected after-wait span from second invocation");
235+
assertTrue(allSegmentText.contains("pause"), "Expected wait:pause span in trace");
242236

243237
// Verify multiple invocation spans (one per Lambda invocation)
244-
var invocationCount = countOccurrences(allSegmentText, "durable.invocation");
238+
var invocationCount = countOccurrences(allSegmentText, "invocation");
245239
assertTrue(
246240
invocationCount >= 2,
247241
"Expected at least 2 invocation spans (multi-invocation), got " + invocationCount);
@@ -264,7 +258,9 @@ private List<TraceSummary> queryTracesWithRetry(Instant startTime, Instant endTi
264258
throws InterruptedException {
265259
// Query by durable.invocation service — our spans are in a separate trace from Lambda's
266260
// built-in X-Ray segment (durable backend propagates its own trace root)
267-
var filterExpression = "service(\"durable.invocation\")";
261+
// Filter by the Lambda function's service name — each function has a unique one.
262+
// This avoids picking up traces from other durable functions that share service.name="invocation".
263+
var filterExpression = "service(\"" + functionName + functionNameSuffix + "\")";
268264
for (int attempt = 0; attempt < XRAY_QUERY_RETRIES; attempt++) {
269265
var response = xrayClient.getTraceSummaries(GetTraceSummariesRequest.builder()
270266
.startTime(startTime)

examples/template.yaml

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -370,9 +370,6 @@ Resources:
370370
- !Sub
371371
- arn:aws:lambda:${AWS::Region}:901920570463:layer:aws-otel-java-agent-${AdotArch}-ver-1-32-0:6
372372
- AdotArch: amd64
373-
Environment:
374-
Variables:
375-
AWS_LAMBDA_EXEC_WRAPPER: /opt/otel-handler
376373

377374
OtelXRayWaitExampleFunction:
378375
Type: AWS::Serverless::Function
@@ -389,9 +386,6 @@ Resources:
389386
- !Sub
390387
- arn:aws:lambda:${AWS::Region}:901920570463:layer:aws-otel-java-agent-${AdotArch}-ver-1-32-0:6
391388
- AdotArch: amd64
392-
Environment:
393-
Variables:
394-
AWS_LAMBDA_EXEC_WRAPPER: /opt/otel-handler
395389

396390
RetryInvokeExampleFunction:
397391
Type: AWS::Serverless::Function

otel-plugin/src/main/java/software/amazon/lambda/durable/otel/OtelPlugin.java

Lines changed: 40 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -5,14 +5,17 @@
55
import static software.amazon.lambda.durable.otel.SpanAttributes.*;
66

77
import io.opentelemetry.api.common.AttributeKey;
8+
import io.opentelemetry.api.common.Attributes;
89
import io.opentelemetry.api.trace.Span;
910
import io.opentelemetry.api.trace.SpanContext;
11+
import io.opentelemetry.api.trace.SpanKind;
1012
import io.opentelemetry.api.trace.StatusCode;
1113
import io.opentelemetry.api.trace.TraceFlags;
1214
import io.opentelemetry.api.trace.TraceState;
1315
import io.opentelemetry.api.trace.Tracer;
1416
import io.opentelemetry.context.Context;
1517
import io.opentelemetry.context.Scope;
18+
import io.opentelemetry.sdk.resources.Resource;
1619
import io.opentelemetry.sdk.trace.SdkTracerProvider;
1720
import io.opentelemetry.sdk.trace.SdkTracerProviderBuilder;
1821
import java.time.Instant;
@@ -128,6 +131,12 @@ public OtelPlugin(SdkTracerProviderBuilder tracerProviderBuilder, ContextExtract
128131
public OtelPlugin(
129132
SdkTracerProviderBuilder tracerProviderBuilder, ContextExtractor contextExtractor, boolean enableMdc) {
130133
this.idGenerator = new DeterministicIdGenerator();
134+
135+
// Set service.name to "invocation" — X-Ray uses this as the display name for SERVER spans,
136+
// creating a separate service node in the trace map labeled "invocation".
137+
var resource = Resource.create(Attributes.of(AttributeKey.stringKey("service.name"), "invocation"));
138+
tracerProviderBuilder.setResource(resource);
139+
131140
this.tracerProvider = tracerProviderBuilder.setIdGenerator(idGenerator).build();
132141
this.tracer = tracerProvider.get(INSTRUMENTATION_NAME);
133142
this.contextExtractor = contextExtractor;
@@ -152,10 +161,17 @@ public void onInvocationStart(InvocationInfo info) {
152161
}
153162
// If no extracted context, idGenerator falls back to ARN-derived trace ID
154163

155-
// Determine parent context for the invocation span
164+
// Determine parent context for the invocation span.
165+
// When the X-Ray header has a Parent field, create the invocation span as a
166+
// child of that segment. This connects our OTLP-exported spans to the Lambda
167+
// service's X-Ray segments.
156168
Context parentContext;
157-
if (extractedContext != null && extractedContext.parentSpanId() != null) {
158-
// Create a remote parent span context from the X-Ray Parent field
169+
var activeSpan = Span.fromContext(Context.current());
170+
if (activeSpan.getSpanContext().isValid()) {
171+
// An auto-instrumenter (e.g., ADOT agent) is active — parent to its span
172+
parentContext = Context.current();
173+
} else if (extractedContext != null && extractedContext.parentSpanId() != null) {
174+
// No active instrumenter, but X-Ray header has parent — create remote parent
159175
var parentSpanContext = SpanContext.createFromRemoteParent(
160176
extractedContext.traceId(),
161177
extractedContext.parentSpanId(),
@@ -166,8 +182,10 @@ public void onInvocationStart(InvocationInfo info) {
166182
parentContext = Context.root();
167183
}
168184

169-
// Create invocation span as child of Lambda's X-Ray segment (via Parent field)
170-
var spanBuilder = tracer.spanBuilder("durable.invocation")
185+
// Create a SERVER span to establish a separate X-Ray service node.
186+
// X-Ray uses service.name for the segment display name.
187+
var spanBuilder = tracer.spanBuilder("invocation")
188+
.setSpanKind(SpanKind.SERVER)
171189
.setParent(parentContext)
172190
.setAttribute(DURABLE_EXECUTION_ARN, info.durableExecutionArn())
173191
.setAttribute(DURABLE_FIRST_INVOCATION, info.isFirstInvocation());
@@ -227,8 +245,6 @@ public void onInvocationEnd(InvocationEndInfo info) {
227245
public void onOperationStart(OperationInfo info) {
228246
if (info.id() == null) return;
229247

230-
idGenerator.setNextSpanOperationId(info.id());
231-
232248
var parentContext = resolveParentContext(info.parentId());
233249

234250
var spanBuilder = tracer.spanBuilder(spanName(info.type(), info.subType(), info.name()))
@@ -237,6 +253,19 @@ public void onOperationStart(OperationInfo info) {
237253
.setAttribute(DURABLE_OPERATION_ID, info.id())
238254
.setAttribute(DURABLE_OPERATION_TYPE, info.type());
239255

256+
if (info.isContinuation()) {
257+
// Operation was already started in a prior invocation — use a random span ID
258+
// and add a Link to the deterministic span from the original invocation for correlation.
259+
var deterministicSpanId = idGenerator.generateSpanIdForOperation(info.id());
260+
var traceId = idGenerator.generateTraceId();
261+
var linkedSpanContext =
262+
SpanContext.create(traceId, deterministicSpanId, TraceFlags.getSampled(), TraceState.getDefault());
263+
spanBuilder.addLink(linkedSpanContext);
264+
} else {
265+
// First execution — use deterministic span ID so continuations can link back
266+
idGenerator.setNextSpanOperationId(info.id());
267+
}
268+
240269
if (info.name() != null) {
241270
spanBuilder.setAttribute(DURABLE_OPERATION_NAME, info.name());
242271
}
@@ -400,17 +429,17 @@ private Context resolveParentContext(String parentId) {
400429

401430
private static String spanName(String type, String subType, String name) {
402431
if (name != null) {
403-
return "durable." + (subType != null ? subType.toLowerCase() : type.toLowerCase()) + ":" + name;
432+
return name;
404433
}
405-
return "durable." + (subType != null ? subType.toLowerCase() : type.toLowerCase());
434+
return subType != null ? subType.toLowerCase() : type.toLowerCase();
406435
}
407436

408437
private static String attemptSpanName(String type, String subType, String name, Integer attempt) {
409438
var base = spanName(type, subType, name);
410439
if (attempt != null) {
411-
return base + " [attempt " + attempt + "]";
440+
return base + " attempt " + attempt;
412441
}
413-
return base + " [fn]";
442+
return base;
414443
}
415444

416445
private static String attemptKey(String operationId, Integer attempt) {

otel-plugin/src/main/java/software/amazon/lambda/durable/otel/XRayContextExtractor.java

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,14 +25,21 @@ public class XRayContextExtractor implements ContextExtractor {
2525

2626
private static final Logger logger = LoggerFactory.getLogger(XRayContextExtractor.class);
2727
private static final String XRAY_ENV_VAR = "_X_AMZN_TRACE_ID";
28+
private static final String XRAY_SYSTEM_PROPERTY = "com.amazonaws.xray.traceHeader";
2829
private static final Pattern HEX_32 = Pattern.compile("[0-9a-f]{32}");
2930
private static final Pattern HEX_16 = Pattern.compile("[0-9a-f]{16}");
3031

3132
@Override
3233
public ExtractedContext extract() {
33-
var traceHeader = System.getenv(XRAY_ENV_VAR);
34+
// Try system property first — Lambda runtime updates this per invocation
35+
// and it avoids JVM environment variable caching issues.
36+
var traceHeader = System.getProperty(XRAY_SYSTEM_PROPERTY);
3437
if (traceHeader == null || traceHeader.isEmpty()) {
35-
logger.debug("No X-Ray trace header found in environment");
38+
// Fallback to environment variable
39+
traceHeader = System.getenv(XRAY_ENV_VAR);
40+
}
41+
if (traceHeader == null || traceHeader.isEmpty()) {
42+
logger.debug("No X-Ray trace header found in environment or system properties");
3643
return null;
3744
}
3845

0 commit comments

Comments
 (0)