wandres.dev
MINIFICACIÓN · terser, esbuild, oxc

Depurar en producción sin exponer tu código

Una traza de pila de producción apunta a app.min.js línea 1 columna 24512 con nombres de una letra: inútil. Los source maps la vuelven legible, pero publicarlos expone tu código original a cualquiera. El patrón que resuelve la tensión: generar mapas hidden, subirlos a un servicio de errores con debug ids y no desplegarlos jamás al público.

⏱ 16 min

Un usuario provoca un error en producción y tu servicio de monitorización te muestra TypeError: no es una funcion en app.min.js:1:24512. Esa traza es inútil: apunta a un engrudo minificado de una sola línea con nombres de una letra. Los source maps la convierten en TypeError en carrito.ts:42:8, dentro de aplicarDescuento, y de golpe sabes qué arreglar. Pero hay una tensión de fondo: el mismo mapa que te devuelve la legibilidad, si lo publicas, entrega tu código fuente original a cualquiera que sepa pedirlo. Depurar bien en producción es resolver esa tensión —quedarte con el beneficio de la traducción sin pagar el coste de la exposición—.

🎯 Al terminar esta lección sabrás
  • Entender por qué una traza de pila minificada es inservible sin su source map.
  • Reconocer la tensión entre poder simbolicar errores y no exponer tu código.
  • Aplicar el patrón hidden más subida a un servicio de errores, sin desplegar los .map.
  • Conocer los debug ids como el mecanismo robusto de emparejamiento en 2026.

La traza ilegible y la tensión que crea

En producción envías código minificado, así que toda excepción que capture tu servicio de errores llega con una traza que apunta a posiciones del .js generado: un archivo, una línea —casi siempre la 1—, una columna enorme y nombres como a.b.c. Sin el source map correspondiente, esa información no te dice nada sobre tu código. Simbolicar —traducir esas posiciones de vuelta a archivo, línea y función originales— es exactamente lo que un source map permite.

Una traza minificada te roba justo aquello que necesitas para actuar:

  • El nombre real de la función donde ocurrió el fallo, sustituido por una letra como a o t.
  • El archivo de origen, colapsado en un único app.min.js que reúne cientos de módulos.
  • La línea y la columna con sentido, reducidas a la línea 1 y una columna de decenas de miles.
  • El fragmento de código alrededor del error, ilegible en una sola línea comprimida.

La solución obvia sería servir los .map junto al .js, como en desarrollo. Y ahí está la trampa. Un source map con sourcesContent lleva dentro el texto íntegro de tu código original: comentarios, lógica de negocio, nombres reveladores, a veces rutas internas o pistas sobre secretos. Publicarlo en tu CDN equivale a publicar tu repositorio en un .map que cualquiera puede abrir con las DevTools. Quieres el beneficio de la simbolización sin el coste de la exposición pública, y esas dos cosas parecen tirar en direcciones opuestas.

Lo que un source map con sourcesContent puede filtrar no es poca cosa:

  • El código fuente íntegro, con su estructura, sus nombres y su lógica de negocio.
  • Los comentarios internos, que a menudo explican decisiones o dejan avisos crudos.
  • Las rutas y nombres de módulos que revelan la organización interna del proyecto.
  • Pistas sobre endpoints, flags o claves que el código menciona aunque no las contenga.
⚠️
hidden borra el enlace, no el archivo

El error más peligroso es creer que sourcemap: "hidden" ya te protege. hidden solo quita el comentario sourceMappingURL del .js; el archivo .map se sigue generando, y si lo despliegas a un CDN en su ruta previsible —app.min.js.map junto a app.min.js— cualquiera puede pedirlo a mano y descargarlo, comentario o no. Ocultar el enlace no oculta el destino. El control real no es quitar el comentario: es no subir el archivo .map al servidor público, o borrarlo después de haberlo usado. hidden es una pieza del patrón, no el patrón entero.

El patrón: hidden, subir, y no desplegar

La forma robusta de depurar en producción invierte el flujo. En vez de servir los mapas junto al código, los generas en hidden, se los entregas en privado a tu servicio de errores durante el release, y luego los eliminas para que nunca lleguen al público. El servicio guarda los mapas en su almacén privado y simboliza las trazas entrantes en su lado; tú ves los errores traducidos en su panel, y el mundo solo ve el .js minificado.

El reparto de responsabilidades queda nítido:

  • Tu CI: genera los mapas, los sube al servicio y borra los locales antes de desplegar.
  • El servicio de errores: los guarda en privado y simboliza cada traza que recibe.
  • Tu CDN: sirve solo el .js minificado, jamás el .map.
  • Tú: lees en el panel el archivo, la línea y la función originales de cada error.
# En el paso de release del CI, sobre la carpeta de artefactos ya construida
npx sentry-cli sourcemaps inject ./dist
npx sentry-cli sourcemaps upload --release "$VERSION" ./dist

# Y despues, ANTES de desplegar, borra los .map para que no viajen al CDN
find ./dist -name "*.map" -type f -delete

Un plugin de bundler —del estilo de @sentry/vite-plugin— automatiza los tres pasos: inyecta los identificadores, sube los mapas al terminar el build y puede borrar los .map locales para que no se desplieguen. El resultado es que tu pipeline produce dos salidas con destinos distintos: el .js minificado va al CDN público, y el .map va, una sola vez y de forma autenticada, al servicio de errores.

El equivalente declarativo, dentro de la config de Vite, deja todo el flujo en un solo sitio:

// vite.config.ts
import { sentryVitePlugin } from "@sentry/vite-plugin";

export default defineConfig({
  build: { sourcemap: "hidden" },   // genera el .map sin enlazarlo desde el .js
  plugins: [
    sentryVitePlugin({
      // inyecta debug ids, sube los .map y luego los borra del artefacto
      sourcemaps: { filesToDeleteAfterUpload: ["./dist/**/*.map"] },
    }),
  ],
});
🗺️

Genera en hidden

El build produce el .map pero no deja rastro de él en el .js. El artefacto público queda mudo sobre dónde vive su mapa.

🔒

Sube en privado

El .map viaja autenticado al servicio de errores, que lo guarda en su almacén y lo usa para simbolicar del lado servidor.

🧹

No lo despliegues

Antes de publicar, los .map se borran del artefacto. Al CDN solo llega el código minificado, jamás el mapa.

flowchart TD
BUILD[Build con mapas hidden] --> UP[Subir los .map al servicio de errores]
UP --> DEL[Borrar los .map locales]
DEL --> DEPLOY[Desplegar solo el .js al CDN publico]
ERR[Error real en produccion] --> TRACE[Traza minificada al servicio]
TRACE --> SYM[Simbolizar con el mapa privado]
SYM --> READ[Ves archivo linea y funcion originales]
style BUILD fill:#89b4fa,color:#11111b
style DEPLOY fill:#a6e3a1,color:#11111b
style READ fill:#a6e3a1,color:#11111b
style ERR fill:#f38ba8,color:#11111b

Debug ids: emparejar por identidad, no por casualidad

Queda un problema fino: ¿cómo sabe el servicio qué mapa corresponde a la traza que acaba de llegar? Durante años se emparejaba por la ruta y el nombre del archivo, un método frágil que se rompía con hashes de contenido cambiantes, despliegues a rutas distintas o varias versiones conviviendo. La respuesta moderna, ya extendida en 2026, son los debug ids: un identificador único que el bundler estampa a la vez en el .js y en su .map. El inject que viste inserta ese id en ambos.

Con debug ids, el emparejamiento deja de depender de coincidencias de URL. La traza llega con el debug id del archivo que la produjo, el servicio busca el mapa que lleva ese mismo id, y la correspondencia es exacta aunque la ruta haya cambiado, aunque haya cinco versiones desplegadas, aunque el nombre no coincida. Es la diferencia entre encontrar a alguien por su huella dactilar y encontrarlo por su dirección: lo primero es identidad, lo segundo es una casualidad que se rompe en cuanto algo se mueve.

El emparejamiento por identidad resiste precisamente lo que descolocaba al de por ruta:

  • Un hash de contenido que cambia el nombre del archivo en cada build.
  • Un despliegue a un dominio o ruta distintos de los que el mapa creyó registrar.
  • Varias versiones conviviendo en producción durante un despliegue gradual.
  • Un proxy o CDN que reescribe las URLs de los assets por el camino.
💡
Incluye sourcesContent en el mapa que subes

Como los mapas que subes al servicio son privados y nunca se despliegan, aquí sí te conviene incrustar sourcesContent: así el servicio puede mostrarte el código alrededor de la línea del error sin ir a buscar tus archivos originales, que quizá ni existan en ese momento. Lo que en producción pública era un riesgo —llevar tu fuente dentro del mapa— en el canal privado del servicio de errores es justo lo que da contexto a la traza. La misma decisión técnica cambia de signo según quién sea el destinatario del mapa.

Por qué no las alternativas más simples

El patrón de subir y borrar no es el único que se intenta, pero sí el que menos supuestos frágiles arrastra. Conviene saber por qué las salidas fáciles se quedan cortas:

  • Servir los .map en abierto: cómodo, pero publica tu fuente entera a quien abra las DevTools o adivine la ruta.
  • Proteger los .map tras autenticación o por IP: viable, pero te obliga a mantener esa capa y a que el servicio sepa autenticarse contra ella.
  • Renunciar a los mapas en producción: cero exposición, pero también cero legibilidad: vuelves a las trazas ilegibles del principio.
  • Confiar en hidden sin borrar el archivo: el peor de los mundos, porque te crees protegido mientras el .map sigue accesible en su ruta previsible.

Subir a un servicio y borrar el archivo local domina a todas: conserva la legibilidad, elimina la exposición y no añade una capa de acceso que después haya que cuidar.

Observabilidad sin exposición: separa el artefacto que el público ejecuta del conocimiento con que lo depuras

Este nivel enseña, a través de un problema muy concreto, un principio que gobierna todo el software en producción: el artefacto que tus usuarios ejecutan y el conocimiento con que tú lo depuras no tienen por qué ser la misma cosa, ni vivir en el mismo sitio, ni ser accesibles para las mismas personas. El código minificado es el artefacto: pequeño, opaco, público, optimizado para correr. El source map es el conocimiento: voluminoso, revelador, privado, optimizado para entender. Durante años el falso dilema fue “o depuras cómodo publicando los mapas, o proteges tu fuente y depuras a ciegas”, y la salida elegante es negarse a aceptarlo. Generas el conocimiento —el mapa—, lo conservas, pero lo guardas donde solo tú llegas —el servicio de errores— y lo emparejas con cada error por identidad —el debug id— y no por la coincidencia frágil de una URL. El resultado es observabilidad total sin exposición ninguna: ves cada excepción con su archivo, su línea y su función originales, mientras el mundo solo ve un engrudo ilegible. Fíjate en que las tres piezas del nivel encajan aquí en una sola frase operativa: minificas para enviar poco, generas source maps en hidden para poder traducir, y los subes en privado con debug ids para depurar sin exponer. Y fíjate en que el principio trasciende con mucho a los mapas. Cada vez que en tu carrera tengas que operar un sistema opaco por diseño —binarios ofuscados, modelos cerrados, artefactos endurecidos— la pregunta será la misma: cómo conservar, aparte y en privado, el conocimiento que te permite entenderlo, sin filtrarlo al ejecutarlo. Quien interioriza que observabilidad y exposición son ejes independientes deja de sacrificar una por la otra y aprende a tener las dos.

⚔️ Depura sin filtrar
  1. Provoca un error en un build de producción sin mapas y comprueba que la traza apunta a app.min.js con una columna gigante e ilegible.
  2. Configura sourcemap: "hidden" y confirma que el .map se genera pero el .js ya no lo enlaza con ningún comentario.
  3. Inyecta debug ids y sube los mapas a un servicio de errores desde tu CI; verifica que la traza ahora se muestra con archivo, línea y función originales.
  4. Comprueba que el .map no está accesible en tu CDN pidiéndolo a mano por su ruta previsible y confirmando que responde con un 404.
  5. Reflexiona sobre por qué el emparejamiento por debug id sobrevive a un cambio de hash de contenido que habría roto el emparejamiento por URL.