Los logs son lo primero que revisas cuando un despliegue falla. Lo segundo que revisas cuando un servicio se comporta mal. Lo tercero que revisas cuando un cliente reporta algo extraño. Si tu PaaS te obliga a acceder por SSH al servidor y ejecutar docker logs -f, ya has perdido diez segundos de contexto y la paciencia de un desarrollador.
Desde la Fase 12 en adelante, sh0 ha tenido streaming de logs en tiempo real en el navegador. Abre la pestaña de Logs de una aplicación y la salida aparece conforme ocurre -- sin recargas, sin sondeo, sin esperas. La implementación abarca tres capas: un endpoint WebSocket en Rust que lee de Docker, una capa de transporte con autenticación JWT y reconexión automática, y un componente Svelte que renderiza logs en un visor estilo terminal.
Así es como construimos cada capa, y la decisión de seguridad que cambió cómo pasamos tokens de autenticación sobre WebSockets.
Capa 1: El endpoint WebSocket de logs
El endpoint de streaming de logs vive en el servidor API de Rust. Su trabajo es simple en concepto: conectar al stream de logs de Docker para un contenedor y reenviar la salida a un cliente WebSocket.
Sondeo vs. streaming
Docker ofrece dos formas de leer logs de contenedores: un GET /containers/{id}/logs de una sola vez con follow=false, y un streaming GET /containers/{id}/logs con follow=true. El enfoque de streaming mantiene una conexión HTTP abierta y envía líneas de log conforme se producen.
Elegimos un enfoque de sondeo con seguimiento de marcas de tiempo. Cada dos segundos, el handler llama a la API de logs de Docker con since=<última_marca_tiempo>, recupera las líneas nuevas y las envía por el WebSocket. Si no hay líneas nuevas, no envía nada -- el WebSocket permanece en silencio.
¿Por qué sondeo en lugar de un stream persistente de Docker? Tres razones:
- Gestión de recursos. Una conexión de streaming a Docker es un descriptor de archivo mantenido. Si 20 usuarios están viendo logs de 20 contenedores, son 20 conexiones persistentes al daemon Docker. El sondeo abre una conexión, lee, la cierra, y el daemon queda libre.
- Simplicidad de reconexión. Si el daemon Docker se reinicia o un contenedor se redesplega, una conexión de streaming se rompe y requiere lógica de reconexión compleja. Un bucle de sondeo simplemente reintenta en el siguiente ciclo.
- Deduplicación por marca de tiempo. Al rastrear la marca de tiempo de la última línea de log recibida, garantizamos cero duplicados entre ciclos de sondeo. El parámetro
sincees un límite inferior exclusivo -- Docker devuelve solo líneas posteriores a esa marca de tiempo.
rustasync fn stream_logs(
ws: &mut WebSocket,
docker: &DockerClient,
container_id: &str,
) -> Result<()> {
let mut last_timestamp = Utc::now() - Duration::seconds(300); // Comenzar con últimos 5 min
loop {
let logs = docker.container_logs(
container_id,
&LogsQuery {
stdout: true,
stderr: true,
since: Some(last_timestamp),
timestamps: true,
tail: None,
}
).await?;
for line in &logs {
if let Some(ts) = line.timestamp {
if ts > last_timestamp {
last_timestamp = ts;
}
}
ws.send(Message::Text(line.message.clone())).await?;
}
tokio::time::sleep(Duration::from_secs(2)).await;
}
}El valor inicial de since se establece cinco minutos antes de la hora actual. Esto significa que al abrir la pestaña de Logs, inmediatamente ves los últimos cinco minutos de salida -- suficiente contexto para entender qué está pasando sin inundar el navegador con horas de historial.
Estadísticas de contenedor
Junto al streaming de logs, añadimos un endpoint de estadísticas de contenedor que devuelve el uso de CPU y memoria en tiempo real:
GET /api/v1/apps/:id/statsEste endpoint llama a GET /containers/{id}/stats?stream=false de Docker (modo de una sola vez), calcula el porcentaje de CPU a partir del delta entre cpu_stats y precpu_stats, y devuelve el uso de memoria tanto en bytes absolutos como en porcentaje del límite del contenedor.
La página de monitoreo usa este endpoint con una auto-actualización de 15 segundos para mostrar medidores de CPU y memoria. No es streaming de logs per se, pero comparte la misma preocupación de infraestructura: obtener datos en vivo de Docker al navegador.
Capa 2: Autenticación WebSocket
Los WebSockets no soportan cabeceras HTTP personalizadas en el handshake inicial en navegadores. El patrón Authorization: Bearer <token> que funciona para APIs REST no funciona aquí. La API WebSocket de los navegadores solo permite establecer la URL y el protocolo.
El enfoque ingenuo (y por qué lo cambiamos)
Nuestra primera implementación pasaba el token JWT como parámetro de consulta en la URL:
ws://host/api/v1/apps/:id/logs/stream?token=eyJhbG...Esto funciona, pero tiene un problema de seguridad: el token aparece en los logs de acceso del servidor, el historial del navegador y cualquier log de proxy entre el cliente y el servidor. Para una herramienta autoalojada donde servidor y cliente están frecuentemente en la misma máquina, este riesgo es modesto. Pero sigue siendo una mala práctica.
El truco de Sec-WebSocket-Protocol
Movimos el token JWT a la cabecera Sec-WebSocket-Protocol. Esta es una cabecera WebSocket legítima que los navegadores permiten establecer durante el handshake:
typescript// Frontend
const ws = new WebSocket(url, [`bearer-${token}`]);rust// Backend
fn extract_token_from_protocol(headers: &HeaderMap) -> Option<String> {
headers
.get("sec-websocket-protocol")
.and_then(|v| v.to_str().ok())
.and_then(|protocols| {
protocols
.split(',')
.map(|p| p.trim())
.find(|p| p.starts_with("bearer-"))
.map(|p| p.strip_prefix("bearer-").unwrap().to_string())
})
}El servidor extrae el token de la cabecera de protocolo, lo valida como JWT y procede con el upgrade WebSocket si es válido. La respuesta incluye el mismo protocolo en la cabecera Sec-WebSocket-Protocol de respuesta, completando la negociación de subprotocolo.
Este enfoque mantiene el token fuera de URLs y logs. Es un patrón bien conocido usado por Hasura, Supabase y otras plataformas que necesitan autenticación WebSocket en navegadores.
Al fallar
Si el token está ausente, expirado o es inválido, el servidor rechaza el upgrade WebSocket con un código de estado 401. El navegador recibe un fallo de conexión, y la lógica de reconexión automática del frontend se activa -- pero solo después de verificar si el store de autenticación aún tiene un token válido. Si el token ha expirado, el cliente redirige al login en lugar de reintentar indefinidamente.
Capa 3: Reconexión automática con backoff exponencial
Las conexiones WebSocket se caen. Las redes no son fiables. Los servidores se reinician. Los contenedores se redesplegan. El frontend debe manejar las desconexiones con elegancia.
La lógica de reconexión usa backoff exponencial comenzando en 1 segundo y limitándose a 30 segundos:
typescriptlet reconnectDelay = $state(1000);
let reconnectTimer: ReturnType<typeof setTimeout>;
function connect() {
ws = new WebSocket(url, [`bearer-${token}`]);
ws.onopen = () => {
reconnectDelay = 1000; // Reiniciar en conexión exitosa
};
ws.onclose = (event) => {
if (event.code === 4001) {
// Fallo de autenticación -- no reconectar, redirigir al login
goto('/login');
return;
}
reconnectTimer = setTimeout(() => {
reconnectDelay = Math.min(reconnectDelay * 2, 30000);
connect();
}, reconnectDelay);
};
}
function disconnect() {
clearTimeout(reconnectTimer);
ws?.close();
}La secuencia es: 1s, 2s, 4s, 8s, 16s, 30s, 30s, 30s... hasta que la conexión tiene éxito, momento en que el retardo se reinicia a 1 segundo. Esto evita saturar el servidor durante una caída mientras se recupera rápidamente cuando el servidor vuelve.
Un código de cierre personalizado (4001) distingue fallos de autenticación de fallos de red. Los fallos de autenticación no deben disparar reconexión -- el usuario necesita iniciar sesión de nuevo.
Capa 4: El componente LogViewer
El componente LogViewer.svelte renderiza líneas de log en un contenedor de fuente monoespaciada estilo terminal:
svelte<script lang="ts">
let { appId } = $props<{ appId: string }>();
let lines = $state<string[]>([]);
let autoScroll = $state(true);
let logContainer: HTMLDivElement;
const MAX_LINES = 1000;
function appendLine(line: string) {
lines = [...lines, line];
if (lines.length > MAX_LINES) {
lines = lines.slice(-MAX_LINES);
}
if (autoScroll) {
tick().then(() => {
logContainer.scrollTop = logContainer.scrollHeight;
});
}
}
function handleScroll() {
const { scrollTop, scrollHeight, clientHeight } = logContainer;
// Auto-scroll si está a menos de 50px del final
autoScroll = scrollHeight - scrollTop - clientHeight < 50;
}
</script>
<div bind:this={logContainer}
onscroll={handleScroll}
class="h-96 overflow-y-auto bg-[var(--bg-secondary)] rounded-lg
font-mono text-xs leading-5 p-4">
{#each lines as line}
<div class="whitespace-pre-wrap break-all">{line}</div>
{/each}
</div>El búfer de 1.000 líneas
Sin un límite de búfer, un servicio con mucha salida podría llevar el uso de memoria del navegador a gigabytes. La constante MAX_LINES limita la salida renderizada a 1.000 líneas. Cuando llega una nueva línea y el búfer está lleno, la línea más antigua se descarta. Es un búfer circular en espíritu, implementado como un slice de array por simplicidad.
Mil líneas cubren aproximadamente 20 minutos de salida para un servidor web típico (a una línea de log por petición por segundo). Para depuración, esto es más que suficiente. Para análisis histórico, los usuarios deberían usar el endpoint de recuperación de logs de una sola vez o un sistema externo de agregación de logs.
Auto-scroll inteligente
El comportamiento de auto-scroll es el detalle de UX más importante del LogViewer. Las reglas son:
- Si el usuario está al final del log, las nuevas líneas se desplazan a la vista automáticamente. Este es el estado por defecto -- abres la pestaña de Logs y ves la salida desplazarse.
- Si el usuario se desplaza hacia arriba para leer líneas anteriores, el auto-scroll se desactiva. Las nuevas líneas siguen llegando y se añaden al búfer, pero la ventana permanece donde el usuario la dejó.
- Si el usuario vuelve al final (dentro de 50 píxeles), el auto-scroll se reactiva.
El umbral de 50 píxeles previene un caso frustrante: el usuario se desplaza hacia abajo hasta "casi el final" pero está a un píxel de distancia, y las nuevas líneas siguen empujando el objetivo más lejos. Con el umbral, estar "lo suficientemente cerca" del final cuenta como "en el final."
La llamada a tick() antes de desplazar asegura que Svelte ha terminado de actualizar el DOM con la nueva línea antes de medir scrollHeight. Sin ella, nos desplazaríamos al final del contenido antiguo, y la nueva línea aparecería debajo del área visible.
Estadísticas de contenedor: El complemento de monitoreo
Los medidores de CPU y memoria en tiempo real en la pestaña de Monitoreo usan un mecanismo diferente al streaming de logs. En lugar de WebSocket, usan un endpoint HTTP simple sondeado cada 15 segundos:
typescript$effect(() => {
const interval = setInterval(async () => {
const stats = await appsApi.getStats(appId);
cpuPercent = stats.cpu_percent;
memoryUsed = stats.memory_usage;
memoryLimit = stats.memory_limit;
}, 15000);
return () => clearInterval(interval);
});Consideramos usar la misma conexión WebSocket para las estadísticas, pero la frecuencia de actualización (cada 15 segundos) no justifica una conexión persistente. El sondeo HTTP es más simple, más cacheable y funciona correctamente detrás de balanceadores de carga que podrían no soportar WebSocket.
Los medidores son círculos SVG con un stroke-dashoffset proporcional al porcentaje. Se animan al actualizarse usando una transición CSS, dando una progresión visual suave en lugar de un cambio numérico brusco.
Lecciones aprendidas
El sondeo con seguimiento de marca de tiempo supera a los streams persistentes para logs. Es más eficiente en recursos, más resistente a reinicios del daemon Docker y más simple de implementar correctamente. El intervalo de sondeo de dos segundos es lo suficientemente rápido para que los usuarios perciban los logs como "en tiempo real."
La autenticación WebSocket vía Sec-WebSocket-Protocol es el patrón correcto. Los tokens en parámetros de consulta son expeditivos pero dejan secretos en los logs. El enfoque de cabecera de protocolo es limpio, compatible con estándares y bien soportado en todos los navegadores.
El auto-scroll es un problema de UX, no técnico. El código es trivial. La decisión de diseño -- cuándo hacer auto-scroll y cuándo detenerse -- es lo que hace el componente agradable de usar. Ajustar el umbral de 50 píxeles requirió más reflexión que la implementación del WebSocket.
Los límites de búfer no son opcionales. Un visor de logs sin límite es una fuga de memoria esperando ocurrir. Mil líneas es un buen valor por defecto: suficiente para depuración, lo bastante pequeño para que el navegador permanezca responsivo.
Los logs en tiempo real son una de esas funcionalidades en las que los usuarios no piensan hasta que las necesitan -- y entonces las necesitan urgentemente. Tenerlos a un clic de distancia, con autenticación, reconexión y un visor bien pensado, es el tipo de detalle que separa una herramienta que toleras de una herramienta que disfrutas.
Siguiente en la serie: i18n desde el día uno: 5 idiomas en 105 sesiones -- por qué construimos sh0 con soporte para cinco idiomas desde la primera sesión del panel, y cómo lo mantuvimos a lo largo de 105 sesiones de desarrollo.