Informe_Resultados_HA 08 - martin-hendricks/MISO-PROYECTO-FINAL-SOLVENTA GitHub Wiki

Informe de resultados — Experimento HA-08

Módulo 4 — EDA Maestría en Ingeniería de Software — MISW4501 Arquitectura de Software Caso de estudio: Solventa — plataforma insurtech Versión: 1.0 — 2026-09-11

Documento base: Diseno_Experimento_HA-08.md (diseño e hipótesis). Implementación: experiments/ha-08-lectura-siniestro/ en el repositorio de código. Bitácora técnica completa de la ejecución: experiments/ha-08-lectura-siniestro/INFORME.md (incluye la detección y corrección de un defecto que invalidó la primera ejecución, y el detalle de cada corrección aplicada tras dos rondas de revisión externa).


1. Resumen ejecutivo

A este volumen (1.000.000 de siniestros) y hasta 80 req/s, la lectura directa sobre el modelo transaccional normalizado (brazo A) ya satisface EC-LAT-11 (p95 ≤ 150 ms) con 19-24× de margen. La hipótesis de que la separación de modelos de lectura (CQRS) es necesaria para cumplir este ASR no se confirma en las condiciones ensayadas. Si se implementa de todos modos, la proyección materializada sin caché (brazo B) es preferible al caché en memoria (brazo C): C introduce un costo de escritura neto sin ganancia de latencia bajo el patrón de acceso ensayado. La justificación válida para separar el modelo de lectura no es la latencia — es el aislamiento de carga sobre Postgres, respaldado con evidencia cuantitativa de CPU (§4, Anexo D).

Este resultado se obtuvo tras corregir un defecto de orquestación que invalidó una primera ejecución completa de 12 corridas (§2) y dos rondas de revisión externa que identificaron errores de interpretación adicionales, ya corregidos en la versión de este informe (ver bitácora técnica para el detalle completo del proceso).


2. Nota sobre la primera ejecución (invalidada)

Una primera ejecución completa del protocolo formal (2026-09-08, 12 corridas) resultó inválida: scripts/verify_parity.sh recreaba el contenedor de la API con los brazos A, B y C en un bucle para comparar payloads, y lo dejaba arrancado con el último brazo del bucle (C). El script de orquestación invocaba esa verificación dentro de cada corrida, después de levantar el brazo correcto y sin volver a levantarlo antes de medir con k6. Consecuencia: las 12 corridas midieron todas el brazo C, sin importar la etiqueta A/B/C del archivo de resultados.

El defecto se detectó, se reprodujo de forma controlada y se corrigió: la verificación de paridad ahora corre una sola vez antes de toda la serie, y se agregó una aserción dura (GET /health → compara el brazo activo contra el esperado) que corta la ejecución si la API no está sirviendo el brazo correcto. Todas las corridas reportadas en este informe (§3-§6) se ejecutaron con esa corrección activa y ninguna falló la aserción. El detalle completo de la detección, reproducción y corrección está en experiments/ha-08-lectura-siniestro/INFORME.md §0.


3. Resultados por brazo (escalón de operación regular)

Protocolo contrabalanceado (cuadrado latino: R1=A→B→C, R2=B→C→A, R3=C→A→B), 10→80 req/s en 4 escalones de 3 min tras 1 min de warm-up descartado, dataset de 1.000.000 de siniestros.

Brazo Corrida RPS p50 (ms) p95 (ms) p99 (ms) Error (%) Hit-rate (%) Lag p95 (s) ¿Cumple?
A R1 10-80 2.63 8.65 n/d¹ 0 n/a ≤0.05² ✅
A R2 10-80 2.82 7.79 n/d¹ 0 n/a ≤0.05² ✅
A R3 10-80 3.06 7.13 n/d¹ 0 n/a ≤0.05² ✅
B R1 10-80 2.63 6.44 n/d¹ 0 n/a ≤0.05² ✅
B R2 10-80 2.54 6.05 n/d¹ 0 n/a ≤0.05² ✅
B R3 10-80 2.70 6.06 n/d¹ 0 n/a ≤0.05² ✅
C R1 10-80 2.84 6.77 n/d¹ 0 n/d³ ≤0.05² ✅
C R2 10-80 2.86 6.72 n/d¹ 0 n/d³ ≤0.05² ✅
C R3 10-80 2.89 6.67 n/d¹ 0 7.6⁴ ≤0.05² ✅
C' R1 10-80 — — — — — — ⚠️ pendiente⁵

¹ p99 no disponible en el protocolo formal — la configuración de k6 usada solo exportó p(90)/p(95) en el resumen. Sí está disponible para la iteración de alta carga (§6). ² El lag reportado por Prometheus es un histograma con el bucket más fino en 0.05s; el valor real es "≤50ms", no una medición puntual — ≥99.5% de las observaciones caen en ese bucket en las 9 corridas. ³ Hit-rate no capturado para C R1/R2: el fix que captura los contadores de caché se aplicó mientras la serie ya estaba en curso, después de que esas dos corridas ya habían pasado el paso de captura. No es recuperable retroactivamente. ⁴ Estimado sobre un solo worker de uvicorn (la API corre --workers 2 sin prometheus_client en modo multiproceso) — esta corrida capturó el 91% de las 23.999 peticiones reales (21.948), muestra representativa. Ver nota de método completa en la bitácora técnica §2.3. ⁵ La variante C' (coalescencia, bypass de re-serialización) corrió bajo el mismo defecto de la sesión inválida (§2) y no se ha repetido con el script corregido.

Consolidado y variabilidad (n=3 por brazo)

Brazo p95 medio (ms) Desviación estándar (ms) Diferencia vs. B Veredicto
A 7.86 0.76 +27% (peor) Cumple con 19× de margen; es el más lento de los tres, pero la diferencia no compromete el ASR
C 6.72 0.05 +9% (peor) Cumple con 22× de margen; peor que B — el costo de escritura del caché supera el ahorro de los aciertos
B 6.19 0.22 — (referencia) Cumple con 24× de margen; el más rápido de los tres

A > C > B es consistente y reproducible en las 3 repeticiones de cada brazo, sin excepción. La diferencia C−B (0.53ms) es ~4× la desviación estándar combinada (~0.14ms) — es señal, no ruido de medición. Pero esa diferencia representa menos del 1.2% del umbral de 150ms: ningún brazo está remotamente en riesgo de incumplir el ASR, así que la diferencia entre ellos no altera el dictamen de cumplimiento — es información útil para elegir entre B y C si se decide implementar CQRS por otras razones (§4), no evidencia de que A sea insuficiente.


4. Sensibilidad al TTL del caché (punto de sensibilidad 2)

Brazo C, n=3 por valor de TTL, mismo protocolo de carga que el escalón regular.

TTL p95 (ms) Hit-rate (%) Staleness observado p95 (s) ¿Aceptable para HA-08?
0 s (sin caché) 6.21 0.0 n/a ✅ — equivale al brazo B sin el SET
30 s (protocolo formal) 6.72 7.6 ≤0.05⁶ ✅ — cumple, pero es el peor de los tres TTL medidos
300 s 6.47 24-33 hasta 300s⁷ ⚠️ — cumple latencia, pero introduce riesgo de consistencia no cuantificado

⁶ El lag medido es el del proyector (evento → proyección), no el que ve el cliente vía caché — ver riesgo de carrera de invalidación abajo. ⁷ arm_c.py tiene una ventana de carrera con el proyector: en un miss, la secuencia es GET falla → lee proyección → escribe en Redis. Si el proyector invalida la clave entre la lectura y la escritura, el SET posterior sobrescribe esa invalidación con un valor obsoleto, que persiste hasta que expire el TTL completo (hasta 300s). No se pudo medir esta carrera en esta ejecución porque EstadoSiniestro no expone el número de versión del siniestro en la respuesta — se documenta como riesgo abierto, no como hallazgo cuantificado.

El costo real de C no está en el GET que falla — está en la serialización y el SET. Comparando series (nota: B, TTL=0 y TTL=30 corrieron en corridas distintas, comparación indicativa): B promedia 6.19ms, C con TTL=0 (que hace el GET fallido pero nunca ejecuta el SET) promedia 6.21ms — diferencia de 0.02ms, prácticamente cero. C con TTL=30 (que sí serializa y escribe en cada miss) promedia 6.72ms — 0.51ms más. El costo agregado está en orjson.dumps + SET, ejecutado en el ~92% de las peticiones dado el hit-rate real. Esto sugiere una mejora no implementada en este experimento: escribir en caché de forma asíncrona, sin bloquear la respuesta al cliente.

El hit-rate bajo no es falta de calentamiento — es el resultado matemático esperado. Con tráfico uniforme sobre el hot set de 10.000 claves, el hit-rate en estado estacionario para una clave con tasa de llegada λ=0.8·rps/10.000 (el factor 0.8 es la fracción de tráfico dirigida al hot set) se aproxima por λ·TTL / (1 + λ·TTL). Ponderado por solicitudes reales de cada escalón, TTL=30s da ~9.6% en estado estacionario — cercano al 7.6% medido. Para TTL=300s la misma fórmula predice ~44% en estado estacionario, pero se midió 24-33%: cada escalón dura 180s, menos que el TTL, y arranca con FLUSHALL — el caché nunca alcanza su estado estacionario dentro de la ventana medida. Correr la corrida más tiempo no cambiaría el hit-rate de TTL=30 (ya en estado estacionario), pero sí el de TTL=300. Lo que sí cambiaría ambos es la distribución de acceso: este diseño usa selección uniforme, pero el acceso real a un sistema de siniestros probablemente sigue una distribución sesgada (tipo Zipf) donde el hit-rate real de producción podría ser sustancialmente más alto.

Nota de calidad de dato: 2 de las 6 corridas de esta serie (TTL=0 r1, TTL=300 r1) muestran una firma de contención del host no detectada en su momento (throughput real de 14.1 y 16.5 req/s contra 30.8 req/s en las 4 restantes) y no se repitieron — el impacto verificado en el promedio es de 0.04-0.09ms, sin cambiar el dictamen. Pendiente de repetir en una iteración v2. Ver bitácora técnica §2.3bis para el detalle.


5. Sensibilidad a la carga (punto de sensibilidad 3)

RPS p95 brazo A p95 brazo B p95 brazo C
10 no disponible⁸ no disponible⁸ no disponible⁸
25 no disponible⁸ no disponible⁸ no disponible⁸
50 no disponible⁸ no disponible⁸ no disponible⁸
80 no disponible⁸ no disponible⁸ no disponible⁸

(p95 global de la corrida completa 10→80 req/s, como referencia: A=8.65ms, B=6.44ms, C=6.77ms, R1)

⁸ El protocolo formal no registró el p95 por escalón individual — el --summary-export de k6 solo expone un p95 agregado de toda la corrida (10→80 req/s), y la vía prevista para reconstruirlo desde Prometheus (histogram_quantile() sobre k6_ha08_estado_duration_bucket) no es viable: se verificó que esa métrica no existe en Prometheus, porque la configuración usada exporta gauges de percentiles precalculados, no histogramas nativos. Los valores de la tabla son el p95 global de R1 como referencia — no reflejan el escalón real. Esto sí se resolvió para la iteración de alta carga (tabla siguiente), agregando thresholds por sub-métrica en options.thresholds de k6.

No se encontró el punto de quiebre dentro de 10-80 req/s — ningún brazo se acercó al umbral de 150ms. Se extendió la búsqueda con una iteración adicional de alta carga:

Escalón (pico nominal) p95 brazo A p95 brazo B p95 brazo C
r150 (rampa 10→150 rps) 5.13 4.84 5.24
r300 (rampa 150→300 rps) 2.63 2.98 2.76
r600 (rampa 300→600 rps) 2.75 1.50 1.90

Nota de método: los "escalones" de k6 (ramping-arrival-rate) son rampas lineales, no mesetas constantes — se confirmó por aritmética (el total de requests observado, 136.499, coincide con la integral de las rampas, 136.500, no con lo que darían mesetas constantes, ≈189.600). "r600" alcanza 600 req/s solo en su último instante, con un promedio real de ~450 req/s en ese tramo. Que r150 tenga el p95 más alto de los tres escalones en los tres brazos no es degradación por carga — es la ventana de arranque en frío tras el docker compose restart postgres del inicio de la corrida (shared_buffers vacío, planes de consulta sin cachear).

Tampoco se encontró el punto de quiebre hasta un pico de 600 req/s (7.5× el máximo del protocolo formal), con 0% de error y 0 iteraciones descartadas en los tres brazos. El p99 de A en el escalón r600 (22.89ms global, 60.16ms en ese tramo) sí muestra degradación de cola que el p95 no captura, señal temprana de que el punto de quiebre está más cerca de lo que sugiere el p95 solo. El punto de quiebre real está por encima de este pico en el montaje actual (1M de filas, índices efectivos, CPU de la API lejos de saturar su límite de 1 core); una vía más económica para localizarlo sería reducir deliberadamente el CPU o el pool de conexiones de la API para forzar contención observable a tasas más bajas — no se ejecutó en esta sesión.


6. Evidencia de aislamiento de carga (hallazgo adicional, no previsto en el diseño original)

Durante la iteración de alta carga se capturó el uso de CPU de Postgres por contenedor, en serie cada 10 segundos durante toda la corrida (no un snapshot puntual). En el tramo final (rampa 300→600 req/s):

Brazo CPU ha08-postgres (promedio / máximo)
A 26.7% / 67.7%
B 10.7% / 13.4%
C 8.1% / 13.6%

A impone ~2.5× la carga de CPU sobre Postgres frente a B/C. El porcentaje de docker stats es relativo a 1 core: el máximo de A (67.7%) equivale a ~0.68 cores, ~34% del límite de 2 cores del contenedor — lejos de saturarlo. Lo que se sostiene es la magnitud relativa (~2.5-3× más CPU que B/C en el mismo tramo), no la cercanía a un límite. Esta es evidencia cuantitativa directa de que B sí se justifica frente a A — no por latencia, sino por aislamiento de carga sobre el almacén transaccional: a mayor escala o con más tráfico de escritura concurrente, esa proporción competiría directamente con las transacciones de negocio (creación de siniestros, actualización de hitos) por los mismos recursos de Postgres, mientras que B (y C) no.


7. Dictamen sobre la hipótesis HD-08

Aplicando el criterio del Anexo B del diseño (se acepta HD-08 si el brazo C cumple p95≤150ms/error<1%/lag≤2s en las 3 corridas, y no hay staleness excesivo):

  • El brazo C sí cumple los tres criterios formales (6.72ms, 0% error, lag ≤50ms) — HD-08 se acepta en su enunciado literal.
  • Pero el propio Anexo B anticipa el "resultado intermedio relevante": "si el brazo B ya cumple el umbral, el caché constituye complejidad no justificada y la recomendación debe ser la alternativa más simple que satisface el ASR". Ese resultado intermedio se materializó, y de forma más extrema de lo previsto: no solo B cumple sin necesidad de C — A, la línea base sin ningún tipo de separación de modelos, ya cumple el ASR con 19× de margen.

El dictamen honesto, siguiendo el propósito declarado del experimento ("determinar el nivel mínimo de complejidad que satisface el ASR"), no es "aceptar HD-08 porque C cumple los tres criterios" — es reconocer que la complejidad mínima suficiente es A, y que recomendar B o C por ser más rápidos que A no se sostiene como argumento de necesidad contra un umbral que A ya cumple holgadamente.

Sobre las sub-hipótesis:

ID Enunciado Veredicto
HD-08.1 A no alcanza el p95 objetivo Refutada. A cumple con 19× de margen — es el más lento de los tres, pero no compromete el umbral.
HD-08.2 B reduce el p95 ≥60% vs. línea base Refutada en magnitud. B es el más rápido (−21% vs. A), en la dirección esperada, pero muy por debajo del 60% planteado.
HD-08.3 C mejora sobre B Refutada, en la dirección contraria a la esperada. C es peor que B (+9%) — el costo de escritura del caché supera el ahorro de los aciertos con el hit-rate real observado.
HD-08.4 Lag de proyección ≤2s Aceptada con margen amplio (≤50ms medido), aunque solo cubre el lag del proyector, no el staleness que ve el cliente vía caché (§4).

Dos caminos honestos para la decisión arquitectónica de Solventa (ninguno es "implementar B porque es más rápido que A"):

  1. Aceptar que, a esta escala y con este patrón de acceso, la hipótesis de que CQRS es necesario para EC-LAT-11 no se confirma — usar A como línea base y reconsiderar CQRS si el punto de quiebre real (§5) se acerca al rango de operación esperado, o si el dataset/patrón de acceso de producción difiere sustancialmente del ensayado.
  2. Justificar la separación de modelos (B) con otro ASR distinto de latencia — aislamiento de carga de lectura sobre el modelo transaccional (§6, con evidencia cuantitativa de CPU), escalabilidad horizontal independiente del lado de lectura, o disponibilidad. Este experimento no midió esos atributos directamente salvo el de CPU (§6), así que la justificación completa requeriría un experimento adicional centrado en esos ASR.

En ambos caminos, el caché en memoria (C) no aporta valor con el patrón de acceso ensayado y agrega complejidad operativa (Redis, invalidación, TTL, el riesgo de carrera de §4) sin contrapartida de latencia.


8. Amenazas a la validez (Anexo D)

Derivadas del montaje local

  • El umbral absoluto de 150ms no es transferible. El hardware de desarrollo no es producción. Se reporta la mejora relativa entre brazos como resultado principal; el valor absoluto es indicativo.
  • El montaje favorece estructuralmente al brazo A. effective_cache_size no reserva memoria — es solo una pista para el planificador de Postgres, no un límite real de qué cabe en caché. La VM de Docker tiene 8.2GB disponibles y el contenedor de Postgres un límite de 2GB; el dataset completo pesa 3GB, pero el 80% del tráfico se concentra en el hot set de 10.000 siniestros (~100.000 filas relevantes, con 5 hitos y 3 documentos exactos por siniestro, verificado por query directa) que cabe cómodamente en caché de página. No hay latencia de red entre contenedores. Un siniestro real con decenas de hitos haría más pesado el JOIN de A. El dictamen "A cumple" debe leerse como "A cumple en estas condiciones", no como garantía a cualquier escala.
  • Contención de recursos del host. Se detectaron dos episodios de contención por procesos ajenos al experimento durante la sesión: un clúster de Kubernetes de Docker Desktop (resuelto deshabilitándolo) y un contenedor de otro proyecto corriendo en paralelo (resuelto deteniéndolo para las corridas afectadas). El p95 nunca se vio comprometido por esta contención — solo la cola extrema (max) y, en dos corridas de TTL no repetidas, el throughput real. Documentado en detalle en la bitácora técnica.
  • Caché de páginas del sistema operativo no controlable. Mitigado mediante el contrabalanceo del orden de brazos (cuadrado latino).

Derivadas del runtime

  • Los contadores de hit-rate son una estimación sobre un solo worker de uvicorn, no el total de la corrida. La API corre con --workers 2 sin prometheus_client en modo multiproceso — cada worker mantiene sus propios contadores, y el curl /metrics que los captura cae en un worker elegido al azar por el balanceo del kernel. La muestra real capturada varió entre 10% y 91% de las peticiones según la corrida. Como ambos workers ejecutan el mismo código, las estimaciones siguen siendo razonables, pero no deben presentarse como el total exacto de consultas.
  • Variabilidad en la cola de la distribución. Mitigado con tres corridas independientes por brazo, descarte del período de calentamiento y reporte de la desviación entre corridas (§3).
  • Número de procesos de servidor como variable confusora. Fijado en 2, idéntico entre brazos.

Derivadas del diseño del experimento

  • Representatividad del perfil de carga. El modelo de conjunto caliente/frío (80%/20%, selección uniforme dentro de cada grupo) es una aproximación razonada, no un dato medido en producción. El hit-rate del brazo C es sensible a este supuesto — una distribución de acceso sesgada (tipo Zipf) probablemente daría un hit-rate real más alto (§4).
  • Riesgo de consistencia eventual no cuantificado. La carrera entre la invalidación del proyector y la escritura de arm_c.py (§4) puede dejar valores obsoletos en caché por hasta el TTL completo; no se pudo medir en esta ejecución por falta de instrumentación (número de versión no expuesto en la respuesta de la API).
  • Un solo tipo de consulta. Se mide únicamente la consulta por identificador de siniestro. El escenario EC-LAT-11 menciona también la consulta de póliza, no cubierta por este experimento.
  • Punto de quiebre no localizado. Ninguno de los tres brazos se degradó dentro del rango de carga ensayado (hasta un pico de 600 req/s) — el experimento no puede pronunciarse sobre el comportamiento de ninguna alternativa por encima de ese rango en este montaje.

9. Siguientes iteraciones recomendadas

En orden de menor a mayor costo:

  1. Repetir las dos corridas de TTL con contención no resuelta (TTL=0 r1, TTL=300 r1, §4) — bajo costo (~26 min), cierra la única zona gris de calidad de dato que queda en esta ejecución.
  2. Configurar PROMETHEUS_MULTIPROC_DIR para que los contadores de caché agreguen ambos workers de uvicorn, en vez de reflejar solo uno al azar.
  3. Instrumentar el número de versión en la respuesta de la API para cuantificar la carrera de invalidación de caché (§4) — actualmente es un riesgo documentado pero no medido.
  4. Forzar contención deliberada (reducir CPU o pool de conexiones de la API) para localizar el punto de quiebre real de cada brazo, en vez de solo confirmar que está por encima del rango ensayado.
  5. Medir el aislamiento de carga con un experimento dedicado, con tráfico de escritura concurrente controlado, para cuantificar el "camino 2" del dictamen (§7) más allá de la evidencia indirecta de CPU de Postgres (§6).
  6. Repetir con un perfil de acceso sesgado (Zipf) en vez de uniforme, para evaluar si el hit-rate real de producción cambia el resultado de C vs. B (§4).

10. Evidencia visual (dashboard de Grafana)

Cada captura corresponde a los 6 paneles del dashboard (p95 interno por brazo, hit-rate de caché, lag de proyección, p95 de k6, CPU y memoria del proceso), acotados a la ventana de tiempo exacta de la serie indicada. JSON del dashboard, reproducible: experiments/ha-08-lectura-siniestro/observability/grafana/provisioning/dashboards/ha08-dashboard.json.

Protocolo formal (9 corridas, §3)

Dashboard de Grafana — protocolo formal

Sensibilidad al TTL (§4)

Dashboard de Grafana — TTL=0

Dashboard de Grafana — TTL=300, corrida r1 original

Dashboard de Grafana — TTL r2/r3, repetición limpia de ambos valores

Alta carga (§5, §6)

Dashboard de Grafana — alta carga A/B/C

Las 9 capturas de la sesión inválida del 2026-09-08 (protocolo formal con el defecto de §2, brazo C medido bajo las 3 etiquetas A/B/C) se conservan como evidencia del proceso de detección del bug, no como validación de resultados — ver experiments/ha-08-lectura-siniestro/results/evidencia/r{1,2,3}-{a,b,c}.png y la bitácora técnica §4.2.


11. Carpeta completa del experimento en el repositorio de código

Todo lo ejecutado —implementación, scripts de orquestación, evidencia cruda y bitácora técnica— vive en el repositorio de código, no en esta wiki:

experiments/ha-08-lectura-siniestro/
├── api/                    # Implementación de los 3 brazos (A/B/C), FastAPI
├── projector/              # Proyector de eventos → proyección materializada
├── db/                     # Esquemas del modelo transaccional y de la proyección
├── load/k6/                # Scripts de carga (protocolo formal, TTL, alta carga)
├── observability/          # Provisioning de Prometheus y Grafana (dashboard JSON)
├── scripts/                # Orquestación: run_experiment.sh, run_serie_*.sh, verify_parity.sh, collect_results.sh
├── results/
│   ├── raw/                # summary_*.json, cache_*.txt, lag_*.txt, stats_*.csv de cada corrida — versionado, auditable
│   ├── evidencia/          # Capturas de Grafana, logs de verify_parity, tamaño del dataset
│   └── consolidado.csv     # Tabla consolidada de las corridas válidas
├── docker-compose.yml
└── INFORME.md              # Bitácora técnica completa: detección del bug, cada corrección, cada corrida

results/raw/ está versionado en el repositorio (no ignorado por git) específicamente para que las cifras de este informe sean auditables sin necesidad de reproducir las corridas.


Anexo — Trazabilidad

Artefacto Identificador / ubicación
Historia de arquitectura HA-08
Escenario de calidad EC-LAT-11
Diseño del experimento Diseno_Experimento_HA-08.md
Implementación experiments/ha-08-lectura-siniestro/
Bitácora técnica completa experiments/ha-08-lectura-siniestro/INFORME.md
Evidencia cruda (auditable) experiments/ha-08-lectura-siniestro/results/raw/ (versionado en el repositorio)
Capturas de dashboard (embebidas en §10) files/HA-08-evidencia/grafana_*.png (esta wiki) y experiments/ha-08-lectura-siniestro/results/evidencia/grafana_*.png (repo de código, fuente original)
Fecha de ejecución válida 2026-09-09 / 2026-09-11
⚠️ **GitHub.com Fallback** ⚠️