Tracing on AWS with OpenTelemetry (X-Ray SDK replacement)
The handoff from application code to AWS X-Ray now that the X-Ray SDKs and daemon are in maintenance mode (since 2026-02-25) — instrument Node.js with OpenTelemetry or ADOT, export OTLP to the CloudWatch agent or an OpenTelemetry Collector, keep X-Ray propagation and annotations, map each X-Ray SDK call to its OpenTelemetry equivalent, and trace Lambda with the OpenTelemetry layer.
markdownpatternv4NYXR-VJ6Tstage: buildtrust: officialservice: xrayservice: cloudwatchservice: lambdainterface: sdkX-Ray SDK maintenance modeX-Ray daemon end of supportmigrate X-Ray to OpenTelemetryADOT Node.jsOpenTelemetry Node.js AWSNodeSDKOTLPTraceExporter 4318CloudWatch agent OTLP tracesOpenTelemetry Collector awsxray exporterAWSXRayPropagatorcaptureAWSv3Client replacementAwsInstrumentationaws.xray.annotationsstartActiveSpan segment subsegmentLambda OpenTelemetry layerApplication SignalsOTEL_TRACES_SAMPLER xray
Tracing on AWS with OpenTelemetry
Don't start new work on aws-xray-sdk. Since 2026-02-25 the X-Ray SDKs and daemon get security fixes only. Instrument with OpenTelemetry (or AWS's distribution of it, ADOT), and send OTLP to a CloudWatch agent or an OpenTelemetry Collector, which forwards to X-Ray. The X-Ray console and traces stay where they are; what changes is the code that produces them.
The handoff
app (OpenTelemetry SDK) ──OTLP http/protobuf :4318 or gRPC :4317──▶ CloudWatch agent ≥ 1.300025.0
or OTel Collector (otlp receiver → awsxray exporter)
──▶ AWS X-Ray
- CloudWatch agent: turn on OTLP trace collection (
traces_collected.otlp, withgrpc_endpointon 4317 andhttp_endpointon 4318). - OTel Collector: an
otlpreceiver and anawsxrayexporter, plus theawsproxyextension if you want X-Ray sampling rules. - Mapping in X-Ray: server spans become segments, other spans subsegments. Attributes become metadata, except keys listed in
aws.xray.annotations, which become searchable annotations. - Propagation: W3C Trace Context is OpenTelemetry's default. Add the X-Ray propagator where requests pass through X-Ray-enabled AWS services.
Node.js: replace the X-Ray SDK
| X-Ray SDK | OpenTelemetry |
|---|---|
captureAWSv3Client(client) | AwsInstrumentation (@opentelemetry/instrumentation-aws-sdk) |
captureHTTPsGlobal / captureHTTPs | HttpInstrumentation (@opentelemetry/instrumentation-http) |
Express openSegment / closeSegment | HttpInstrumentation + ExpressInstrumentation |
new AWSXRay.Segment, captureFunc, manual mode | tracer.startActiveSpan(…) (SpanKind.SERVER → segment, SpanKind.INTERNAL → subsegment) |
addAnnotation | Put the key in the aws.xray.annotations attribute |
| Centralized sampling rules | OTEL_TRACES_SAMPLER=xray with ADOT auto-instrumentation only. Plain OpenTelemetry JS has no X-Ray remote sampler |
instrumentation.ts, loaded before the app (node --require ./instrumentation.js app.js):
import { NodeSDK } from "@opentelemetry/sdk-node";
import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-proto";
import { AWSXRayPropagator } from "@opentelemetry/propagator-aws-xray";
import { awsEcsDetector, awsEc2Detector } from "@opentelemetry/resource-detector-aws";
import { HttpInstrumentation } from "@opentelemetry/instrumentation-http";
import { AwsInstrumentation } from "@opentelemetry/instrumentation-aws-sdk";
const sdk = new NodeSDK({
serviceName: "orders-api",
traceExporter: new OTLPTraceExporter({ url: "http://localhost:4318/v1/traces" }), // agent or collector
textMapPropagator: new AWSXRayPropagator(),
resourceDetectors: [awsEcsDetector, awsEc2Detector],
instrumentations: [new HttpInstrumentation(), new AwsInstrumentation()],
});
sdk.start();
process.on("SIGTERM", () => { void sdk.shutdown(); });
A custom span with an annotation:
import { trace, SpanKind, SpanStatusCode } from "@opentelemetry/api";
const tracer = trace.getTracer("orders");
export async function chargeCard(orderId: string, amountCents: number) {
return tracer.startActiveSpan("chargeCard", { kind: SpanKind.INTERNAL }, async (span) => {
span.setAttributes({ orderId, amountCents, "aws.xray.annotations": ["orderId"] });
try {
return "ok"; // call the payment provider here
} catch (err) {
span.recordException(err as Error);
span.setStatus({ code: SpanStatusCode.ERROR });
throw err;
} finally {
span.end();
}
});
}
To auto-instrument everything instead, use @opentelemetry/auto-instrumentations-node, narrowed with OTEL_NODE_ENABLED_INSTRUMENTATIONS / OTEL_NODE_DISABLED_INSTRUMENTATIONS. Or use ADOT's Node.js auto-instrumentation, which also supports X-Ray sampling.
On ECS, run the CloudWatch agent or a collector as a sidecar and point the exporter at it. The guide's own example uses http://cwagent:4318/v1/traces. The task role then needs to write to X-Ray, which the hub hasn't collected; check the agent's documentation.
Lambda
- Recommended: the AWS Lambda Layer for OpenTelemetry. It enables Application Signals by default; set
OTEL_AWS_APPLICATION_SIGNALS_ENABLED=falsefor tracing only. - Alternative: the AWS managed ADOT JavaScript layer.
- For manual spans next to the layer's automatic ones, add
@opentelemetry/apiat the version the layer's SDK uses. A mismatched version can produce spans that go nowhere (the hub's reading).
Known-good, known-bad, and which side is wrong
Known-good:
instrumentation.jsloads with--requirebefore the app's modules.- OTLP goes to a running CloudWatch agent (≥ 1.300025.0) or a collector with an
awsxrayexporter. - The X-Ray propagator is set.
- Annotations are listed in
aws.xray.annotations.
| Known-bad | Symptom |
|---|---|
Instrumentation imported after the app (or after @aws-sdk/* clients are created) | Few or no AWS SDK and HTTP spans. Auto-instrumentation patches modules as they load (the hub's reading of --require ordering) |
| Exporter URL points at a port nothing listens on (4318 vs 4317 mismatch, sidecar missing) | No traces in X-Ray; export errors in the app's logs |
| Expecting X-Ray sampling rules with plain OpenTelemetry JS | Sampling ignores the console rules: OTEL_TRACES_SAMPLER=xray works only with ADOT auto-instrumentation |
Keys set as span attributes but not listed in aws.xray.annotations | Traces arrive, but you can't filter on those keys (they are metadata) |
Manual spans with an @opentelemetry/api version different from the Lambda layer's | Manual spans missing next to automatic ones (the hub's reading) |
Which side is wrong:
- No traces at all → the export side (endpoint, agent or collector, permissions).
- Traces but missing AWS SDK spans → the instrumentation side (load order, instrumentations list).
- Traces but broken across services → propagation (X-Ray propagator not configured where requests pass through X-Ray-enabled services).
What this doesn't change
- Logs and metrics:
reference/cloudwatch/log-groups.md,reference/cloudwatch/metrics-and-math.md. - When a Lambda log line contains an X-Ray trace ID, Logs Insights exposes it as
@xrayTraceId. Use that to go from a slow invocation to its trace (reference/cloudwatch/logs-insights-queries.md).