En 06-04 montaste el primer pilar de la observabilidad. AlpinaShop tiene ahora un panel, comprobaciones de disponibilidad desde cuatro continentes y tres alertas que llegan al móvil de Marta. Es un salto enorme respecto a esperar el correo de un cliente.

Pero fíjate en lo que ocurre cuando esa alerta salta de verdad. Son las once de la noche y el móvil de Marta muestra: "Tasa de error 5xx por encima del 2 % durante 5 minutos". Marta abre el panel. Confirma: el 4,3 % de las peticiones fallan, la latencia p95 ha subido de 400 ms a 3,2 segundos, y empezó hace unos veinte minutos. Y ahora, ¿qué?

La métrica le ha dicho que algo va mal. No le dice qué. No le dice qué excepción se está lanzando, ni a qué clientes les pasa, ni si es en el catálogo o en el carrito, ni si el culpable es la aplicación, la base de datos o una llamada a una API externa. Para eso hacen falta los otros dos pilares.

Esta lección los construye. Y termina con el recorrido completo de ese incidente, desde la alerta hasta la línea de código exacta, porque esa correlación —saltar de la alerta a la métrica, de la métrica al log y del log a la traza— es la razón por la que existe la observabilidad.

Contenido

  1. La entrada de log estructurada
  2. De dónde salen los logs automáticamente
  3. Emitir logs bien desde la aplicación Flask
  4. El explorador de logs y su lenguaje de consultas
  5. Consultas que resuelven incidentes reales de AlpinaShop
  6. Buckets, retención, vistas y ámbitos
  7. Sumideros: exportar a BigQuery, Cloud Storage y Pub/Sub
  8. Filtros de exclusión: no pagar por ruido
  9. Métricas basadas en logs
  10. Cloud Trace: qué es el rastreo distribuido
  11. Instrumentar Flask con OpenTelemetry
  12. Leer una cascada y encontrar la consulta lenta
  13. Muestreo y coste de las trazas
  14. Cloud Profiler: perfilado continuo
  15. La correlación que lo une todo: un incidente de principio a fin
  16. Coste por volumen y las tres decisiones que lo controlan

  1. La entrada de log estructurada

Un log no es una línea de texto. En Cloud Logging, una entrada de log es un objeto con campos, y entender esos campos es lo que convierte los logs de un vertedero en una base de datos consultable.

Campo Qué contiene Por qué importa
timestamp Cuándo ocurrió Ordenar y correlacionar
resource Qué recurso lo emitió, con sus etiquetas Filtrar por servicio, clúster, instancia
severity DEBUG, INFO, WARNING, ERROR, CRITICAL Filtrar por gravedad
logName Nombre del log Separar accesos, aplicación, auditoría
textPayload Texto plano Lo que emite un print
jsonPayload Objeto JSON con tus campos Lo que permite consultar de verdad
labels Etiquetas propias clave-valor Dimensiones de negocio
httpRequest Método, URL, código, latencia, IP Analítica de acceso
trace Identificador de la traza La clave de la correlación (apartado 15)
spanId Identificador del span dentro de la traza Precisión en la correlación
insertId Identificador único de la entrada Deduplicación
operation Agrupa entradas de una operación larga Seguir un proceso por pasos

La diferencia entre textPayload y jsonPayload es la diferencia entre poder investigar y no poder.

Sin estructura:

Error procesando pedido 8842 del cliente 1502: timeout en la pasarela

Es legible para un humano y opaco para una máquina. Para responder a "¿cuántos pedidos fallaron por timeout de la pasarela en la última hora, y de qué importe?" hay que analizar texto con expresiones regulares, y basta que alguien cambie el mensaje para que todo se rompa.

Con estructura:

{
  "severity": "ERROR",
  "jsonPayload": {
    "mensaje": "Error procesando pedido",
    "id_pedido": "8842",
    "id_cliente_hash": "a3f9c1...",
    "causa": "timeout_pasarela",
    "importe_eur": 249.90,
    "duracion_ms": 5012,
    "reintento": 2
  },
  "labels": {"componente": "checkout", "version": "a3f9c1b"},
  "trace": "projects/alpinashop-prod/traces/4bf92f3577b34da6a3ce929d0e0e4736"
}

Ahora esa pregunta es un filtro. Y la penúltima línea permite algo más potente todavía: saltar directamente a la traza completa de esa petición, con todo lo que ocurrió antes y después. Volveremos a ello.

  1. De dónde salen los logs automáticamente

Igual que con las métricas, hay una buena noticia: buena parte de los logs ya se están recogiendo sin que hayas hecho nada.

Origen Log Qué contiene ¿Activado por defecto?
Balanceador requests Cada petición: URL, código, latencia, IP, país No: hay que activarlo
Cloud SQL postgres.log / mysql-error.log Errores, consultas lentas Parcialmente
GKE stdout / stderr de los contenedores Lo que la aplicación escribe
GKE events Eventos de Kubernetes
Cloud Run / Functions stdout / stderr + peticiones Aplicación y accesos
VPC Flow Logs vpc_flows Conexiones de red, bytes No: hay que activarlo
Cloud Armor Dentro del log del balanceador Reglas aplicadas y bloqueos Con el log del LB
Logs de auditoría activity Quién hizo qué en la API Sí (actividad de administración)
Logs de auditoría data_access Quién leyó qué datos No: se activa y cuesta

Dos casillas merecen atención.

Los logs del balanceador no están activados por defecto, y son de los más valiosos que existe: contienen cada petición a la tienda con su latencia, su código, su país de origen y el resultado de la caché de CDN. Activarlos:

gcloud compute backend-services update bs-catalogo-web \
  --global \
  --enable-logging \
  --logging-sample-rate=1.0 \
  --project=alpinashop-prod

El --logging-sample-rate=1.0 registra el 100 % de las peticiones. Es lo correcto mientras el volumen sea moderado; con millones de peticiones diarias se baja a 0,1 o 0,05 y se acepta perder detalle a cambio de coste. Empieza por 1.0 y ajusta cuando veas la factura.

Los VPC Flow Logs registran las conexiones de red y son imprescindibles para diagnosticar problemas de conectividad y para investigar incidentes de seguridad. Su volumen es alto, así que se activan con muestreo:

gcloud compute networks subnets update sn-web-euw1 \
  --region=europe-west1 \
  --enable-flow-logs \
  --logging-aggregation-interval=interval-5-sec \
  --logging-flow-sampling=0.5 \
  --project=alpinashop-prod

Y los logs de auditoría merecen una mención con remisión: los de actividad de administración —quién creó, modificó o borró un recurso— están siempre activos y son gratuitos. Los de acceso a datos —quién leyó qué— hay que activarlos, generan un volumen enorme y se pagan. La política de auditoría a nivel de organización, con sus implicaciones de cumplimiento, es tema de 07-07; aquí basta saber que existen y que responden a la pregunta "¿quién borró ese recurso?".

  1. Emitir logs bien desde la aplicación Flask

Aquí es donde el equipo de AlpinaShop tiene más margen de mejora. El catálogo hoy hace esto:

# Lo que hay hoy en el catálogo
print(f"Procesando pedido {id_pedido}")
print("Error: " + str(e))

Funciona en el sentido de que el texto acaba en Cloud Logging —en GKE, todo lo que va a stdout se recoge—. Pero produce entradas con textPayload, todas con severidad INFO aunque sean errores, sin ningún campo consultable y sin identificador de traza.

La forma correcta, con la biblioteca cliente:

# catalogo/registro.py
import logging
import google.cloud.logging
from google.cloud.logging.handlers import StructuredLogHandler
from google.cloud.logging_v2.handlers import setup_logging

# En GKE y Cloud Run, StructuredLogHandler escribe JSON a stdout
# y el agente lo recoge. Sin llamadas extra a la API: es lo más eficiente.
handler = StructuredLogHandler()
setup_logging(handler)

log = logging.getLogger("catalogo")
log.setLevel(logging.INFO)

Y su uso, con los campos como datos y no como texto:

# catalogo/pedidos.py
from catalogo.registro import log

def procesar_pedido(pedido):
    # El diccionario 'json_fields' se convierte en jsonPayload
    log.info("Pedido recibido", extra={"json_fields": {
        "id_pedido": pedido.id,
        "importe_eur": float(pedido.importe),
        "num_lineas": len(pedido.lineas),
        "canal": pedido.canal,
    }})

    try:
        resultado = cobrar(pedido)
    except TimeoutPasarela as e:
        # log.exception añade automáticamente el traceback completo
        log.exception("Timeout en la pasarela de pago", extra={"json_fields": {
            "id_pedido": pedido.id,
            "importe_eur": float(pedido.importe),
            "causa": "timeout_pasarela",
            "duracion_ms": e.duracion_ms,
        }})
        raise

Las cinco reglas de un log útil, que valen más que cualquier biblioteca:

Regla Mal Bien
Los datos van en campos, no en el mensaje f"pedido {id} falló" "Pedido fallido" + {"id_pedido": id}
La severidad debe ser correcta Todo INFO ERROR para errores, WARNING para avisos
Nunca datos personales Correo, nombre, tarjeta Hash del identificador de cliente
Contexto suficiente para actuar "Error" Qué operación, sobre qué, por qué falló
Sin ruido Un log por cada iteración de un bucle Un log por operación, con el resumen

La tercera regla no es negociable y va más allá del buen gusto. Los logs se retienen, se exportan a BigQuery, se leen por varias personas y sobreviven meses. Un correo electrónico o un número de tarjeta escrito en un log es una fuga de datos personales con implicaciones de RGPD, coherente con todo lo visto en 04-07 sobre DLP. Si necesitas identificar a un cliente en los logs, usa un hash estable que permita correlacionar sin identificar.

Sobre la severidad, un criterio práctico que evita discusiones eternas:

Nivel Cuándo ¿Genera alerta?
DEBUG Detalle de desarrollo Nunca en producción
INFO Eventos normales del negocio No
WARNING Algo raro que se recuperó solo No, pero se vigila
ERROR La operación falló para el usuario
CRITICAL El servicio no puede seguir Sí, urgente

La distinción entre WARNING y ERROR es la que se hace mal más a menudo: un reintento que después funciona es WARNING —el usuario no se enteró—; un reintento agotado que devuelve un error al cliente es ERROR.

  1. El explorador de logs y su lenguaje de consultas

El explorador de logs es la interfaz de consulta, y su lenguaje es sencillo pero tiene detalles que conviene conocer.

Los operadores básicos:

resource.type="k8s_container"                    # igualdad exacta
severity>=ERROR                                  # comparación de gravedad
jsonPayload.importe_eur>100                      # comparación numérica
jsonPayload.mensaje:"pasarela"                   # ':' es CONTIENE, no igualdad
jsonPayload.causa=~"timeout.*"                   # expresión regular
timestamp>="2026-08-05T20:00:00Z"                # rango temporal
resource.type="k8s_container" AND severity=ERROR # combinación
NOT jsonPayload.ruta="/salud"                    # negación
Operador Significado Nota importante
= Igualdad exacta Distingue mayúsculas
: Contiene Búsqueda de subcadena
=~ / !~ Coincide / no coincide con regex Más lento
>=, <=, >, < Comparación Números, fechas y severidades
AND, OR, NOT Lógicos Usa paréntesis para agrupar
- delante Excluye -severity=INFO

Tres consejos de rendimiento que cambian mucho la experiencia, porque una consulta mal escrita sobre semanas de logs tarda minutos:

  1. Acota siempre el tiempo primero. Es el filtro más eficiente con diferencia.
  2. Filtra por resource.type pronto. Reduce el espacio de búsqueda de golpe.
  3. Evita la búsqueda global de texto libre si puedes filtrar por un campo concreto: buscar en jsonPayload.causa es órdenes de magnitud más rápido que buscar la palabra en todo el contenido.

Desde la línea de comandos, para guiones y para automatizar:

gcloud logging read \
  'resource.type="k8s_container"
   AND resource.labels.namespace_name="tienda"
   AND severity>=ERROR
   AND timestamp>="2026-08-05T20:00:00Z"' \
  --limit=50 --format=json --project=alpinashop-prod

  1. Consultas que resuelven incidentes reales de AlpinaShop

La teoría es corta; lo útil son los patrones. Estas cinco consultas cubren la mayoría de las investigaciones reales.

Consulta 1 — Los errores 500 desde el último despliegue. La primera pregunta ante cualquier incidente:

resource.type="k8s_container"
resource.labels.namespace_name="tienda"
severity>=ERROR
timestamp>="2026-08-05T20:15:00Z"

Y refinada, para ver si el problema es de una versión concreta —lo que enlaza con el $COMMIT_SHA de 06-01—:

resource.type="k8s_container"
resource.labels.namespace_name="tienda"
severity>=ERROR
labels.version="a3f9c1b"

Consulta 2 — Aislar la petición de un cliente que se ha quejado. Un cliente escribe diciendo que su compra falló a las 20:34:

resource.type="k8s_container"
jsonPayload.id_cliente_hash="a3f9c1e8b2..."
timestamp>="2026-08-05T20:30:00Z"
timestamp<="2026-08-05T20:40:00Z"

Aquí se ve el valor de la regla de los datos personales: el hash permite encontrar al cliente sin haber escrito nunca su correo en un log. Atención al cliente convierte el correo en hash con la misma función, y la investigación funciona igual.

Consulta 3 — Quién borró un recurso. Los logs de auditoría, que responden a la pregunta más incómoda:

logName="projects/alpinashop-prod/logs/cloudaudit.googleapis.com%2Factivity"
protoPayload.methodName="v1.compute.firewalls.delete"
timestamp>="2026-08-01T00:00:00Z"

La respuesta incluye protoPayload.authenticationInfo.principalEmail —quién— y protoPayload.resourceName —qué—. Es la consulta que resuelve en treinta segundos discusiones que de otro modo duran una tarde.

Consulta 4 — Peticiones lentas del balanceador con su origen. Sobre el log de acceso del apartado 2:

resource.type="http_load_balancer"
httpRequest.latency>="2s"
httpRequest.requestUrl:"/catalogo"

Y el mismo log responde una pregunta de coste de 03-03, el ratio de aciertos de la CDN:

resource.type="http_load_balancer"
jsonPayload.cacheId!=""
jsonPayload.statusDetails="response_from_cache"

Consulta 5 — Errores de la función de imágenes. Cerrando el círculo con 06-03:

resource.type="cloud_run_revision"
resource.labels.service_name="procesar-imagen-producto"
severity>=ERROR

Y la que habría detectado el bucle infinito del ejercicio de aquella lección, contando invocaciones por hora:

resource.type="cloud_run_revision"
resource.labels.service_name="procesar-imagen-producto"
jsonPayload.message:"Procesada correctamente"

  1. Buckets, retención, vistas y ámbitos

Los logs se almacenan en buckets de logs, que no tienen nada que ver con los de Cloud Storage.

Todo proyecto tiene dos por defecto:

Bucket Contenido Retención por defecto ¿Configurable?
_Required Logs de auditoría de actividad de administración 400 días No, y es gratuito
_Default Todo lo demás 30 días

Y se pueden crear buckets propios, que es lo que hace falta cuando distintos tipos de log necesitan distinto tratamiento:

# Un bucket con retención larga para lo relacionado con pagos
gcloud logging buckets create logs-pagos \
  --location=europe-west1 \
  --retention-days=2555 \
  --description="Logs de transacciones - retención 7 años por normativa" \
  --project=alpinashop-prod

# Y otro con retención corta para logs de depuración de desarrollo
gcloud logging buckets create logs-debug \
  --location=europe-west1 \
  --retention-days=7 \
  --project=alpinashop-dev

Elegir la retención tiene dos dimensiones que conviene separar:

Necesidad Retención Dónde
Diagnosticar un incidente 7-30 días Bucket de logs
Analizar tendencias Meses Exportado a BigQuery
Cumplimiento normativo Años Exportado a Cloud Storage

Guardar años de logs en un bucket de logs es la forma más cara de conservarlos. Para retención larga, el patrón correcto es exportar a Cloud Storage con clase de almacenamiento fría, que es el tema del apartado siguiente.

Las vistas de log (log views) permiten dar acceso a un subconjunto de un bucket. Es la pieza que resuelve un problema real de permisos: dar a Lucía acceso a los logs de la aplicación sin exponerle los logs de auditoría ni los de pagos.

gcloud logging views create vista-catalogo \
  --bucket=_Default --location=global \
  --log-filter='resource.type="k8s_container" AND resource.labels.namespace_name="tienda"' \
  --project=alpinashop-prod

gcloud logging views add-iam-policy-binding vista-catalogo \
  --bucket=_Default --location=global \
  --member='group:[email protected]' \
  --role=roles/logging.viewAccessor \
  --project=alpinashop-prod

Sin vistas, el permiso de lectura de logs es todo o nada, y "todo" incluye los logs de auditoría. Con vistas, el acceso se acota, coherente con el mínimo privilegio de 03-04.

  1. Sumideros: exportar a BigQuery, Cloud Storage y Pub/Sub

Un sumidero (sink) es una regla que dice: "las entradas que cumplan este filtro, envíalas también a este destino". Es el mecanismo que conecta los logs con el resto de la plataforma.

flowchart LR
    A[Cloud Logging<br/>enrutador de logs] --> B{Filtros de<br/>los sumideros}
    B -->|logs de acceso| C[BigQuery<br/>análisis SQL]
    B -->|todo, comprimido| D[Cloud Storage<br/>archivo barato]
    B -->|errores críticos| E[Pub/Sub<br/>reacción automática]
    B -->|por defecto| F[Bucket _Default<br/>30 días]

A BigQuery, para analizar. El caso de AlpinaShop: analizar el log de acceso del balanceador con SQL, cruzándolo con los datos de alpinashop_analitica:

gcloud logging sinks create sumidero-acceso-bq \
  bigquery.googleapis.com/projects/alpinashop-datos/datasets/logs_acceso \
  --log-filter='resource.type="http_load_balancer"' \
  --use-partitioned-tables \
  --project=alpinashop-prod

# El sumidero crea una cuenta de servicio propia a la que hay que dar permiso
SA=$(gcloud logging sinks describe sumidero-acceso-bq \
     --project=alpinashop-prod --format='value(writerIdentity)')
gcloud projects add-iam-policy-binding alpinashop-datos \
  --member="$SA" --role=roles/bigquery.dataEditor

El paso del writerIdentity se olvida constantemente: el sumidero se crea, parece funcionar, y no llega nada al destino porque falta el permiso. Es el primer sitio donde mirar si un sumidero no entrega.

El --use-partitioned-tables es importante por coste: particiona por fecha, así que una consulta sobre un día no escanea meses, aplicando lo aprendido en 04-01.

Y entonces se pueden hacer análisis que con el explorador serían imposibles:

-- Top 20 de URLs más lentas de la última semana, con volumen
SELECT
  httpRequest.requestUrl AS url,
  COUNT(*) AS peticiones,
  ROUND(APPROX_QUANTILES(
    CAST(REGEXP_EXTRACT(httpRequest.latency, r'([\d.]+)') AS FLOAT64), 100)[OFFSET(95)], 3
  ) AS latencia_p95_s,
  COUNTIF(httpRequest.status >= 500) AS errores_5xx
FROM `alpinashop-datos.logs_acceso.requests_*`
WHERE _TABLE_SUFFIX BETWEEN FORMAT_DATE('%Y%m%d', DATE_SUB(CURRENT_DATE(), INTERVAL 7 DAY))
                        AND FORMAT_DATE('%Y%m%d', CURRENT_DATE())
GROUP BY url
HAVING peticiones > 100
ORDER BY latencia_p95_s DESC
LIMIT 20;

A Cloud Storage, para archivar barato. El destino de la retención larga:

gcloud logging sinks create sumidero-archivo-gcs \
  storage.googleapis.com/alpinashop-logs-archivo \
  --log-filter='logName:"cloudaudit.googleapis.com" OR jsonPayload.componente="checkout"' \
  --project=alpinashop-prod

Con una regla de ciclo de vida en el bucket (02-02) que baje a Nearline a los 30 días, a Coldline a los 90 y a Archive al año, siete años de logs de pagos cuestan una fracción minúscula de lo que costarían en un bucket de logs.

A Pub/Sub, para reaccionar. El sumidero más interesante conceptualmente, porque cierra el círculo con 06-03:

gcloud logging sinks create sumidero-seguridad-pubsub \
  pubsub.googleapis.com/projects/alpinashop-prod/topics/eventos-seguridad \
  --log-filter='protoPayload.methodName=~"compute.firewalls.(insert|patch|delete)"
                OR protoPayload.methodName="SetIamPolicy"' \
  --project=alpinashop-prod

Cada vez que alguien toca una regla de firewall o una política de IAM, llega un mensaje al topic, y una Cloud Function de 06-03 puede publicarlo en el canal de seguridad de Slack. De un log a una reacción automática, sin intervención humana.

Destino Latencia Coste Para qué
BigQuery Segundos Almacenamiento + consultas Analizar con SQL
Cloud Storage Minutos (por lotes) El más barato Archivo, cumplimiento
Pub/Sub Segundos Por mensaje Reaccionar en tiempo real
Otro bucket de logs Inmediata Ingesta Retención distinta por tipo
Otro proyecto Segundos Ingesta Agregación centralizada (07-07)

  1. Filtros de exclusión: no pagar por ruido

Un sumidero copia; un filtro de exclusión descarta. Y es la palanca más directa sobre la factura de logging.

El razonamiento es sencillo: hay logs que se generan en volumen enorme y no aportan nada al diagnóstico. En AlpinaShop, los principales son las comprobaciones de estado —hc-catalogo golpea /salud cada pocos segundos desde varias sondas, generando decenas de miles de entradas idénticas al día— y las peticiones a recursos estáticos que sirve la CDN.

# Excluir las comprobaciones de estado del log del balanceador
gcloud logging sinks update _Default \
  --add-exclusion=name=excluir-health-checks,\
filter='resource.type="http_load_balancer" AND httpRequest.requestUrl:"/salud"' \
  --project=alpinashop-prod

# Excluir el 95 % de los accesos correctos a estáticos, conservando una muestra
gcloud logging sinks update _Default \
  --add-exclusion=name=excluir-estaticos,\
filter='resource.type="http_load_balancer"
        AND httpRequest.status=200
        AND httpRequest.requestUrl=~"\.(css|js|png|jpg|webp|woff2)$"',\
percent=95 \
  --project=alpinashop-prod

El parámetro percent=95 es muy útil y poco conocido: excluye una muestra aleatoria en lugar de todo. Conservar el 5 % permite seguir viendo tendencias y detectar problemas con los estáticos, pagando la vigésima parte.

Candidato a excluir Volumen típico Riesgo de excluirlo
Comprobaciones de estado Muy alto Ninguno: las métricas ya las cubren
Estáticos con código 200 Alto Bajo, si conservas una muestra
DEBUG en producción Alto Ninguno: no debería estar activo
Logs de alpinashop-dev Medio Bajo: retención corta basta
Errores de cualquier tipo Bajo Nunca los excluyas
Logs de auditoría Medio Nunca: obligación normativa

Y la advertencia imprescindible: una entrada excluida no se guarda en ningún sitio y no se puede recuperar. Si excluyes algo que después resulta necesario para investigar un incidente, no hay vuelta atrás. Revisa cada exclusión con la pregunta: ¿podría necesitar esto durante una investigación? Ante la duda, muestrea en lugar de excluir del todo.

  1. Métricas basadas en logs

Aquí se cierra el vínculo con 06-04. Una métrica basada en logs convierte entradas que cumplen un filtro en una métrica de Cloud Monitoring, sin tocar el código de la aplicación.

Es la técnica más rápida para instrumentar algo que ya se está registrando.

Métrica de contador, para contar ocurrencias:

gcloud logging metrics create pagos_fallidos \
  --description="Pagos que fallan por timeout de la pasarela" \
  --log-filter='resource.type="k8s_container"
                AND jsonPayload.causa="timeout_pasarela"' \
  --project=alpinashop-prod

Con etiquetas para poder desglosar, definida desde un fichero:

# metrica-pagos-fallidos.yaml
name: pagos_fallidos
description: Pagos que fallan por timeout de la pasarela
filter: |
  resource.type="k8s_container"
  AND jsonPayload.causa="timeout_pasarela"
labelExtractors:
  canal: EXTRACT(jsonPayload.canal)
  version: EXTRACT(labels.version)
metricDescriptor:
  metricKind: DELTA
  valueType: INT64
  labels:
    - key: canal
    - key: version

Fíjate en la etiqueta version: permite responder de inmediato a "¿estos fallos empezaron con el despliegue de ayer?", correlacionando con el SHA de 06-01. Y respeta la regla de cardinalidad de 06-04: canal tiene tres valores, version unas decenas. Nunca extraigas id_pedido como etiqueta.

Métrica de distribución, para valores numéricos:

name: importe_pedidos
description: Distribución del importe de los pedidos completados
filter: |
  resource.type="k8s_container"
  AND jsonPayload.evento="pedido_completado"
valueExtractor: EXTRACT(jsonPayload.importe_eur)
metricDescriptor:
  metricKind: DELTA
  valueType: DISTRIBUTION

Eso permite pintar el p50 y el p95 del importe medio de pedido, un dato de negocio puro obtenido sin escribir una línea de instrumentación específica.

Enfoque Ventaja Inconveniente
Métrica basada en logs Sin tocar el código, inmediata Depende del formato del log; si cambia, se rompe
Métrica personalizada (06-04) Explícita, robusta Requiere código y despliegue

La recomendación práctica: empieza con métricas basadas en logs para validar rápido qué merece la pena medir, y promociona a métricas personalizadas las que resulten importantes de verdad. Y ten presente el riesgo: si alguien renombra un campo del jsonPayload, la métrica deja de contar silenciosamente y la alerta asociada deja de saltar. Un cambio en el formato de un log es un cambio con consecuencias, y merece mención en la revisión de código.

  1. Cloud Trace: qué es el rastreo distribuido

Las métricas dicen que el p95 es de 3,2 segundos. Los logs dicen que hubo errores. Ninguno de los dos dice dónde se fueron esos 3,2 segundos.

Ese es el hueco que llena el rastreo distribuido, y su necesidad crece con el número de piezas. Una petición al catálogo de AlpinaShop atraviesa hoy: el balanceador, Cloud Armor, la CDN, el pod de GKE, una consulta a Cloud SQL, una lectura de Firestore para el carrito y quizá una llamada a la Vision API. Si tarda tres segundos, ¿quién tiene la culpa?

Dos conceptos:

  • Una traza representa una petición completa a través de todo el sistema, identificada por un trace_id.
  • Un span representa una operación dentro de esa traza, con inicio, fin y un span padre. Los spans forman un árbol.
flowchart TD
    A["GET /catalogo/piolet-01<br/>3.240 ms — span raíz"] --> B["ficha_producto<br/>3.200 ms"]
    A --> C["render_plantilla<br/>40 ms"]
    B --> D["SELECT productos<br/>40 ms"]
    B --> E["SELECT stock_por_talla<br/>3.020 ms — 93% del total"]
    B --> F["recomendaciones<br/>20 ms"]

Con solo mirar el diagrama, la respuesta salta a la vista: SELECT stock_por_talla tarda 3.020 de los 3.240 ms. Ninguna métrica y ningún log habrían señalado eso con esa claridad.

La propagación del contexto es lo que permite que los spans de servicios distintos formen una sola traza. El servicio que inicia la petición genera un trace_id y lo envía en una cabecera; cada servicio que la recibe la lee, crea sus spans como hijos y la propaga a su vez.

Cabecera Origen Formato Estado en 2026
traceparent Estándar W3C 00-{trace_id}-{span_id}-{flags} La recomendada
X-Cloud-Trace-Context Google {trace_id}/{span_id};o={flags} Nativa de GCP, aún muy presente
b3 / X-B3-TraceId Zipkin Varias Heredada

El balanceador de GCP añade X-Cloud-Trace-Context automáticamente a cada petición entrante, así que AlpinaShop ya tiene identificadores de traza circulando sin haber hecho nada. Lo que falta es que la aplicación los use.

  1. Instrumentar Flask con OpenTelemetry

OpenTelemetry es el estándar del sector para instrumentación —métricas, trazas y logs—, independiente del proveedor. Instrumentar con OpenTelemetry y exportar a Cloud Trace significa que, si mañana AlpinaShop cambia de destino, se cambia el exportador y no la instrumentación.

# requirements.txt
opentelemetry-api==1.*
opentelemetry-sdk==1.*
opentelemetry-instrumentation-flask==0.*
opentelemetry-instrumentation-requests==0.*
opentelemetry-instrumentation-sqlalchemy==0.*
opentelemetry-exporter-gcp-trace==1.*
opentelemetry-propagator-gcp==1.*
# catalogo/trazas.py
from opentelemetry import trace
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import BatchSpanProcessor
from opentelemetry.sdk.trace.sampling import TraceIdRatioBased, ParentBased
from opentelemetry.exporter.cloud_trace import CloudTraceSpanExporter
from opentelemetry.instrumentation.flask import FlaskInstrumentor
from opentelemetry.instrumentation.requests import RequestsInstrumentor
from opentelemetry.instrumentation.sqlalchemy import SQLAlchemyInstrumentor
from opentelemetry.propagate import set_global_textmap
from opentelemetry.propagators.cloud_trace_propagator import CloudTraceFormatPropagator
import os


def configurar_trazas(app, engine):
    """Instrumenta la aplicación Flask y exporta a Cloud Trace."""

    # Muestreo: ParentBased respeta la decisión del servicio que originó
    # la traza; si es el primero, muestrea según la proporción indicada.
    proporcion = float(os.environ.get("TRACE_SAMPLE_RATE", "0.1"))
    proveedor = TracerProvider(sampler=ParentBased(TraceIdRatioBased(proporcion)))

    # BatchSpanProcessor agrupa los spans y los envía en lotes:
    # imprescindible para no añadir latencia a cada petición.
    proveedor.add_span_processor(BatchSpanProcessor(CloudTraceSpanExporter()))
    trace.set_tracer_provider(proveedor)

    # Usar el formato de Google para entenderse con el balanceador
    set_global_textmap(CloudTraceFormatPropagator())

    # Instrumentación automática: spans sin tocar el código de negocio
    FlaskInstrumentor().instrument_app(app)   # un span por petición
    RequestsInstrumentor().instrument()       # un span por llamada HTTP saliente
    SQLAlchemyInstrumentor().instrument(engine=engine)  # un span por consulta SQL

Con esas tres últimas líneas ya tienes trazas útiles sin modificar ninguna función de negocio. Para el detalle propio, spans manuales:

# catalogo/producto.py
from opentelemetry import trace
tracer = trace.get_tracer(__name__)

def ficha_producto(sku):
    with tracer.start_as_current_span("ficha_producto") as span:
        span.set_attribute("sku", sku)          # atributos de BAJA cardinalidad

        with tracer.start_as_current_span("consultar_stock"):
            stock = consultar_stock_por_talla(sku)
        span.set_attribute("tallas_disponibles", len(stock))

        with tracer.start_as_current_span("recomendaciones"):
            recos = obtener_recomendaciones(sku)   # tabla de 05-07

        return render(sku, stock, recos)

Y la pieza que lo cambia todo: incluir el trace_id en los logs. Es lo que permitirá el recorrido del apartado 15:

# catalogo/registro.py
from opentelemetry import trace

def campos_traza() -> dict:
    """Devuelve los campos que enlazan un log con su traza."""
    span = trace.get_current_span()
    ctx = span.get_span_context()
    if not ctx.is_valid:
        return {}
    proyecto = os.environ["GOOGLE_CLOUD_PROJECT"]
    return {
        "logging.googleapis.com/trace": f"projects/{proyecto}/traces/{ctx.trace_id:032x}",
        "logging.googleapis.com/spanId": f"{ctx.span_id:016x}",
        "logging.googleapis.com/trace_sampled": ctx.trace_flags.sampled,
    }


def log_info(mensaje: str, **campos):
    log.info(mensaje, extra={"json_fields": {**campos, **campos_traza()}})

Esas claves especiales logging.googleapis.com/trace y .../spanId no son campos cualesquiera: Cloud Logging las reconoce y rellena los campos trace y spanId de la entrada. Y con eso, la consola muestra un enlace directo de cada log a su traza, y de cada traza a sus logs.

  1. Leer una cascada y encontrar la consulta lenta

Con la instrumentación puesta, así se lee una traza lenta del catálogo de AlpinaShop:

Span Duración % del total Observación
GET /catalogo/piolet-01 3.240 ms 100 % El span raíz
├─ ficha_producto 3.200 ms 99 % Casi todo el tiempo
│ ├─ SELECT productos 40 ms 1 % Normal
│ ├─ SELECT stock_por_talla 3.020 ms 93 % El culpable
│ └─ recomendaciones 20 ms 1 % La tabla precalculada de 05-07
└─ render_plantilla 40 ms 1 % Normal

El diagnóstico es inmediato, y las tres cosas que hay que mirar en cualquier cascada son siempre las mismas:

  1. ¿Qué span ocupa el mayor porcentaje? Aquí, stock_por_talla con el 93 %. Optimizar cualquier otra cosa es perder el tiempo.
  2. ¿Hay huecos sin cubrir? Un intervalo dentro del span padre que ningún hijo explica suele ser tiempo de espera: bloqueos, contención de pool de conexiones o código no instrumentado.
  3. ¿Hay spans repetidos? Cincuenta spans SELECT idénticos consecutivos son la firma inconfundible del problema N+1: una consulta por cada elemento de una lista, en lugar de una consulta para todos.

Con el atributo SQL que añade la instrumentación de SQLAlchemy, la traza incluye la consulta:

SELECT s.talla, s.unidades
FROM stock s
WHERE s.sku = 'piolet-01'
  AND s.almacen IN (SELECT id FROM almacenes WHERE activo = true);

Y a partir de ahí, la investigación es de base de datos: EXPLAIN ANALYZE sobre esa consulta, revisar índices, mirar los logs de consultas lentas de Cloud SQL. La traza no arregla el problema; lo localiza en treinta segundos en lugar de en tres horas, y eso es exactamente lo que se le pide.

Un patrón que merece mención aparte: si esa misma consulta tarda 40 ms en un entorno de pruebas y 3.020 ms en producción, el problema probablemente no es la consulta sino la contención —el pool de conexiones agotado, con la petición esperando a que se libere una—. Ahí la métrica de conexiones de Cloud SQL de 06-04 y la traza se refuerzan mutuamente: la traza dice dónde se espera, la métrica dice por qué.

  1. Muestreo y coste de las trazas

Trazar el 100 % de las peticiones tiene dos costes: el de ingesta de spans, que se factura, y el de rendimiento en la aplicación, que aunque pequeño no es nulo. Por eso se muestrea.

Estrategia Cómo funciona Cuándo
Proporción fija Un porcentaje de las trazas Lo habitual: 1-10 % en producción
Basada en el padre Respeta la decisión del primer servicio Siempre, combinada con la anterior
Siempre activo 100 % Desarrollo y depuración puntual
Basada en la cola Decide al terminar, según el resultado Ideal, requiere colector propio

La combinación ParentBased(TraceIdRatioBased(0.1)) del apartado 11 es la correcta por defecto, y merece explicación: TraceIdRatioBased(0.1) muestrea el 10 % de las trazas nuevas, y ParentBased garantiza que si una traza se muestreó al principio, todos los servicios que la reciben también la muestrean. Sin ParentBased, cada servicio decidiría por su cuenta y obtendrías trazas incompletas con agujeros, que son peores que no tener trazas.

El muestreo basado en la cola es lo que todo el mundo querría —guardar el 100 % de las trazas lentas o con error, y el 1 % de las normales— porque las trazas interesantes son precisamente las anómalas. Requiere un colector de OpenTelemetry que retenga los spans hasta el final de la petición para decidir. Para AlpinaShop hoy es complejidad excesiva; conviene saber que existe para cuando el sistema crezca.

Recomendación por entorno:

Entorno Proporción Motivo
alpinashop-dev 100 % Volumen bajo, se quiere ver todo
alpinashop-prod normal 5-10 % Suficiente para detectar patrones
Durante un incidente Subir temporalmente Por eso es una variable de entorno

Que la proporción sea una variable de entorno (TRACE_SAMPLE_RATE) es deliberado: permite subirla al 100 % durante una investigación sin redesplegar código, y bajarla después.

  1. Cloud Profiler: perfilado continuo

Una traza dice que una función tarda 800 ms. No dice qué hace durante esos 800 ms. Para eso está el perfilado.

Cloud Profiler recoge continuamente muestras de CPU y memoria de la aplicación en producción, con una sobrecarga muy baja —del orden de un pequeño porcentaje—, y muestra un gráfico de llamas con dónde se consume el tiempo y la memoria.

# catalogo/app.py, al principio del arranque
import googlecloudprofiler

try:
    googlecloudprofiler.start(
        service="catalogo-web",
        service_version=os.environ["VERSION_IMAGEN"],  # el SHA de 06-01
        verbose=0,
    )
except (ValueError, NotImplementedError) as e:
    log.warning("No se pudo iniciar el profiler: %s", e)

Los tres casos donde Profiler resuelve lo que ningún otro pilar puede:

Caso Síntoma Lo que revela
Fuga de memoria El pod se reinicia cada pocas horas por OOM Qué estructura crece sin liberarse
CPU alta sin causa clara La métrica dice 85 %, la traza no señala nada Qué función concreta consume
Optimizar lo que importa Se quiere mejorar rendimiento Dónde se va el tiempo de verdad

El tercero merece una advertencia: la intuición sobre dónde está el cuello de botella suele ser errónea. Es habitual pasar dos días optimizando una función que consume el 3 % del tiempo total mientras el 60 % se lo lleva una serialización JSON que nadie miró. Profiler evita ese desperdicio con datos en lugar de conjeturas.

Y la comparación por versión es donde brilla: con service_version fijado al SHA de la imagen, se puede comparar el perfil de dos versiones y ver exactamente qué introdujo una regresión de rendimiento.

  1. La correlación que lo une todo: un incidente de principio a fin

Este apartado es la razón de ser de todo el módulo. Volvemos al momento con el que empezó la lección.

flowchart TD
    A[23:14 ALERTA<br/>Tasa 5xx > 2%] --> B[23:15 PANEL<br/>4,3% errores, p95 3,2s]
    B --> C[23:17 LOGS<br/>severity=ERROR<br/>últimos 30 min]
    C --> D[23:19 Un patrón:<br/>causa=timeout_consulta<br/>en /catalogo]
    D --> E[23:21 TRAZA<br/>desde el trace del log]
    E --> F[23:23 CASCADA<br/>SELECT stock_por_talla<br/>3.020 ms]
    F --> G[23:25 CAUSA<br/>índice eliminado<br/>en la migración de las 22:50]
    G --> H[23:31 MITIGACIÓN<br/>vuelta atrás con 06-01]

23:14 — La alerta. Llega al móvil de Marta. Trae el campo documentation con los cuatro primeros pasos, así que no empieza desde cero.

23:15 — El panel. Confirma: 4,3 % de errores, p95 de 3,2 s frente a los 400 ms habituales, y la CPU del MIG baja. Ese último dato es informativo por sí solo: si hubiera un pico de tráfico, la CPU estaría alta. No lo está, así que el problema no es de capacidad.

23:17 — Los logs. Primera consulta, la del apartado 5:

resource.type="k8s_container"
resource.labels.namespace_name="tienda"
severity>=ERROR
timestamp>="2026-08-05T23:00:00Z"

Aparecen unas 800 entradas. Marta mira una:

{
  "severity": "ERROR",
  "jsonPayload": {
    "mensaje": "Timeout consultando stock",
    "sku": "piolet-01",
    "causa": "timeout_consulta",
    "duracion_ms": 5001,
    "id_cliente_hash": "a3f9c1..."
  },
  "labels": {"componente": "catalogo", "version": "a3f9c1b"},
  "trace": "projects/alpinashop-prod/traces/4bf92f3577b34da6a3ce929d0e0e4736"
}

23:19 — El patrón. Refina la consulta agrupando por causa y descubre que el 94 % de los errores tienen causa="timeout_consulta" y todos son de rutas de catálogo. No es un fallo general: es una operación concreta.

23:21 — La traza. Y aquí está el salto que justifica toda la instrumentación del apartado 11: en la consola, el campo trace de esa entrada de log es un enlace. Un clic y aparece la traza completa de esa petición exacta, la del cliente concreto que sufrió el error.

23:23 — La cascada. La traza es la del apartado 12: SELECT stock_por_talla consume 3.020 de 3.240 ms. Con la consulta SQL visible en los atributos del span.

23:25 — La causa. Marta ejecuta la consulta 3 sobre los logs de auditoría y sobre el historial de Cloud Build: a las 22:50 se aplicó una migración de base de datos que, entre otras cosas, eliminó y recreó una tabla auxiliar sin volver a crear el índice sobre (sku, almacen). Sin ese índice, la consulta pasa de un acceso indexado a un recorrido completo.

23:31 — La mitigación. Se recrea el índice, y en paralelo se prepara la vuelta atrás de la versión con el pipeline de 06-01. La latencia vuelve a la normalidad en tres minutos.

Diecisiete minutos desde la alerta hasta la causa raíz identificada. Sin este recorrido, la misma investigación habría sido: enterarse por un cliente a la mañana siguiente, mirar la web, no reproducir el problema porque el tráfico nocturno es distinto, revisar logs sin estructura buscando texto, sospechar de tres cosas equivocadas y, con suerte, dar con ello al final del día.

Pieza Qué aportó exactamente
Alerta (06-04) Enterarse en 5 minutos, no a la mañana siguiente
Panel (06-04) Confirmar el alcance y descartar la falta de capacidad
Logs estructurados Agrupar 800 errores en un patrón, no leer 800 líneas
jsonPayload Agrupar por causa sin analizar texto
Campo trace El salto del log a la traza: un clic
Traza Localizar el 93 % del tiempo en una consulta
Atributo SQL del span La consulta exacta, sin adivinar
Logs de auditoría Correlacionar con el cambio de las 22:50
Etiqueta version Saber qué versión introdujo el problema
Pipeline (06-01) Vuelta atrás en minutos, con una imagen conocida

La lección que hay que llevarse: ninguna pieza sirve sola. Una alerta sin logs solo produce ansiedad. Logs sin estructura son un vertedero. Trazas sin logs correlacionados obligan a buscar a ciegas cuál mirar entre millones. El valor está en los enlaces entre las piezas, y el enlace concreto que hace posible todo el recorrido es una línea de código: incluir el trace_id en cada entrada de log.

  1. Coste por volumen y las tres decisiones que lo controlan

El modelo es sencillo: se paga por GB ingerido, con un volumen gratuito mensual. La retención dentro del periodo por defecto no se factura aparte; ampliarla, sí. Y verifica siempre los precios vigentes en la documentación oficial.

Componente Se paga por Nivel gratuito
Ingesta de logs GB ingeridos Un volumen mensual generoso
Retención ampliada GB × mes por encima del periodo por defecto
Logs de auditoría de administración Nada Siempre gratuitos
Ingesta de trazas Spans ingeridos Un volumen mensual
Profiler Nada Incluido
Sumideros a Cloud Storage o Pub/Sub El destino, no el enrutamiento

Las tres decisiones que controlan la factura, por orden de impacto:

Decisión 1: qué se ingiere. Es la más importante con diferencia, porque afecta a todo lo demás. Los filtros de exclusión del apartado 8 sobre comprobaciones de estado y estáticos suelen recortar entre el 40 % y el 70 % del volumen de un sitio web típico, sin perder nada de valor diagnóstico. Y en la aplicación: DEBUG desactivado en producción y ningún log dentro de bucles.

Decisión 2: cuánto tiempo se guarda y dónde. Treinta días en el bucket de logs para diagnóstico, y lo que deba conservarse más tiempo, exportado. Un sumidero a Cloud Storage con ciclo de vida a Coldline y Archive hace que siete años de logs de pagos cuesten órdenes de magnitud menos que ampliar la retención del bucket.

Decisión 3: cuánto se muestrea. Aplica al log del balanceador (--logging-sample-rate), a los VPC Flow Logs (--logging-flow-sampling) y a las trazas (TRACE_SAMPLE_RATE). Con volumen alto, el 10 % suele bastar para detectar patrones, porque la estadística no necesita el censo completo.

Y el error que hay que evitar por encima de todos: excluir logs de error para ahorrar. Son un porcentaje mínimo del volumen y el 100 % del valor durante un incidente. El ahorro está en el ruido repetitivo, nunca en lo excepcional.

Una perspectiva final para calibrar: para AlpinaShop, la observabilidad completa —logs, trazas, métricas, alertas— está en el orden de una fracción pequeña del coste de la infraestructura que observa. Comparado con los diecisiete minutos del apartado 15 frente a un día entero de investigación a ciegas, y con los pedidos que se salvan al detectar un incidente en cinco minutos en lugar de en doce horas, es de las inversiones más rentables de la plataforma.

Errores Comunes y Consejos

Usar print en lugar de logging estructurado. El texto acaba en Cloud Logging, sí, pero sin severidad, sin campos consultables y sin trace. Es la diferencia entre poder investigar y no poder.

Meter los datos en el mensaje en lugar de en campos. f"pedido {id} falló" obliga a analizar texto. "Pedido fallido" más {"id_pedido": id} permite filtrar y agregar.

Escribir datos personales en los logs. Correos, nombres, tarjetas, direcciones. Los logs se retienen, se exportan y los lee mucha gente. Usa hashes estables.

Todo con severidad INFO. Impide filtrar por gravedad y hace inútiles las alertas basadas en severity>=ERROR. Y un reintento que funciona es WARNING, no ERROR.

Olvidar el writerIdentity al crear un sumidero. El sumidero se crea sin error y no entrega nada. Es el primer sitio donde mirar cuando un destino está vacío.

Excluir logs sin pensarlo dos veces. Lo excluido no se guarda en ningún sitio y no se recupera. Ante la duda, muestrea con percent en lugar de excluir del todo. Y nunca excluyas errores ni logs de auditoría.

Guardar años de logs en un bucket de logs. Es la forma más cara. Para retención larga, sumidero a Cloud Storage con ciclo de vida.

Etiquetas de alta cardinalidad en métricas basadas en logs. El mismo error de 06-04: EXTRACT(jsonPayload.id_pedido) crea una serie temporal por pedido. Extrae dimensiones con decenas de valores, no miles.

Trazar sin ParentBased en el muestreador. Cada servicio decide por su cuenta y obtienes trazas incompletas con agujeros, que confunden más de lo que ayudan.

No incluir el trace_id en los logs. Es una línea de código y es la pieza que convierte tres herramientas independientes en un sistema de diagnóstico. Sin ella, saltar del log a la traza es imposible.

Optimizar por intuición en lugar de por perfil. Dos días mejorando una función que consume el 3 % del tiempo. Mide primero con Profiler.

Consejo final: la instrumentación se prueba antes del incidente. Provoca un error a propósito en alpinashop-dev, busca su log, salta a su traza y comprueba que el recorrido completo funciona. Descubrir a las once de la noche que el campo trace está vacío es descubrirlo en el peor momento posible.

Ejercicios

Ejercicio 1: rediseñar el logging de un módulo

Este es el código real de una función del catálogo de AlpinaShop:

def procesar_devolucion(id_pedido, email_cliente, motivo):
    print("Procesando devolución del pedido " + str(id_pedido))
    try:
        pedido = obtener_pedido(id_pedido)
        if pedido.estado != "entregado":
            print("Pedido no entregado, no se puede devolver: " + str(id_pedido))
            return False
        reembolso = calcular_reembolso(pedido)
        ejecutar_reembolso(pedido, reembolso)
        print("Devolución OK para " + email_cliente + ", importe " + str(reembolso))
        return True
    except Exception as e:
        print("Error: " + str(e))
        return False

Reescríbelo con logging estructurado corrigiendo todos los problemas que detectes, justifica cada cambio, y escribe la consulta del explorador de logs que responda a "¿cuántas devoluciones se rechazaron por estado incorrecto esta semana, y de qué importe medio?".

Ejercicio 2: diseñar la estrategia de retención y exportación

AlpinaShop genera aproximadamente: 80 GB/mes de logs del balanceador, 15 GB/mes de logs de la aplicación, 30 GB/mes de VPC Flow Logs, 2 GB/mes de logs de auditoría de administración y 5 GB/mes de logs de Cloud SQL. Requisitos: los logs de transacciones de pago deben conservarse 7 años por normativa; el equipo necesita 30 días para diagnóstico; Lucía quiere analizar el log de acceso con SQL durante los últimos 12 meses; y hay que reducir el coste todo lo posible sin perder capacidad de diagnóstico. Diseña la configuración completa de buckets, sumideros y exclusiones, con una estimación cualitativa del ahorro.

Ejercicio 3: diagnosticar con una traza incompleta

Un cliente reporta que la página de un producto tarda 8 segundos. Al buscar la traza, Marta encuentra: span raíz GET /catalogo/crampon-02 de 8.100 ms; dentro, consultar_producto de 120 ms y render_plantilla de 90 ms; y nada más. Los 7.890 ms restantes no están cubiertos por ningún span hijo. Los logs de esa petición solo muestran una entrada INFO al principio. Explica qué significa ese hueco, enumera al menos cuatro hipótesis con lo que las distinguiría, y detalla qué instrumentación añadirías para que este caso sea diagnosticable la próxima vez.

Soluciones

Solución 1

Problemas del código original, siete en total:

Problema Gravedad Consecuencia
print en lugar de logging Alta Sin severidad, sin campos, sin trace
email_cliente en el log Crítica Fuga de datos personales, RGPD
Datos concatenados en el mensaje Alta Imposible filtrar o agregar
Todo con severidad implícita INFO Alta Un error no se distingue de un evento normal
except Exception sin traceback Alta Se pierde dónde falló
Sin contexto en el error Alta "Error: X" no dice de qué pedido
Rechazo registrado como texto Media No se puede contar ni analizar

Versión reescrita:

from catalogo.registro import log, campos_traza
import hashlib

def hash_cliente(email: str) -> str:
    """Hash estable: permite correlacionar sin identificar."""
    return hashlib.sha256(email.lower().encode()).hexdigest()[:16]


def procesar_devolucion(id_pedido, email_cliente, motivo):
    # Contexto común a todas las entradas de esta operación
    contexto = {
        "id_pedido": str(id_pedido),
        "id_cliente_hash": hash_cliente(email_cliente),   # NUNCA el correo
        "motivo": motivo,
        "operacion": "devolucion",
        **campos_traza(),                                  # enlace con la traza
    }

    log.info("Devolución iniciada", extra={"json_fields": contexto})

    try:
        pedido = obtener_pedido(id_pedido)

        if pedido.estado != "entregado":
            # WARNING, no ERROR: el sistema funciona, es una regla de negocio
            log.warning("Devolución rechazada", extra={"json_fields": {
                **contexto,
                "resultado": "rechazada",
                "causa": "estado_incorrecto",
                "estado_pedido": pedido.estado,
                "importe_pedido_eur": float(pedido.importe),
            }})
            return False

        reembolso = calcular_reembolso(pedido)
        ejecutar_reembolso(pedido, reembolso)

        log.info("Devolución completada", extra={"json_fields": {
            **contexto,
            "resultado": "completada",
            "importe_reembolso_eur": float(reembolso),
            "dias_desde_entrega": (hoy() - pedido.fecha_entrega).days,
        }})
        return True

    except PedidoNoEncontrado:
        log.error("Devolución fallida: pedido inexistente",
                  extra={"json_fields": {**contexto, "resultado": "error",
                                         "causa": "pedido_no_encontrado"}})
        return False

    except ErrorPasarelaReembolso as e:
        # log.exception incluye el traceback completo automáticamente
        log.exception("Devolución fallida: error de la pasarela",
                      extra={"json_fields": {**contexto, "resultado": "error",
                                             "causa": "error_pasarela",
                                             "codigo_pasarela": e.codigo}})
        raise      # se relanza: alguien tiene que enterarse

    except Exception:
        log.exception("Devolución fallida: error inesperado",
                      extra={"json_fields": {**contexto, "resultado": "error",
                                             "causa": "desconocida"}})
        raise

Justificación de los cambios más importantes:

  • El hash del correo resuelve la fuga sin perder capacidad de investigación: atención al cliente aplica la misma función y encuentra las entradas.
  • WARNING para el rechazo por estado. Es una decisión deliberada: el sistema hizo lo correcto, no hay avería. Si fuera ERROR, la alerta de 06-04 saltaría por devoluciones perfectamente normales, alimentando la fatiga del apartado 11 de aquella lección.
  • El campo resultado con valores acotados (completada, rechazada, error) permite construir un embudo completo con una sola métrica basada en logs.
  • Excepciones específicas antes que la genérica. Cada una registra su causa, lo que permite distinguir un problema de la pasarela de un error de programación, que requieren respuestas muy distintas.
  • raise en los errores reales. El código original devolvía False tanto si el pedido no era devolvible como si la pasarela estaba caída, y eso hace imposible distinguirlos desde fuera.
  • campos_traza() en todas las entradas. Lo que permite el salto del apartado 15.

Consulta pedida:

resource.type="k8s_container"
resource.labels.namespace_name="tienda"
jsonPayload.operacion="devolucion"
jsonPayload.resultado="rechazada"
jsonPayload.causa="estado_incorrecto"
timestamp>="2026-07-29T00:00:00Z"

Para el importe medio, el explorador cuenta pero no promedia bien. Dos opciones. La rápida, una métrica de distribución basada en logs sobre jsonPayload.importe_pedido_eur con ese filtro, que da el p50 y la media en Cloud Monitoring. La completa, un sumidero a BigQuery y SQL:

SELECT
  COUNT(*) AS rechazadas,
  ROUND(AVG(CAST(JSON_VALUE(jsonPayload.importe_pedido_eur) AS FLOAT64)), 2) AS importe_medio,
  JSON_VALUE(jsonPayload.estado_pedido) AS estado
FROM `alpinashop-datos.logs_app.stdout_*`
WHERE _TABLE_SUFFIX BETWEEN '20260729' AND '20260805'
  AND JSON_VALUE(jsonPayload.causa) = 'estado_incorrecto'
GROUP BY estado
ORDER BY rechazadas DESC;

Y una observación que va más allá del ejercicio: ese desglose por estado_pedido es información de producto, no de infraestructura. Si resulta que la mayoría de los rechazos son de pedidos en estado en_transito, el hallazgo no es técnico: es que hay clientes intentando devolver algo que aún no han recibido, y probablemente la interfaz no se lo está explicando bien. Un buen logging estructurado acaba respondiendo preguntas de negocio que nadie había pensado hacer.

Solución 2

Volumen total: 132 GB/mes. El log del balanceador es el 61 % y los Flow Logs el 23 %: entre ambos, el 84 %. Ahí está todo el margen.

Paso 1 — Exclusiones (la palanca principal):

Exclusión Filtro Ahorro estimado
Comprobaciones de estado httpRequest.requestUrl:"/salud" ~15 GB/mes
Estáticos 200, 95 % muestreado Extensiones de assets, status=200 ~30 GB/mes
Flow Logs a muestreo 0,25 --logging-flow-sampling=0.25 ~15 GB/mes
DEBUG en producción severity=DEBUG ~2 GB/mes

Volumen ingerido resultante: unos 70 GB/mes, cerca de la mitad. Y sin perder capacidad de diagnóstico: las comprobaciones de estado ya están cubiertas por las métricas de 06-04, los estáticos conservan una muestra del 5 % suficiente para ver tendencias, y el 25 % de Flow Logs sigue detectando patrones de conectividad.

Paso 2 — Buckets con retención diferenciada:

Bucket Contenido Retención Motivo
_Default Aplicación, LB, SQL, Flow Logs 30 días Ventana de diagnóstico
_Required Auditoría de administración 400 días Fijo y gratuito
logs-pagos jsonPayload.componente="checkout" 30 días Ojo, ver abajo

La decisión clave, y es contraintuitiva: el bucket logs-pagos no se configura con 7 años de retención. Guardar siete años en un bucket de logs es la opción más cara con diferencia. La normativa exige conservar, no exige que estén consultables en el explorador de logs. Por eso:

Paso 3 — Sumideros:

# 1. Cumplimiento: pagos a Cloud Storage, con ciclo de vida
gcloud logging sinks create sumidero-pagos-archivo \
  storage.googleapis.com/alpinashop-logs-pagos \
  --log-filter='jsonPayload.componente="checkout" OR jsonPayload.operacion="devolucion"' \
  --project=alpinashop-prod

# 2. Análisis: log de acceso a BigQuery, particionado
gcloud logging sinks create sumidero-acceso-bq \
  bigquery.googleapis.com/projects/alpinashop-datos/datasets/logs_acceso \
  --log-filter='resource.type="http_load_balancer"' \
  --use-partitioned-tables \
  --project=alpinashop-prod

# 3. Seguridad: cambios sensibles a Pub/Sub
gcloud logging sinks create sumidero-seguridad \
  pubsub.googleapis.com/projects/alpinashop-prod/topics/eventos-seguridad \
  --log-filter='protoPayload.methodName=~"firewalls\.(insert|patch|delete)" OR protoPayload.methodName="SetIamPolicy"' \
  --project=alpinashop-prod

Con el ciclo de vida del bucket de archivo: Nearline a 30 días, Coldline a 90, Archive a 365, borrado a 2.555 días (7 años). Y en BigQuery, caducidad de partición a 365 días para el requisito de Lucía.

Estimación cualitativa del ahorro:

Concepto Antes Después
Ingesta 132 GB/mes ~70 GB/mes
Retención de pagos 7 años en bucket de logs Archive en Cloud Storage
Análisis de 12 meses Imposible sin retención larga BigQuery particionado
Capacidad de diagnóstico 30 días completos 30 días completos

El ahorro grueso viene de dos sitios: la mitad del volumen ingerido y, sobre todo, sacar la retención larga del servicio caro al barato, donde la diferencia de precio por GB-mes entre un bucket de logs y Archive es de dos órdenes de magnitud.

Y tres advertencias que completan el diseño. Primera: nunca excluir errores ni auditoría; son un porcentaje mínimo del volumen y el 100 % del valor. Segunda: verificar que el sumidero de pagos captura todo lo que la normativa exige antes de reducir la retención del bucket, porque si el filtro está mal, el requisito legal se incumple en silencio. Tercera: poner una alerta sobre el volumen ingerido con lo de 06-04, para enterarse si alguien introduce un log en un bucle antes de que llegue la factura.

Solución 3

Qué significa el hueco. 7.890 ms de los 8.100 no están cubiertos por ningún span. Y eso tiene un significado muy concreto: está ocurriendo algo que la instrumentación no ve. Solo hay dos familias de explicación —código no instrumentado, o tiempo de espera que no es una operación— y distinguirlas es todo el ejercicio.

Es importante notar algo: los dos spans que sí existen son rápidos. Si el problema estuviera en la consulta o en el renderizado, se vería. El tiempo se va en el espacio entre spans, y eso es lo que hay que investigar.

Cuatro hipótesis, con lo que las distingue:

Hipótesis Qué ocurre Cómo distinguirla
1. Llamada externa no instrumentada Una API de terceros, un caché remoto, un servicio interno sin instrumentar VPC Flow Logs: ¿hay tráfico saliente en esa ventana? Revisar el código en busca de urllib, httpx u otro cliente no cubierto por RequestsInstrumentor
2. Espera por el pool de conexiones La petición espera a que se libere una conexión a Cloud SQL Métrica de conexiones (06-04): ¿está en el límite? El span de la consulta es corto, pero se esperó mucho antes de empezarlo
3. Bloqueo por GIL, CPU o memoria Otra petición monopoliza el proceso, o hay recolección de basura agresiva Cloud Profiler: gráfico de llamas en esa franja. Métrica de CPU del pod y de memoria
4. Espera de red externa Latencia en la respuesta al cliente, cliente lento o TCP con problemas Comparar total_latencies con backend_latencies en el log del balanceador: si difieren mucho, el tiempo está fuera de la aplicación

Cuál es la más probable y por qué. La hipótesis 1, y por un razonamiento que conviene explicitar: la instrumentación automática de OpenTelemetry cubre Flask, requests y SQLAlchemy. Si el código usa cualquier otro clienteurllib3 directamente, un SDK de un proveedor de pagos, un cliente de Redis, la biblioteca de Firestore— esas llamadas son completamente invisibles. Y ocho segundos con un patrón tan limpio huele a timeout de una llamada externa, muy probablemente uno configurado en 8 segundos exactos.

La hipótesis 2 es la segunda candidata y tiene una firma reconocible: se manifiesta bajo carga y desaparece cuando el tráfico baja. Si el problema solo ocurre en horas punta, es esa.

Instrumentación a añadir, en orden de prioridad:

1. Instrumentar todos los clientes salientes. La medida que resuelve el caso:

# Añadir a catalogo/trazas.py
from opentelemetry.instrumentation.urllib3 import URLLib3Instrumentor
from opentelemetry.instrumentation.redis import RedisInstrumentor
from opentelemetry.instrumentation.httpx import HTTPXClientInstrumentor

URLLib3Instrumentor().instrument()      # cubre SDKs que no usan requests
RedisInstrumentor().instrument()
HTTPXClientInstrumentor().instrument()

2. Un span explícito alrededor de cada operación de negocio. La regla general: si una operación puede tardar, envuélvela en un span. Es barato y elimina huecos por construcción:

def ficha_producto(sku):
    with tracer.start_as_current_span("ficha_producto") as span:
        span.set_attribute("sku", sku)

        with tracer.start_as_current_span("obtener_conexion_bd"):
            conn = pool.acquire()       # ← LA ESPERA DEL POOL, ahora visible

        with tracer.start_as_current_span("consultar_producto"):
            producto = consultar(conn, sku)

        with tracer.start_as_current_span("consultar_valoraciones"):
            valoraciones = api_valoraciones.obtener(sku)   # ← la llamada externa

El span obtener_conexion_bd es especialmente valioso porque separa el tiempo de espera del tiempo de trabajo, que es justo lo que confunde la hipótesis 2 con la 1.

3. Un log al entrar y al salir de cada operación larga, con duracion_ms. Aunque falte el span, dos entradas de log con marca de tiempo acotan dónde se fue el tiempo.

4. Timeouts explícitos y agresivos en todo cliente externo, y registro cuando saltan:

try:
    resp = requests.get(url, timeout=(2, 3))   # conexión 2 s, lectura 3 s
except requests.Timeout:
    log.warning("Timeout en API de valoraciones", extra={"json_fields": {
        "servicio": "api_valoraciones", "timeout_s": 3, **campos_traza()}})
    valoraciones = []      # degradación elegante: la ficha se muestra sin ellas

Esta cuarta medida es la más importante a largo plazo, y va más allá del diagnóstico. Sin timeout explícito, la mayoría de los clientes HTTP esperan indefinidamente o el tiempo por defecto del sistema operativo, que puede ser muy largo. Un servicio del que dependes y que se degrada arrastra al tuyo, y ocho segundos de espera es exactamente eso. Con timeout corto y degradación elegante, una API de valoraciones caída produce una ficha sin valoraciones en 3 segundos en lugar de una página que no carga en 8.

Y la lección general del ejercicio: un hueco en una traza no es un fallo de la traza, es información. Te está diciendo con precisión que hay una parte del sistema que no estás observando. La reacción correcta no es desconfiar de la herramienta, sino preguntarse qué hace el código en ese intervalo que nadie instrumentó. En este caso, casi seguro, esperar a alguien que no contesta.

Conclusión

AlpinaShop tiene los tres pilares de la observabilidad completos, y —más importante— tiene los enlaces entre ellos.

Sabes qué es una entrada de log estructurada y por qué la diferencia entre textPayload y jsonPayload es la diferencia entre poder investigar y no poder. Conoces todos sus campos, y sabes cuál es el decisivo: trace, el que enlaza el log con su traza.

Sabes de dónde salen los logs automáticamente y cuáles hay que activar explícitamente: los del balanceador —de los más valiosos que existen, con su latencia, su país y su resultado de CDN— y los VPC Flow Logs. Y sabes emitirlos bien desde Flask, con las cinco reglas: los datos van en campos y no en el mensaje, la severidad debe ser correcta, nunca datos personales, contexto suficiente para actuar y sin ruido. Con el criterio de severidad que más se falla: un reintento que funciona es WARNING; uno agotado, ERROR.

Dominas el explorador de logs y su lenguaje —con el : que es "contiene" y no "igual"—, los tres consejos de rendimiento, y cinco patrones de consulta que resuelven incidentes reales: los errores desde un despliegue, la petición de un cliente concreto localizada por su hash, quién borró un recurso según los logs de auditoría, las peticiones lentas del balanceador y los errores de la función de imágenes.

Conoces los buckets de logs con _Required y _Default, la retención con sus tres horizontes —diagnóstico, análisis y cumplimiento, cada uno en su sitio— y las vistas que permiten dar acceso a un subconjunto sin exponer los logs de auditoría. Sabes crear sumideros a BigQuery para analizar con SQL, a Cloud Storage para archivar barato con ciclo de vida y a Pub/Sub para reaccionar con una función de 06-03, con el writerIdentity que todo el mundo olvida. Y sabes usar los filtros de exclusión con el percent que muestrea en lugar de descartar, con la advertencia de que lo excluido no se recupera nunca y de que los errores y la auditoría no se excluyen jamás.

Tienes las métricas basadas en logs —contadores y distribuciones— que instrumentan sin tocar el código y alimentan las alertas de 06-04, con la etiqueta version que responde a "¿esto empezó con el despliegue de ayer?" y la misma regla de cardinalidad de siempre.

Sabes qué es el rastreo distribuido: traza y span, la propagación del contexto con traceparent de W3C y X-Cloud-Trace-Context que el balanceador ya está añadiendo. Sabes instrumentar Flask con OpenTelemetry —estándar, no propietario—, con la instrumentación automática de Flask, requests y SQLAlchemy, spans manuales para el detalle, y la pieza clave: incluir el trace_id en cada log. Sabes leer una cascada mirando las tres cosas que importan —qué span domina, si hay huecos y si hay spans repetidos que delaten un N+1—, y sabes muestrear con ParentBased(TraceIdRatioBased(...)) para no acabar con trazas incompletas. Conoces Cloud Profiler y sus tres casos, con la advertencia de que la intuición sobre dónde está el cuello de botella suele fallar.

Y tienes el recorrido completo del apartado 15: de la alerta a la métrica, de la métrica al log, del log a la traza y de la traza a la línea de código, en diecisiete minutos. Con la lección que resume todo el módulo: ninguna pieza sirve sola; el valor está en los enlaces, y el enlace que lo hace posible cabe en una línea de código.

Por último, conoces el coste y las tres decisiones que lo controlan —qué se ingiere, cuánto se guarda y dónde, cuánto se muestrea— con el error que nunca hay que cometer: ahorrar excluyendo errores, que son el mínimo del volumen y el máximo del valor.

Mira ahora dónde está AlpinaShop. El código está en GitHub y se revisa. El pipeline construye, prueba y despliega solo. Las funciones reaccionan a eventos. Hay paneles, alertas, comprobaciones desde cuatro continentes, logs estructurados, trazas y perfiles. Cuando algo falla, se sabe en cinco minutos y se diagnostica en veinte.

Y sin embargo, toda la infraestructura que sostiene eso —la VPC, el balanceador, el clúster, las alertas que acabas de crear, los sumideros que acabas de configurar— sigue existiendo porque alguien ejecutó los comandos correctos. En 06-05 viste el problema con claridad, entendiste qué es la infraestructura como código, conociste Deployment Manager y aprendiste el procedimiento de migración. Lo que falta es la herramienta.

En 06-07, la última lección del módulo, llega Terraform: el estado y por qué nunca va a Git, el flujo plan y apply, el código real de AlpinaShop en HCL, los módulos, la importación de todo lo creado a mano y el plan automático en cada pull request. Y con ello, la infraestructura de AlpinaShop deja por fin de vivir en el historial de un terminal.

Curso de Google Cloud Platform (GCP)

Módulo 1: Introducción a Google Cloud Platform

Módulo 2: Servicios principales de GCP

Módulo 3: Redes y seguridad

Módulo 4: Datos y análisis

Módulo 5: Aprendizaje automático e IA

Módulo 6: DevOps y monitoreo

Módulo 7: Temas avanzados de GCP

Módulo 8: Proyecto final

© Copyright 2026. Todos los derechos reservados