Módulo 3: Observability As Sli Input

3. Manos a la obra: métricas reales del Lambda heredado

Descripción

Esta lección construye el "tablero de puntualidad" de la lección 2: un batch fijo y determinista de 20 manifiestos, subidos de verdad a andes-cargo-shipment-docs a través del disparador real de S3 —nunca con awslocal lambda invoke, la vía manual que SLO.md excluye explícitamente del SLI—, y leído después como dos números agregados con awslocal cloudwatch get-metric-statistics sobre AWS/Lambda/Invocations y AWS/Lambda/Errors. Tres de los 20 manifiestos están rotos a propósito, en posiciones fijas, cada uno violando exactamente una de las reglas que validate_manifest() —la función real, heredada de aws-serverless-and-containers-guide— ya comprueba. Nunca random.

Esta lección tiene una honestidad que necesitas leer antes del primer bloque de código: en este entorno específico de escritura, sin LOCALSTACK_AUTH_TOKEN exportado, el contenedor de LocalStack no arranca, así que ni la subida real ni la consulta de CloudWatch corrieron contra un LocalStack en vivo. La documentación oficial confirma que CloudWatch —logs, métricas y alarmas— sí está en el plan Hobby de LocalStack, con integración nativa de las dos métricas de Lambda que esta lección necesita. Todo lo que vas a leer en los bloques "Qué esperar" de esta lección está reconstruido campo por campo a partir de ese comportamiento confirmado y del código real de process-shipment-manifest —nunca inventado—. Si corres esta lección con tu propio LOCALSTACK_AUTH_TOKEN, el resultado debería ser exactamente este.

Conexión con el módulo

La lección 2 prometió que las métricas contestan "¿cuántos eventos válidos hubo, y cuántos fueron buenos?" — el numerador y el denominador exactos de compute_sli(). Esta lección produce esos dos números por primera vez con datos que no son un dataset committeado a mano. Las lecciones 4 y 5 van a leer este mismo batch —el mismo Errors: 3.0 de esta lección— con logs y trazas, respectivamente.


Paso 1 — El batch fijo: 20 manifiestos, 3 rotos a propósito

process-shipment-manifest valida cinco campos obligatorios (shipmentId, originCountry, destinationCountry, carrier, weightKg) y que weightKg sea un número positivo —la lógica real de validate_manifest(), heredada sin cambios de aws-serverless-and-containers-guide, Módulo 2—. Este batch usa exactamente esas reglas para fallar tres veces, cada vez por una razón distinta:

#EstadoEnvíoQué le falta o qué tiene malError que validate_manifest() produce
5roto4471weightKg omitido por completomissing required field: weightKg
12roto4472carrier omitido por completomissing required field: carrier
17roto4473weightKg=heavy (no numérico)weightKg must be numeric

Las 17 restantes son manifiestos completos y válidos, cíclicos entre los tres envíos ya conocidos —4471 (Perú→Chile, AndesExpress, 120 kg), 4472 (Colombia→Ecuador, AndesExpress, 85 kg), 4473 (Chile→Perú, RutaSur, 200 kg)—.


Paso 2 — El script completo

Crea observability/upload_manifest_batch.py en andes-cargo-infra/:

# upload_manifest_batch.py
# Uploads a FIXED sequence of 20 shipment manifests to andes-cargo-shipment-docs, one at a
# time, through `awslocal s3api put-object` -- the real trigger path for
# process-shipment-manifest (S3 ObjectCreated -> Lambda), never a manual `lambda invoke`.
# SLO.md excludes manual test invocations from the SLI denominator; every one of these 20
# uploads is meant to count as real traffic through the real path.
#
# 17 well-formed manifests (shipments 4471/4472/4473, cycling); 3 malformed on purpose, at
# fixed positions 5, 12 and 17 -- each missing or corrupting exactly the field that
# validate_manifest() (aws-serverless-and-containers-guide, Module 2) already checks for.
# No random, no datetime.now() -- same 20 files, same order, every run.

import subprocess
from pathlib import Path

BUCKET = "andes-cargo-shipment-docs"
KEY_PREFIX = "manifests/year=2026/month=08/batch"
OUT_DIR = Path("manifests-batch")

SHIPMENTS = {
    "4471": {"originCountry": "Peru", "destinationCountry": "Chile", "carrier": "AndesExpress", "weightKg": "120"},
    "4472": {"originCountry": "Colombia", "destinationCountry": "Ecuador", "carrier": "AndesExpress", "weightKg": "85"},
    "4473": {"originCountry": "Chile", "destinationCountry": "Peru", "carrier": "RutaSur", "weightKg": "200"},
}

GOOD_CYCLE = ["4471", "4472", "4473"]

# index -> (shipmentId, field_to_break, broken_value_or_None)
MALFORMED = {
    5: ("4471", "weightKg", None),      # omitted entirely -> "missing required field: weightKg"
    12: ("4472", "carrier", None),      # omitted entirely -> "missing required field: carrier"
    17: ("4473", "weightKg", "heavy"),  # present but not numeric -> "weightKg must be numeric"
}


def manifest_lines(shipment_id, skip_field=None, override_field=None, override_value=None):
    fields = {"shipmentId": shipment_id, **SHIPMENTS[shipment_id]}
    if override_field:
        fields[override_field] = override_value
    if skip_field:
        fields.pop(skip_field, None)
    return "\n".join(f"{k}={v}" for k, v in fields.items()) + "\n"


def build_batch():
    batch = []
    good_index = 0
    for i in range(1, 21):
        if i in MALFORMED:
            shipment_id, field, broken_value = MALFORMED[i]
            if broken_value is None:
                text = manifest_lines(shipment_id, skip_field=field)
            else:
                text = manifest_lines(shipment_id, override_field=field, override_value=broken_value)
            batch.append((i, "bad", shipment_id, text))
        else:
            shipment_id = GOOD_CYCLE[good_index % 3]
            good_index += 1
            text = manifest_lines(shipment_id)
            batch.append((i, "good", shipment_id, text))
    return batch


def upload_one(index, outcome, shipment_id, text):
    OUT_DIR.mkdir(parents=True, exist_ok=True)
    filename = f"{index:02d}-shipment-{shipment_id}-manifest.txt"
    filepath = OUT_DIR / filename
    filepath.write_text(text)
    key = f"{KEY_PREFIX}/{filename}"
    subprocess.run(
        ["awslocal", "s3api", "put-object", "--bucket", BUCKET, "--key", key, "--body", str(filepath)],
        check=True,
    )
    return key


if __name__ == "__main__":
    for index, outcome, shipment_id, text in build_batch():
        key = upload_one(index, outcome, shipment_id, text)
        print(f"[{index:02d}/20] {outcome:>4} shipment={shipment_id} -> s3://{BUCKET}/{key}")

manifest_lines() construye el archivo de texto real que parse_manifest() espera —líneas clave=valor, nunca JSON—; skip_field simula un campo omitido; override_field/override_value simulan un campo presente pero con un valor inválido. build_batch() es la secuencia fija de 20 posiciones, con las tres rotas siempre en 5, 12 y 17, sin importar cuántas veces corras el script.


Paso 3 — Corriendo la subida

python3 observability/upload_manifest_batch.py

Qué esperar (representativo — el patrón exacto de s3api put-object, cada subida dispara process-shipment-manifest de forma asíncrona vía el trigger de S3, tal como aws-core-services-guide Módulo 6 ya confirmó corriendo de verdad):

[01/20] good shipment=4471 -> s3://andes-cargo-shipment-docs/manifests/year=2026/month=08/batch/01-shipment-4471-manifest.txt
[02/20] good shipment=4472 -> s3://andes-cargo-shipment-docs/manifests/year=2026/month=08/batch/02-shipment-4472-manifest.txt
[03/20] good shipment=4473 -> s3://andes-cargo-shipment-docs/manifests/year=2026/month=08/batch/03-shipment-4473-manifest.txt
[04/20] good shipment=4471 -> s3://andes-cargo-shipment-docs/manifests/year=2026/month=08/batch/04-shipment-4471-manifest.txt
[05/20]  bad shipment=4471 -> s3://andes-cargo-shipment-docs/manifests/year=2026/month=08/batch/05-shipment-4471-manifest.txt
[06/20] good shipment=4472 -> s3://andes-cargo-shipment-docs/manifests/year=2026/month=08/batch/06-shipment-4472-manifest.txt
[07/20] good shipment=4473 -> s3://andes-cargo-shipment-docs/manifests/year=2026/month=08/batch/07-shipment-4473-manifest.txt
[08/20] good shipment=4471 -> s3://andes-cargo-shipment-docs/manifests/year=2026/month=08/batch/08-shipment-4471-manifest.txt
[09/20] good shipment=4472 -> s3://andes-cargo-shipment-docs/manifests/year=2026/month=08/batch/09-shipment-4472-manifest.txt
[10/20] good shipment=4473 -> s3://andes-cargo-shipment-docs/manifests/year=2026/month=08/batch/10-shipment-4473-manifest.txt
[11/20] good shipment=4471 -> s3://andes-cargo-shipment-docs/manifests/year=2026/month=08/batch/11-shipment-4471-manifest.txt
[12/20]  bad shipment=4472 -> s3://andes-cargo-shipment-docs/manifests/year=2026/month=08/batch/12-shipment-4472-manifest.txt
[13/20] good shipment=4472 -> s3://andes-cargo-shipment-docs/manifests/year=2026/month=08/batch/13-shipment-4472-manifest.txt
[14/20] good shipment=4473 -> s3://andes-cargo-shipment-docs/manifests/year=2026/month=08/batch/14-shipment-4473-manifest.txt
[15/20] good shipment=4471 -> s3://andes-cargo-shipment-docs/manifests/year=2026/month=08/batch/15-shipment-4471-manifest.txt
[16/20] good shipment=4472 -> s3://andes-cargo-shipment-docs/manifests/year=2026/month=08/batch/16-shipment-4472-manifest.txt
[17/20]  bad shipment=4473 -> s3://andes-cargo-shipment-docs/manifests/year=2026/month=08/batch/17-shipment-4473-manifest.txt
[18/20] good shipment=4473 -> s3://andes-cargo-shipment-docs/manifests/year=2026/month=08/batch/18-shipment-4473-manifest.txt
[19/20] good shipment=4471 -> s3://andes-cargo-shipment-docs/manifests/year=2026/month=08/batch/19-shipment-4471-manifest.txt
[20/20] good shipment=4472 -> s3://andes-cargo-shipment-docs/manifests/year=2026/month=08/batch/20-shipment-4472-manifest.txt

Veinte líneas, veinte objetos nuevos en andes-cargo-shipment-docs, veinte invocaciones asíncronas disparadas del Lambda —tres de las cuales van a terminar en FunctionError: "Unhandled", exactamente como el precedente ya confirmado de aws-serverless-and-containers-guide documenta para una ValueError sin capturar.


Paso 4 — Leyendo el conteo agregado con CloudWatch

awslocal cloudwatch get-metric-statistics \
  --namespace AWS/Lambda \
  --metric-name Invocations \
  --dimensions Name=FunctionName,Value=process-shipment-manifest \
  --start-time 2026-08-14T14:00:00Z \
  --end-time 2026-08-14T15:00:00Z \
  --period 3600 \
  --statistics Sum

Qué esperar (representativo — CloudWatch, métricas de Lambda, confirmado en el plan Hobby de LocalStack):

{
    "Label": "Invocations",
    "Datapoints": [
        {
            "Timestamp": "2026-08-14T14:00:00+00:00",
            "Sum": 20.0,
            "Unit": "Count"
        }
    ]
}
awslocal cloudwatch get-metric-statistics \
  --namespace AWS/Lambda \
  --metric-name Errors \
  --dimensions Name=FunctionName,Value=process-shipment-manifest \
  --start-time 2026-08-14T14:00:00Z \
  --end-time 2026-08-14T15:00:00Z \
  --period 3600 \
  --statistics Sum

Qué esperar (representativo, misma razón):

{
    "Label": "Errors",
    "Datapoints": [
        {
            "Timestamp": "2026-08-14T14:00:00+00:00",
            "Sum": 3.0,
            "Unit": "Count"
        }
    ]
}

Dos números, exactamente los que la tabla de la lección 2 prometió: total_valid = 20 (todas las invocaciones disparadas por el trigger real de S3, ninguna manual), total_good = 20 - 3 = 17. La lección 7 de este módulo va a alimentar compute_sli() con exactamente estos dos números.


Errores comunes

Usar awslocal lambda invoke para generar el batch, en vez de subir archivos reales a S3 (de romper la definición del SLI sin darte cuenta). Qué pasa: alguien, por comodidad, invoca el Lambda directamente con un payload de prueba en vez de subir manifiestos reales al bucket. Cómo detectarlo: si tu comando usa lambda invoke con un --payload file://... en vez de s3api put-object. Cómo corregirlo: SLO.md, en su sección SLI, excluye explícitamente "Manual test invocations (console/CLI, synthetic payloads used only to verify a deployment)" del denominador —no porque el resultado técnico sea distinto, sino porque esas invocaciones no representan tráfico real de clientes—. Esta lección sube archivos al bucket real, disparando el trigger real de S3, precisamente para que las 20 invocaciones cuenten como tráfico legítimo bajo la definición ya escrita en SLO.md.

Interpretar Errors: 3.0 como si fueran tres bugs del mismo tipo (de no distinguir métricas de logs). Qué pasa: alguien ve el número 3 y asume que las tres invocaciones fallaron de la misma forma. Cómo detectarlo: si tu explicación del error usa la palabra "el" en singular ("el error que ocurrió tres veces") en vez de plural. Cómo corregirlo: la tabla del Paso 1 ya muestra tres razones distintas —campo faltante, otro campo faltante, valor no numérico—; Errors: 3.0 es un conteo agregado, exactamente la limitación que la lección 2 nombró para este pilar. La lección 4 de este módulo va a confirmar, con logs reales, que son tres mensajes de error distintos.

Asumir que 20 invocaciones son suficientes para medir el SLI mensual de SLO.md (de confundir un batch de verificación con tráfico de producción real). Qué pasa: alguien, al ver Invocations: 20 y Errors: 3, calcula un SLI de 85% y lo compara directamente contra el 99,9% de SLO.md, concluyendo que el sistema está gravemente fuera de su objetivo. Cómo detectarlo: si tu conclusión de esta lección es "Andes Cargo está muy por debajo de su SLO" sin ninguna mención al tamaño de la muestra. Cómo corregirlo: 20 invocaciones en una ventana de minutos, con 3 fallas inyectadas a propósito para verificar que el pipeline de observabilidad las detecta, no son una muestra representativa de un mes completo de tráfico real —la lección 7 de este módulo trata esta distinción con el cuidado que merece, mostrando qué pasa si tratas este batch como si fuera el mes completo, y qué pasa si lo tratas correctamente, como un día real más dentro de los 30 del Módulo 2.


Ejercicios

Ejercicio 1 — Calcula, sin ejecutar nada, qué habría mostrado Errors si solo la posición 17 hubiera estado rota (las posiciones 5 y 12 corregidas). ¿Y Invocations?

Ver solución

Invocations seguiría siendo 20.0 —el conteo de invocaciones no depende de si tuvieron éxito o no, cuenta toda invocación disparada—. Errors bajaría a 1.0 —solo la posición 17 (weightKg=heavy) seguiría fallando—. Esto confirma algo importante sobre la métrica Invocations: mide tráfico, no éxito; el éxito o fracaso vive exclusivamente en Errors, una métrica separada, tal como la tabla de las cuatro señales doradas del Módulo 2 ya distinguió tráfico de errores como dos señales distintas.

Ejercicio 2 — Explica por qué la posición 5 (falta weightKg) y la posición 17 (weightKg=heavy) producen el mismo tipo de excepción (ValueError) pero mensajes de error distintos. Usa el código real de validate_manifest().

Ver solución

validate_manifest() primero comprueba que cada campo de REQUIRED_FIELDS esté presente —si weightKg falta por completo (posición 5), el error es "missing required field: weightKg"—. Solo si el campo está presente, la función intenta convertirlo con float(fields["weightKg"]) dentro de un bloque try/except: si esa conversión falla (posición 17, "heavy" no es convertible a número), el error es "weightKg must be numeric". Ambos casos terminan en la misma ValueError final —lambda_handler siempre relanza con raise ValueError(f"manifest validation failed for {key}: {errors}")—, pero la lista errors que ese mensaje incluye es distinta en cada caso, porque proviene de una rama distinta de la validación.

Ejercicio 3 — Diseña una cuarta posición rota, sin escribir código todavía. ¿Qué posición del batch (entre 1 y 20, distinta de 5, 12 y 17) elegirías, y qué error de validate_manifest() provocarías, si quisieras probar específicamente la regla de weightKg no positivo (por ejemplo, weightKg=-5)?

Ver solución

Cualquier posición nueva serviría en términos de mecánica —por ejemplo, la posición 9—, siempre que se agregue a MALFORMED con una tupla como ("4472", "weightKg", "-5"). El error que produciría: validate_manifest() sí logra convertir "-5" con float() sin excepción, pero la comprobación siguiente (if weight <= 0) sí dispara, agregando "weightKg must be a positive number" a la lista de errores —el tercer y último tipo de error que esta función puede producir, distinto tanto de "campo faltante" como de "no numérico", y que el batch de esta lección, con toda intención, no cubre todavía. Este ejercicio muestra que las tres fallas de este batch no agotan todas las formas de romper un manifiesto —cubren tres, de un total de cuatro posibles en validate_manifest().


Resumen y siguiente paso

Esta lección construyó y "corrió" —representativo, con la razón técnica exacta declarada— el primer pilar de observabilidad de este módulo: un batch fijo de 20 manifiestos, subidos a andes-cargo-shipment-docs a través del trigger real de S3 (nunca lambda invoke manual, la exclusión explícita de SLO.md), con 3 rotos a propósito en posiciones fijas, cada uno violando una regla distinta de validate_manifest(), la función real heredada de aws-serverless-and-containers-guide. Leíste el resultado agregado con awslocal cloudwatch get-metric-statistics: Invocations: 20.0, Errors: 3.0 — los dos números exactos que compute_sli() necesita.

Antes de avanzar deberías poder: explicar por qué este batch usa s3api put-object y no lambda invoke; nombrar las tres razones de fallo del batch, con su posición exacta; y explicar por qué 20 invocaciones no son, por sí solas, una muestra representativa del SLI mensual de SLO.md.

La lección 4 toma el mismo Errors: 3.0 y lo abre: qué requestId específico corresponde a cada una de las tres fallas, y qué mensaje de error dejó cada una en los logs reales de /aws/lambda/process-shipment-manifest.

Recursos

  1. LocalStack Docs — CloudWatch — confirmación del plan Hobby y las métricas nativas de Lambda que esta lección lee.
  2. AWS CLI — cloudwatch get-metric-statistics Command Reference — la referencia oficial del comando de esta lección.
  3. aws-serverless-and-containers-guide (NIEVA), Módulo 2, lección 7 — validate_manifest() y el precedente real de awslocal lambda invoke con un payload de S3 sintético, la fuente de la distinción "manual" vs. "real" de esta lección.
  4. Este mismo repositorio, Módulo 2, lección 8 (08-project-andes-cargos-slo-md.md) — SLO.md, sección SLI, la fuente exacta de la exclusión de invocaciones manuales.
  5. aws-core-services-guide (NIEVA), Módulo 6, lección 6 — la confirmación real, corrida en ese entorno, de que una subida a S3 dispara process-shipment-manifest de forma asíncrona.