Módulo 7: Analizar resultados y CI

3. Exportar resultados (JSON/CSV) y por qué

Descripción

El resumen que aparece en la pantalla al final de una corrida es cómodo para una mirada rápida, pero es efímero: cierras la terminal y se fue. Para hacer algo serio con un resultado —analizarlo con otra herramienta, adjuntarlo a un reporte, y sobre todo compararlo con corridas futuras— hay que exportarlo a un archivo. En esta lección aprendes a exportar: en k6 con k6 run --out json=results.json (y --out csv), presentado como contenido rotulado; y del lado ejecutable, con el generador de Python que escribe sus métricas reales a un results.json de verdad, más un CSV histórico que acumula corridas para ver la tendencia entre releases. La idea central es que exportar no es un lujo: es lo que convierte una corrida de un evento que se evapora en un artefacto que se guarda, se estudia y se compara.

Conexión con el módulo: esta lección produce el archivo que la lección 2 aprendió a leer y que la lección 4 va a comparar. Sin exportar no hay baseline, y sin baseline no hay detección de regresiones —así que esta lección es el cimiento de la mitad de analizar—. Reúsa las métricas del módulo 3 (p50/p95/p99, RPS, error), ahora serializadas a disco. Lo que sigue (lección 4) toma dos de estos archivos y los compara para atrapar una regresión.

La foto de la báscula, no el número que gritaste

Imagina que cada mañana te pesas y gritas el número en voz alta: "¡78!". La información existió por un segundo y se fue. No puedes saber si subiste respecto al mes pasado, porque no hay registro; solo tienes el eco del número de hoy. Ahora imagina que cada mañana anotas el peso en una libreta con la fecha. De pronto tienes algo mucho más valioso que un número: tienes una serie. Puedes ver la tendencia, comparar hoy con hace un mes, detectar que llevas tres semanas subiendo poco a poco.

El resumen en pantalla es el número gritado. La exportación a archivo es la libreta. Un results.json guardado con la fecha de la corrida es una entrada en esa libreta, y una carpeta llena de ellos —o un CSV que las acumula— es la serie que te deja ver la tendencia del rendimiento a lo largo del tiempo. La pregunta "¿el p95 empeoró con el último release?" solo se puede responder si anotaste el de antes. Exportar es anotar.

Exportar en k6 (contenido)

k6 exporta el detalle de una corrida con la bandera --out. Es contenido rotulado —k6 no está instalado aquí—, fiel a la documentación oficial:

# CONTENIDO (no ejecutado aqui): exportar los resultados de k6. Ver grafana.com/docs/k6.

# A JSON: una linea JSON por cada punto de metrica (cada peticion, cada check...).
k6 run --out json=results.json load_test.js

# A JSON comprimido (los archivos crecen rapido; gzip ayuda):
k6 run --out json=results.gz load_test.js

# A CSV: una fila por punto de metrica, comodo para hojas de calculo.
k6 run --out csv=results.csv load_test.js

Un matiz importante sobre el formato de k6. El --out json de k6 no escribe un solo objeto con el resumen: escribe un punto de métrica por línea (formato JSON Lines), una entrada por cada latencia, cada check, cada intervalo. Un fragmento se ve así (contenido):

// CONTENIDO (no ejecutado aqui): forma del --out json de k6 (una linea por punto).
{"type":"Point","metric":"http_req_duration","data":{"time":"2026-07-25T03:00:01Z","value":4.7,"tags":{"status":"200","name":"quote"}}}
{"type":"Point","metric":"http_req_duration","data":{"time":"2026-07-25T03:00:01Z","value":6.6,"tags":{"status":"200","name":"quote"}}}
{"type":"Point","metric":"http_reqs","data":{"time":"2026-07-25T03:00:01Z","value":1,"tags":{"status":"200"}}}

Ese formato crudo y detallado es potente —lo puedes cargar en una base de datos de series temporales, en Grafana, en un cuaderno de pandas— pero no es el resumen: es la materia prima de la que el resumen sale. Para comparar corridas de forma simple, casi siempre te conviene un archivo resumido: una sola foto con p50/p95/p99, RPS y error por corrida. Eso es exactamente lo que produce el generador de Python, y es lo que usaremos para detectar regresiones.

El lado ejecutable: exportar un resumen a JSON

Aquí está el generador de Python que sí corre, con el añadido central de este módulo: además de medir, escribe sus métricas a un archivo JSON. Es el load_generator que ya conoces (de los módulos anteriores) con dos cambios: calcula los percentiles con statistics.quantiles y, al final, serializa un diccionario resumen a disco con json.dump.

"""Generador de carga que EXPORTA sus metricas a JSON.

Golpea un endpoint de Reservo con N peticiones concurrentes, mide la latencia
real de cada una, calcula p50/p95/p99, RPS y tasa de error, y escribe todo a un
archivo JSON. Ese JSON es el artefacto que despues se analiza fuera y se compara
entre corridas (el equivalente ejecutable de `k6 run --out json=results.json`).

Uso: python3.14 load_and_export.py BASE_URL PATH TOTAL CONCURRENCY OUT.json LABEL
"""
import json
import statistics
import sys
import time
import urllib.error
import urllib.request
from concurrent.futures import ThreadPoolExecutor

BASE_URL = sys.argv[1]
PATH = sys.argv[2]
TOTAL = int(sys.argv[3])
CONCURRENCY = int(sys.argv[4])
OUT = sys.argv[5]
LABEL = sys.argv[6] if len(sys.argv) > 6 else "run"


def one_request():
    """Una peticion POST. Devuelve (latency_ms, ok) donde ok=respuesta correcta."""
    payload = json.dumps({"room": "Focus", "tier": "basic", "hours": 3}).encode()
    req = urllib.request.Request(
        f"{BASE_URL}{PATH}", data=payload,
        headers={"Content-Type": "application/json"}, method="POST",
    )
    start = time.perf_counter()
    try:
        with urllib.request.urlopen(req) as resp:
            body = json.loads(resp.read())
        ok = resp.status == 200 and body.get("price_cents") == 7500
    except (urllib.error.URLError, OSError):
        ok = False
    latency_ms = (time.perf_counter() - start) * 1000
    return latency_ms, ok


def pct(sorted_ms, p):
    """Percentil p (0..100) con statistics.quantiles (metodo inclusivo)."""
    if len(sorted_ms) < 2:
        return sorted_ms[0] if sorted_ms else 0.0
    cuts = statistics.quantiles(sorted_ms, n=100, method="inclusive")
    return cuts[p - 1]


def main():
    latencies = []
    ok_count = 0
    wall_start = time.perf_counter()
    with ThreadPoolExecutor(max_workers=CONCURRENCY) as pool:
        futures = [pool.submit(one_request) for _ in range(TOTAL)]
        for fut in futures:
            latency_ms, ok = fut.result()
            latencies.append(latency_ms)
            ok_count += 1 if ok else 0
    wall_seconds = time.perf_counter() - wall_start

    latencies.sort()
    error_rate = (TOTAL - ok_count) / TOTAL
    results = {
        "label": LABEL,
        "endpoint": PATH,
        "requests": TOTAL,
        "concurrency": CONCURRENCY,
        "duration_s": round(wall_seconds, 3),
        "rps": round(TOTAL / wall_seconds, 1),
        "error_rate": round(error_rate, 4),
        "checks_rate": round(ok_count / TOTAL, 4),
        "latency_ms": {
            "min": round(min(latencies), 2),
            "p50": round(pct(latencies, 50), 2),
            "p95": round(pct(latencies, 95), 2),
            "p99": round(pct(latencies, 99), 2),
            "max": round(max(latencies), 2),
            "avg": round(statistics.mean(latencies), 2),
        },
    }
    with open(OUT, "w") as f:
        json.dump(results, f, indent=2)
    m = results["latency_ms"]
    print(f"[{LABEL}] {PATH}  {TOTAL} req  concurrencia {CONCURRENCY}")
    print(f"  rps={results['rps']}  error_rate={results['error_rate']:.2%}  "
          f"checks={results['checks_rate']:.2%}")
    print(f"  p50={m['p50']}ms  p95={m['p95']}ms  p99={m['p99']}ms  max={m['max']}ms")
    print(f"  -> escrito {OUT}")


if __name__ == "__main__":
    main()

Dos decisiones que vale la pena entender:

  • statistics.quantiles(data, n=100, method="inclusive") divide la serie ordenada en 100 grupos y devuelve los 99 puntos de corte; el corte número p es el percentil p. Así cuts[94] (con p=95) es el p95. Es la forma rigurosa que el módulo 3 introdujo, ahora al servicio de un archivo que se guarda.
  • El JSON es un resumen, no el detalle crudo. A diferencia del --out json de k6 (una línea por punto), aquí escribimos una sola foto compacta por corrida: label, endpoint, rps, error_rate y el bloque latency_ms con los percentiles. Es exactamente lo que se necesita para comparar dos corridas sin cargar millones de puntos.

Lo corremos contra los dos endpoints —/quote (baseline) y /quote_slow (degradado)—. Salida real:

Qué esperar — cada corrida imprime su resumen y confirma el archivo escrito:

$ python3.14 load_and_export.py http://127.0.0.1:PORT /quote 600 30 results_baseline.json baseline
[baseline] /quote  600 req  concurrencia 30
  rps=5449.6  error_rate=0.00%  checks=100.00%
  p50=4.7ms  p95=6.66ms  p99=19.91ms  max=22.63ms
  -> escrito results_baseline.json

$ python3.14 load_and_export.py http://127.0.0.1:PORT /quote_slow 600 30 results_actual.json actual
[actual] /quote_slow  600 req  concurrencia 30
  rps=538.1  error_rate=0.00%  checks=100.00%
  p50=54.74ms  p95=61.27ms  p99=73.62ms  max=75.82ms
  -> escrito results_actual.json

Y esto es lo que quedó en disco —el artefacto exportado, que puedes abrir, versionar, adjuntar o comparar—. Salida real del contenido de los archivos:

Qué esperar — un JSON resumen por corrida, con los percentiles y el throughput:

$ cat results_baseline.json
{
  "label": "baseline",
  "endpoint": "/quote",
  "requests": 600,
  "concurrency": 30,
  "duration_s": 0.11,
  "rps": 5449.6,
  "error_rate": 0.0,
  "checks_rate": 1.0,
  "latency_ms": {
    "min": 2.7,
    "p50": 4.7,
    "p95": 6.66,
    "p99": 19.91,
    "max": 22.63,
    "avg": 5.29
  }
}

Ese archivo es la libreta con la entrada de hoy. La corrida ya no se evaporó: quedó guardada, lista para que la lección 4 la compare con la siguiente.

Acumular corridas en un CSV (comparar la tendencia)

Un solo archivo es una foto; el valor de exportar aparece cuando acumulas corridas y ves la tendencia. Un CSV es ideal para eso: cada fila es una corrida, el archivo crece con el tiempo, y se abre en cualquier hoja de cálculo. Este pequeño anexador lee un results.json y agrega una fila al CSV:

"""Anexa una corrida exportada a un CSV historico.

Exportar no es solo para una corrida: acumular corridas en un CSV deja comparar
la tendencia entre releases. Cada fila es una corrida; el CSV crece con el tiempo.

Uso: python3.14 append_run_csv.py results.json runs.csv
"""
import csv
import json
import os
import sys

with open(sys.argv[1]) as f:
    r = json.load(f)
csv_path = sys.argv[2]
row = {
    "label": r["label"], "endpoint": r["endpoint"], "rps": r["rps"],
    "p50": r["latency_ms"]["p50"], "p95": r["latency_ms"]["p95"],
    "p99": r["latency_ms"]["p99"], "error_rate": r["error_rate"],
}
write_header = not os.path.exists(csv_path)
with open(csv_path, "a", newline="") as f:
    w = csv.DictWriter(f, fieldnames=list(row.keys()))
    if write_header:
        w.writeheader()
    w.writerow(row)
print(f"anexada corrida '{r['label']}' a {csv_path}")

Lo corremos con las dos corridas exportadas. Salida real:

Qué esperar — dos filas en runs.csv, una por corrida, con la baseline rápida y la degradada lenta lado a lado:

$ python3.14 append_run_csv.py results_baseline.json runs.csv
anexada corrida 'baseline' a runs.csv
$ python3.14 append_run_csv.py results_actual.json runs.csv
anexada corrida 'actual' a runs.csv
$ cat runs.csv
label,endpoint,rps,p50,p95,p99,error_rate
baseline,/quote,5449.6,4.7,6.66,19.91,0.0
actual,/quote_slow,538.1,54.74,61.27,73.62,0.0

Ahí está la libreta con dos entradas, y la tendencia salta a la vista: el p95 pasó de 6.66 a 61.27 ms y el RPS se desplomó de 5449 a 538. Con dos filas ya se ve; con veinte, una regresión lenta que se acumula release tras release también se vería. Esa es la razón entera de exportar: sin la libreta, cada corrida es un número gritado que nadie puede comparar.

Errores comunes

Confundir el --out json de k6 con un resumen. Qué pasa: alguien corre k6 run --out json=out.json, abre el archivo esperando ver p95: ..., y encuentra millones de líneas de puntos crudos. Por qué pasa: el --out json de k6 escribe el detalle (un punto por métrica), no el resumen. Cómo detectarlo: el archivo tiene una línea {"type":"Point",...} por petición, no una foto. Cómo corregirlo: si quieres el resumen, usa handleSummary de k6 para escribir un JSON compacto (contenido de k6), o —como aquí— exporta un resumen tú mismo. El --out json crudo es para herramientas de análisis, no para leer a ojo.

No poner fecha ni etiqueta a la corrida exportada. Qué pasa: se guardan varios results.json sin distinguir cuál es de cuándo, y al comparar no se sabe cuál es el baseline. Por qué pasa: exportar sin metadatos. Cómo detectarlo: una carpeta de archivos idénticos de nombre, imposibles de ordenar. Cómo corregirlo: incluye una label (o timestamp / hash del commit) en el JSON y en el nombre del archivo, para saber qué corrida es cuál. Aquí la label ("baseline"/"actual") cumple ese papel.

Exportar y nunca comparar. Qué pasa: se acumulan cientos de results.json que nadie vuelve a mirar. Por qué pasa: exportar se vuelve un ritual sin propósito. Cómo detectarlo: si tienes archivos exportados pero nunca comparas dos, exportar no te está dando nada. Cómo corregirlo: el valor de exportar es comparar —analizar la tendencia (este CSV) o detectar una regresión (lección 4)—. Exporta con la intención de comparar.

Ejercicios

Ejercicio 1 — JSON resumen vs JSON crudo. El --out json de k6 y el results.json del generador Python son los dos "JSON", pero muy distintos. (a) ¿Qué contiene cada uno? (b) ¿Cuál usarías para comparar rápido dos corridas y por qué? (c) ¿Para qué serviría el crudo?

Ver solución
  • (a) El --out json de k6 contiene el detalle: una línea por punto de métrica (cada latencia, cada check), en formato JSON Lines. El results.json del generador contiene el resumen: una sola foto con p50/p95/p99, RPS y error de la corrida.
  • (b) El resumen. Para comparar dos corridas basta con leer el p95 de cada una; el resumen lo tiene directo, mientras que del crudo tendrías que recalcular los percentiles a partir de millones de puntos.
  • (c) El crudo sirve para análisis profundo: cargarlo en Grafana o pandas, ver la latencia a lo largo del tiempo dentro de la corrida (¿subió al final?), filtrar por tags (por endpoint, por status). Es la materia prima; el resumen es la conclusión.

Ejercicio 2 — Lee el CSV. El runs.csv tiene estas dos filas: baseline,/quote,5449.6,4.7,6.66,19.91,0.0 y actual,/quote_slow,538.1,54.74,61.27,73.62,0.0. (a) ¿Cuánto empeoró el p95? (b) ¿Y el RPS? (c) ¿Qué historia cuenta la fila actual?

Ver solución
  • (a) El p95 pasó de 6.66 ms a 61.27 ms: se multiplicó por ~9.2 (empeoró +820%).
  • (b) El RPS cayó de 5449.6 a 538.1: bajó a ~1/10. Con cada petición tardando ~45 ms más, el sistema despacha muchísimo menos por segundo.
  • (c) Que el endpoint se volvió lento (el retardo fijo de /quote_slow): la lógica sigue correcta (mismo precio, error 0%), pero el rendimiento se desplomó —tarda 9x más y aguanta 1/10 del throughput—. Es el retrato de una regresión de rendimiento.

Ejercicio 3 — Diseña la libreta. Quieres guardar el resumen de cada corrida nightly para poder ver la tendencia del p95 en el último mes. (a) ¿Qué le añadirías al JSON exportado para que las corridas se puedan ordenar en el tiempo? (b) ¿Nombrarías los archivos igual o distinto, y cómo?

Ver solución
  • (a) Un timestamp (fecha y hora de la corrida, en formato ISO como 2026-07-25T03:00:00Z) y, si corre en CI, el hash del commit que se probó. Así cada corrida queda anclada a un momento y a una versión del código, y se pueden ordenar cronológicamente para graficar la tendencia.
  • (b) Distinto, incluyendo la fecha en el nombre: results-2026-07-25.json, results-2026-07-26.json... Un nombre único por corrida evita sobrescribir la anterior (perder el baseline) y permite ordenar la carpeta por fecha. Guardar todos en un CSV acumulado, como aquí, es la otra mitad: el CSV es la serie, los JSON son las fotos individuales.

Resumen y siguiente paso

En esta lección aprendiste a exportar una corrida para que deje de ser un número efímero y se vuelva un artefacto. En k6 se hace con --out json / --out csv (contenido), recordando que el --out json escribe el detalle crudo (un punto por línea), no el resumen. Del lado ejecutable, el generador de Python escribe un results.json resumen —p50/p95/p99, RPS, error— con json.dump, y un anexador acumula corridas en un runs.csv para ver la tendencia. Viste los archivos de verdad en disco: la baseline rápida (p95 6.66 ms) y la degradada lenta (p95 61.27 ms), lado a lado en el CSV.

La idea que se lleva la lección: exportar es anotar en la libreta. Sin la entrada de ayer no puedes saber si hoy empeoraste; exportar guarda el baseline que hace posible comparar. Antes de avanzar deberías poder: explicar la diferencia entre el JSON crudo de k6 y un JSON resumen; nombrar por qué se exporta (analizar fuera, guardar, comparar); y leer una tendencia en un CSV de corridas. Lo que sigue, en la lección 4, es el pago de haber exportado: tomar dos de estos archivos —un baseline y una corrida actual— y compararlos para detectar una regresión de rendimiento, con un chequeo que falla con exit code.

Recursos