Operar en producción: los pocos errores que quedan, analítica por puertos y despliegue estático
Operar una aplicación cuyo lenguaje ha eliminado las excepciones plantea un problema que casi nadie anticipa: las herramientas de observabilidad de la industria están construidas sobre la premisa de que hay fallos que capturar, y cuando no los hay, el panel se queda vacío y el equipo se queda ciego. Esta lección recorre los tres frentes de la explotación real. Primero enumera con exactitud la clase de fallos que sí pueden ocurrir en producción, que es corta y muy concreta, y explica por qué casi todos viven en el borde con el mundo exterior y no en la lógica. Segundo, propone reorientar la instrumentación desde la captura de excepciones hacia la emisión deliberada de eventos de dominio a través de puertos, con las decisiones de granularidad, muestreo y privacidad que eso arrastra. Tercero, examina el despliegue como sitio estático: qué significa que el artefacto sea un archivo inerte, cómo se combinan la huella de contenido y las cabeceras de caché, por qué la política de seguridad de contenido puede ser estricta, y por qué la configuración del entorno debe viajar en las banderas y no dentro del paquete compilado.
Hay una experiencia recurrente en los equipos que llevan a producción su primera aplicación en Elm, y suele describirse con una mezcla de alivio y desconcierto. El alivio es que el panel de errores no se llena. El desconcierto llega la primera vez que un usuario informa de que algo no funciona y el equipo descubre que no tiene ni un rastro, ni una traza de pila, ni una alerta, porque las herramientas que había instaladas escuchaban excepciones y no hubo ninguna. La aplicación no se rompió: hizo exactamente lo que su código decía, y lo que su código decía estaba mal, o el dato que recibió no era el que se esperaba, o el usuario se quedó mirando un estado de carga que nunca terminó. Operar en este régimen exige un cambio de mentalidad que va más allá de la elección de herramienta. Cuando el fallo deja de anunciarse solo, la observabilidad deja de ser algo que se instala y pasa a ser algo que se diseña, y el sitio donde se diseña es el mismo vocabulario de mensajes que ya describe todo lo que puede pasar en la aplicación.
- Enumerar con precisión las clases de fallo que sí pueden ocurrir en tiempo de ejecución y situar cada una en el borde donde vive.
- Diseñar una instrumentación basada en la emisión deliberada de eventos de dominio en lugar de en la captura de excepciones.
- Decidir granularidad, muestreo y tratamiento de datos personales al enviar telemetría desde el bucle de actualización.
- Desplegar el artefacto como sitio estático con huella de contenido, caché inmutable y configuración inyectada por banderas.
Lo que todavía puede fallar
La lista es corta y merece aprenderse de memoria porque delimita dónde hay que mirar. Una rama marcada como pendiente con la función de tarea por hacer sí interrumpe la ejecución, y esa es la única forma de provocar un fallo desde código puro. La recursión sin caso base agota la pila, igual que en cualquier otro lenguaje. Las banderas de arranque o los valores que entran por un puerto con una forma distinta de la declarada producen un error explícito en el momento de cruzar la frontera, no después. Un nodo anfitrión inexistente hace que el montaje falle. Y la modificación del documento por parte de código externo dentro de la región gobernada puede romper la siguiente comparación del árbol virtual. Las cinco tienen algo en común: ninguna nace de la lógica del programa, todas nacen del contacto con lo que está fuera.
Hay además una categoría que no interrumpe nada y que por eso resulta más peligrosa: la del resultado equivocado sin ruido. La aritmética de coma flotante puede producir un valor no numérico que se propaga silenciosamente por los cálculos; los enteros que superan el rango representable dejan de comportarse como enteros; una división entera entre cero devuelve cero en lugar de fallar. Nada de eso levanta una alarma, y el único mecanismo de defensa disponible es el mismo de siempre: modelar el dominio de forma que el valor imposible no quepa en el tipo, y validar en el borde en lugar de confiar.
A esa categoría pertenece también el fallo operativo más común de todos y el que ninguna herramienta detectará jamás: la aplicación se queda esperando. Una petición que no responde, una suscripción que nunca emite, un mensaje que se envió por un puerto y cuya respuesta el anfitrión olvidó devolver, y el usuario frente a un indicador de carga permanente. El programa está perfectamente sano, su modelo está en un estado legítimo y su ciclo funciona; simplemente ese estado no tiene salida. Modelar los estados de carga con un tipo que incluya el caso de tiempo agotado, y emparejar cada petición con un vencimiento explícito, es la única forma de convertir ese silencio en algo observable.
type Peticion a
= Pendiente
| Cargando { desde : Time.Posix }
| Completada a
| Fallida Http.Error
| Vencida
Tarea pendiente
La única forma de detener el programa desde código puro. En producción no debería quedar ninguna, y la compilación optimizada ayuda a evitarlas.
Pila agotada
Recursión sin caso base o un ciclo de mensajes que se alimenta a sí mismo. Se ve en el navegador, no en el compilador.
Borde mal formado
Banderas o valores de puerto con forma distinta de la declarada. Falla al cruzar, con mensaje concreto y localizado.
Documento intervenido
Código externo que modifica nodos dentro de la región gobernada. El árbol virtual encuentra algo que no dejó allí.
Instrumentar una aplicación que no se cae
Si no hay excepciones que capturar, la telemetría tiene que emitirse a propósito, y el sitio natural es la función de actualización, que ya conoce todos los eventos del sistema porque su tipo de mensajes es la enumeración exhaustiva de todo lo que puede ocurrir. La forma habitual es un único puerto de salida que acepta un valor codificado con el nombre del evento y su carga, y una función pequeña que decide qué mensajes merecen viajar. La tentación de emitir por cada mensaje debe resistirse: produce un caudal enorme, dominado por movimientos de ratón y pulsaciones de tecla, en el que el evento significativo queda enterrado. Lo que interesa registrar son transiciones de dominio, no interacciones.
port telemetria : E.Value -> Cmd msg
evento : String -> List ( String, E.Value ) -> Cmd msg
evento nombre campos =
telemetria (E.object (( "evento", E.string nombre ) :: campos))
update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
case msg of
ReservaConfirmada id ->
( { model | estado = Confirmada id }
, evento "reserva_confirmada" [ ( "id", E.string id ) ]
)
DatosRecibidos (Err err) ->
( { model | estado = Fallo }
, evento "decodificacion_fallida"
[ ( "detalle", E.string (D.errorToString err) )
, ( "pantalla", E.string (nombrePantalla model) )
]
)
El caso de la decodificación fallida merece atención aparte porque es el sustituto natural del informe de excepción. Cuando el servidor cambia un campo, un valor llega nulo donde no debía o una versión antigua de la interfaz de datos responde a un cliente nuevo, el fallo se materializa como un valor de error con una descripción legible en lugar de como una interrupción. Ese valor es exactamente lo que hay que enviar, junto con el suficiente contexto del modelo para reconstruir la situación, porque aquí no hay traza de pila que rescatar. La descripción del error de decodificación indica la ruta concreta dentro del documento donde la forma no encajó, y eso suele ser más útil que una traza.
Vale la pena detenerse en lo que sustituye a la traza de pila, porque no es un sustituto peor sino distinto. Una traza responde a la pregunta de por dónde pasó la ejecución, que en un sistema con estado repartido es la única pista disponible. Aquí la pregunta útil es otra: en qué estado estaba el modelo y qué mensaje lo llevó a este punto. Ambos datos existen, son valores ordinarios y se pueden serializar. Los equipos que operan aplicaciones grandes en este estilo suelen mantener una lista corta de los últimos mensajes recibidos dentro del propio modelo, en un campo destinado a eso, y adjuntarla al informe cuando algo va mal. Es reconstruir la historia con datos del dominio en lugar de con direcciones de retorno, y para investigar un fallo de producto resulta bastante más informativo.
app.ports.telemetria.subscribe((carga) => {
if (Math.random() > MUESTREO) return;
navigator.sendBeacon("/telemetria", JSON.stringify({
...carga,
version: window.VERSION_ARTEFACTO,
sesion: window.SESION_ANONIMA,
}));
});
Situar la emisión de telemetría en la función de actualización concede acceso al estado completo de la aplicación en cada transición, y esa comodidad es precisamente el riesgo. Enviar el modelo entero para tener contexto es la solución tentadora y casi siempre incorrecta: arrastra fuera del navegador datos personales que nadie autorizó a exportar, contenidos de formularios a medio escribir, identificadores internos y a veces credenciales de sesión. La disciplina consiste en construir una función explícita que extraiga del modelo únicamente los campos que la investigación necesita, mantenerla en un módulo propio y revisarla como se revisa cualquier otro punto de salida de datos. El muestreo es la segunda decisión: por debajo de cierto volumen conviene enviarlo todo, y por encima hay que muestrear, pero nunca los eventos de fallo, que son raros y son los que importan.
Desplegar un artefacto inerte
El resultado de la construcción es un archivo de JavaScript y un documento que lo carga, y esa simplicidad tiene consecuencias operativas grandes. No hay proceso que mantener vivo, ni tiempo de ejecución del servidor que actualizar por seguridad, ni memoria que se degrade con los días, ni despliegue que pueda quedar a medias entre dos versiones del código. El artefacto se sube a un almacén de objetos o a una red de distribución, y servirlo es leer un archivo. La rutina de despliegue completa consiste en compilar con optimización, minificar, poner al nombre del archivo una huella derivada de su contenido, subirlo y actualizar el documento que lo referencia.
De esa inercia se sigue una propiedad que conviene aprovechar: la reversión es instantánea y no tiene riesgo. Como el artefacto anterior sigue existiendo con su propio nombre y la red de distribución lo conserva, deshacer un despliegue consiste en volver a apuntar el documento al nombre viejo, sin migraciones que revertir, sin procesos que reiniciar y sin ventana durante la cual coexistan dos versiones incompatibles del servidor. Merece la pena diseñar el proceso para que esa reversión sea un solo comando y esté ensayada, porque es la red de seguridad que permite desplegar a menudo, y desplegar a menudo es lo que mantiene pequeños los cambios.
elm make src/Main.elm --optimize --output=dist/elm.js
HUELLA=$(sha256sum dist/elm.js | cut -c1-8)
uglifyjs dist/elm.js --compress 'pure_funcs=[F2,F3,F4,A2,A3,A4],pure_getters,keep_fargs=false,unsafe' \
| uglifyjs --mangle --output "dist/app.$HUELLA.js"
/app.9f3c2a1b.js Cache-Control: public, max-age=31536000, immutable
/index.html Cache-Control: no-cache
/* Content-Security-Policy: script-src self
Las cabeceras se derivan de esa huella sin misterio: el archivo con huella en el nombre es inmutable y puede cachearse durante un año, porque un cambio de contenido produce un nombre distinto; el documento que lo referencia no se cachea o se cachea muy poco, porque es lo único que hay que revalidar. Si el programa es de los que controlan la dirección, el servidor necesita además devolver ese mismo documento para cualquier ruta que no corresponda a un archivo, de modo que entrar directamente por una dirección interna funcione. Y como la salida del compilador no usa evaluación dinámica de cadenas ni construye funciones a partir de texto, la política de seguridad de contenido puede prohibir ambas cosas sin romper nada, lo cual elimina de un golpe una familia entera de vectores de inyección.
Hay una decisión de despliegue que conviene tomar el primer día porque después cuesta deshacerla. Si la dirección de la interfaz de datos, la clave pública del servicio de pagos o el identificador del entorno se incrustan en el código fuente y se resuelven en tiempo de compilación, el artefacto deja de ser reutilizable: hay que compilar una vez para pruebas y otra para producción, y lo que se validó en un entorno no es literalmente lo mismo que se ejecuta en el otro. Si en cambio esos valores viajan en las banderas, inyectados por el documento que sirve la página, el mismo archivo compilado y verificado se promociona sin recompilar de un entorno al siguiente. Además el decodificador de banderas se convierte en la validación de la configuración: un despliegue con una variable ausente falla al arrancar, de forma inmediata y evidente, en lugar de fallar tres pantallas más adelante.
flowchart LR A[Codigo fuente] --> B[elm make con optimize] B --> C[Minificar y anadir huella] C --> D[Subir a la red de distribucion] D --> E[Documento con banderas del entorno] E --> F[Aplicacion en el navegador] F --> G[Puerto de telemetria] G --> H[Eventos de dominio y fallos de borde] style B fill:#cba6f7,color:#11111b style D fill:#89b4fa,color:#11111b style H fill:#a6e3a1,color:#11111b
Merece la pena mirar de frente la incomodidad que produce un panel de errores vacío, porque revela algo sobre cómo la industria ha llegado a entender la observabilidad. Durante veinte años el modelo dominante ha sido reactivo y accidental: el programa se rompe, alguien captura la excepción, la traza de pila viaja a un servicio y el equipo reconstruye a posteriori qué ocurrió. Ese modelo es tan cómodo que se ha confundido con la observabilidad misma, y por eso quitarle las excepciones a un sistema se siente como quitarle la vista. Pero conviene notar lo que ese modelo nunca cubrió, que es la mayoría de lo que importa. La traza de pila nunca contó que un usuario abandonara un formulario en el tercer paso, ni que una pantalla tardara nueve segundos en tener contenido útil, ni que una de cada veinte respuestas del servidor trajera un campo con forma inesperada que el cliente absorbió sin protestar, ni que una funcionalidad entera llevara meses sin usarse. Todo eso siempre hubo que instrumentarlo a propósito, y lo que cambia en un sistema sin excepciones no es la naturaleza del trabajo sino la ilusión de que ese trabajo ya estaba hecho. La ventaja real, y es considerable, es que en esta arquitectura existe un lugar canónico donde ponerlo: el tipo de mensajes es la enumeración exhaustiva y comprobada por el compilador de todo lo que puede suceder en la aplicación, de modo que la pregunta de qué eventos deberíamos estar observando tiene por primera vez una respuesta con límites conocidos. Se puede recorrer la lista entera y decidir caso por caso, y cuando alguien añada un caso nuevo, la exhaustividad lo pondrá delante. La conclusión que exportaría a cualquier sistema, tenga excepciones o no, es que un fallo silencioso y un fallo ruidoso no se distinguen por su gravedad sino por si alguien decidió de antemano que se contara, y que esa decisión es trabajo de diseño y no de compra de herramienta.
- Enumera las cinco clases de fallo posibles y localiza en un proyecto tuyo dónde podría materializarse cada una.
- Añade un puerto de telemetría y emite eventos solo para transiciones de dominio; cuenta cuántos mensajes hay en total y cuántos elegiste instrumentar.
- Provoca un fallo de decodificación cambiando un campo en la respuesta del servidor y comprueba qué información contiene el error frente a lo que te daría una traza de pila.
- Escribe la función que extrae del modelo el contexto que se envía y justifica campo por campo por qué sale del navegador.
- Despliega el mismo artefacto compilado en dos entornos distintos usando solo las banderas, y verifica que la ausencia de una variable falla en el arranque.
- Aplica una política de seguridad de contenido que prohíba la evaluación dinámica, comprueba que la aplicación sigue funcionando y explica qué familia de ataques acabas de descartar.