Deep Dive · AWS X-Ray

AWS X-Ray for MCP Servers: Three Patterns for Per-Tool Distributed Tracing

Published 2026-09-19 · 22 min read

Adding AWS X-Ray to an MCP server is not the same as adding it to a conventional web application. Two details break every naive implementation. First, the Server-Sent Events transport holds one long-lived HTTP connection per client, so the X-Ray middleware creates exactly one root segment for the entire session — without explicit subsegment management, all tool calls collapse into a flat trace with no per-tool breakdown. Second, on Lambda, the runtime creates the root segment for you from the _X_AMZN_TRACE_ID environment variable before your handler runs; if you try to create your own root segment, you produce a detached orphan that never appears in traces. This guide synthesizes five X-Ray topics — distributed tracing and SSE context, sampling rules and cost control, Lambda integration and cold start visibility, annotations and metadata for queryable traces, and service map topology — around three structural patterns: the SSE trace context problem, Lambda segment anatomy and cold start observability, and annotations as your trace query language.

Why X-Ray for MCP is different: the tracing surface table

Before mapping X-Ray primitives to MCP server concerns, it helps to enumerate what the tracing surface looks like. An MCP server is not a stateless request-response API. It has sessions. Tool calls share compute context across the lifetime of an SSE connection. Downstream dependencies — DynamoDB, S3, external LLM APIs — are called inside those tool handlers, sometimes with per-tool variance that a service-level metric will never surface.

Observability gap Root cause X-Ray primitive Key complication
No per-tool latency breakdown All tool calls in an SSE session share one HTTP request → one root segment Custom subsegments per tool handler SSE tool calls are not new HTTP requests; subsegments must be attached manually to the stored root segment
Cold start cost invisible Lambda Init phase runs before the handler; trace shows only Invocation unless active tracing is on Active tracing with Tracing.ACTIVE Runtime creates the root segment — creating a second one in handler code produces an orphan trace
Can't filter traces by tool name X-Ray filter expressions only query the annotation index; subsegment names are not indexed Annotations on each subsegment Max 50 annotations per trace; keys must be alphanumeric + underscore; values max 250 chars — violations silently dropped
DynamoDB / S3 not in service map AWS SDK v3 clients not patched with captureAWSv3Client Service map via SDK auto-instrumentation Patch must happen at module scope before any client instance is created; patching after instantiation has no effect
Health check probes exhaust sampling quota ALB / ECS health probes hit the same endpoint at high frequency; default sampling rule samples them like real traffic Custom sampling rules with zero rate for /health* Without an explicit zero-rate rule, health probes consume the reservoir and crowd out real tool call traces

Each gap requires a different primitive; they don't overlap. The rest of this guide walks through the three patterns that determine how they compose.

Pattern 1: The SSE trace context problem

The Server-Sent Events transport is the most common way to deploy an MCP server, and it is the transport most likely to produce misleading traces. Understanding why requires understanding how X-Ray's Express middleware works: it intercepts each incoming HTTP request, creates a root segment at the request start, and closes it when the response finishes. For a typical REST API, one request = one trace. For an SSE endpoint, one request = one long-lived TCP connection that remains open for the entire session.

X-Ray sees a single GET /mcp/sse request and creates exactly one root segment. That segment's timeline in the X-Ray console spans the entire session duration — potentially hours. Every tool call that happens inside that session, including all their downstream DynamoDB reads and external API calls, is either invisible (if no subsegments are created) or appears as a flat list of unnamed operations under the root segment (if generic subsegments are used). You cannot answer "which tool call was slow" by looking at the trace because there's no per-tool structure.

The fix is a two-part pattern: capture the root segment when the SSE connection is established, store it in a session-scoped Map, and then in each tool handler retrieve that stored segment and create a named child subsegment explicitly.

Part A: Capture and store the root segment at connection time

import AWSXRay from "aws-xray-sdk-node";
import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js";
import express from "express";

const app = express();

// X-Ray middleware: MUST be first — creates root segment on every HTTP request
app.use(AWSXRay.express.openSegment("mcp-server"));

// sessionSegments: stores the root segment for the duration of each SSE session
const sessionSegments = new Map<string, AWSXRay.Segment>();

app.get("/mcp/sse", async (req, res) => {
  const sessionId = crypto.randomUUID();

  // The openSegment middleware already created a root segment for this GET request.
  // resolveSegment() returns it. Capture it now — after the response starts streaming,
  // the middleware context will not be available inside async tool handlers.
  const rootSegment = AWSXRay.resolveSegment() as AWSXRay.Segment;
  rootSegment.addAnnotation("session_id", sessionId);
  sessionSegments.set(sessionId, rootSegment);

  res.setHeader("Content-Type", "text/event-stream");
  res.setHeader("Cache-Control", "no-cache");
  res.setHeader("Connection", "keep-alive");

  const transport = new SSEServerTransport("/mcp/messages", res);
  // Pass sessionId through so tool registration can retrieve the segment
  const mcpServer = createMcpServer(sessionId);
  await mcpServer.connect(transport);

  // Close the root segment when the client disconnects
  req.on("close", () => {
    const seg = sessionSegments.get(sessionId);
    if (seg && !seg.isClosed()) seg.close();
    sessionSegments.delete(sessionId);
  });
});

// X-Ray middleware: MUST be last — closes segment for non-SSE routes
app.use(AWSXRay.express.closeSegment());

Part B: Create per-tool subsegments attached to the stored root segment

Inside each tool handler, retrieve the stored root segment and call addNewSubsegment on it. Then run the tool body inside AWSXRay.resolveManualSegmentContextExt — this is the critical call that sets the async context so any downstream AWS SDK calls made inside the tool body will automatically create child subsegments under your tool subsegment rather than floating loose.

// Helper: wraps a tool handler in a named subsegment attached to a stored root segment
// and runs the body inside the correct X-Ray async context
async function tracedSessionTool<T>(
  sessionId: string,
  toolName: string,
  args: unknown,
  fn: (sub: AWSXRay.Subsegment | null) => Promise<T>
): Promise<T> {
  const rootSegment = sessionSegments.get(sessionId);

  if (!rootSegment) {
    // No segment (local dev, pre-connection call) — run unwrapped
    return fn(null);
  }

  const sub = rootSegment.addNewSubsegment(`tool:${toolName}`);
  sub.addAnnotation("tool", toolName);
  sub.addAnnotation("session_id", sessionId);

  // resolveManualSegmentContextExt: sets the active segment context for the duration
  // of the callback so captureAWSv3Client-patched SDK calls nest under this subsegment
  return AWSXRay.resolveManualSegmentContextExt(
    async () => {
      const start = Date.now();
      try {
        const result = await fn(sub);
        sub.addAnnotation("success", true);
        sub.addAnnotation(
          "duration_bucket",
          Date.now() - start < 100 ? "fast" : Date.now() - start < 1000 ? "normal" : "slow"
        );
        sub.close();
        return result;
      } catch (err) {
        sub.addAnnotation("success", false);
        sub.addAnnotation("error_type", (err as Error).constructor.name.slice(0, 50));
        sub.addMetadata("error", { message: (err as Error).message });
        sub.addError(err as Error);
        sub.close();
        throw err;
      }
    },
    rootSegment
  );
}

// Usage in tool registration:
function createMcpServer(sessionId: string) {
  const server = new McpServer({ name: "mcp-server", version: "1.0.0" });

  server.tool(
    "search_documents",
    "Full-text search across indexed documents",
    { query: z.string(), limit: z.number().optional() },
    async ({ query, limit = 10 }) => {
      return tracedSessionTool(sessionId, "search_documents", { query, limit }, async (sub) => {
        sub?.addMetadata("input", { query, limit });
        // DynamoDB call here creates an automatic child subsegment
        // visible in the trace timeline as "DynamoDB" nested under "tool:search_documents"
        const results = await dynamo.send(new QueryCommand({ /* ... */ }));
        sub?.addMetadata("result_count", results.Count);
        return { content: [{ type: "text", text: JSON.stringify(results.Items) }] };
      });
    }
  );

  return server;
}

The Streamable HTTP transport: no manual context needed

The newer Streamable HTTP transport (the replacement for SSE) does not have this problem. Each POST /mcp request is a separate HTTP request with its own root segment. The Express openSegment middleware creates a fresh segment for each POST, and AWSXRay.resolveSegment() returns it correctly inside the handler. You still want per-tool subsegments for the timeline breakdown, but you do not need the session-Map pattern because the context is correct by default.

One non-obvious gap: captureHTTPsGlobal — which patches the Node.js https module to trace all outbound HTTPS calls — does not intercept native fetch() in Node 18+. The built-in fetch uses its own internal HTTP machinery that bypasses the patched https module. If your tool handlers call external APIs via fetch (OpenAI, Anthropic, GitHub), those calls will be invisible in the X-Ray trace even with the global patch applied. Use axios or node-fetch (which do use the https module), or add manual subsegments with sub.addRemoteRequestData(host, port, true) to make external API calls visible in the service map.

Pattern 2: Lambda segment anatomy and cold start observability

Lambda's X-Ray integration differs from ECS in one fundamental way that catches everyone off guard: the Lambda runtime creates the root segment for you. It reads the _X_AMZN_TRACE_ID environment variable — which the runtime sets fresh on every invocation from the caller's trace context — and creates the root segment before your handler code executes. Your code only ever adds to this segment; it never creates it.

The consequence: if you call AWSXRay.express.openSegment("mcp-server") inside a Lambda handler (which is what you'd do on ECS), you create a second root segment that is not connected to the runtime-created one. That orphan segment may or may not be flushed to the X-Ray service, and when it is, it appears as a separate disconnected trace with no relationship to the Lambda invocation visible in the console. The correct Lambda pattern is to use AWSXRay.resolveSegment() to get the runtime-created root segment and add subsegments and annotations to it.

The Lambda trace segment hierarchy

// Lambda X-Ray trace structure (active tracing enabled):
//
// Cold start invocation:
//   Root segment: "my-mcp-lambda" (created by Lambda runtime from _X_AMZN_TRACE_ID)
//     ├── Subsegment: "Initialization"       ← cold start cost (module-level code)
//     │     Duration: 800–2000ms on first invocation
//     │     Contains: SDK client creation, DB connection setup, layer loading
//     └── Subsegment: "Invocation"           ← handler execution
//           └── Subsegment: "tool:list_docs"  ← your custom subsegment
//                 └── Subsegment: "DynamoDB"   ← auto via captureAWSv3Client
//
// Warm invocation:
//   Root segment: "my-mcp-lambda"
//     └── Subsegment: "Invocation"           ← no "Initialization" — directly warm
//           └── Subsegment: "tool:list_docs"
//                 └── Subsegment: "DynamoDB"
//
// SnapStart restore (Node.js opt-in):
//   Root segment: "my-mcp-lambda"
//     ├── Subsegment: "Restore"              ← snapshot restore, not full init
//     │     Duration: 50–200ms (vs 800–2000ms for Init)
//     └── Subsegment: "Invocation"

The presence or absence of the "Initialization" subsegment is the authoritative cold start indicator — no code annotation required. A trace with an Initialization subsegment is a cold start; without it, the invocation ran on a warm container. You can filter for cold starts using the X-Ray console's "subsegment.name = Initialization" filter, or programmatically via GetTraceSummaries.

Correct Lambda setup: module-scope SDK patching

import AWSXRay from "aws-xray-sdk-node";
import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
import { DynamoDBDocumentClient } from "@aws-sdk/lib-dynamodb";
import { S3Client } from "@aws-sdk/client-s3";
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";

// MODULE SCOPE — runs once per cold start, reused across warm invocations
// captureAWSv3Client MUST be called here, not inside the handler.
// Calling it inside the handler patches clients that are already in use
// and may result in partial or no instrumentation on the first invocation.
const rawDynamo = new DynamoDBClient({ region: process.env.AWS_REGION ?? "us-east-1" });
const dynamo = DynamoDBDocumentClient.from(AWSXRay.captureAWSv3Client(rawDynamo));

const rawS3 = new S3Client({ region: process.env.AWS_REGION ?? "us-east-1" });
const s3 = AWSXRay.captureAWSv3Client(rawS3);

// Track init duration at module scope for cold start annotation
const moduleInitStart = Date.now();
let initDurationMs: number | null = null;

// HANDLER — runs per invocation
export const lambdaHandler = async (event: APIGatewayProxyEventV2WithResponseStream, context: Context) => {
  // Lambda runtime has already created the root segment from _X_AMZN_TRACE_ID.
  // resolveSegment() returns it. Do NOT call openSegment() here.
  const segment = AWSXRay.resolveSegment();

  // Annotate cold start on first invocation
  if (initDurationMs === null) {
    initDurationMs = Date.now() - moduleInitStart;
    segment?.addAnnotation("cold_start", true);
    segment?.addMetadata("init_duration_ms", initDurationMs);
  } else {
    segment?.addAnnotation("cold_start", false);
  }

  segment?.addAnnotation("request_id", context.awsRequestId);
  segment?.addAnnotation("function_version", context.functionVersion);

  // Each tool handler creates a child subsegment — same pattern as ECS
  const server = buildMcpServer(segment);
  const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined });
  await server.connect(transport);

  // streamifyResponse is NOT available in all Lambda runtimes —
  // for function URLs with response streaming, use the awslambda.streamifyResponse wrapper.
  // The wrapper extends the Invocation subsegment duration to stream close time,
  // making long-running tool calls visible as their full duration rather than
  // the handler-return time.
  await transport.handleRequest(event, context, event.body ? JSON.parse(event.body) : undefined);
};

SnapStart: Init vs Restore in traces

Lambda SnapStart (currently available for Node.js with opt-in configuration) takes a memory snapshot of the initialized execution environment after the Init phase. Subsequent "cold starts" restore from snapshot rather than re-running module initialization, replacing the "Initialization" subsegment with a "Restore" subsegment — typically 50–200ms instead of 800–2000ms.

This distinction matters for observability: if you're alerting on annotation.cold_start = true to detect slow cold starts, a SnapStart restore will not have the Initialization subsegment but will have the Restore subsegment. The cold start latency budget is met, but the trace structure is different. Monitor both subsegment types: filter subsegment.name = "Initialization" for true cold starts and subsegment.name = "Restore" for SnapStart restore events. The total invocation duration is the right comparison metric — Initialization at 1,500ms and Restore at 100ms represent the same latency improvement regardless of which subsegment you see.

The streamifyResponse trap for MCP streaming

Lambda Function URLs support response streaming via the awslambda.streamifyResponse wrapper. Without it, Lambda closes the invocation segment when the handler function returns, even if data is still being streamed. For MCP servers using the Streamable HTTP transport with Lambda, a tool call that streams a long response would show in the trace as ending when the handler returned the stream object — not when the stream finished. The Invocation subsegment would show, say, 50ms (handler startup time) rather than the actual 3,500ms total duration.

With streamifyResponse, the Lambda runtime keeps the invocation open and the Invocation subsegment duration extends to match the stream close time. This is especially useful for identifying tool calls that approach the Lambda timeout: a 14,800ms Invocation subsegment on a function with a 15,000ms timeout is a near-miss that needs investigation.

Pattern 3: Annotations as your trace query language

X-Ray's filter expressions are the primary way to find specific traces after the fact — to answer questions like "show me all failures from the search_documents tool in the last hour" or "show me every trace where the tenant acme-corp hit a slow tool call." Filter expressions query the annotation index, not the full trace content. An annotation is a key-value pair that you explicitly add to a segment or subsegment with addAnnotation(). If you don't add it, you can't filter on it.

The annotation schema — which keys you decide to index and what values they carry — is effectively the query language you'll have when things go wrong at 2am. Design it intentionally, before you're debugging a production incident.

The recommended annotation schema for MCP servers

// Core annotations — add to EVERY tool subsegment
// These enable the most important filter expressions
interface McpToolAnnotations {
  tool: string;           // "search_documents" — primary filter dimension
  success: boolean;       // false = error path — combine with other filters
  duration_bucket: string; // "fast"|"normal"|"slow" — derived from actual latency
  error_type?: string;    // "ValidationError"|"TimeoutError" — error categorization
}

// Session-level annotations — add to the root segment at connection time
// These enable per-session and per-tenant analysis
interface McpSessionAnnotations {
  session_id: string;     // UUID — correlate with application logs
  tenant?: string;        // tenant ID from JWT — per-tenant error rate
  client_name?: string;   // "claude-desktop"|"cursor"|"zed" — client analytics
  client_version?: string; // "3.1.0" — version-specific bug hunting
  plan?: string;          // "free"|"author"|"team" — usage by tier
}

// Implementation: safe annotation helper prevents silent drops
function safeAnnotation(
  sub: AWSXRay.Subsegment | AWSXRay.Segment | null,
  key: string,
  value: string | number | boolean
): void {
  if (!sub) return;
  const safeKey = key.replace(/[^a-zA-Z0-9_]/g, "_").slice(0, 499);
  if (typeof value === "string") {
    sub.addAnnotation(safeKey, value.slice(0, 249)); // X-Ray max is 250; truncate at 249
  } else {
    sub.addAnnotation(safeKey, value);
  }
}

// Complete tool wrapper with full annotation schema
async function tracedTool<T>(opts: {
  toolName: string;
  args: unknown;
  sessionAnnotations: McpSessionAnnotations;
  segment: AWSXRay.Segment | null;
  fn: () => Promise<T>;
}): Promise<T> {
  const { toolName, args, sessionAnnotations, segment } = opts;
  const sub = segment?.addNewSubsegment(`tool:${toolName}`) ?? null;

  // Annotate session context on the subsegment (also on root segment at connection time)
  if (sessionAnnotations.tenant) safeAnnotation(sub, "tenant", sessionAnnotations.tenant);
  if (sessionAnnotations.client_name) safeAnnotation(sub, "client_name", sessionAnnotations.client_name);

  // Annotate tool identity — required for per-tool filter expressions
  safeAnnotation(sub, "tool", toolName);

  // Store full args in metadata — NOT in annotations (args can be megabytes)
  sub?.addMetadata("input", args);

  const start = Date.now();
  try {
    const result = await opts.fn();
    const duration = Date.now() - start;

    safeAnnotation(sub, "success", true);
    safeAnnotation(sub, "duration_bucket",
      duration < 100 ? "fast" : duration < 1000 ? "normal" : "slow"
    );
    sub?.addMetadata("duration_ms", duration);
    sub?.close();
    return result;
  } catch (err) {
    const duration = Date.now() - start;
    safeAnnotation(sub, "success", false);
    safeAnnotation(sub, "error_type", (err as Error).constructor.name.slice(0, 50));
    sub?.addMetadata("error", {
      message: (err as Error).message,
      stack: (err as Error).stack?.slice(0, 2000),
    });
    sub?.addMetadata("duration_ms", duration);
    sub?.addError(err as Error);
    sub?.close();
    throw err;
  }
}

Filter expressions that matter for MCP operations

With the schema above, these filter expressions work immediately in the X-Ray console "Search Traces" view and via the GetTraceSummaries API:

// 1. All tool failures in the last hour — starting point for incident triage
annotation.success = false AND service("mcp-server")

// 2. Failures from a specific tool — narrow to one tool after pattern identified
annotation.tool = "search_documents" AND annotation.success = false

// 3. Slow calls from a specific tenant — per-tenant SLA monitoring
annotation.tenant = "acme-corp" AND annotation.duration_bucket = "slow"

// 4. All cold start invocations (Lambda) — cold start impact investigation
annotation.cold_start = true AND service("mcp-server")

// 5. Error type breakdown — categorize failures before deciding fix priority
annotation.error_type = "TimeoutError" AND service("mcp-server")

// 6. Client version analysis — check if new client version introduced regressions
annotation.client_version = "3.2.0" AND annotation.success = false

// 7. Cross-service: slow DynamoDB calls within tool traces
// service() matches X-Ray segment names; DynamoDB auto-segments use "DynamoDB"
service("DynamoDB") AND duration > 0.5 AND annotation.tool = "search_documents"

// 8. High-value tenant errors — prioritize incident response by tier
annotation.plan = "team" AND annotation.success = false

X-Ray groups: filter expressions as standing alarms

X-Ray groups are saved filter expressions. When a group has InsightsEnabled: true, X-Ray emits CloudWatch metrics for traces that match — specifically ApproximateTraceCount, ErrorCount, FaultCount, and ThrottleCount under the AWS/XRay namespace with a GroupName dimension. This lets you alarm on tool error rates without building a separate metrics pipeline.

// CDK: X-Ray groups with CloudWatch metric alarms
import * as xray from "aws-cdk-lib/aws-xray";
import * as cloudwatch from "aws-cdk-lib/aws-cloudwatch";

// Group: any tool call that failed
const toolErrorsGroup = new xray.CfnGroup(this, "McpToolErrors", {
  groupName: "mcp-tool-errors",
  filterExpression: 'annotation.success = false AND service("mcp-server")',
  insightsConfiguration: {
    insightsEnabled: true,       // required for CloudWatch metric emission
    notificationsEnabled: true,  // optional: SNS notification on anomaly detection
  },
});

// Group: slow tool calls — separate alarm threshold from error alarm
const slowToolsGroup = new xray.CfnGroup(this, "McpSlowTools", {
  groupName: "mcp-slow-tools",
  filterExpression: 'annotation.duration_bucket = "slow" AND service("mcp-server")',
  insightsConfiguration: { insightsEnabled: true, notificationsEnabled: false },
});

// Alarm: more than 10 tool errors in a 5-minute window
new cloudwatch.Alarm(this, "McpToolErrorAlarm", {
  metric: new cloudwatch.Metric({
    namespace: "AWS/XRay",
    metricName: "ErrorCount",
    dimensionsMap: { GroupName: "mcp-tool-errors" },
    period: cdk.Duration.minutes(5),
    statistic: "Sum",
  }),
  threshold: 10,
  evaluationPeriods: 1,
  alarmDescription: "MCP tool error spike — check X-Ray traces for annotation.success = false",
});

// The same group creates a separate ServiceLens view in CloudWatch:
// CloudWatch → Application Monitoring → ServiceLens → select "mcp-tool-errors" group
// You see the service map filtered to only traces matching the group expression.

What goes in metadata, not annotations

Annotations are indexed — they cost storage proportional to the number of traces and the index cardinality. The 50-annotation-per-trace limit and 250-character value limit are hard constraints that the SDK enforces by silently dropping violations (no exception is thrown). Metadata has neither limit (bounded only by the 500KB trace size cap) and is not indexed — it's for debugging data that you look up after identifying a trace via filter expression, not for finding traces in the first place.

The practical split: index anything you'd filter on (tool name, tenant, success flag, error type, client version, duration bucket). Store everything else in metadata (full request arguments, response previews, stack traces, detailed timing breakdowns, intermediate results). A common mistake is putting full tool arguments in an annotation — they routinely exceed 250 characters, get silently truncated at 250, and the truncation makes them useless while consuming one of your 50 annotation slots. Put args in sub.addMetadata("input", args) instead.

Sampling cost control: keeping X-Ray affordable

X-Ray pricing is $5 per million traces recorded (first 100K free per month). At 200 requests per second with the default sampling rule (1 req/sec reservoir + 5% fixed rate, 3 ECS tasks), you generate approximately 57 traces per second — about 150 million traces per month — which costs around $750/month. For most MCP server deployments, this is unacceptably expensive. The three levers are: reduce the fixed rate, reduce the reservoir size, and zero-out health check probes.

// CDK: production sampling rule stack for a typical MCP server

// Rule 1: Zero-rate for health check probes — health checks at 60 req/sec
// would consume 3,600 reservoir tokens per minute otherwise
new xray.CfnSamplingRule(this, "McpHealthZero", {
  samplingRule: {
    ruleName: "mcp-health-zero",
    priority: 1,          // highest priority — evaluate first
    reservoirSize: 0,
    fixedRate: 0.0,       // never sample health checks
    serviceName: "mcp-server",
    serviceType: "*",
    host: "*",
    httpMethod: "GET",
    urlPath: "/health*",  // matches /health, /healthz, /health/ready
    resourceArn: "*",
    version: 1,
  },
});

// Rule 2: Higher reservoir for SSE connection events
// SSE connections are sparse and highly diagnostic — sample 50% or all
new xray.CfnSamplingRule(this, "McpSseConnections", {
  samplingRule: {
    ruleName: "mcp-sse-connections",
    priority: 5,
    reservoirSize: 50,    // always sample first 50 SSE connections/sec
    fixedRate: 0.3,       // 30% of additional connections
    serviceName: "mcp-server",
    serviceType: "*",
    host: "*",
    httpMethod: "GET",
    urlPath: "/mcp/sse",
    resourceArn: "*",
    version: 1,
  },
});

// Rule 3: Low fixed-rate for high-frequency POST (Streamable HTTP tool calls)
new xray.CfnSamplingRule(this, "McpPostEndpoint", {
  samplingRule: {
    ruleName: "mcp-post-endpoint",
    priority: 10,
    reservoirSize: 5,     // always sample first 5 POSTs/sec per task
    fixedRate: 0.01,      // 1% of remaining — at 200 req/sec: ~2/sec beyond reservoir
    serviceName: "mcp-server",
    serviceType: "*",
    host: "*",
    httpMethod: "POST",
    urlPath: "/mcp*",
    resourceArn: "*",
    version: 1,
  },
});

// With this rule set vs default:
// Default:  ~57 traces/sec fleet → ~150M/month → ~$750/month
// Custom:   ~8 traces/sec fleet → ~21M/month → ~$100/month
// SSE connections fully sampled; POST tool calls at 1%+reservoir; health zero

One important detail: the reservoir is per host, not per fleet. With 10 ECS tasks each running the mcp-post-endpoint rule at reservoirSize: 5, the total fleet reservoir is 50 traces per second — all always-sampled. Centralized sampling coordinates quota distribution across the fleet, but the total is the sum of per-host quotas. If you increase reservoir size to reduce the impact of the low fixed rate, you need to account for fleet scale: a reservoirSize: 10 rule on 20 tasks is 200 always-sampled traces per second before the fixed rate kicks in.

Centralized sampling requires xray:GetSamplingRules, xray:GetSamplingTargets, and xray:GetSamplingStatisticSummaries on the task or Lambda execution role. Without these permissions, the SDK silently falls back to local sampling rules — which default to 1 req/sec reservoir + 5% fixed rate across the entire fleet without coordination. The symptom is that your custom centralized rules seem to have no effect, and you see "Failed to get sampling rules" in the X-Ray daemon logs.

Service map topology for MCP servers

The X-Ray service map is the graph view of your MCP server's dependency health — each node is a service (your MCP server, DynamoDB, S3, external APIs), and each edge shows request rate, error rate, latency percentiles, and the error/fault/throttle breakdown. It appears automatically once you have AWS SDK auto-instrumentation in place via captureAWSv3Client.

Three distinctions matter for reading the service map correctly:

ElastiCache, MSK, and external APIs are not automatically instrumented by the AWS SDK patch. Use sub.addRemoteRequestData(host, port, true) to make them appear as properly typed edges rather than generic parent-service overhead:

// Manual service map edge for Redis / ElastiCache
async function tracedRedisGet(key: string, parentSub: AWSXRay.Subsegment | null) {
  const sub = parentSub?.addNewSubsegment("Redis") ?? null;
  sub?.addRemoteRequestData(
    process.env.REDIS_HOST ?? "localhost",
    parseInt(process.env.REDIS_PORT ?? "6379"),
    true // is_remote = true → renders as external dependency edge in service map
  );
  try {
    const value = await redis.get(key);
    sub?.close();
    return value;
  } catch (err) {
    sub?.addError(err as Error);
    sub?.close();
    return null;
  }
}

// Manual edge for external LLM APIs (OpenAI, Anthropic)
async function tracedLlmCall(host: string, fn: () => Promise<Response>) {
  const segment = AWSXRay.resolveSegment();
  const sub = segment?.addNewSubsegment(host) ?? null;
  sub?.addRemoteRequestData(host, 443, true);
  try {
    const resp = await fn();
    sub?.addAnnotation("http_status", resp.status);
    if (!resp.ok) {
      resp.status >= 500 ? sub?.addFaultFlag() : sub?.addErrorFlag();
      if (resp.status === 429) sub?.addThrottleFlag(); // renders purple in service map
    }
    sub?.close();
    return resp;
  } catch (err) {
    sub?.addError(err as Error);
    sub?.close();
    throw err;
  }
}

Consolidated failure modes

Symptom Cause Fix
All tool calls in an SSE session show as flat operations under root segment No per-tool subsegments; resolveManualSegmentContextExt not used for SSE tool handlers Store root segment at SSE connection time; use addNewSubsegment("tool:name") and resolveManualSegmentContextExt in each tool handler
Lambda traces show no AWS SDK subsegments (DynamoDB / S3 invisible) captureAWSv3Client called inside the handler (runs per-invocation) instead of module scope (runs once per cold start) Move captureAWSv3Client calls to module scope above the handler function
Lambda trace shows no Init subsegment on first invocation Active tracing not enabled; TracingConfig: Mode: PassThrough or tracing checkbox not checked Set tracing: lambda.Tracing.ACTIVE in CDK or Tracing: Active in SAM; force a new cold start by deploying a code change
Orphan trace appears in X-Ray console alongside Lambda trace AWSXRay.express.openSegment() called inside Lambda handler — creates a second root segment Remove openSegment from Lambda handler; use AWSXRay.resolveSegment() to get the runtime-provided root segment
Filter expression returns no results despite tool annotations being set Subsegment not closed before querying; closed segments flush asynchronously to X-Ray Ensure sub.close() is called; annotations are queryable only after the trace is flushed (typically within a few seconds of segment close)
Annotation silently missing from trace detail view Key contains disallowed characters (dots, colons, slashes) — SDK drops without throwing Sanitize keys with /[^a-zA-Z0-9_]/g replacement; use safeAnnotation helper to enforce constraints
Custom sampling rules have no visible effect Task/Lambda role missing xray:GetSamplingRules — SDK falls back to local default silently Add GetSamplingRules + GetSamplingTargets to the execution role; check daemon logs for "Failed to get sampling rules" messages
Health check probes filling X-Ray console with low-value traces No sampling rule with ReservoirSize: 0, FixedRate: 0.0 for /health* Add a priority-1 rule matching /health* with zero reservoir and zero fixed rate
DynamoDB or S3 edge missing from service map No sampled traces in the selected time window, or captureAWSv3Client not applied Widen time window; increase sampling rate temporarily; confirm client is wrapped before use
Streaming Lambda trace ends early — doesn't show full tool response duration Lambda handler returned a stream object but was not wrapped with streamifyResponse Wrap handler with awslambda.streamifyResponse() so the Invocation subsegment stays open until the stream closes
X-Ray group CloudWatch metrics not appearing InsightsEnabled: false on the group — metric emission requires Insights enabled Set insightsConfiguration: { insightsEnabled: true }; note Insights is $35/group/month

IAM policy summary

The minimum IAM policy for a production MCP server X-Ray setup (ECS task role or Lambda execution role):

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": [
        "xray:PutTraceSegments",       // emit trace data to X-Ray service
        "xray:PutTelemetryRecords",    // emit telemetry (included in AWSXRayDaemonWriteAccess)
        "xray:GetSamplingRules",       // centralized sampling — NOT in AWSXRayDaemonWriteAccess
        "xray:GetSamplingTargets",     // centralized sampling quota distribution
        "xray:GetSamplingStatisticSummaries"  // sampling statistics
      ],
      "Resource": "*"
    }
  ]
}
// Use AWS managed policy AWSXRayDaemonWriteAccess for the first two actions,
// then add the GetSampling* actions separately.
// AWSXRayFullAccess grants everything but is broader than needed for a server role.

The complete setup checklist

  1. Install aws-xray-sdk-node and add the ECS task role / Lambda execution role policy above.
  2. For ECS: add the X-Ray daemon sidecar container to the task definition (public.ecr.aws/xray/aws-xray-daemon, UDP port 2000). Set AWS_XRAY_DAEMON_ADDRESS=127.0.0.1:2000 and AWS_XRAY_CONTEXT_MISSING=LOG_ERROR on the application container.
  3. For Lambda: enable active tracing via Tracing: Active. No daemon — Lambda runtime handles flush.
  4. At module scope: call AWSXRay.captureAWSv3Client on each SDK client. For ECS, also call AWSXRay.config([AWSXRay.plugins.ECSPlugin]).
  5. Register Express middleware: app.use(AWSXRay.express.openSegment("mcp-server")) first, app.use(AWSXRay.express.closeSegment()) last.
  6. For SSE transport: capture root segment at connection time, store in session Map, use resolveManualSegmentContextExt in tool handlers.
  7. In each tool handler: create a named subsegment, add tool / success / duration_bucket annotations, store full args in metadata.
  8. Create centralized sampling rules: zero-rate for /health*, higher reservoir for SSE connections, low fixed-rate for POST tool calls.
  9. Create X-Ray groups for tool errors and slow calls; wire group CloudWatch metrics to alarms.
  10. Add manual addRemoteRequestData subsegments for ElastiCache, MSK, and external APIs that don't appear automatically.

Further reading