Módulo 3: Métricas — latencia, throughput y errores

8. Mini-proyecto: mide las métricas de Reservo

Descripción

Llegó el momento de poner el módulo entero en tus manos. En este mini-proyecto levantas la API de Reservo —con el endpoint lento declarado—, corres un generador de carga sobre ella, y produces un reporte de métricas completo: p50, p95 y p99 de latencia, RPS y tasa de error, todo medido de verdad. Luego escribes el bloque de resumen de k6 equivalente como contenido, e interpretas por escrito la pregunta que atraviesa todo el módulo: por qué el p95 le importa al usuario más que el promedio. No es un ejercicio de repetir definiciones; es el trabajo real de quien interpreta una prueba de carga, hecho por ti de principio a fin.

Conexión con el módulo: este proyecto ejercita las siete lecciones a la vez. Levantas la API (la infraestructura del módulo 1), lanzas VUs (módulo 2), mides latencia y calculas percentiles (lecciones 2-3), reportas throughput (lección 4) y tasa de error (lección 5), escribes y lees un resumen de k6 (lección 6), e interpretas promedio vs p95 (lección 7). Es el cierre del módulo y la antesala del 4, donde en vez de una carga fija empezarás a variar la carga en el tiempo (rampas, picos) y observarás cómo se mueven estas mismas métricas.

Qué vas a entregar

Tu entrega tiene cuatro piezas:

  1. La API de Reservo (server.py) corriendo, con el endpoint lento /quote_slow declarado.
  2. El generador de carga (loadgen.py) que golpea un endpoint, mide cada latencia, y calcula p50/p95/p99, RPS y % de error.
  3. La salida real de una corrida sobre /quote_slow (métricas medidas) y una sobre /quote_flaky (para la tasa de error).
  4. Una interpretación escrita (un párrafo) que responda: para el usuario de Reservo, ¿por qué el p95 dice más que el promedio? Con tus propios números.

Paso 1: la API de Reservo (con el endpoint lento declarado)

Este es el servidor canónico de Reservo, idéntico al de toda la guía, más los dos endpoints de laboratorio que este módulo declara (/quote_slow y /quote_flaky). Guárdalo como server.py. Fíjate en que se sirve en puerto 0: el sistema operativo asigna uno libre y el servidor lo imprime en su primera línea, para no chocar con nada.

# server.py — API de Reservo (canónica) + endpoints declarados del módulo 3
import json, random, time
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer

# Tarifas por hora en CENTAVOS enteros (nunca float para dinero).
HOURLY_CENTS = {"Focus": 2500, "Studio": 4000, "Boardroom": 8000}

def price_cents(room, tier, hours):
    """Precio en centavos: tarifa * horas, con 20% de descuento entero si es pro."""
    base = HOURLY_CENTS[room] * hours
    if tier == "pro":
        return base * 80 // 100   # descuento entero, sin decimales
    return base

class ReservoHandler(BaseHTTPRequestHandler):
    def log_message(self, *args):
        pass  # silencio: no ensuciar la salida de la prueba

    def _send(self, status, payload):
        body = json.dumps(payload).encode()
        self.send_response(status)
        self.send_header("Content-Type", "application/json")
        self.send_header("Content-Length", str(len(body)))
        self.end_headers()
        self.wfile.write(body)

    def _read_json(self):
        length = int(self.headers.get("Content-Length", 0))
        return json.loads(self.rfile.read(length) or b"{}")

    def do_GET(self):
        if self.path == "/rooms":
            rooms = [{"room": r, "hourly_cents": c} for r, c in HOURLY_CENTS.items()]
            self._send(200, {"rooms": rooms})
        else:
            self._send(404, {"error": "not_found"})

    def do_POST(self):
        body = self._read_json()
        room, tier, hours = body.get("room"), body.get("tier"), body.get("hours")

        if self.path == "/quote":
            self._send(200, {"price_cents": price_cents(room, tier, hours)})

        elif self.path == "/quote_slow":
            # DECLARADO: simula una dependencia lenta con COLA larga.
            if random.random() < 0.10:
                time.sleep(random.uniform(0.12, 0.25))    # la cola (tail)
            else:
                time.sleep(random.uniform(0.004, 0.012))  # el caso común
            self._send(200, {"price_cents": price_cents(room, tier, hours)})

        elif self.path == "/quote_flaky":
            # DECLARADO: responde rápido, pero ~10% falla con 500.
            if random.random() < 0.10:
                self._send(500, {"error": "upstream_unavailable"})
            else:
                self._send(200, {"price_cents": price_cents(room, tier, hours)})

        elif self.path == "/book":
            self._send(200, {
                "booking_id": f"bk_{room}_{tier}_{hours}",
                "price_cents": price_cents(room, tier, hours),
                "confirmed": True,
            })
        else:
            self._send(404, {"error": "not_found"})

class ReservoServer(ThreadingHTTPServer):
    daemon_threads = True
    request_queue_size = 256   # backlog amplio: la cola es del endpoint, no del socket

if __name__ == "__main__":
    server = ReservoServer(("127.0.0.1", 0), ReservoHandler)  # puerto 0: el SO asigna
    print(server.server_address[1], flush=True)               # 1ª línea: el puerto
    server.serve_forever()

Antes de medir, verifica las anclas —que la API sigue siendo la de siempre— arrancándola y golpeándola con curl. Esta es la salida real:

Focus/basic/3h -> {"price_cents": 7500}
Focus/pro/3h   -> {"price_cents": 6000}

Si ves 7500 y 6000, tu Reservo es la canónica y puedes medir con confianza.

Paso 2: el generador de carga

Este es loadgen.py, el generador que reúne todo lo del módulo. Recibe el puerto, el endpoint, el total de peticiones y la concurrencia; mide cada latencia lado cliente, cuenta los errores, y al final calcula e imprime el reporte completo con statistics.

# loadgen.py — genera carga y reporta p50/p95/p99, RPS y % de error REALES
import json, statistics, sys, time, urllib.request
from concurrent.futures import ThreadPoolExecutor

PORT = int(sys.argv[1])
PATH = sys.argv[2] if len(sys.argv) > 2 else "/quote"
TOTAL = int(sys.argv[3]) if len(sys.argv) > 3 else 2000
CONCURRENCY = int(sys.argv[4]) if len(sys.argv) > 4 else 50

URL = f"http://127.0.0.1:{PORT}{PATH}"
PAYLOAD = json.dumps({"room": "Focus", "tier": "basic", "hours": 3}).encode()

def one_request():
    """Una petición. Devuelve (latencia_ms, ok). Cronometrada lado CLIENTE."""
    start = time.perf_counter()
    try:
        req = urllib.request.Request(URL, data=PAYLOAD,
                                     headers={"Content-Type": "application/json"})
        with urllib.request.urlopen(req, timeout=10) as resp:
            resp.read()
            ok = (resp.status == 200)
    except Exception:
        ok = False           # timeout, conexión caída, 500... todo es fallo
    return (time.perf_counter() - start) * 1000, ok

def pct(data, p):
    """Percentil p (1-99) con statistics.quantiles, método inclusivo."""
    return statistics.quantiles(data, n=100, method="inclusive")[p - 1]

def main():
    latencies, errors = [], 0
    wall_start = time.perf_counter()
    with ThreadPoolExecutor(max_workers=CONCURRENCY) as pool:
        for latency_ms, ok in pool.map(lambda _: one_request(), range(TOTAL)):
            latencies.append(latency_ms)
            if not ok:
                errors += 1
    wall = time.perf_counter() - wall_start
    latencies.sort()

    print(f"endpoint         {PATH}")
    print(f"total requests   {TOTAL}")
    print(f"concurrency      {CONCURRENCY} clientes")
    print(f"throughput RPS   {TOTAL / wall:8.1f} req/s")
    print(f"errors           {errors} ({errors / TOTAL * 100:.2f}%)")
    print("-- latencia (ms), lado cliente --")
    print(f"avg (promedio)   {statistics.fmean(latencies):8.2f}")
    print(f"p50              {pct(latencies, 50):8.2f}")
    print(f"p95              {pct(latencies, 95):8.2f}")
    print(f"p99              {pct(latencies, 99):8.2f}")

if __name__ == "__main__":
    main()

Paso 3: mide (la salida real)

Arranca el servidor, captura el puerto que imprime, y corre el generador. En una terminal:

# arranca la API; su primera línea es el puerto asignado por el SO
python3 server.py
# -> (imprime algo como 55870)

# en otra terminal, con ese puerto, corre el generador sobre el endpoint lento
python3 loadgen.py 55870 /quote_slow 2000 50

Qué esperar en /quote_slow. Como el endpoint tiene cola larga, verás un promedio moderado pero un p95 y un p99 muy por encima —la firma de la cola—. Esta es la salida real:

endpoint         /quote_slow
total requests   2000
concurrency      50 clientes
throughput RPS     1517.6 req/s
errors           0 (0.00%)
-- latencia (ms), lado cliente --
avg (promedio)      28.64
p50                 12.18
p95                182.81
p99                240.31

Ahora mide la tasa de error sobre el endpoint flaky:

python3 loadgen.py 55870 /quote_flaky 2000 30

Qué esperar en /quote_flaky. Latencia excelente, pero ~10% de error. Salida real:

endpoint         /quote_flaky
total requests   2000
concurrency      30 clientes
throughput RPS     4761.1 req/s
errors           200 (10.00%)
-- latencia (ms), lado cliente --
avg (promedio)       6.25
p50                  5.45
p95                 10.06
p99                 27.71

Con estas dos corridas tienes todo el material del reporte: de /quote_slow, la historia de la latencia con cola (p95 = 182.81 ms, muy por encima del promedio de 28.64); de /quote_flaky, la historia de la tasa de error (10% con una latencia que, sola, parecería sana). Nota que tus números no serán idénticos a estos al centavo de milisegundo —una prueba de carga varía un poco entre corridas, según la máquina y su estado—, pero la forma será la misma: en /quote_slow, p95 ≫ promedio; en /quote_flaky, ~10% de error con latencia baja.

Paso 4: el resumen de k6 equivalente (contenido)

Como parte de la entrega, escribe cómo se vería el resumen de k6 run para una prueba equivalente sobre /quote_slow. Recuerda: k6 no está instalado; esto es contenido rotulado, fiel al formato oficial, no ejecutado. Mapea tus métricas medidas a las líneas de k6:

  # CONTENIDO (así se ve `k6 run`; k6 no está instalado)

  █ TOTAL RESULTS

    HTTP
    http_req_duration..................: avg=28.6ms min=4.9ms med=12.2ms max=257ms p(90)=38.6ms p(95)=182.8ms
    http_req_failed....................: 0.00%   0 out of 2000
    http_reqs..........................: 2000    1517.6/s

    EXECUTION
    iterations.........................: 2000    1517.6/s
    vus................................: 50      min=50   max=50
    vus_max............................: 50      min=50   max=50

Fíjate en el puente: tu p95 = 182.81 de Python es la columna p(95)=182.8ms de k6; tu errors 0 (0.00%) es http_req_failed 0.00%; tu RPS 1517.6 es la tasa de http_reqs 1517.6/s; tu concurrency 50 es vus_max 50. No es magia: es lo mismo que mediste, en el formato de k6.

Paso 5: la interpretación escrita

Cierra la entrega con un párrafo que responda, con tus números, la pregunta del módulo: para el usuario de Reservo, ¿por qué el p95 dice más que el promedio? Un ejemplo de respuesta bien hecha:

En /quote_slow, el promedio de latencia fue 28.64 ms, pero el p95 fue 182.81 ms —más de seis veces mayor—. El promedio describe a un usuario que casi no existe: la mitad de las peticiones (p50 = 12.18 ms) fue más rápida que la mitad de ese promedio, mientras que 1 de cada 20 usuarios (el p95) esperó 183 ms o más. Si le prometiera al negocio "la cotización tarda 29 ms de media", estaría escondiendo que una parte real de los clientes vive una espera seis veces peor. El p95 hace visible a ese usuario de la cola —el que se frustra y quizás abandona—, y por eso es la métrica sobre la que se escribe una SLO (p(95) < 200 ms), no el promedio. Además, la corrida de /quote_flaky recuerda que la latencia no basta: allí el p95 era excelente (10 ms) pero el 10% de las peticiones falló, así que "rápido" no significó "bien". El veredicto honesto usa los tres instrumentos: p95 de latencia, RPS y tasa de error, leídos juntos.

Rúbrica de autoevaluación

Marca cada punto. Si fallas uno, vuelve a la lección indicada.

#Criterio¿Lo cumples?Lección
1La API arranca en puerto 0 y devuelve las anclas (7500, 6000).1
2El generador mide cada latencia lado cliente y cuenta errores (ok = status == 200).2, 5
3Calculas p50/p95/p99 con statistics.quantiles(..., n=100, method="inclusive").3
4Reportas el RPS como total / wall_time.4
5Reportas la tasa de error como errors / total y la lees primero.5
6En /quote_slow, tu p95 es mucho mayor que tu promedio (la cola).7
7En /quote_flaky, tienes ~10% de error con latencia baja.5
8Escribes el resumen de k6 como contenido rotulado, no como algo ejecutado.6
9Tu interpretación explica, con tus números, por qué el p95 > promedio importa.7
10Tu veredicto lee los tres instrumentos juntos (latencia, throughput, error).1, 5

Errores comunes

Reportar solo /quote_slow y olvidar la tasa de error. Qué pasa: la entrega tiene un p95 precioso de análisis de cola, pero ninguna corrida con errores, así que el tercer instrumento queda sin ejercitar. Por qué pasa: la lección de la cola es la vistosa y roba la atención. Cómo detectarlo: si tu reporte no tiene una corrida con % de error > 0, te falta la mitad del veredicto. Cómo corregirlo: incluye la corrida de /quote_flaky y léela con el orden correcto (error primero).

Presentar el resumen de k6 como si lo hubieras ejecutado. Qué pasa: la entrega pega un bloque de k6 sin rótulo, dando a entender que se corrió k6 run. Por qué pasa: se olvida la regla del entorno. Cómo detectarlo: si tu bloque de k6 no dice "contenido, no ejecutado", incumple la honestidad de la guía. Cómo corregirlo: rotúlalo siempre. Lo que ejecutaste de verdad es el generador de Python; el resumen de k6 es contenido fiel al formato.

Interpretar con adjetivos en vez de números. Qué pasa: la interpretación dice "el p95 es importante porque muestra la experiencia real", sin un solo número. Por qué pasa: es más fácil repetir la lección que aplicarla. Cómo detectarlo: si tu párrafo no cita tu p95, tu promedio y tu tasa de error, es genérico. Cómo corregirlo: ancla cada afirmación en un número medido tuyo ("mi promedio fue 28.64 pero mi p95 fue 182.81, seis veces más").

Resumen y siguiente paso

En este mini-proyecto mediste las métricas de Reservo con tus manos, de principio a fin: levantaste la API canónica con su endpoint lento declarado, corriste un generador de carga que calcula p50/p95/p99, RPS y % de error reales, escribiste el resumen de k6 equivalente como contenido rotulado, e interpretaste con tus propios números por qué el p95 le dice al usuario más que el promedio. Viste, medido por ti, las dos verdades del módulo: en /quote_slow, un p95 (182.81 ms) seis veces mayor que el promedio (28.64) —la cola que el promedio esconde—; y en /quote_flaky, un 10% de error con latencia excelente —el recordatorio de que "rápido" no es "bien"—.

Con esto cierras el corazón interpretativo de la guía. Ya sabes qué son las tres familias de métricas, cómo se calculan, cómo se leen en el resumen de k6, y —lo más importante— cómo sacar un veredicto honesto leyéndolas juntas y en el orden correcto. Antes de seguir deberías poder hacer todo el proyecto sin mirar las lecciones: levantar, medir, reportar e interpretar.

Lo que sigue, en el módulo 4, es dejar de medir una carga fija y empezar a modelar cómo varía la carga en el tiempo: los perfiles de carga (stages, rampas de subida y bajada, picos) y los executors de k6. La pregunta cambia de "¿qué métricas produce esta carga?" a "¿cómo se mueven estas métricas cuando la carga sube, se sostiene y baja?" —y para responderla necesitas exactamente los instrumentos que dominaste aquí—.

Recursos