Módulo 7: Observability Latency And Evals In Production

3. Manos a la obra: CloudWatch Logs y Metrics para el extractor

Descripción

Esta lección construye observability/structured_log.py — un logger estructurado mínimo, sin dependencias, que instrumenta extract-shipment-manifest-fields con líneas JSON en vez del patrón print(f"...") de texto libre que process-shipment-manifest (heredado) ya usa. El logger en sí es código Python puro: corrió de verdad para escribir esta lección, sobre dos invocaciones fijas y deterministas, y cada bloque "Qué esperar" de esa parte es salida literal. Lo que sigue después —leer esos logs con awslocal logs filter-log-events, publicar una métrica personalizada con awslocal cloudwatch put-metric-data— es representativo, por la misma razón exacta que sre-and-incident-response-guide, Módulo 3, lecciones 3 y 4 ya declararon: sin LOCALSTACK_AUTH_TOKEN exportado, el contenedor de LocalStack no arranca en este entorno específico, así que ningún comando awslocal corrió contra un LocalStack en vivo. CloudWatch Logs y Metrics sí están confirmados en el plan Hobby — la limitación es de este entorno de escritura, no del servicio.

Conexión con el módulo

La lección 2 prometió tres SLI; esta lección construye la instrumentación que, en producción, alimentaría los tres — no calcula ningún SLI todavía (eso es la lección 4). La lección 4 va a reusar el mismo vocabulario de eventos que el logger de esta lección define (ManifestParseFailedReceived, GuardrailBlocked, ShipmentWritten), aplicado esta vez sobre un conjunto de prueba mucho más grande y 100% literal.


Analogía: la caja negra de un avión, no una libreta de notas

Un piloto que escribe, a mano, en una libreta, "todo normal, aterrizamos bien" al final de cada vuelo deja un registro — pero uno que ningún sistema automatizado puede leer, comparar entre vuelos, ni sumar en un panel. La caja negra de un avión real registra eventos estructurados: un campo fijo para la altitud, uno para la velocidad, uno para cada alarma que sonó, cada uno en un formato exacto y predecible, sin importar qué vuelo sea. print(f"Invalid manifest {key}: {errors}") —el patrón que process-shipment-manifest ya usa, heredado de aws-serverless-and-containers-guide— es la libreta de notas: legible para un humano, pero frágil para una máquina, porque cualquier cambio de redacción rompe cualquier script que intente extraer datos de ese texto con una expresión regular. structured_log.py, el logger de esta lección, es la caja negra: cada línea es un objeto JSON con un campo event de un vocabulario fijo y cerrado, más los campos que ese evento específico necesita — nunca texto libre que un humano escribió pensando en otro humano.


Paso 1 — El logger estructurado, completo

observability/structured_log.py, en la raíz de andes-cargo-infra/:

#!/usr/bin/env python3
"""structured_log.py -- a small, dependency-free structured logging helper
for extract-shipment-manifest-fields.

Every call to log_event() prints exactly one JSON object per line to stdout
-- the format Lambda's runtime captures verbatim into
/aws/lambda/extract-shipment-manifest-fields, the same CloudWatch Logs group
naming convention aws-core-services-guide already established for
process-shipment-manifest (/aws/lambda/<function-name>).

Why JSON-per-line instead of process-shipment-manifest's plain
print(f"...") pattern (Module 1, lesson 3): a structured line is queryable
with CloudWatch Logs Insights field extraction and with a CloudWatch Logs
metric filter's JSON pattern syntax (Step 3 of this lesson), without
depending on a fragile string match -- exactly the gap Module 7, lesson 4's
escalation-rate script sidesteps entirely by never parsing log text at all
(it computes straight from parse_manifest(), never from a log line).

Never uses random. requestId and every other per-invocation value are
passed in explicitly by the caller -- this module never reads
datetime.now() or any other non-deterministic source itself, so a test can
assert on an exact, fixed log line.
"""

from __future__ import annotations

import json
import sys


def log_event(event: str, **fields) -> None:
    """Print one structured JSON log line to stdout. `event` is a short,
    fixed name (e.g. "ManifestParseFailedReceived", "GuardrailBlocked",
    "ShipmentWritten") -- always one of a known, closed set, never a
    free-text message, so a CloudWatch Logs Insights query or a metric
    filter can match it exactly, without a fragile regex over prose."""
    record = {"event": event, **fields}
    print(json.dumps(record, sort_keys=True), file=sys.stdout)

log_event() no decide qué loguear ni cuándo — esa decisión vive en el handler, en el Paso 2. Su único trabajo es garantizar que, sin importar quién lo llame, la línea resultante sea JSON válido, con las claves en el mismo orden (sort_keys=True), para que dos líneas del mismo tipo de evento sean diffables byte por byte si sus datos coinciden.


Paso 2 — El vocabulario cerrado de eventos, y dónde vive cada uno en handler.py

extract-shipment-manifest-fields/handler.py (Módulo 1, lección 4; el .zip que el Módulo 5, lección 6 ya firmó con cosign) llama a log_event() en tres puntos fijos de su flujo — nunca un cuarto, nunca un mensaje improvisado a mitad de una función:

EventoCuándo se emiteCampos
ManifestParseFailedReceivedAl recibir el evento ManifestParseFailed (Módulo 1, lección 3), antes de cualquier otro procesamientorequestId, manifestKey
PiiRedactedBeforeInvokeSolo si pre_invoke_checks.py (Módulo 4, lección 5) encontró PII en el texto crudorequestId, entityCounts (el mismo dict que ScrubResult.entity_counts ya produce)
GuardrailBlockedSi post_invoke_checks.py (Módulo 4, lección 6) rechaza el candidato — real o representativorequestId, reason, errors (la misma tupla que ValidationResult.errors ya produce)
ShipmentWrittenSi el candidato pasa la validación de esquema, justo antes de write_shipment_record()requestId, shipmentId

Fíjate en algo importante: cada evento reusa un tipo de dato que ya existeentity_counts de pre_invoke_checks.py, errors de post_invoke_checks.py. El logger nunca inventa su propia representación de "qué salió mal"; se apoya, sin duplicar lógica, en los dos chequeos propios que el Módulo 4 ya construyó y ya probó con pytest.

Este es el fragmento de handler.py que esta lección instrumenta — solo las líneas relevantes al logging, no el archivo completo (el Módulo 5, lección 6 ya fijó el tamaño y el hash exactos del .zip antes de esta instrumentación; cualquier cambio real a handler.py a partir de aquí exigiría volver a firmarlo con cosign, un paso fuera del alcance de esta lección):

# extract-shipment-manifest-fields/handler.py -- excerpt: the three
# log_event() call sites this lesson adds.

from observability.structured_log import log_event
from guardrails.pre_invoke_checks import scrub_pii
from guardrails.post_invoke_checks import validate_shipment_fields


def handler(event, context):
    manifest_key = event["detail"]["manifestKey"]
    raw_text = event["detail"]["rawText"]
    request_id = context.aws_request_id

    log_event("ManifestParseFailedReceived", requestId=request_id, manifestKey=manifest_key)

    pii_result = scrub_pii(raw_text)  # Module 4, lesson 5
    if pii_result.found_pii:
        log_event("PiiRedactedBeforeInvoke", requestId=request_id, entityCounts=pii_result.entity_counts)

    # candidate = invoke_bedrock(pii_result.redacted_text)  -- never called
    # in this $0 lab; see Module 7, lesson 7 for why. What follows uses a
    # REPRESENTATIVE candidate, hand-built and labeled as such.
    candidate = get_representative_candidate(manifest_key)

    validation = validate_shipment_fields(candidate)  # Module 4, lesson 6
    if not validation.is_valid:
        log_event("GuardrailBlocked", requestId=request_id, reason="schema", errors=list(validation.errors))
        return {"statusCode": 422}

    write_shipment_record(candidate, manifest_key)
    log_event("ShipmentWritten", requestId=request_id, shipmentId=candidate["shipmentId"])
    return {"statusCode": 200}

Paso 3 — Corriendo el logger, de verdad, sobre dos invocaciones fijas

Dos invocaciones, deterministas, cada una con un requestId fijo (nunca generado con uuid4() al vuelo) — la misma disciplina de "eventos de prueba fijos" que el Módulo 1, lección 3 ya usó para los envíos 4471/4472/4473:

Invocación 1 — el manifiesto 4471, sin PII, candidato representativo completo (escribe en Shipments). Invocación 2 — el manifiesto 4473, con un correo y un teléfono incidentales (el mismo texto del Módulo 4, lección 5), candidato representativo incompleto (el guardrail propio lo bloquea).

from guardrails.pre_invoke_checks import scrub_pii
from guardrails.post_invoke_checks import validate_shipment_fields
from observability.structured_log import log_event

# --- Invocacion 1: shipment 4471, texto limpio, escritura exitosa --------
REQUEST_ID_1 = "5b6f3e2a-8c91-4d7a-b0e5-1f9c2a6d8b34"
MANIFEST_1 = "shipmentId=4471\noriginCountry=Peru\ndestinationCountry=Chile\ncarrier=AndesExpress\nweightKg=120"

log_event("ManifestParseFailedReceived", requestId=REQUEST_ID_1, manifestKey="manifests/year=2026/month=08/shipment-4471-manifest.txt")
pii_1 = scrub_pii(MANIFEST_1)
if pii_1.found_pii:
    log_event("PiiRedactedBeforeInvoke", requestId=REQUEST_ID_1, entityCounts=pii_1.entity_counts)

candidate_1 = {  # REPRESENTATIVE -- Module 7, lesson 7
    "shipmentId": "4471", "originCountry": "Peru", "destinationCountry": "Chile",
    "carrier": "AndesExpress", "weightKg": "120",
}
validation_1 = validate_shipment_fields(candidate_1)
if not validation_1.is_valid:
    log_event("GuardrailBlocked", requestId=REQUEST_ID_1, reason="schema", errors=list(validation_1.errors))
else:
    log_event("ShipmentWritten", requestId=REQUEST_ID_1, shipmentId=candidate_1["shipmentId"])

# --- Invocacion 2: shipment 4473, PII en el texto, candidato incompleto --
REQUEST_ID_2 = "c8e42d15-9a3b-4f8e-b6c1-7d2a4e9f3b58"
MANIFEST_2 = "Hi team, following up on shipment AC-4473. Contact me at ana.rojas@andescargo.com or call +51 987 654 321 if you need anything. RutaSur handles pickup Friday."

log_event("ManifestParseFailedReceived", requestId=REQUEST_ID_2, manifestKey="manifests/year=2026/month=08/shipment-4473-manifest.txt")
pii_2 = scrub_pii(MANIFEST_2)
if pii_2.found_pii:
    log_event("PiiRedactedBeforeInvoke", requestId=REQUEST_ID_2, entityCounts=pii_2.entity_counts)

candidate_2 = {  # REPRESENTATIVE -- deliberately incomplete, Module 7, lesson 7
    "shipmentId": "4473", "originCountry": "Chile", "destinationCountry": "Peru", "carrier": "RutaSur",
}
validation_2 = validate_shipment_fields(candidate_2)
if not validation_2.is_valid:
    log_event("GuardrailBlocked", requestId=REQUEST_ID_2, reason="schema", errors=list(validation_2.errors))
else:
    log_event("ShipmentWritten", requestId=REQUEST_ID_2, shipmentId=candidate_2["shipmentId"])
python3 observability/drive_handler_logging.py

Qué esperar (literal — corrido de verdad, mismo entorno de esta guía):

{"event": "ManifestParseFailedReceived", "manifestKey": "manifests/year=2026/month=08/shipment-4471-manifest.txt", "requestId": "5b6f3e2a-8c91-4d7a-b0e5-1f9c2a6d8b34"}
{"event": "ShipmentWritten", "requestId": "5b6f3e2a-8c91-4d7a-b0e5-1f9c2a6d8b34", "shipmentId": "4471"}
{"event": "ManifestParseFailedReceived", "manifestKey": "manifests/year=2026/month=08/shipment-4473-manifest.txt", "requestId": "c8e42d15-9a3b-4f8e-b6c1-7d2a4e9f3b58"}
{"entityCounts": {"EMAIL": 1, "PHONE": 1}, "event": "PiiRedactedBeforeInvoke", "requestId": "c8e42d15-9a3b-4f8e-b6c1-7d2a4e9f3b58"}
{"errors": ["missing required field(s): weightKg"], "event": "GuardrailBlocked", "reason": "schema", "requestId": "c8e42d15-9a3b-4f8e-b6c1-7d2a4e9f3b58"}

Cinco líneas, cuatro tipos de evento distintos, ambas invocaciones completas de principio a fin. Fíjate en la Invocación 1: nunca aparece PiiRedactedBeforeInvoke — el texto clave=valor de 4471 no tiene ningún correo ni teléfono, así que pii_1.found_pii es False, y el if de esa línea nunca se cumple. Nada se loguea "por si acaso"; cada evento aparece exactamente cuando, y solo cuando, la condición real que lo dispara ocurrió. Corrida dos veces, esta salida es idéntica, byte por byte — ningún requestId generado al vuelo, ningún timestamp que varíe.


Paso 4 — Leyendo estos logs con awslocal, representativo

En producción, cada una de las cinco líneas del Paso 3 llegaría a /aws/lambda/extract-shipment-manifest-fields — el mismo nombre de grupo de logs automático que Lambda ya asigna a cualquier función, la convención que aws-core-services-guide, Módulo 6 ya estableció para process-shipment-manifest. Consultarlas con un filtro estructurado:

awslocal logs filter-log-events \
  --log-group-name /aws/lambda/extract-shipment-manifest-fields \
  --filter-pattern '{ $.event = "GuardrailBlocked" }' \
  --start-time 2026-08-14T14:00:00Z \
  --end-time 2026-08-14T15:00:00Z

Qué esperar (representativo — sin LOCALSTACK_AUTH_TOKEN, el contenedor de LocalStack no arranca en este entorno específico; CloudWatch Logs sí está confirmado en el plan Hobby, la misma limitación exacta que sre-and-incident-response-guide, Módulo 3, lección 4 ya declaró):

{
    "events": [
        {
            "logStreamName": "2026/08/14/[$LATEST]7f2a9c4e1b8d3f6a0c5e9b2d4a7f1c83",
            "timestamp": 1786732815041,
            "message": "{\"errors\": [\"missing required field(s): weightKg\"], \"event\": \"GuardrailBlocked\", \"reason\": \"schema\", \"requestId\": \"c8e42d15-9a3b-4f8e-b6c1-7d2a4e9f3b58\"}\n",
            "ingestionTime": 1786732815488,
            "eventId": "39234501234567890123456789012345678901"
        }
    ],
    "searchedLogStreams": [
        { "logStreamName": "2026/08/14/[$LATEST]7f2a9c4e1b8d3f6a0c5e9b2d4a7f1c83", "searchedCompletely": true }
    ]
}

--filter-pattern '{ $.event = "GuardrailBlocked" }' es la sintaxis real de filtro JSON de CloudWatch Logs — no una invención de esta lección; encuentra exactamente una línea de las cinco, la de la Invocación 2, porque es la única con "event": "GuardrailBlocked". Es exactamente la ventaja de un log estructurado sobre uno de texto libre: el filtro no necesita adivinar dónde empieza y termina un mensaje — la clave event lo dice, sin ambigüedad.


Paso 5 — De logs estructurados a una métrica, con un metric filter

Un PutMetricFilter sobre el grupo de logs convierte, automáticamente, cada línea que coincida con un patrón en un punto de datos de CloudWatch — sin que ningún código de la aplicación tenga que llamar a put-metric-data explícitamente en cada invocación:

awslocal logs put-metric-filter \
  --log-group-name /aws/lambda/extract-shipment-manifest-fields \
  --filter-name guardrail-blocked-count \
  --filter-pattern '{ $.event = "GuardrailBlocked" }' \
  --metric-transformations \
    metricName=GuardrailBlockedCount,metricNamespace=AndesCargo/GenAI,metricValue=1,defaultValue=0

Qué esperar (representativo, misma razón que el Paso 4):

(sin salida -- put-metric-filter no devuelve nada en éxito, el mismo comportamiento que confirma AWS CLI para este comando)
awslocal cloudwatch get-metric-statistics \
  --namespace AndesCargo/GenAI \
  --metric-name GuardrailBlockedCount \
  --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": "GuardrailBlockedCount",
    "Datapoints": [
        { "Timestamp": "2026-08-14T14:00:00+00:00", "Sum": 1.0, "Unit": "Count" }
    ]
}

Sum: 1.0 — coincide, exactamente, con la única línea GuardrailBlocked de las cinco del Paso 3. Este es el mecanismo completo que, en producción, alimentaría el SLI 3 de la lección 2 (tasa de bloqueo del guardrail) de forma automática, sin que nadie tenga que leer logs a mano: cada GuardrailBlocked incrementa GuardrailBlockedCount; cada ManifestParseFailedReceived incrementaría un TotalInvocationsCount equivalente; la división de ambos, en un dashboard, es la tasa de bloqueo en vivo.


Errores comunes

Loguear el texto crudo del manifiesto completo dentro de un evento estructurado (de arrastrar el hábito de print(f"...")). Qué pasa: alguien, instrumentando su propio handler.py, agrega rawText=raw_text al llamado de log_event("ManifestParseFailedReceived", ...), pensando que "más contexto es mejor". Cómo detectarlo: si tu línea de log de ManifestParseFailedReceived incluye el cuerpo completo de un correo de un socio logístico. Cómo corregirlo: la tabla del Paso 2 declara, con precisión, exactamente qué campos lleva cada evento — manifestKey (una referencia, no el contenido), nunca el texto crudo. Loguear contenido de negocio sin pasar primero por pre_invoke_checks.py (que corre después, no antes, de este evento específico) arriesga escribir PII sin redactar directamente en CloudWatch Logs, un destino que ningún guardrail de esta guía protege.

Confundir el --filter-pattern de texto libre (?"Invalid manifest", el patrón que sre-and-incident-response-guide usó para logs de texto plano) con la sintaxis JSON ({ $.event = "..." }) de esta lección (de mezclar dos formatos de log distintos). Qué pasa: alguien, familiarizado con el Módulo 3 de la guía hermana, intenta usar '?"GuardrailBlocked"' contra los logs estructurados de esta lección. Cómo detectarlo: si tu filtro usa comillas dobles con signo de interrogación en vez de la sintaxis { $.campo = valor }. Cómo corregirlo: la sintaxis con ? busca una subcadena de texto en un log de texto libre — funcionaría, de hecho, contra los logs estructurados de esta lección también, porque "GuardrailBlocked" sigue siendo una subcadena del JSON—, pero la sintaxis { $.event = "GuardrailBlocked" } es estrictamente más precisa: filtra por el valor exacto de un campo específico, sin depender de que ninguna otra parte del JSON contenga la misma palabra por coincidencia. Usa la sintaxis de campo siempre que el log sea JSON — es, precisamente, la razón por la que esta lección construyó un logger estructurado en primer lugar.

Presentar la salida del Paso 4 o el Paso 5 como si el LocalStack de este entorno la hubiera producido de verdad (de perder la etiqueta "representativo"). Qué pasa: alguien copia el JSON del Paso 5 y lo describe como "confirmé la métrica en CloudWatch". Cómo detectarlo: si tu descripción de esta lección no menciona, en ningún lugar, la ausencia de LOCALSTACK_AUTH_TOKEN. Cómo corregirlo: la disciplina de esta guía, y de sre-and-incident-response-guide antes que ella, es explícita: el Paso 3 —el logger en sí, corriendo Python puro— es literal; los Pasos 4 y 5 —cualquier comando awslocal— son representativos en este entorno específico, construidos con precisión sobre el comportamiento real y confirmado de CloudWatch en el plan Hobby, nunca presentados como ejecutados aquí.


Ejercicios

Ejercicio 1 — Corre observability/drive_handler_logging.py tú mismo y cuenta cuántas líneas de salida obtienes. Antes de correrlo, predice el número usando la tabla del Paso 2 y las dos invocaciones del Paso 3.

Ver solución

Cinco líneas: la Invocación 1 produce dos (ManifestParseFailedReceived, ShipmentWritten — sin PII, sin bloqueo); la Invocación 2 produce tres (ManifestParseFailedReceived, PiiRedactedBeforeInvoke, GuardrailBlocked — el correo/teléfono incidental dispara el segundo evento, el candidato incompleto dispara el tercero en vez de un ShipmentWritten). El total, 2 + 3 = 5, coincide exactamente con la salida literal del Paso 3.

Ejercicio 2 — Diseña el --filter-pattern de CloudWatch Logs que encontraría, específicamente, las líneas donde entityCounts.EMAIL es mayor que cero. Usa la sintaxis JSON del Paso 4 como referencia.

Ver solución

{ $.entityCounts.EMAIL > 0 } — la sintaxis JSON de CloudWatch Logs soporta acceso a campos anidados con notación de punto ($.entityCounts.EMAIL) y operadores de comparación numérica, exactamente igual que el ejemplo { $.statusCode >= 500 } de la documentación oficial de AWS. Contra la salida del Paso 3, este patrón encontraría exactamente la línea PiiRedactedBeforeInvoke de la Invocación 2 (entityCounts.EMAIL: 1), y ninguna otra — las demás líneas ni siquiera tienen la clave entityCounts, así que la comparación nunca se evalúa como verdadera para ellas.

Ejercicio 3 — Explica por qué structured_log.py nunca lanza una excepción si log_event() recibe un campo que no es serializable a JSON (por ejemplo, un objeto de Python arbitrario). ¿Es esto una fortaleza o una debilidad del diseño de esta lección?

Ver solución

En realidad, structured_log.py lanzaría una excepción en ese caso — json.dumps() de la biblioteca estándar de Python levanta un TypeError si alguno de los valores del dict no es serializable (por ejemplo, un objeto sin un método __str__/__repr__ compatible). Esto es una fortaleza deliberada, no un descuido: un logger que silenciosamente descartara o truncara un campo no serializable estaría escondiendo un error de programación —alguien pasó el tipo de dato equivocado a log_event()— exactamente en el momento en que sería más fácil detectarlo y corregirlo, antes de que ese error se propague a producción. Compara esto con la decisión de diseño de pre_invoke_checks.py (Módulo 4, lección 5), que nunca lanza una excepción por texto sin PII —esa decisión tiene sentido porque "no encontrar PII" es un resultado normal y esperado—, mientras que "pasar un objeto no serializable a un logger" no lo es. Cada script de esta guía falla ruidosamente donde el silencio escondería un bug, y falla silenciosamente (o simplemente no hace nada) donde el silencio es el comportamiento correcto.


Resumen y siguiente paso

Esta lección construyó observability/structured_log.py, un logger JSON de una sola función, y lo corrió de verdad sobre dos invocaciones fijas y deterministas, produciendo cinco líneas literales que cubren los cuatro tipos de evento del vocabulario cerrado de este módulo. Confirmaste, con salida real, que cada evento aparece exactamente cuando su condición se cumple, nunca "por si acaso". Viste, con la misma honestidad exacta que sre-and-incident-response-guide ya estableció, por qué leer esos logs con awslocal logs filter-log-events y convertirlos en una métrica con awslocal logs put-metric-filter es representativo en este entorno específico —sin LOCALSTACK_AUTH_TOKEN—, aunque CloudWatch Logs/Metrics estén confirmados en el plan Hobby.

Antes de avanzar deberías poder: nombrar los cuatro eventos del vocabulario cerrado y cuándo se emite cada uno; escribir un --filter-pattern JSON para un campo anidado nuevo; y explicar la diferencia entre la sintaxis de filtro de texto libre (?"...") y la sintaxis JSON ({ $.campo = valor }).

La lección 4 deja atrás CloudWatch por completo y calcula, con código Python puro, ejecutado sobre un conjunto fijo de 50 eventos de prueba, el único SLI de esta guía que es 100% literal: la tasa de escalamiento.

Recursos

  1. AWS Docs — Filter pattern syntax for metric filters, subscription filters, filter log events, and Live Tail — fuente exacta de la sintaxis JSON { $.campo = valor } usada en los Pasos 4 y 5 de esta lección.
  2. AWS Docs — PutMetricFilter — referencia oficial del comando del Paso 5.
  3. sre-and-incident-response-guide, Módulo 3, lecciones 3 y 4 — el precedente exacto de "logs/métricas representativos en este entorno, sin LOCALSTACK_AUTH_TOKEN", reconfirmado aquí sin volver a investigarlo.
  4. Este mismo curso, Módulo 4, lecciones 5 y 6 — el origen de scrub_pii() y validate_shipment_fields(), reusados sin cambios en el Paso 3 de esta lección.
  5. Este mismo curso, Módulo 5, lección 6 — el origen del .zip firmado de extract-shipment-manifest-fields, y la razón por la que esta lección solo muestra un fragmento de handler.py, no el archivo completo.