Módulo 3: Observability As Sli Input
4. Manos a la obra: logs reales con `jq`
Descripción
Errors: 3.0 es todo lo que la lección 3 pudo decir sobre las fallas del batch — un conteo, sin ningún detalle. Esta lección abre esas tres fallas: qué requestId específico corresponde a cada una, y qué mensaje de error real dejó validate_manifest() en cada caso. La consulta a CloudWatch Logs que trae esos datos es, como en la lección anterior, representativa —la razón es idéntica: sin LOCALSTACK_AUTH_TOKEN, LocalStack no arranca en este entorno—. Lo que cambia en esta lección, y es la pieza central: una vez que esa salida (representativa, pero fiel al formato real que CloudWatch Logs produce) se guarda en un archivo, jq la procesa de verdad — el binario corrió, en este mismo entorno, para producir cada bloque "Qué esperar" de la segunda mitad de esta lección.
Conexión con el módulo
La lección 2 prometió que los logs contestan "¿cuáles, específicamente, fueron los eventos malos, y por qué?" — esta lección lo demuestra con las tres invocaciones fallidas del batch de la lección 3, identificadas por requestId, cada una con su mensaje de error real. La lección 5 va a tomar uno de esos tres requestId —el de la posición 17— y seguirlo con una traza completa en Jaeger.
Paso 1 — Consultando los logs reales de las tres fallas
Cada invocación de process-shipment-manifest escribe en /aws/lambda/process-shipment-manifest (el nombre de grupo de logs que aws-core-services-guide, Módulo 6, ya estableció como la convención automática de Lambda: /aws/lambda/<nombre-de-la-función>). Dos líneas por invocación fallida son las que importan aquí: el print(f"Invalid manifest {key}: {errors}") real de lambda_handler justo antes de relanzar la excepción, y la línea REPORT que el runtime de Lambda agrega automáticamente al final de cada invocación, con Status: error\tError Type: Unhandled quand la invocación no capturó su excepción — exactamente el comportamiento que aws-serverless-and-containers-guide ya confirmó para este mismo Lambda.
Un patrón de filtro con dos alternativas (?"..." ?"...", la sintaxis OR de CloudWatch Logs Insights) trae ambas líneas en una sola consulta:
awslocal logs filter-log-events \
--log-group-name /aws/lambda/process-shipment-manifest \
--filter-pattern '?"Invalid manifest" ?"Status: error"' \
--start-time 2026-08-14T14:00:00Z \
--end-time 2026-08-14T15:00:00Z
Qué esperar (representativo — CloudWatch Logs, confirmado en el plan Hobby de LocalStack; guarda esta salida exactamente como observability/manifest-log-events.json, la vas a necesitar en el Paso 2):
{
"events": [
{
"logStreamName": "2026/08/14/[$LATEST]4a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d",
"timestamp": 1786732801005,
"message": "Invalid manifest manifests/year=2026/month=08/batch/05-shipment-4471-manifest.txt: ['missing required field: weightKg']\n",
"ingestionTime": 1786732801512,
"eventId": "38234501234567890123456789012345678901"
},
{
"logStreamName": "2026/08/14/[$LATEST]4a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d",
"timestamp": 1786732801041,
"message": "REPORT RequestId: a47f3e21-8b6a-4c9d-9f12-3d8e7b1a2c44\tDuration: 38.47 ms\tBilled Duration: 39 ms\tMemory Size: 128 MB\tMax Memory Used: 44 MB\tStatus: error\tError Type: Unhandled\n",
"ingestionTime": 1786732801530,
"eventId": "38234501234567890123456789012345678902"
},
{
"logStreamName": "2026/08/14/[$LATEST]4a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d",
"timestamp": 1786732808211,
"message": "Invalid manifest manifests/year=2026/month=08/batch/12-shipment-4472-manifest.txt: ['missing required field: carrier']\n",
"ingestionTime": 1786732808698,
"eventId": "38234501234567890123456789012345678903"
},
{
"logStreamName": "2026/08/14/[$LATEST]4a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d",
"timestamp": 1786732808253,
"message": "REPORT RequestId: f3c91a08-2e4d-4b7f-8a3c-5e9d1f6b8a72\tDuration: 41.02 ms\tBilled Duration: 42 ms\tMemory Size: 128 MB\tMax Memory Used: 45 MB\tStatus: error\tError Type: Unhandled\n",
"ingestionTime": 1786732808710,
"eventId": "38234501234567890123456789012345678904"
},
{
"logStreamName": "2026/08/14/[$LATEST]4a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d",
"timestamp": 1786732814977,
"message": "Invalid manifest manifests/year=2026/month=08/batch/17-shipment-4473-manifest.txt: ['weightKg must be numeric']\n",
"ingestionTime": 1786732815488,
"eventId": "38234501234567890123456789012345678905"
},
{
"logStreamName": "2026/08/14/[$LATEST]4a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d",
"timestamp": 1786732815012,
"message": "REPORT RequestId: c8e42d15-9a3b-4f8e-b6c1-7d2a4e9f3b58\tDuration: 35.88 ms\tBilled Duration: 36 ms\tMemory Size: 128 MB\tMax Memory Used: 44 MB\tStatus: error\tError Type: Unhandled\n",
"ingestionTime": 1786732815499,
"eventId": "38234501234567890123456789012345678906"
}
],
"searchedLogStreams": [
{
"logStreamName": "2026/08/14/[$LATEST]4a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d",
"searchedCompletely": true
}
]
}
Seis eventos, dos por cada una de las tres invocaciones fallidas —la línea de validación que explica el "por qué", y la línea REPORT que trae el requestId—, en el mismo orden en que ocurrieron (posiciones 5, 12, 17 del batch de la lección 3).
Paso 2 — jq, corriendo de verdad, en tres pasos
A partir de aquí, todo corrió: jq no depende de LocalStack, procesa el archivo que acabas de guardar sin ninguna limitación de este entorno.
Paso 2a — Los tres requestId, extraídos de las líneas REPORT con una expresión regular:
jq -r '.events[] | select(.message | contains("Status: error")) | .message | capture("RequestId: (?<requestId>[a-f0-9-]+)") | .requestId' observability/manifest-log-events.json
Qué esperar (literal — corrido con jq en este entorno):
a47f3e21-8b6a-4c9d-9f12-3d8e7b1a2c44
f3c91a08-2e4d-4b7f-8a3c-5e9d1f6b8a72
c8e42d15-9a3b-4f8e-b6c1-7d2a4e9f3b58
Paso 2b — Las tres razones de validación, extraídas de las líneas Invalid manifest:
jq -r '.events[] | select(.message | contains("Invalid manifest")) | .message' observability/manifest-log-events.json
Qué esperar (literal):
Invalid manifest manifests/year=2026/month=08/batch/05-shipment-4471-manifest.txt: ['missing required field: weightKg']
Invalid manifest manifests/year=2026/month=08/batch/12-shipment-4472-manifest.txt: ['missing required field: carrier']
Invalid manifest manifests/year=2026/month=08/batch/17-shipment-4473-manifest.txt: ['weightKg must be numeric']
(Las líneas en blanco son el propio salto de línea que print() deja al final de cada mensaje — un detalle real del formato de log, no un error de este comando.)
Paso 2c — Combinando ambas en un solo objeto por invocación fallida:
jq '
(.events | map(select(.message | contains("Invalid manifest"))) | map(.message | rtrimstr("\n"))) as $reasons
| (.events | map(select(.message | contains("Status: error"))) | map(.message | capture("RequestId: (?<id>[a-f0-9-]+)").id)) as $ids
| [range(0; $ids | length) | {requestId: $ids[.], reason: $reasons[.]}]
' observability/manifest-log-events.json
Qué esperar (literal):
[
{
"requestId": "a47f3e21-8b6a-4c9d-9f12-3d8e7b1a2c44",
"reason": "Invalid manifest manifests/year=2026/month=08/batch/05-shipment-4471-manifest.txt: ['missing required field: weightKg']"
},
{
"requestId": "f3c91a08-2e4d-4b7f-8a3c-5e9d1f6b8a72",
"reason": "Invalid manifest manifests/year=2026/month=08/batch/12-shipment-4472-manifest.txt: ['missing required field: carrier']"
},
{
"requestId": "c8e42d15-9a3b-4f8e-b6c1-7d2a4e9f3b58",
"reason": "Invalid manifest manifests/year=2026/month=08/batch/17-shipment-4473-manifest.txt: ['weightKg must be numeric']"
}
]
Este es, literalmente, el detalle que Errors: 3.0 no podía dar: tres requestId distintos, tres razones distintas, cada una trazable hasta la línea exacta de validate_manifest() que la produjo. Guarda el requestId c8e42d15-9a3b-4f8e-b6c1-7d2a4e9f3b58 —la posición 17— para la lección 5, que lo sigue con una traza completa.
Errores comunes
Confundir "representativo" (la consulta a CloudWatch) con "representativo" (el resultado de jq) — son dos cosas distintas en esta lección. Qué pasa: alguien, al leer que el Paso 1 es representativo, asume que el Paso 2 también lo es. Cómo detectarlo: si tu explicación de esta lección dice "todo esto es representativo" sin distinguir el paso 1 del paso 2. Cómo corregirlo: el Paso 1 (la consulta a CloudWatch) es representativo porque depende de LocalStack, que no arranca en este entorno. El Paso 2 (jq sobre el archivo ya guardado) es completamente real —jq no depende de ningún servicio de AWS, corrió de verdad, y el archivo manifest-log-events.json que produce el Paso 1 es el único punto de conexión entre ambos.
Escribir una expresión jq distinta y esperar el mismo resultado exacto de esta lección (de no entender qué hace capture). Qué pasa: alguien escribe una expresión propia para extraer el requestId —por ejemplo, con split(": ") en vez de capture()— y el resultado no coincide con el de esta lección. Cómo detectarlo: si tu salida del Paso 2a tiene texto adicional (como RequestId: incluido) en vez de solo el UUID. Cómo corregirlo: capture("RequestId: (?<requestId>[a-f0-9-]+)") usa una expresión regular con un grupo nombrado (?<requestId>) que extrae solo la parte que coincide con [a-f0-9-]+ —caracteres hexadecimales y guiones— después del texto literal "RequestId: ". Cualquier expresión que no aísle exactamente ese patrón va a incluir texto de más o de menos.
Tratar los tres requestId extraídos como si fueran arbitrarios, sin conexión con la lección 3 (de perder el hilo del batch fijo). Qué pasa: alguien procesa esta lección de forma aislada, sin notar que los tres requestId corresponden exactamente a las posiciones 5, 12 y 17 del batch de la lección anterior. Cómo detectarlo: si no puedes decir, de memoria, qué posición del batch de 20 corresponde a c8e42d15-9a3b-4f8e-b6c1-7d2a4e9f3b58. Cómo corregirlo: la lección 1 de este módulo ya lo advirtió — este módulo corre sobre un único batch fijo, leído con tres instrumentos. El requestId c8e42d15-... no es un dato nuevo; es la misma invocación fallida número 17 de la lección 3, ahora identificada con precisión.
Ejercicios
Ejercicio 1 — Escribe, sin ejecutarlo todavía, un comando jq que cuente cuántas invocaciones fallidas hay en el archivo, sin extraer ningún detalle. Usa el mismo archivo manifest-log-events.json.
Ver solución
jq '[.events[] | select(.message | contains("Status: error"))] | length' observability/manifest-log-events.json
Este comando produce 3 — el mismo número que Errors: 3.0 ya dio en la lección 3, ahora confirmado contando directamente las líneas REPORT con Status: error en los logs, en vez de leer un agregado de CloudWatch. Es una forma útil de verificar, de manera independiente, que la métrica y el log cuentan la misma historia.
Ejercicio 2 — Modifica el comando del Paso 2b para que, en vez de imprimir el mensaje completo, extraiga solo la lista de errores entre corchetes (por ejemplo, ['missing required field: weightKg']). Pista: usa capture() con una expresión regular que capture todo lo que está entre [ y ].
Ver solución
jq -r '.events[] | select(.message | contains("Invalid manifest")) | .message | capture("(?<errors>\\[.*\\])") | .errors' observability/manifest-log-events.json
Con el archivo de esta lección, produce:
['missing required field: weightKg']
['missing required field: carrier']
['weightKg must be numeric']
El patrón \\[.*\\] (los corchetes escapados con \\, porque son caracteres especiales en una expresión regular) captura todo lo que hay entre el primer [ y el último ] de la línea — exactamente la representación en texto de la lista Python errors que validate_manifest() construyó.
Ejercicio 3 — Explica por qué el Paso 2c usa range(0; $ids | length) en vez de, por ejemplo, zip($ids; $reasons) (si tu versión de jq la tiene) o cualquier otra forma de combinar dos listas. ¿Qué garantiza que $ids[.] y $reasons[.] correspondan siempre a la misma invocación, en el mismo índice?
Ver solución
La garantía no viene de range() en sí —viene de que ambas listas, $ids y $reasons, se construyen filtrando el mismo arreglo .events en el mismo orden en que aparece en el archivo, y ese archivo, a su vez, preserva el orden cronológico real en que CloudWatch Logs recibió cada línea (posición 5 antes que 12, antes que 17). Como cada invocación fallida escribió exactamente una línea Invalid manifest seguida, poco después, de exactamente una línea REPORT con Status: error, filtrar cada tipo de línea por separado preserva el mismo orden relativo en ambas listas resultantes — el elemento en el índice 0 de $ids corresponde, por construcción, al elemento en el índice 0 de $reasons. Esta correspondencia por posición es frágil en general (se rompería si alguna invocación fallara sin dejar ambas líneas, o si el orden cronológico no se preservara) — una razón más para no tratar este truco como una técnica universal de jq, sino como algo válido específicamente para la estructura de este archivo.
Resumen y siguiente paso
Esta lección abrió las tres fallas que la lección 3 solo pudo contar: con una consulta representativa a CloudWatch Logs (misma razón de siempre: sin LOCALSTACK_AUTH_TOKEN, LocalStack no arranca aquí) guardaste el detalle real de las seis líneas relevantes, y con jq —corrido de verdad, en tres pasos progresivos— extrajiste los tres requestId, las tres razones de validación, y los combinaste en un solo objeto por invocación fallida. Confirmaste, además, que el conteo de líneas con error (3) coincide exactamente con el Errors: 3.0 de la lección anterior — la métrica y el log cuentan la misma historia, con distinto nivel de detalle.
Antes de avanzar deberías poder: explicar la diferencia entre lo representativo (el Paso 1) y lo real (el Paso 2) de esta lección; recitar los tres requestId extraídos, con su razón de fallo correspondiente; y modificar una expresión capture() simple para extraer un fragmento distinto de un mensaje de log.
La lección 5 toma el requestId de la posición 17 —c8e42d15-9a3b-4f8e-b6c1-7d2a4e9f3b58— y lo sigue con una traza real de OpenTelemetry, mostrando en qué paso exacto del flujo upload → Lambda → DynamoDB esa invocación se cortó.
Recursos
- jq Manual —
capture— la función de esta lección para extraer datos con expresiones regulares con nombre. - jq Manual —
select— el filtro que separa las líneasREPORTde las líneasInvalid manifesten esta lección. - AWS CLI —
logs filter-log-eventsCommand Reference — la referencia oficial del comando representativo del Paso 1, incluida la sintaxis de patrones?"..." ?"...". - LocalStack Docs — CloudWatch Logs — confirmación del plan Hobby para el servicio de logs.
- Este mismo repositorio, Módulo 3, lección 3 (
03-hands-on-real-metrics-from-the-inherited-lambda.md) — el batch fijo cuyas tres fallas esta lección abre en detalle.