Lesson 05-01 ended with an uncomfortable success. Marta found the eight-second order: 7,402 ms
inside PostgreSQL, an N+1 pattern with 39 queries. But getting there took four Logs Insights
queries, a timeline assembled by hand and, above all, somebody having had the foresight to
propagate the X-Peticion-Id header across every component. If tomorrow the problem is in the
database connection, or in the payment gateway, or in a Lambda import that takes two seconds to
start, you have to begin from scratch and guess all over again where to look.
The underlying problem is not one of effort: it is one of data model. Metrics aggregate and lose the individual case. Logs keep the individual case but lose the relationship between what happened in the shop and what happened in the Lambda. Neither of them stores the structure of the request: who called whom, in what order, how long each leg took and which ones overlap.
AWS X-Ray stores exactly that. Its unit of work is not a number or a line of text: it is a
complete request, with its call tree and its timeline. From "something is slow" to "this
specific call takes 6.2 seconds, and 5.9 of them are in a query against pedidos".
This lesson instruments the MercadoFresco shop, enables tracing on the
mercadofresco-estado-pedido Lambda and goes back to the 8.14-second order to see it, this time, in
a single graph.
Cost warning. X-Ray charges per trace recorded and per trace retrieved or scanned. It is cheap if sampling is well configured and ruinous if you trace 100 % of high traffic. There is a whole section on economical sampling strategy.
Contents
- Why metrics and logs are not enough
- The data model: trace, segment and subsegment
- The trace ID and its propagation
- Sampling: why not every request is traced
- MercadoFresco's sampling rules
- Annotations versus metadata
- A complete trace of an order, in a diagram
- The permissions X-Ray needs
- Instrumenting the shop with
aws_xray_sdk - Manual subsegments and useful annotations
- The X-Ray daemon on EC2 and in containers
- Enabling tracing on
mercadofresco-estado-pedido - Enabling tracing on the ALB, on CloudFront and on API Gateway
- The service map: how to read it
- Trace filters: the search language
- Latency analysis: histograms and percentiles
- The guided case: the 8.14-second order
- X-Ray Insights
- Relationship with CloudWatch ServiceLens
- OpenTelemetry and ADOT: the open alternative
- Cost and economical sampling strategy
- Cleanup
Why metrics and logs are not enough
| Metrics (05-01) | Logs (05-01) | Traces (X-Ray) | |
|---|---|---|---|
| Unit | Aggregated time series | Line of text | Complete request |
| Answers | How much? Is it fine? | What exactly happened? | Where did the time go? |
| Keeps the individual case | No | Yes | Yes |
| Keeps the causal relationship | No | No | Yes |
| Cardinality supported | Very low | High | High |
| Dominant cost | No. of series | GB ingested | No. of traces |
| Typical retention | 15 months | Days or weeks | 30 days |
| Detects | That something is wrong | Which error came out | Which component is to blame |
The column that changes everything is "keeps the causal relationship". One shop log line says
llamada a lambda estado-pedido and another log line, in a different group, says
REPORT Duration: 480 ms. That those two events belong to the same request is something you
have to reconstruct. X-Ray knows it from birth, because the identifier travels with the request.
And there are three questions that only a trace can answer:
- How much time did the request spend waiting versus working? A log line saying "total duration 8,140 ms" does not distinguish between computing and waiting for somebody else.
- Which calls were made in parallel and which in series? It is the difference between 39 queries of 190 ms in sequence (7.4 s) and 39 queries in parallel (0.2 s).
- What happened before the code? A Lambda's cold-start time, the TLS connection setup, DNS resolution. None of that appears in your application log, because your application was not running yet.
The data model: trace, segment and subsegment
Three concepts and one hierarchy:
- Trace: everything that happens as a result of one request. It is identified by a trace ID and groups segments from every service involved.
- Segment: the work of one service or resource within the trace. The shop generates one segment; the Lambda generates its own. It contains a name, start and end times, and status.
- Subsegment: a leg inside a segment. A SQL query, an HTTP call to another service, a block of computation you want to measure.
flowchart TD
T["TRACE 1-68a2f4c1-3b8d9e2a5f7c1b4d8e0a2f6c<br/>total duration: 8,140 ms"]
T --> S1["SEGMENT: mercadofresco-tienda<br/>0 ms - 8,140 ms"]
S1 --> SS1["subsegment: validar_stock<br/>27 ms - 84 ms"]
S1 --> SS2["subsegment: Invoke estado-pedido<br/>140 ms - 690 ms"]
S1 --> SS3["subsegment: SQL insert lines<br/>700 ms - 8,110 ms"]
SS3 --> SS3a["39 SELECT price queries<br/>190 ms each, IN SERIES"]
T --> S2["SEGMENT: mercadofresco-estado-pedido<br/>145 ms - 685 ms"]
S2 --> SS4["subsegment: Initialization<br/>145 ms - 460 ms - cold start"]
S2 --> SS5["subsegment: SNS Publish<br/>500 ms - 660 ms"]
Note the detail that makes this model useful: the Lambda's segment (145-685 ms) is contained in
the shop's Invoke subsegment (140-690 ms), and the 10 ms difference is the network latency plus
that of the Lambda API itself. That figure appears in no log of either service.
A segment, in its real —simplified— JSON representation, looks like this:
{
"trace_id": "1-68a2f4c1-3b8d9e2a5f7c1b4d8e0a2f6c",
"id": "6b1c2d3e4f5a6b7c",
"name": "mercadofresco-tienda",
"start_time": 1785142692.004,
"end_time": 1785142700.144,
"http": {
"request": {
"method": "POST",
"url": "https://mercadofresco.example/api/pedidos/confirmar",
"client_ip": "203.0.113.45",
"user_agent": "Mozilla/5.0 ..."
},
"response": { "status": 200, "content_length": 412 }
},
"aws": {
"ec2": { "instance_id": "i-0abc123def456", "availability_zone": "eu-west-1a" }
},
"annotations": {
"pedido_id": "48213",
"metodo_pago": "tarjeta",
"num_lineas": 38,
"entorno": "produccion"
},
"metadata": {
"default": {
"carrito": { "productos": ["tomate-rama", "lechuga-batavia", "..."] }
}
},
"subsegments": [
{
"id": "7c2d3e4f5a6b7c8d",
"name": "validar_stock",
"start_time": 1785142692.031,
"end_time": 1785142692.088
}
]
}The error, fault and throttle fields mark the outcome, and the distinction matters because the
service map paints them in different colours:
| Field | Means | HTTP code | Colour on the map |
|---|---|---|---|
error |
Client error | 4xx | Yellow |
fault |
Server error | 5xx | Red |
throttle |
Throttling | 429 | Purple |
| (none) | Correct | 2xx / 3xx | Green |
The trace ID and its propagation
A trace ID looks like this:
1-68a2f4c1-3b8d9e2a5f7c1b4d8e0a2f6c │ │ └── 96 random bits in hexadecimal │ └── Unix timestamp of the origin, in hexadecimal └── version (always 1)
That the timestamp is inside the identifier is not decorative: it lets X-Ray locate the trace without a global index, and it is the reason traces older than 30 days cannot be queried.
Propagation is done with the X-Amzn-Trace-Id HTTP header:
| Field | What it is |
|---|---|
Root |
The trace ID. Generated by the first instrumented component. |
Parent |
The ID of the segment making the call. This is what builds the tree. |
Sampled |
1 = trace it; 0 = do not trace it. The decision is taken once and respected. |
Self |
Added by the ALB when a header already came from the client. |
sequenceDiagram
participant C as Client
participant CF as CloudFront
participant ALB as alb-mercadofresco-tienda
participant T as Shop EC2
participant L as Lambda estado-pedido
participant D as DynamoDB / RDS
C->>CF: POST /api/pedidos/confirmar
CF->>ALB: (forwards)
ALB->>T: X-Amzn-Trace-Id: Root=1-68a2...;Sampled=1
Note over ALB: The ALB GENERATES the header<br/>if none came
T->>L: Invoke + Root=1-68a2...;Parent=6b1c...
L->>D: Query + Root=1-68a2...;Parent=9d4e...
D-->>L: result
L-->>T: response
T-->>C: 200 OK
Three rules to internalise:
- The first instrumented component generates the
Root. If the ALB has tracing enabled, it generates it. If not, your application does. - The sampling decision is taken once, at the origin, and everybody respects it. If the
Rootarrives withSampled=0, the Lambda will not send its segment even with tracing enabled. This avoids incomplete traces and confuses many people when debugging: "I have enabled X-Ray on the Lambda and I see nothing" usually means the sampling decision was taken upstream. - The SDKs propagate the header automatically on the outgoing calls they intercept. On a call they do not intercept —an exotic HTTP client, a home-made queue— you have to propagate it yourself.
Sampling: why not every request is traced
MercadoFresco receives, on a normal Friday, in the order of 2 million requests a day. Tracing them all would cost, at 5 USD per million traces recorded, around 300 USD a month in recording alone, without counting retrievals. And 99.9 % of those traces would be identical and boring: 80 ms requests that work.
Sampling decides what fraction is traced. A sampling rule has two parts:
reservoir: a fixed number of requests per second that are always traced. It is the floor: it guarantees you always have examples, even with very low traffic.fixed_rate: the percentage of the rest that is traced. It is the proportional ceiling: it guarantees representativeness when traffic goes up.
With reservoir: 1 and fixed_rate: 0.05:
| Requests/second | From the reservoir | From the remaining 5 % | Total traced |
|---|---|---|---|
| 1 | 1 | 0 | 1 (100 %) |
| 10 | 1 | 0.45 | ~1.45 (14.5 %) |
| 100 | 1 | 4.95 | ~5.95 (6 %) |
| 1,000 | 1 | 49.95 | ~51 (5.1 %) |
That is the design: at low volume you trace almost everything (and it costs you nothing); at high volume you trace a stable percentage (and the cost grows in a controlled way).
Rules are evaluated by priority, from the lowest number to the highest, and the first one that
matches wins. The matching criteria are: service_name, service_type, host, http_method,
url_path, and request attributes.
MercadoFresco's sampling rules
Marta's strategy: trace little of what is cheap and frequent, and a lot of what is expensive and important.
| Priority | Name | Matches | Reservoir | Fixed rate | Reason |
|---|---|---|---|---|---|
| 100 | muestreo-mercadofresco-checkout |
POST /api/pedidos/* |
2/s | 100 % | It is the money |
| 200 | muestreo-mercadofresco-admin |
/admin/* |
1/s | 50 % | Little traffic, much value |
| 300 | muestreo-mercadofresco-estaticos |
/static/*, /favicon.ico |
0/s | 0 % | Pure noise |
| 400 | muestreo-mercadofresco-salud |
/salud |
0/s | 0 % | The health check every 15 s |
| 9000 | muestreo-mercadofresco-defecto |
Everything else | 1/s | 5 % | Representativeness |
# The rule that matters: trace 100% of order confirmations.
aws xray create-sampling-rule --cli-input-json '{
"SamplingRule": {
"RuleName": "muestreo-mercadofresco-checkout",
"Priority": 100,
"FixedRate": 1.0,
"ReservoirSize": 2,
"ServiceName": "mercadofresco-tienda",
"ServiceType": "*",
"Host": "*",
"HTTPMethod": "POST",
"URLPath": "/api/pedidos/*",
"Version": 1,
"ResourceARN": "*",
"Attributes": {}
}
}' --profile mercadofresco-dev --region eu-west-1
# And the one that saves the most money: do NOT trace the health check.
aws xray create-sampling-rule --cli-input-json '{
"SamplingRule": {
"RuleName": "muestreo-mercadofresco-salud",
"Priority": 400,
"FixedRate": 0.0,
"ReservoirSize": 0,
"ServiceName": "*",
"ServiceType": "*",
"Host": "*",
"HTTPMethod": "GET",
"URLPath": "/salud",
"Version": 1,
"ResourceARN": "*"
}
}' --profile mercadofresco-dev --region eu-west-1That /salud deserves a calculation. The ALB checks /salud every 15 seconds against each instance
(03-03). With 4 instances at peak: 4 × 4 = 16 requests per minute, 691,200 a month. At 5 % that would
be 34,560 monthly traces of a 3 ms response saying OK. They add absolutely nothing and they
dirty the latency histograms with thousands of points at 3 ms that shift the percentiles. Excluding
them is the first optimisation to make, always.
Rules are managed centrally: the SDK downloads them every 10 seconds from the X-Ray API. You change a rule in the console and every instance in the ASG applies it within seconds, with no code deployment. This enables something very useful: raising sampling to 100 % during an incident and lowering it when it is over.
Annotations versus metadata
It is the most practical distinction in X-Ray and the one that decides whether you find something or not:
| Annotation | Metadata | |
|---|---|---|
| Indexed? | Yes | No |
| Can you filter by it? | Yes | No |
| Limit | 50 per trace | Segment size (64 KB) |
| Types | String, number, boolean | Any JSON |
| Use | pedido_id, metodo_pago, provincia |
The full basket, the gateway's response |
The rule is simple: if you are going to search by it, it is an annotation; if you only want to see it when you open the trace, it is metadata.
And here is the advantage over CloudWatch that is worth underlining. In 05-01 we saw that putting
PedidoId as a metric dimension would cost 190,000 USD a month. As an X-Ray annotation it is
free and searchable on top: annotation.pedido_id = "48213". It is exactly the gap that metrics
cannot cover.
MercadoFresco's annotations, chosen to answer real questions:
| Annotation | Example | Question it answers |
|---|---|---|
pedido_id |
"48213" |
"The customer says their order took ages" |
cliente_hash |
"a3f8c1e9" |
"Does it always happen to this customer?" |
metodo_pago |
"tarjeta" |
"Is it slow only with one gateway?" |
num_lineas |
38 |
"Does the slowness grow with the size of the order?" |
provincia |
"Madrid" |
"Is it a problem with one delivery area?" |
version_app |
"2.14.3" |
"Did it start with the latest deployment?" |
entorno |
"produccion" |
Separating production from testing |
Privacy warning. Annotations and metadata are stored in X-Ray and are visible to anyone with read permission. Never put emails, names, addresses, card numbers or direct personal identifiers there. That is why MercadoFresco uses
cliente_hash, a hash of the internal identifier, and not the email address. If you handle personal data under the GDPR, this decision must be reviewed by whoever handles compliance in your organisation.
A complete trace of an order, in a diagram
gantt
title Trace 1-68a2f4c1 - confirmation of order 48213 - total 8,140 ms
dateFormat X
axisFormat %L ms
section Shop EC2
Shop segment :active, t1, 0, 8140
validar_stock :done, t2, 27, 57
Invoke estado-pedido :done, t3, 140, 550
SQL insert header :done, t4, 700, 40
SQL 39 SELECT price :crit, t5, 745, 7410
responder :done, t6, 8110, 30
section Lambda estado-pedido
Lambda segment :active, l1, 145, 540
Initialization cold start :crit, l2, 145, 315
SNS Publish :done, l3, 500, 160
section RDS pedidos
PostgreSQL queries :crit, r1, 745, 7402
A graph like this —which in the X-Ray console is called the waterfall view and generates itself— tells the whole story in two seconds of reading: there is a red bar of 7.4 seconds and everything else is irrelevant in comparison. Compare it with the spreadsheet Marta assembled by hand in 05-01.
The permissions X-Ray needs
The rol-mercadofresco-tienda role needs to be able to send segments and download the sampling
rules. AWS has a managed policy for this:
aws iam attach-role-policy \
--role-name rol-mercadofresco-tienda \
--policy-arn arn:aws:iam::aws:policy/AWSXRayDaemonWriteAccess \
--profile mercadofresco-devIf you prefer explicit least privilege, which is what we teach in 04-01, the effective content is this:
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "EnviarTrazas",
"Effect": "Allow",
"Action": [
"xray:PutTraceSegments",
"xray:PutTelemetryRecords"
],
"Resource": "*"
},
{
"Sid": "DescargarReglasDeMuestreo",
"Effect": "Allow",
"Action": [
"xray:GetSamplingRules",
"xray:GetSamplingTargets",
"xray:GetSamplingStatisticSummaries"
],
"Resource": "*"
}
]
}None of those actions accepts a resource ARN —X-Ray has no per-trace resources—, so
"Resource": "*" is correct here; it is the same case as cloudwatch:PutMetricData in 05-01.
For the Lambda roles, rol-lambda-miniaturas and that of mercadofresco-estado-pedido, the managed
policy AWSXRayDaemonWriteAccess is enough, or the even more specific
AWSXRayWriteOnlyAccess.
And to read traces, the group of people on call needs:
{
"Effect": "Allow",
"Action": [
"xray:GetTraceSummaries",
"xray:BatchGetTraces",
"xray:GetServiceGraph",
"xray:GetTraceGraph",
"xray:GetTimeSeriesServiceStatistics",
"xray:GetGroups",
"xray:GetInsightSummaries",
"xray:GetInsight"
],
"Resource": "*"
}Instrumenting the shop with aws_xray_sdk
Installation:
And the minimal instrumentation of a Flask application, which is what runs on the ASG instances:
"""X-Ray instrumentation in the MercadoFresco shop."""
from flask import Flask, request, jsonify
from aws_xray_sdk.core import xray_recorder, patch_all
from aws_xray_sdk.ext.flask.middleware import XRayMiddleware
# 1. Configure the recorder BEFORE creating the application.
xray_recorder.configure(
service="mercadofresco-tienda", # node name on the map
daemon_address="127.0.0.1:2000", # where the daemon listens
context_missing="LOG_ERROR", # do NOT raise if the context is missing
sampling=True, # use the centralised rules
plugins=("EC2Plugin",), # adds instance_id and AZ to the segment
)
# 2. patch_all automatically instruments the supported libraries:
# boto3, botocore, requests, httplib, sqlite3, psycopg2, pymysql, aiohttp...
patch_all()
app = Flask(__name__)
# 3. The middleware creates one segment per incoming HTTP request,
# reads the X-Amzn-Trace-Id header and respects the sampling decision.
XRayMiddleware(app, xray_recorder)Four decisions to understand, because each one avoids a real problem:
context_missing="LOG_ERROR"is probably the most important parameter in the file. With the default value (RUNTIME_ERROR), any instrumented code that runs outside an HTTP request —a scheduled task, a maintenance script, a background thread, the batched metric publisher from 05-01— raises an exception and brings the task down. WithLOG_ERROR, it writes an error to the log and carries on. Observability must never bring the application down.plugins=("EC2Plugin",)makes every segment include theinstance_idand the availability zone. When a single ASG instance is degraded, that annotation is what reveals it. There areECSPluginandElasticBeanstalkPluginequivalents.patch_all()includespsycopg2, the PostgreSQL driver. That means every query againstmercadofresco-pedidosgenerates a subsegment automatically, with the query text sanitised. It is exactly what is going to give the N+1 away.daemon_address: the SDK does not talk to the X-Ray API. It writes UDP packets to a local daemon, which takes care of batching and sending them. That is why an instrumented call adds microseconds, not milliseconds.
If you prefer finer instrumentation,
patch()accepts a list of modules:patch(("boto3", "psycopg2", "requests")). Avoid instrumentingsqlite3in production if you use it for high-frequency local caches: you would generate thousands of useless subsegments.
Manual subsegments and useful annotations
Automatic instrumentation covers the calls to external services. The legs of your code you have to mark yourself:
"""Order confirmation, instrumented."""
import hashlib
from aws_xray_sdk.core import xray_recorder
@app.route("/api/pedidos/confirmar", methods=["POST"])
def confirm_order():
data = request.get_json()
order_id = data["pedido_id"]
# ANNOTATIONS: indexed and filterable. Done first of all,
# so they are present even if the request fails later on.
segment = xray_recorder.current_segment()
segment.put_annotation("pedido_id", str(order_id))
segment.put_annotation("metodo_pago", data["metodo_pago"])
segment.put_annotation("num_lineas", len(data["lineas"]))
segment.put_annotation("provincia", data["direccion"]["provincia"])
segment.put_annotation("version_app", app.config["VERSION"])
# A hash, NEVER the email or the customer's direct identifier.
segment.put_annotation(
"cliente_hash",
hashlib.sha256(data["cliente_id"].encode()).hexdigest()[:8],
)
# METADATA: not indexed, but visible when you open the trace.
segment.put_metadata("carrito", data["lineas"], "negocio")
segment.put_metadata("importe_total", data["total_eur"], "negocio")
# SUBSEGMENT as a context manager: it closes itself, even on exception.
with xray_recorder.in_subsegment("validar_stock") as sub:
available = check_stock(data["lineas"])
sub.put_annotation("stock_ok", available)
if not available:
# error = the customer's problem (4xx), yellow on the map.
sub.add_error_flag()
return jsonify({"error": "sin stock"}), 409
with xray_recorder.in_subsegment("calcular_portes"):
shipping = calculate_shipping(data["direccion"])
# This call does NOT need a manual subsegment: patch_all has instrumented
# boto3, and the Lambda invocation appears in the trace on its own.
response = lambda_client.invoke(
FunctionName="mercadofresco-estado-pedido",
Payload=json.dumps({"pedido_id": order_id}),
)
# Nor does this one: psycopg2 is instrumented and every query
# generates its own subsegment with the sanitised SQL.
with xray_recorder.in_subsegment("persistir_pedido"):
save_order(order_id, data["lineas"], shipping)
return jsonify({"estado": "confirmado", "pedido_id": order_id}), 200And the decorator, for functions that are called from several places:
from aws_xray_sdk.core import xray_recorder
@xray_recorder.capture("calcular_portes")
def calculate_shipping(address):
"""Every call to this function creates its own subsegment."""
...Work in threads and in asynchronous tasks
The most common instrumentation failure in production. The SDK stores the current segment in a per-thread context variable. If you spawn a new thread, that thread has no context and everything instrumented that it runs will fail or be lost:
import threading
from aws_xray_sdk.core import xray_recorder
def process_in_parallel(entity):
# Capture the segment in the parent thread...
parent_segment = xray_recorder.current_subsegment() or xray_recorder.current_segment()
def work():
# ...and restore it in the child thread.
xray_recorder.context.put_segment(parent_segment)
with xray_recorder.in_subsegment("trabajo_paralelo"):
do_something(entity)
threading.Thread(target=work).start()If this strikes you as fragile, you are right: it is. It is one of the serious arguments in favour of OpenTelemetry, which solves context propagation more cleanly. We look at it at the end of the lesson.
The X-Ray daemon on EC2 and in containers
The X-Ray daemon is a lightweight process that listens on port 2000/UDP, batches the segments the SDKs send it and forwards them to the X-Ray API in batches.
flowchart LR
A["Application<br/>aws_xray_sdk"] -->|"UDP 2000<br/>microseconds"| D["X-Ray daemon<br/>local process"]
D -->|"HTTPS in batches<br/>every second"| X["X-Ray API<br/>eu-west-1"]
Why this architecture? Because it decouples the application from the network. If the X-Ray API is slow or down, the application never notices: it keeps writing UDP packets that, in the worst case, are lost. A lost trace is not a problem; a shop blocked waiting for the tracing API is.
Installation on the ASG instances, added to the user data of lt-mercadofresco-tienda:
#!/bin/bash
set -euo pipefail
# Amazon Linux 2023. AWS publishes the package in a per-region bucket.
curl -fsSL -o /tmp/xray.rpm \
"https://s3.dualstack.eu-west-1.amazonaws.com/aws-xray-assets.eu-west-1/xray-daemon/aws-xray-daemon-3.x.rpm"
dnf install -y /tmp/xray.rpm
cat > /etc/amazon/xray/cfg.yaml <<'YAML'
TotalBufferSizeMB: 16
Concurrency: 8
Region: eu-west-1
Socket:
UDPAddress: 127.0.0.1:2000
TCPAddress: 127.0.0.1:2000
LocalMode: false
LogLevel: warn
YAML
systemctl enable --now xray
systemctl is-active xray || exit 1Field notes:
UDPAddress: 127.0.0.1:2000, not0.0.0.0. It should only listen on the local interface; nobody from outside has any business sending it segments.TotalBufferSizeMB: if the buffer fills up, the daemon drops segments and notes it in its log. SeeingSegmentsRejectedCountgrowing means you have to raise it or lower the sampling.- Nothing needs opening in
sg-mercadofresco-tienda: the traffic is local. The outbound path to the X-Ray API goes through the NAT (03-01), or better still, through a VPC interface endpoint forcom.amazonaws.eu-west-1.xrayif you want it never to touch the internet at all.
In containers (module 10), the daemon is deployed as a sidecar container in the same task
definition, and the application reaches it over localhost in ECS with awsvpc networking. Detailed
in 10-01.
Enabling tracing on mercadofresco-estado-pedido
In Lambda there is no daemon to install: AWS runs it for you inside the execution environment. All you need is a checkbox and a permission.
# 1. The permission
aws iam attach-role-policy \
--role-name rol-mercadofresco-estado-pedido \
--policy-arn arn:aws:iam::aws:policy/AWSXRayDaemonWriteAccess \
--profile mercadofresco-dev
# 2. The checkbox: active tracing mode
aws lambda update-function-configuration \
--function-name mercadofresco-estado-pedido \
--tracing-config Mode=Active \
--profile mercadofresco-dev --region eu-west-1The two modes:
| Mode | Behaviour |
|---|---|
PassThrough (default) |
Only traces if the request already came with Sampled=1 |
Active |
The function decides the sampling if it was not decided already |
For mercadofresco-estado-pedido, which the shop calls, PassThrough would be enough and cheaper.
For mercadofresco-generar-miniaturas, which an S3 event triggers and which has no instrumented
origin, you need Active or nothing will ever be traced.
With just that you already get the segment with the duration, the cold start and the errors. To see inside the function, you have to instrument it just as on EC2:
"""mercadofresco-estado-pedido, instrumented."""
import json
import os
import boto3
from aws_xray_sdk.core import xray_recorder, patch_all
# In Lambda the daemon is at the address in the environment variable,
# which the execution environment itself defines. Nothing to configure.
patch_all()
sns = boto3.client("sns")
TOPIC = os.environ["ARN_TEMA_ALERTAS"]
def handler(event, context):
order_id = event["pedido_id"]
# In Lambda, the root segment is created by the execution environment
# and is read-only: annotations go in a SUBSEGMENT.
subsegment = xray_recorder.begin_subsegment("procesar_estado")
try:
subsegment.put_annotation("pedido_id", str(order_id))
subsegment.put_annotation("origen", event.get("origen", "tienda"))
status = compute_status(order_id)
subsegment.put_annotation("estado_resultante", status)
# boto3 is patched: this call appears on its own as a subsegment
# and it also draws the SNS node on the service map.
sns.publish(
TopicArn=TOPIC,
Subject=f"Order {order_id}: {status}",
Message=json.dumps({"pedido_id": order_id, "estado": status}),
)
return {"estado": status}
except Exception as e:
subsegment.add_exception(e, []) # marks fault: red on the map
raise
finally:
xray_recorder.end_subsegment()The detail that costs everybody time the first time round: in Lambda you cannot annotate the root
segment. The execution environment creates and controls it. xray_recorder.current_segment()
exists, but put_annotation on it is silently ignored. Annotations always go in a subsegment of
your own.
Another valuable detail: the Initialization subsegment you see in Lambda traces is the cold
start. If your Duration p99 is bad but the p50 is fine, look at that subsegment: it is almost
always a heavy import that can be moved inside the handler or trimmed. It is the diagnosis that in
02-05 we could only guess at.
Enabling tracing on the ALB, on CloudFront and on API Gateway
| Service | What it adds | How it is enabled |
|---|---|---|
| ALB | Generates X-Amzn-Trace-Id if none arrives |
Automatic, always on |
| API Gateway | Its own segment with the integration latency | Checkbox per stage |
| CloudFront | Does not generate X-Ray segments | Correlated via x-amz-cf-id |
| SQS / SNS | Propagate the header | Automatic (07-01, 07-02) |
| Step Functions | One segment per state | Checkbox (07-04) |
The ALB generates the header automatically and there is nothing to enable: it is the reason the
MercadoFresco shop receives a Root without anyone having programmed it. What the ALB does not
do is generate a segment of its own, so its processing time does not appear as a node on the map.
That latency is still visible in CloudWatch's TargetResponseTime metric (05-01), and that is a good
example of the two tools complementing each other.
If one day MercadoFresco puts an API behind API Gateway:
aws apigateway update-stage \
--rest-api-id abc123def4 --stage-name produccion \
--patch-operations op=replace,path=/tracingEnabled,value=true \
--profile mercadofresco-dev --region eu-west-1CloudFront is the missing piece, and it pays to be honest: CloudFront does not take part in
X-Ray traces. Its own identifier is x-amz-cf-id, which appears in its access logs. To correlate
the CDN's time with the trace, MercadoFresco records that value as an annotation:
With that, given a trace you can look up the corresponding line in the CloudFront logs in S3, and the other way round. It is not as convenient as a node on the map, but it closes the circle.
The service map: how to read it
The service map is the graph X-Ray builds by aggregating every trace in the selected time window. It is not an architecture diagram drawn by anybody: it is what actually happens, deduced from the traffic.
flowchart LR
C(("Client")) --> T["mercadofresco-tienda<br/>1,240 t/min<br/>avg lat. 0.18 s<br/>errors 0.2%"]
T --> L["mercadofresco-estado-pedido<br/>Lambda<br/>avg lat. 0.48 s"]
T --> R[("mercadofresco-pedidos<br/>PostgreSQL<br/>avg lat. 2.10 s<br/>RED")]
T --> S3[("mercadofresco-catalogo-fotos<br/>S3")]
L --> SN["alertas-mercadofresco<br/>SNS"]
L --> R
How to read it, element by element:
| Element | Meaning |
|---|---|
| Circle | A node: a service, a resource or the client |
| Size of the circle | Traffic volume |
| Green ring | Successful requests |
| Yellow ring | error — 4xx failures (the client's fault) |
| Red ring | fault — 5xx failures (your fault) |
| Purple ring | throttle — throttling (429) |
| Arrow | A call from one node to another |
| Client node | The origin: not a service of yours |
The node types matter too: X-Ray distinguishes service nodes (something you instrument, with its own segment) from downstream resource nodes (a database, an S3 bucket, which are not instrumented but whose time is measured from the caller). A red RDS node means calls to RDS are failing or slow, not that the PostgreSQL server is reporting anything.
Three patterns you recognise at a glance on a real map:
- One isolated red node with everything else green: the culprit is obvious.
- Everything red downstream of a node: there is a cascading failure; the deepest one is to blame.
- A node that appears out of nowhere and should not be there: a dependency somebody introduced without telling anyone. It is one of the reasons this map is useful even with no incident at all.
Groups let you have a filtered map, for example of the purchase flow only:
aws xray create-group \
--group-name grupo-mercadofresco-pedidos \
--filter-expression 'service("mercadofresco-tienda") AND annotation.entorno = "produccion" AND http.url CONTAINS "/api/pedidos"' \
--insights-configuration InsightsEnabled=true,NotificationsEnabled=true \
--profile mercadofresco-dev --region eu-west-1A group with InsightsEnabled also generates its own metrics in CloudWatch, on which you can create
alarms.
Trace filters: the search language
Here is X-Ray's real power. The filter expression language:
| Expression | Finds |
|---|---|
service("mercadofresco-tienda") |
Traces that go through that service |
service("mercadofresco-pedidos") { fault } |
Traces where that node failed |
responsetime > 5 |
Traces longer than 5 seconds |
duration > 3 AND duration < 10 |
Between 3 and 10 seconds |
http.status = 500 |
By response code |
http.url CONTAINS "/api/pedidos" |
By path |
annotation.pedido_id = "48213" |
One specific order |
annotation.cliente_hash = "a3f8c1e9" |
Every request from one customer |
annotation.num_lineas > 25 |
Large orders |
annotation.version_app = "2.14.3" |
The new version only |
error = true |
4xx failures |
fault = true |
5xx failures |
throttle = true |
Throttling |
service("estado-pedido") { fault } AND responsetime > 2 |
Combinations with AND, OR, NOT |
edge("mercadofresco-tienda", "mercadofresco-pedidos") |
Traces crossing that specific edge |
From the CLI:
# All the slow confirmations of the last 3 hours
aws xray get-trace-summaries \
--start-time $(date -d '3 hours ago' +%s) \
--end-time $(date +%s) \
--filter-expression 'http.url CONTAINS "/api/pedidos/confirmar" AND responsetime > 5' \
--query 'TraceSummaries[].[Id,Duration,ResponseTime,Http.HttpStatus]' \
--output table \
--profile mercadofresco-dev --region eu-west-1
# And the full detail of one specific trace
aws xray batch-get-traces \
--trace-ids 1-68a2f4c1-3b8d9e2a5f7c1b4d8e0a2f6c \
--profile mercadofresco-dev --region eu-west-1A script Marta uses when a specific complaint comes in, and which replaces the four Logs Insights queries from 05-01:
"""Find the trace of a specific order and break down where the time went."""
import boto3
import time
xray = boto3.client("xray", region_name="eu-west-1")
def investigate_order(order_id, hours=24):
now = int(time.time())
summaries = xray.get_trace_summaries(
StartTime=now - hours * 3600,
EndTime=now,
FilterExpression=f'annotation.pedido_id = "{order_id}"',
)
if not summaries["TraceSummaries"]:
print(f"No traces for order {order_id}.")
print("Possible causes: it was not sampled, or more than 30 days have passed.")
return
for summary in summaries["TraceSummaries"]:
trace_id = summary["Id"]
print(f"\nTrace {trace_id}: {summary['Duration']:.3f} s")
detail = xray.batch_get_traces(TraceIds=[trace_id])
for trace in detail["Traces"]:
for segment in trace["Segments"]:
import json
doc = json.loads(segment["Document"])
dur = doc["end_time"] - doc["start_time"]
print(f" [{dur*1000:8.1f} ms] {doc['name']}")
for sub in doc.get("subsegments", []):
d = sub["end_time"] - sub["start_time"]
mark = " <-- HERE" if d > 1.0 else ""
print(f" [{d*1000:8.1f} ms] {sub['name']}{mark}")
investigate_order("48213")Latency analysis: histograms and percentiles
The X-Ray console offers, for every node on the map and for every search, a latency histogram on a logarithmic scale. It is the tool that reveals what a percentile summarises away.
A unimodal histogram —a single hump— means every request behaves alike: if it is slow, it is slow for everybody, and the problem is structural.
A bimodal histogram —two humps— means there are two distinct populations of requests, and
that is the most valuable finding X-Ray offers. The one for mercadofresco-tienda at Friday's peak:
| Latency | Requests | Interpretation |
|---|---|---|
| 60-200 ms | 94 % | Normal requests |
| 200 ms - 2 s | 4 % | Product pages with many photos |
| 5-9 s | 2 % | The second hump: the problem |
A p95 of 1.8 s would not have shown anything odd. The p99 of 8.2 s did. But what really convinces
is seeing that there are two separate humps: it is not a general degradation, it is a specific
subset of requests behaving differently. And in X-Ray you can select the second hump on the
histogram with the mouse and see only those traces. That leads straight to the guided case.
A useful comparison that is worth learning to make, between two versions of the application:
# p95 latency before the deployment of version 2.14.3
aws xray get-time-series-service-statistics \
--start-time $(date -d '2026-07-29 00:00' +%s) \
--end-time $(date -d '2026-07-30 00:00' +%s) \
--group-name grupo-mercadofresco-pedidos \
--entity-selector-expression 'service("mercadofresco-tienda")' \
--period 3600 \
--profile mercadofresco-dev --region eu-west-1And the filter that isolates exactly the effect of a deployment, which is the question asked most often after releasing a version:
The guided case: the 8.14-second order
We go back to order 48213, this time with X-Ray. Compare the effort with the four queries of 05-01.
Step 1. Find the trace. A single search:
One trace appears, 8.14 s. In 05-01 this took two Insights queries and knowing the customer's hash.
Step 2. Open the waterfall view. You see immediately:
| Leg | Duration | % of total |
|---|---|---|
validar_stock |
57 ms | 0.7 % |
Invoke mercadofresco-estado-pedido |
550 ms | 6.8 % |
↳ Initialization (cold start) |
315 ms | 3.9 % |
calcular_portes |
12 ms | 0.1 % |
persistir_pedido |
40 ms | 0.5 % |
SELECT × 39 on productos |
7,410 ms | 91.0 % |
| Rest | 71 ms | 0.9 % |
Step 3. See the nature of the problem. Expanding the database subsegment reveals 39 consecutive subsegments, each of about 190 ms, with the same sanitised SQL:
This is what a log cannot show: the shape. Thirty-nine identical bars, one after another, never overlapping. The diagnosis is read off the graph before anybody reads the SQL. It is the N+1 pattern: the code walks the order lines and queries each product's price separately.
Step 4. Confirm it is systematic and not an isolated case. The filter:
Returns 187 traces in 24 hours. And the complement:
Returns 2. The slowness grows with the number of order lines: confirmed. This is exactly the
question the annotations were there to answer, and it is why num_lineas was an annotation and not
metadata.
Step 5. Quantify the impact on the business. With the previous filter over 30 days: 5,610 orders affected, with more than 4 seconds of waiting at confirmation. They are the largest orders, that is, the highest-value ones. That figure is what turns a technical task into a priority.
Step 6. Fix it. A single query instead of 39:
Step 7. Verify with data, not with impressions. It is deployed as version 2.14.4 and compared:
| Metric | Before (2.14.3) | After (2.14.4) |
|---|---|---|
| p50 of confirmation | 1.9 s | 0.21 s |
| p95 | 6.8 s | 0.44 s |
| p99 | 8.4 s | 0.61 s |
| SQL subsegments per order | 39-42 | 3 |
DatabaseConnections at peak |
185 | 96 |
Note the last row, which is a side effect nobody had anticipated: removing the N+1 halves the
pressure on mercadofresco-pedidos. The alarm
mercadofresco-rds-conexiones-altas from 05-01 stops sitting on the edge every Friday. A latency
problem was also a capacity problem.
And step 8, which closes the loop with 05-01: now that we know what the problem is, an alarm is
created so it does not return unannounced. The grupo-mercadofresco-pedidos group with Insights
enabled publishes metrics in CloudWatch, and the TiempoConfirmacionPedido metric we created in
05-01 already serves to detect the regression. X-Ray diagnoses; CloudWatch watches. Neither one replaces the other.
X-Ray Insights
Insights continuously analyses the traces of a group, builds a baseline of normal behaviour and detects anomalies without you defining any thresholds. When it finds one, it opens an "insight" with:
- The start time and the state (active or resolved).
- The probable root cause: the node and the error type that contribute most.
- The impact: number of requests and users affected.
- An anomaly graph against the baseline.
aws xray update-group \
--group-name grupo-mercadofresco-pedidos \
--insights-configuration InsightsEnabled=true,NotificationsEnabled=true \
--profile mercadofresco-dev --region eu-west-1
aws xray get-insight-summaries \
--start-time $(date -d '7 days ago' +%s) --end-time $(date +%s) \
--states ACTIVE CLOSED \
--group-name grupo-mercadofresco-pedidos \
--profile mercadofresco-dev --region eu-west-1With NotificationsEnabled=true, each insight generates an EventBridge event (07-03), which can
be routed to alertas-mercadofresco. That is the link that turns X-Ray from a diagnostic tool into a
detection tool.
Two honest limitations:
- Insights detects anomalies relative to the usual, not problems. If your service has always taken 8 seconds, it will never flag it.
- It needs volume. With little sampled traffic, the baseline is noisy and produces false positives. One more reason not to sample at 1 %.
Relationship with CloudWatch ServiceLens
ServiceLens is the CloudWatch view that brings the three signals together on a single screen. It is literally the X-Ray service map, but with the CloudWatch metrics and a direct link to the correlated logs.
| From ServiceLens you can… | And so… |
|---|---|
| See the map with CloudWatch metrics overlaid | Latency, error rate and requests per node |
| Click a node and see its metrics | Without switching console |
| Click a node and see its logs | Filtered by the time window of the spike |
| Go from a latency spike to the traces of that spike | In two clicks |
| See the top contributors to the error | Which URL, which instance |
Requirement: X-Ray enabled and, for log correlation, that the groups are associated. The workflow ServiceLens enables, and the one Marta ends up using every day:
flowchart LR
A["CloudWatch alarm<br/>high p95 latency"] --> B["ServiceLens:<br/>which node is red?"]
B --> C["X-Ray: traces<br/>of that spike"]
C --> D["Waterfall:<br/>which subsegment?"]
D --> E["Logs Insights:<br/>what the log said<br/>at that instant"]
E --> F["Diagnosis"]
Metric → map → trace → subsegment → log. That is the complete path, and MercadoFresco now has all of it.
OpenTelemetry and ADOT: the open alternative
It would be dishonest to teach X-Ray without saying that today the industry standard is OpenTelemetry (OTel), a CNCF project that defines an API, an SDK and a protocol (OTLP) that are neutral with respect to the vendor.
AWS Distro for OpenTelemetry (ADOT) is AWS's officially supported distribution of OpenTelemetry, which can send traces to X-Ray and to any other destination at the same time.
| X-Ray SDK | OpenTelemetry / ADOT | |
|---|---|---|
| Standard | AWS proprietary | Open (CNCF) |
| Signals | Traces | Traces, metrics and logs |
| Destinations | X-Ray | X-Ray, Prometheus, Jaeger, Grafana, Datadog… |
| Languages | 6 official ones | Dozens |
| Automatic instrumentation | Good on AWS | Very broad, with an automatic agent in Java/Python/Node |
| Context propagation | Its own (X-Amzn-Trace-Id) |
W3C Trace Context (traceparent) + AWS |
| Maturity on AWS | Very high, years | High and growing |
| Initial complexity | Low | Medium (there is a collector to configure) |
| Lock-in risk | High | Low |
| AWS's recommendation today | Supported | Preferred for new projects |
What to recommend, with judgement:
- A new project, or one that intends not to be tied to AWS: OpenTelemetry with ADOT. The extra initial cost —configuring the collector— pays for itself the first time you want to send the same traces somewhere else, or to migrate.
- An existing project already instrumented with the X-Ray SDK, all on AWS: there is no urgency. It works, it is supported and migrating has a cost.
- MercadoFresco's case: Marta has instrumented with the X-Ray SDK because it is the quickest to get going and her whole system is on AWS. She has noted in the technical decision record that migrating to ADOT is the natural evolution when the application grows or if multi-cloud ever comes up. It is not hidden technical debt: it is a conscious decision with a review date.
A compatibility note that saves a bad time: X-Ray propagates X-Amzn-Trace-Id and OTel propagates
traceparent (W3C). If you mix the two in one system, you have to configure the xray propagator
in OTel so that the traces do not break at the boundary.
A minimal ADOT example in Python, so you can see the difference in shape:
pip install aws-opentelemetry-distro
# Automatic instrumentation does not touch your code
OTEL_PYTHON_DISTRO=aws_distro \
OTEL_PYTHON_CONFIGURATOR=aws_configurator \
OTEL_TRACES_EXPORTER=otlp_proto_http \
OTEL_EXPORTER_OTLP_TRACES_ENDPOINT=http://localhost:4318/v1/traces \
OTEL_PROPAGATORS=xray \
OTEL_RESOURCE_ATTRIBUTES="service.name=mercadofresco-tienda" \
opentelemetry-instrument python app.pyNote that there is not a single line of application code: opentelemetry-instrument wraps the
process and instruments the known libraries. For a large, existing application that is an enormous
advantage.
Cost and economical sampling strategy
| Item | Reference price | Monthly free tier |
|---|---|---|
| Trace recorded | 5.00 USD per million | 100,000 |
| Trace retrieved or scanned | 0.50 USD per million | 1,000,000 |
| X-Ray Insights | 1.00 USD per million traces analysed | — |
| Storage | Included (30 days) | — |
"Trace retrieved" is every time somebody opens a trace or runs a filter. Querying is cheap; recording is the expensive part.
Calculation for MercadoFresco. Traffic: about 2 million requests a day, 60 million a month.
Scenario A: no rules, default sampling (1/s + 5 %).
| Item | Traces/month | Cost |
|---|---|---|
| Recorded | ~3.3 million | 16.50 USD |
| Retrieved | ~200,000 | 0.00 USD (free tier) |
| Total | ~16.50 USD |
Scenario B: with MercadoFresco's rules.
| Rule | Requests/month | Sampling | Traces |
|---|---|---|---|
/salud |
691,200 | 0 % | 0 |
| Static files | 34,000,000 | 0 % | 0 |
/api/pedidos/* |
660,000 | 100 % | 660,000 |
/admin/* |
40,000 | 50 % | 20,000 |
| Rest | 24,600,000 | 1/s + 5 % | ~1,400,000 |
| Total recorded | ~2.08 million |
| Item | Cost |
|---|---|
| Recorded: (2,080,000 − 100,000) × 5 / 1,000,000 | 9.90 USD |
| Retrieved: ~500,000 | 0.00 USD |
| Insights on the orders group | ~0.70 USD |
| Total | ~10.60 USD/month |
Less cost than scenario A and with 100 % of the order confirmations traced, which is the only thing that really matters. That is what a good sampling strategy does: it does not trace less, it traces better.
Scenario C: the mistake, 100 % sampling of everything.
60 million traces: 300 USD/month, plus the impact on the daemon's performance and on the
console's usability, which fills up with /salud traces. Never do it outside a bounded debugging
window.
The five rules of the economical strategy:
- Always exclude
/saludand the static files. It is 57 % of MercadoFresco's traffic and its diagnostic value is zero. - Trace 100 % of the business-critical routes. They are few requests and they are what matters.
- A reservoir of at least 1/s in the default rule, so you have examples even in the small hours.
- Raise sampling temporarily during an incident and lower it when it is over. The rules are centralised and apply within 10 seconds, with no deployment.
- Watch the cost with a budget filtered by service = X-Ray (module 11).
And a performance warning, not a cost one: every subsegment has a price in size. A segment cannot
exceed 64 KB, and a patch_all() over an application that makes thousands of small queries per
request generates enormous segments that the daemon ends up dropping. If you see
SegmentsRejectedCount in the daemon log, review what you are instrumenting.
Cleanup
# Disable tracing on the Lambda
aws lambda update-function-configuration \
--function-name mercadofresco-estado-pedido \
--tracing-config Mode=PassThrough \
--profile mercadofresco-dev --region eu-west-1
# Delete the sampling rules
for R in muestreo-mercadofresco-checkout muestreo-mercadofresco-admin \
muestreo-mercadofresco-estaticos muestreo-mercadofresco-salud; do
aws xray delete-sampling-rule --rule-name "$R" \
--profile mercadofresco-dev --region eu-west-1
done
# Delete the group
aws xray delete-group --group-name grupo-mercadofresco-pedidos \
--profile mercadofresco-dev --region eu-west-1
# Remove the permissions
aws iam detach-role-policy --role-name rol-mercadofresco-tienda \
--policy-arn arn:aws:iam::aws:policy/AWSXRayDaemonWriteAccess \
--profile mercadofresco-dev
# And on the instances, stop the daemon
sudo systemctl disable --now xrayImportant: traces already recorded cannot be deleted and expire on their own after 30 days.
There is no storage that keeps costing money. What you do have to do is remove the instrumentation
from the code if you are not going to keep using it, or at least leave
context_missing="LOG_ERROR" so an SDK with no daemon does not fill the log with errors.
Common Mistakes and Tips
1. "I have enabled X-Ray and I see nothing". In order of likelihood: (a) the daemon is not
running or is not listening on 2000/UDP; (b) xray:PutTraceSegments is missing from the role; (c)
the request arrived with Sampled=0 and the decision is respected downstream; (d) wrong region.
2. Putting annotations on a Lambda's root segment. They are silently ignored: the root segment is controlled by the execution environment. Annotations go in a subsegment you create yourself.
3. Leaving context_missing at its default value. Any instrumented code outside an HTTP request
—scheduled tasks, threads, scripts— will raise exceptions and bring the process down. Always set
LOG_ERROR.
4. Confusing an annotation with metadata. If you cannot filter by something, you put it in as metadata. Only annotations are indexed, and there is a maximum of 50 per trace.
5. Putting personal data in annotations. Email, name, address, card: never. Use a hash. Traces are seen by anyone with read permission, and they live for 30 days.
6. Tracing the health check. /salud every 15 seconds generates tens of thousands of useless
traces a month, dirties the histograms and shifts the percentiles. It is the first exclusion to create.
7. Sampling at 100 % "so as not to miss anything". 300 USD a month in MercadoFresco's case, an unusable console and a daemon dropping segments. Tracing better is not tracing more.
8. Losing the context in threads. The segment lives in a per-thread variable. If you spawn a thread, you have to pass it the segment explicitly. It is the most frequent failure in applications with parallelism.
9. Looking for traces from two months ago. They do not exist: X-Ray retains 30 days, full stop. If you need historical analysis, export what interests you to S3 while it is still available.
10. Expecting CloudFront to appear on the map. It does not. Correlate with the cf_id annotation
and the CDN logs in S3.
11. Instrumenting without propagating on your own calls. If your code calls another service with
an HTTP client the SDK does not patch, the X-Amzn-Trace-Id header does not travel and the trace is
cut off there. Propagate it by hand.
12. Using X-Ray as a replacement for logs. It is not. Traces are sampled: the request you are looking for may not be there. Logs have everything. They complement each other: the trace tells you where to look, the log tells you what it said.
Final tip: the investment that pays best in X-Ray is not the instrumentation, but choosing five
or six annotations well. pedido_id, cliente_hash and num_lineas are the ones that solved this
case. Think about what questions you are going to be asked —"it always happens to this customer", "it
started with Tuesday's deployment", "only with large orders"— and annotate exactly what is needed to
answer them with a filter.
Exercises
Exercise 1: designing the sampling strategy of a new service
MercadoFresco is launching a public API so that the affiliated local shops can check stock and create wholesale orders. Expected traffic:
| Route | Requests/day | Typical latency | Criticality |
|---|---|---|---|
GET /api/v1/stock/{sku} |
4,000,000 | 25 ms | Medium |
GET /api/v1/catalogo |
120,000 | 300 ms | Medium |
POST /api/v1/pedidos-mayoristas |
3,000 | 1.8 s | Maximum |
POST /api/v1/facturas |
800 | 4.0 s | Maximum |
GET /salud |
5,760 | 2 ms | None |
GET /api/v1/docs |
400 | 40 ms | None |
Requirements: every order and every invoice must be individually investigable; you must be able to
diagnose /stock latency without going bankrupt; the X-Ray budget is 25 USD a month.
Design the complete set of sampling rules with priorities, reservoirs and rates. Calculate the number of monthly traces and the cost. Justify each rule and say what annotations you would put in the API.
Exercise 2: reading a waterfall and identifying three problems
This is the waterfall of a 6.45-second trace of POST /api/pedidos/confirmar:
[0 ms] mercadofresco-tienda 6,450 ms [12 ms] ├─ subsegment: cargar_configuracion 890 ms [905 ms] ├─ subsegment: SecretsManager GetSecretValue 310 ms [1,220 ms] ├─ subsegment: validar_stock 45 ms [1,270 ms] ├─ subsegment: SELECT ... FROM productos WHERE id=? 95 ms [1,370 ms] ├─ subsegment: SELECT ... FROM productos WHERE id=? 92 ms [1,465 ms] ├─ subsegment: SELECT ... FROM productos WHERE id=? 88 ms [1,560 ms] ├─ (repeated 11 more times, in series) 1,030 ms [2,595 ms] ├─ subsegment: Invoke mercadofresco-estado-pedido 3,780 ms [2,610 ms] │ └─ Lambda SEGMENT 3,750 ms [2,615 ms] │ ├─ Initialization 2,980 ms [5,600 ms] │ ├─ SNS Publish 95 ms [5,700 ms] │ └─ SELECT ... FROM pedidos 60 ms [6,380 ms] └─ subsegment: responder 70 ms
Identify at least three distinct problems, order them by impact in milliseconds, propose a concrete solution for each one and estimate the resulting latency. State as well what annotation or subsegment you would add so as to be able to detect each of these problems automatically in the future.
Exercise 3: the case X-Ray does not solve on its own
One Monday, Sara reports that 3 % of last week's orders have not produced an invoice. Customers
have their order confirmed and their delivery under way, but there is no invoice. There are no errors
in CloudWatch, no alarm has fired, mercadofresco-estado-pedido records no Errors, and in X-Ray
the traces of those orders appear complete and green.
Architecture involved: the shop confirms the order, publishes to the alertas-mercadofresco topic,
and an invoicing process subscribed to that topic generates the PDF and uploads it to
mercadofresco-informes-analitica. That invoicing process is not instrumented with X-Ray.
Explain: why X-Ray has not detected it, what MercadoFresco's observability is missing, what combination of the tools from 05-01 and 05-02 you would use to diagnose it, and what instrumentation and what alarm you would leave in place so that next time it is detected in minutes. Be specific with the commands.
Solutions
Solution 1
Preliminary analysis. Total traffic is 4,124,960 requests/day ≈ 124 million/month. At the default
sampling (5 %) that would be 6.2 million traces: 30.50 USD. We go over budget, and on top of that
we would spend almost all of it on identical /stock traces.
Proposed rules:
| Priority | Name | Matches | Reservoir | Rate | Traces/month |
|---|---|---|---|---|---|
| 100 | muestreo-api-facturas |
POST /api/v1/facturas |
1/s | 100 % | 24,000 |
| 110 | muestreo-api-pedidos-mayoristas |
POST /api/v1/pedidos-mayoristas |
1/s | 100 % | 90,000 |
| 200 | muestreo-api-catalogo |
GET /api/v1/catalogo |
1/s | 10 % | ~400,000 |
| 300 | muestreo-api-stock |
GET /api/v1/stock/* |
1/s | 0.5 % | ~600,000 |
| 800 | muestreo-api-salud |
GET /salud |
0/s | 0 % | 0 |
| 810 | muestreo-api-docs |
GET /api/v1/docs |
0/s | 0 % | 0 |
| 9000 | muestreo-api-defecto |
Rest | 1/s | 5 % | ~50,000 |
Detailed calculation for /stock, which holds 97 % of the traffic and where everything is decided:
- 4,000,000/day ÷ 86,400 s = 46.3 requests per second.
- Reservoir: 1/s × 2,592,000 s/month = 2,592,000… which is already too many. Careful with the reservoir when volume is high: the reservoir is a floor, not a ceiling, and with 46 requests per second there is always one to trace.
We redo it: with a 1/s reservoir the monthly floor is 2.59 million traces from /stock alone
(12.95 USD). That is too much for what it contributes. Reservoir 0 and a fixed rate of 0.05 %:
- 124,000,000 × 0.0005 = 62,000 traces/month. Enough for a representative latency histogram of a route that responds in 25 ms.
Corrected table:
| Priority | Name | Reservoir | Rate | Traces/month |
|---|---|---|---|---|
| 100 | muestreo-api-facturas |
1/s | 100 % | 24,000 |
| 110 | muestreo-api-pedidos-mayoristas |
1/s | 100 % | 90,000 |
| 200 | muestreo-api-catalogo |
1/s | 10 % | ~360,000 |
| 300 | muestreo-api-stock |
0/s | 0.05 % | ~62,000 |
| 800 | muestreo-api-salud |
0/s | 0 % | 0 |
| 810 | muestreo-api-docs |
0/s | 0 % | 0 |
| 9000 | muestreo-api-defecto |
1/s | 5 % | ~50,000 |
| Total | ~586,000 |
Cost: (586,000 − 100,000) × 5 / 1,000,000 = 2.43 USD/month in recording, plus retrievals within the free tier. Well below the 25 USD, and with 100 % of invoices and wholesale orders traced.
With the margin left over you can raise /catalogo to 25 % and enable Insights on the API group.
The lesson of the exercise: the reservoir is dangerous on very-high-volume routes, because it a floor of one trace per second, which over a month is 2.6 million. For massive traffic, reservoir 0 and a very low rate; for scarce and valuable traffic, a high reservoir and a 100 % rate.
Proposed annotations for the API:
| Annotation | Reason |
|---|---|
tienda_id |
Each affiliated shop is a customer; you must be able to isolate it |
factura_id / pedido_mayorista_id |
Explicit requirement: individual investigation |
num_lineas |
The same as in the shop: slowness grows with size |
version_api |
v1, v2: comparing versions |
plan_tarifa |
Do premium customers get better latency? |
sku |
No: 40,000 values. It would go as metadata or in the log |
Solution 2
Problem 1 — The Lambda's cold start: 2,980 ms (46 % of the total).
The Initialization subsegment of nearly 3 seconds is a pathological cold start. Normal in Python is
200-600 ms; 2,980 ms points to very heavy imports —typically pandas, numpy, a full SDK— or a
connection established at module scope.
Solutions, in order of cost-benefit:
- Review the
imports and move into thehandlerthose used on only some paths. - Use
boto3.client()at module level (that is fine: it is reused between invocations) but do not open database connections there. - Reduce the size of the deployment package; use layers for the large dependencies.
- If after that it is still high and latency matters: provisioned concurrency (02-05), which removes the cold start in exchange for paying for reserved capacity.
Estimated saving: from 2,980 to ~400 ms. −2,580 ms.
Problem 2 — The N+1, again: 1,305 ms (20 %).
Fourteen SELECT ... FROM productos WHERE id=? in series, of about 93 ms each. The same pattern as
the guided case, on a different route.
Solution: WHERE id = ANY(...) in a single query. Saving: from 1,305 to ~100 ms. −1,205 ms.
Note: 93 ms for a SELECT by primary key is also high. It is worth checking whether there is a
suitable index, whether the connection is being established on every query (no connection pooling), or
whether the RDS instance is saturated. A separate conectar_bd subsegment would clear it up.
Problem 3 — Configuration loaded on every request: 890 ms (14 %).
cargar_configuracion taking 890 ms at the start of every request is configuration read from
disk, from the network or from Parameter Store while hot. It should be loaded once when the process
starts and kept in memory.
Solution: load it at process start and refresh it every N minutes in a background thread. Saving: from 890 to ~1 ms. −889 ms.
Problem 4 — Secrets Manager on the critical path: 310 ms (5 %).
GetSecretValue on every request. As we saw in 04-03, the secret rotates every 30 days: there is no
reason whatsoever to read it on every purchase. Cache it with a TTL of 5-15 minutes and retry on
receiving an authentication error.
Saving: from 310 to ~0 ms on 99.9 % of requests. −310 ms.
Problem 5 — The call to the Lambda is synchronous and blocking.
The 3,780 ms of the Invoke are entirely on the customer's critical path. But do you really need to
wait for the order status to be computed before answering "confirmed"? Almost certainly not: it is a
good candidate for asynchronous invocation or for a queue (07-01).
Potential saving: the whole 3,780 ms disappear from what the customer perceives.
Summary ordered by impact:
| # | Problem | Saving |
|---|---|---|
| 1 | Lambda cold start | −2,580 ms |
| 2 | N+1 on products | −1,205 ms |
| 3 | Configuration on every request | −889 ms |
| 4 | Uncached secret | −310 ms |
| 5 | Unnecessary synchronous invocation | −3,780 ms (architectural) |
Resulting latency applying 1-4: 6,450 − 4,984 = ~1,470 ms. Applying 5 as well: ~430 ms. From 6.45 s to less than half a second.
Instrumentation to detect it automatically in the future:
| What to add | Detects |
|---|---|
A conectar_bd subsegment separate from the query |
The lack of connection pooling |
A num_consultas_sql annotation per request |
The N+1, with a filter annotation.num_consultas_sql > 10 |
A cache_config_hit annotation (boolean) |
Configuration improperly reloaded |
An arranque_frio annotation in the Lambda |
Traces affected by a cold start |
An alarm on the Lambda's Duration p99 |
The cold-start regression |
| X-Ray Insights on the group | Unforeseen changes in behaviour |
The num_consultas_sql annotation deserves a comment: it is a derived annotation, computed by
the application itself by counting the queries of the request. With it, a filter
annotation.num_consultas_sql > 10 finds every N+1 in the system, present and future, without
knowing in advance where they are. It is the kind of annotation that separates thought-through
instrumentation from copied instrumentation.
Solution 3
Why X-Ray did not detect it, and it is important to understand this properly.
X-Ray traces what is instrumented. The invoicing process is not, so as far as X-Ray is concerned
it does not exist. The shop's traces end at SNS Publish with status 200 —the message was
published correctly— and everything looks green. The failure is after the last instrumented point,
and that is the structural blind spot of any tracing system.
And there is a second, subtler reason: even if you instrumented the invoicer, the pattern is asynchronous. The shop publishes and forgets. The shop's trace ends at publication; the invoicer's would be a different trace, triggered by the message. X-Ray relates them if the SDK propagates the header through SNS (it does, in the message attributes), but the absence of a trace generates no signal at all. Nobody is counting how many messages should have been processed.
What MercadoFresco's observability is missing: it has no check that the two halves of an asynchronous process add up. It is the classic gap of event-driven architectures, and it is solved with reconciliation, not with traces.
Diagnosis, step by step:
1. Quantify it and bound it in time. A direct query against the database to find out how many and when:
SELECT date_trunc('hour', p.confirmado_en) AS hour,
count(*) FILTER (WHERE f.id IS NULL) AS without_invoice,
count(*) AS total
FROM pedidos p
LEFT JOIN facturas f ON f.pedido_id = p.id
WHERE p.confirmado_en > now() - interval '7 days'
GROUP BY 1 ORDER BY 1;If the failures cluster in specific hours, there is a one-off cause; if they are spread out uniformly at 3 %, there is a probabilistic failure —an exhausted timeout, a race condition, a concurrency limit—.
2. Check the SNS link. The SNS metrics in CloudWatch say whether the message went out and whether delivery failed:
aws cloudwatch get-metric-statistics \
--namespace AWS/SNS --metric-name NumberOfNotificationsFailed \
--dimensions Name=TopicName,Value=alertas-mercadofresco \
--start-time $(date -d '7 days ago' -u +%FT%TZ) \
--end-time $(date -u +%FT%TZ) \
--period 3600 --statistics Sum \
--profile mercadofresco-dev --region eu-west-1If NumberOfNotificationsFailed is zero, the message reached the invoicer and the problem is inside
it. If it is not zero, the problem is delivery and you have to look at the SNS retry policy (07-02)
and whether there is a dead-letter queue.
3. Look at the invoicer's logs with Logs Insights. This is where 05-01 does the work:
fields @timestamp, @message, pedido_id, error | filter ispresent(pedido_id) | stats count() as eventos by pedido_id | filter eventos < 2 | limit 50
That is: orders that entered the invoicer but never got as far as writing the completion line. And the definitive cross-check, looking for one specific order with no invoice across all the groups at once:
aws logs start-query \
--log-group-names /mercadofresco/tienda/aplicacion \
/mercadofresco/facturacion/aplicacion \
--start-time $(date -d '3 days ago' +%s) --end-time $(date +%s) \
--query-string 'fields @timestamp, @log, @message
| filter @message like /48213/
| sort @timestamp asc' \
--profile mercadofresco-dev --region eu-west-1If the order appears in the shop and never appears in invoicing, the message was lost. If it appears and then cuts off halfway, the process died: memory, an exhausted timeout, an uncaught exception.
Probable diagnosis —and the most frequent in this scenario—: the invoicer takes longer than its timeout allows when generating the PDF for large orders, dies, and SNS does not retry indefinitely. With no dead-letter queue, the message disappears silently. It is a stable 3 %: the largest orders.
What to leave in place, in four layers:
a) Instrument the invoicer with X-Ray. It is the first and most obvious thing:
from aws_xray_sdk.core import xray_recorder, patch_all
xray_recorder.configure(service="mercadofresco-facturacion",
context_missing="LOG_ERROR")
patch_all()With pedido_id and factura_id annotations. From then on, an order's trace includes its invoicing
and the filter service("mercadofresco-facturacion") { fault } finds the failures.
b) A dead-letter queue. Without one there is no way to know what was lost. MercadoFresco already
has the pattern in place with mercadofresco-miniaturas-fallidas (02-05); here the equivalent is
needed, and with an alarm on ApproximateNumberOfMessagesVisible > 0. The full pattern —queues,
retries, idempotency— is lesson 07-05.
c) The reconciliation metric, which is the underlying solution. A process that every 15 minutes compares confirmed orders with issued invoices and publishes the difference:
"""Reconciliation: publishes how many orders have gone 30 minutes without an invoice."""
import boto3
cw = boto3.client("cloudwatch", region_name="eu-west-1")
pending = count_orders_without_invoice(age_minutes=30)
cw.put_metric_data(
Namespace="MercadoFresco/Tienda",
MetricData=[{
"MetricName": "PedidosSinFactura",
"Dimensions": [{"Name": "Entorno", "Value": "produccion"}],
"Value": pending,
"Unit": "Count",
}],
)And its alarm:
aws cloudwatch put-metric-alarm \
--alarm-name mercadofresco-pedidos-sin-factura \
--alarm-description "There are confirmed orders with no invoice after 30 minutes" \
--namespace MercadoFresco/Tienda --metric-name PedidosSinFactura \
--dimensions Name=Entorno,Value=produccion \
--statistic Maximum --period 900 --evaluation-periods 2 --datapoints-to-alarm 2 \
--threshold 5 --comparison-operator GreaterThanThreshold \
--treat-missing-data breaching \
--alarm-actions arn:aws:sns:eu-west-1:111122223333:alertas-mercadofresco \
--profile mercadofresco-dev --region eu-west-1With --treat-missing-data breaching: if the reconciliation process itself stops running, that is an
incident too.
d) The general rule to extract. In an asynchronous system, traces do not detect what did not happen. They only detect what happened badly. For what did not happen you need a reconciliation metric comparing the two halves of the process: orders against invoices, messages sent against messages processed, files uploaded against thumbnails generated.
It is exactly the same reasoning that in 05-01 led to putting --treat-missing-data breaching on
mercadofresco-sin-pedidos: the absence of a signal is a signal, but only if somebody is
counting it.
Conclusion
MercadoFresco has gone from "something is slow" to "this specific call takes 6.2 seconds, and 5.9 of them are in one query". You know why metrics and logs are not enough: metrics aggregate and lose the individual case, logs keep the case but not the causal relationship between what happened in the shop and what happened in the Lambda. Only a trace stores the complete structure of a request.
You have mastered the model: trace, segment per service and subsegment per leg, with
error (yellow, 4xx), fault (red, 5xx) and throttle (purple). You know the trace ID with its
embedded timestamp —and why that implies the 30 days of retention— and the X-Amzn-Trace-Id
header with its Root, Parent and Sampled fields, along with the rule that misleads most: the
sampling decision is taken once at the origin and everybody respects it, so "I have enabled X-Ray
on the Lambda and I see nothing" almost always means it arrived with Sampled=0.
You know how to configure sampling with its reservoir and its fixed rate, and why that
combination traces almost everything when there is little traffic and a stable percentage when there
is a lot. You have set up MercadoFresco's rules: 100 % of /api/pedidos/*, 0 % of /salud and of
the static files, 5 % by default. And you have learnt that the reservoir is treacherous on
very-high-volume routes, where 1/s is 2.6 million traces a month.
You are clear on the distinction that decides whether you will find anything: indexed, filterable
annotations (50 maximum, free cardinality) versus metadata that is only visible when you open
the trace. And you know that pedido_id as an X-Ray annotation is free and searchable, whereas as a
metric dimension in CloudWatch it would cost 190,000 USD a month: it is exactly the gap X-Ray covers.
With the corresponding privacy warning: cliente_hash, never the email address.
You have instrumented the shop with aws_xray_sdk —xray_recorder.configure with
context_missing="LOG_ERROR" so that observability never brings the application down, patch_all()
which instruments boto3 and psycopg2 so that every query generates its subsegment, and the
EC2Plugin that adds the instance and the AZ—, you have created manual subsegments with
in_subsegment, and you know that in threads the context has to be passed by hand. You have deployed
the daemon on the ASG instances, understanding why it exists: the SDK writes local UDP and never
waits for the network. And you have enabled tracing on mercadofresco-estado-pedido with a policy
and Mode=Active, knowing that in Lambda annotations go in a subsegment because the root segment
is read-only, and that the Initialization subsegment is the cold start we could only guess at in 02-05.
You know how to read the service map —size, colours, client and resource nodes, and the three
patterns you recognise at a glance—, and how to search with the filter language: service(),
fault, responsetime > 5, edge(), annotation.pedido_id = "48213". And you know how to read a
bimodal histogram, which is where you really see the two populations of requests that a
percentile summarises into a single number.
And you have closed the case we had been dragging along since module 4. Order 48213: a single
search by annotation, the waterfall, and there it was —39 consecutive subsegments of 190 ms, 91 %
of the total time, an N+1—. Confirmed as systematic with annotation.num_lineas > 25 AND responsetime > 4, quantified at 5,610 affected orders in 30 days, corrected with
WHERE id = ANY(...), and verified: p95 from 6.8 s to 0.44 s, and as a bonus DatabaseConnections
from 185 to 96 at Friday's peak. Compare that with the four queries and the spreadsheet of 05-01.
You know X-Ray Insights with its notifications via EventBridge, ServiceLens as the view that joins metric → map → trace → subsegment → log, and OpenTelemetry with ADOT as the open standard AWS recommends today for new projects, with MercadoFresco's decision written down and given a review date instead of hidden away. And you know that all of this costs about 10.60 USD a month thanks to a sampling strategy that traces better, not more, against the 300 USD of tracing everything.
One question from module 4 remains unanswered, and it is the one neither metrics nor traces can
answer. CloudWatch knows what the application says about itself. X-Ray knows where a request went.
Neither of the two knows who called the AWS API. Nobody yet knows who decrypted the last database
backup with alias/mercadofresco-datos, or who read the mercadofresco/produccion/rds/mfadmin
secret, or from which IP address, or whether any of those calls failed with AccessDenied because
somebody was trying doors.
That log exists, it is called AWS CloudTrail and it has been recording everything since day one
without anybody looking at it. In lesson 05-03, "AWS CloudTrail", we will see the essential
difference between logging API calls and logging what your application says, the free 90-day history
versus a persistent trail, how to create trail-mercadofresco towards an encrypted bucket with
integrity validation —and why that bucket must be impossible to delete—, the annotated anatomy of a
real kms:Decrypt event, the cost difference between management events and data events, and how to
investigate with Athena and SQL who assumed a role, who read a secret and which calls failed with
AccessDenied.
AWS Course
Module 1: Introduction to AWS
- What Is AWS?
- Setting Up Your AWS Account
- AWS Global Infrastructure
- The AWS Management Console
- AWS CLI and SDKs
Module 2: Core AWS Services
Module 3: Networking and Content Delivery
Module 4: Security and Identity
- AWS Identity and Access Management (IAM)
- AWS Key Management Service (KMS)
- Secrets Manager and Parameter Store
- AWS Shield
- AWS WAF
Module 5: Monitoring and Management
Module 6: Databases
Module 7: Application Integration
- Amazon SQS
- Amazon SNS
- Amazon EventBridge
- AWS Step Functions
- Integration Patterns: Idempotency, Retries and Dead-Letter Queues
