CodeGym /Cursos /Go SELF /Dónde registrar: los bordes CLI/HTTP, no «en todas partes...

Dónde registrar: los bordes CLI/HTTP, no «en todas partes»

Go SELF
Nivel 52, Lección 3
Disponible

1. Por qué no conviene registrar en cada función

Si alguna vez depuraste un programa con el método «vamos a poner Println en todas partes», ya conoces la sensación: al principio parece que eres un genio de la observabilidad, y a los 10 minutos en la consola aparece una sopa de texto en la que se hunde todo lo importante. Los logs funcionan exactamente igual. Si registras «en todas partes», los logs dejan de ser un sistema de navegación y se convierten en ruido, como si el GPS te dijera cada segundo: «Sigues conduciendo».

El problema principal no es que haya muchos logs. El problema es que empiezan a duplicarse entre sí y dificultan entender dónde está el «punto de decisión». En Go esto se nota especialmente, porque es habitual propagar los errores hacia arriba por la pila de llamadas, añadiendo contexto mediante wrapping. Si además registras en cada nivel, obtendrás 3–7 mensajes iguales que describen el mismo problema con palabras distintas, y eso no ayuda, sino que estorba.

Para que el registro sea útil, necesitamos disciplina: el log es un efecto secundario, y debe tener un responsable. En una aplicación normal, el «dueño de los logs» vive en el borde, donde decidimos qué hacer con el error: mostrárselo al usuario, devolver una respuesta HTTP, terminar el proceso, reintentar, etc.

2. El borde de la aplicación: qué es y por qué solemos registrar ahí

El término «borde» suena solemne, como si estuviéramos defendiendo un estado de una invasión de bugs. En realidad, es más simple: el borde es el lugar donde tu código se encuentra con el mundo exterior. Para CLI, eso es main() y los manejadores de comandos que leen argumentos e imprimen el resultado. Para HTTP, es handler, que lee la petición y escribe la respuesta. Para la importación de un archivo, es la función que abre el archivo y escribe un informe. Para un paquete de biblioteca, es la función pública de la API que llama código ajeno.

¿Por qué es tan importante el borde? Porque solo en el borde está claro «qué hacemos después». Dentro de la lógica de negocio (el hipotético AddTask) normalmente no queremos decidir si hay que mostrar algo al usuario, registrar el error como Error o considerarlo una situación «esperada». La función interna debe decir honestamente: «No he podido, aquí está el error» y devolver error. Y el borde decide qué significa eso para el usuario y para los logs.

Conviene tener en la cabeza un esquema simple de «capas» (aunque el proyecto todavía sea pequeño):

flowchart TD
    A[CLI: main/command] --> B[app-layer: use cases]
    B --> C[storage/adapter]
    C --> D[OS/FS/Network]

    A:::border
    D:::outside

    classDef border fill:#f2f8ff,stroke:#1c6dd0,stroke-width:2px;
    classDef outside fill:#fff7f2,stroke:#d0671c,stroke-width:2px;

Aquí el borde está a la izquierda (CLI) y a la derecha (interacción con el SO/red como «mundo exterior»). Pero desde el punto de vista del registro, nos interesa sobre todo el borde izquierdo: es él quien debe decidir cuánto y cómo escribir en los logs.

3. CLI como borde: un log final por error

Ahora seguiremos desarrollando nuestra aplicación de ejemplo: un sencillo todo manager. Supongamos que tenemos el comando add, que añade una tarea, y list, que imprime la lista. El usuario espera que stdout sea apto para scripts (tabla/JSON), y que stderr se use para errores y diagnóstico.

La estructura más saludable para una CLI en Go se ve así: main() crea las dependencias, llama a run() y, solo entonces, decide qué hacer con el error. run() devuelve error, y no hace log.Fatal dentro; de lo contrario, empiezas a «matar el proceso» profundamente en el código y pierdes el control (y, de paso, corres el riesgo de saltarte defer, si era importante).

Esqueleto mínimo:

package main

import (
	"log/slog"
	"os"
)

func main() {
	logger := slog.New(slog.NewTextHandler(os.Stderr, nil))

	if err := run(logger, os.Args[1:]); err != nil {
		logger.Error("command failed", slog.Any("err", err))
		os.Exit(1)
	}
}

Fíjate en el sentido: el registro del error ocurre una sola vez, donde tomamos la decisión de que «esto es fatal para el comando» (exit code 1). Ese es el borde.

Y ahora un ejemplo de lo fácil que es «romper» la situación si registras dentro:

package main

import (
	"log/slog"
)

func doSomething(logger *slog.Logger) error {
	logger.Error("failed to do something") // bad: we log here...
	return errSomething                   // ...and also return an error
}

Si main() también registra err, obtendrás duplicados. El usuario verá dos Error para un solo problema. Y si además storage también registra, serán tres.

En CLI conviene tener presente esta regla: las funciones internas devuelven el error, y el borde decide si registrarlo o no.

4. Añadimos contexto al error, no al log

Una razón típica por la que un principiante empieza a registrar en cada función es el miedo a perder contexto. «Si solo devuelvo err, ¡arriba nadie entenderá dónde ocurrió!». Ese miedo es normal, pero no se resuelve con logs, sino con el hecho de que en Go los errores suelen envolverse y añadir contexto mediante fmt.Errorf("...: %w").

Es decir, dentro de las capas no registramos, sino que hacemos algo así:

package storage

import (
	"fmt"
	"os"
)

func loadFile(path string) ([]byte, error) {
	b, err := os.ReadFile(path)
	if err != nil {
		return nil, fmt.Errorf("read tasks file %q: %w", path, err)
	}
	return b, nil
}

La idea de que «el error sube con contexto» es uno de los patrones principales de Go, y la biblioteca estándar lo apoya expresamente: envuelves el error, conservando la causa, pero añadiendo tu propio «marco de sentido».

El efecto clave para el registro es este: dentro añadiste contexto, fuera (en el borde) registraste el error final una sola vez. Obtienes claridad y ausencia de duplicados.

5. Dónde registrar en CLI: inicio, fin y duración

Ahora imaginemos que queremos ver en los logs cuánto tarda en ejecutarse el comando list (por ejemplo, la lectura de un archivo puede ser lenta). Esto sí es diagnóstico real, no «charla por hablar». Y tiene sentido registrarlo en el borde: el comando empezó, el comando terminó, hay duration.

Ejemplo de registro «cuidadoso» de la ejecución de un comando:

package main

import (
	"log/slog"
	"time"
)

func run(logger *slog.Logger, args []string) error {
	start := time.Now()

	err := runList(args) // supongamos que dentro devolvemos error, pero no registramos
	dur := time.Since(start)

	if err != nil {
		logger.Error("list failed", slog.String("operation", "list"), slog.Duration("duration", dur), slog.Any("err", err))
		return err
	}

	logger.Info("list ok", slog.String("operation", "list"), slog.Duration("duration", dur))
	return nil
}

Sí, aquí el registro ocurre dentro de run(), pero run() en nuestra aplicación forma parte del borde (CLI orchestration). Es el lugar donde gestionamos el escenario del comando, no el «dominio interno».

Pero si runList empieza a escribir logger.Info("opened file"), logger.Info("parsed JSON"), logger.Info("sorted tasks"), acabarás rápidamente con una situación en la que el comando «list» produce 20 líneas de logs incluso en el caso normal. Al final, cuando ocurra un error real, será más difícil verlo.

6. HTTP como borde: registra el handler, no el dominio

Aunque nuestra aplicación principal ahora mismo es CLI, conviene acostumbrarse desde ya a una idea: para HTTP la regla es exactamente la misma, solo que el «usuario» está en la red. El borde es tu handler: lee la petición, llama a la lógica y escribe la respuesta.

En el artículo clásico sobre el manejo de errores en una aplicación web en Go, la idea se formula de una manera muy «go-ish»: el handler puede devolver un «error para el desarrollador» y un «mensaje para el usuario», y la capa externa decide qué registrar y qué enviar al cliente.

Miniilustración (simplificada, sin profundizar en el diseño de la API HTTP):

package httpapi

import (
	"log/slog"
	"net/http"
)

func handler(logger *slog.Logger, app App) http.HandlerFunc {
	return func(w http.ResponseWriter, r *http.Request) {
		if err := app.Do(r.Context()); err != nil {
			logger.Error("request failed", slog.String("operation", "do"), slog.Any("err", err))
			http.Error(w, "internal error", http.StatusInternalServerError)
			return
		}

		w.WriteHeader(http.StatusOK)
	}
}

Aquí vuelve a verse la regla: app.Do(...) no registra. Devuelve un error. Y el borde (handler) decide: registramos, respondemos con 500, no revelamos detalles al cliente.

Y otra consecuencia importante: no todos los errores en HTTP son un «Error» en los logs. Por ejemplo, el usuario envió un parámetro incorrecto. Eso es un 400, pero desde el punto de vista del servidor puede ser una situación normal. Para ello, muchas veces basta con Info o Warn (o incluso nada de logs, si es un caso ruidoso y frecuente). La decisión la toma otra vez el borde, porque solo él sabe qué cuenta como «normal» para tu producto.

7. Política sin duplicados: un problema, un Error-log

Ahora reunamos todo en una idea clara. Los duplicados aparecen cuando:

  1. la función interna registró el problema;
  2. devolvió el error;
  3. la capa superior lo registró otra vez.

Para evitarlo, conviene elegir para el proyecto una política sencilla:

Política: el Error-log se escribe exactamente allí donde «tomamos una decisión sobre el error».
Para CLI, eso suele ser main()/run() (se fija el exit code).
Para HTTP, eso es handler/middleware (se elige el código de estado y el cuerpo de la respuesta).

¿Y qué deben hacer las capas internas? Las capas internas hacen dos cosas: devuelven error y añaden contexto mediante wrapping.

La comparación entre «cómo sería malo» y «cómo es mejor» se puede mostrar con un mismo ejemplo.

Mala variante (duplicados):

package storage

import (
	"log/slog"
	"os"
)

func Load(logger *slog.Logger, path string) ([]byte, error) {
	b, err := os.ReadFile(path)
	if err != nil {
		logger.Error("read failed") // duplicate
		return nil, err
	}
	return b, nil
}

Buena variante (contexto a través del error):

package storage

import (
	"fmt"
	"os"
)

func Load(path string) ([]byte, error) {
	b, err := os.ReadFile(path)
	if err != nil {
		return nil, fmt.Errorf("load %q: %w", path, err)
	}
	return b, nil
}

Y ya en el borde:

package main

import (
	"log/slog"
)

func run(logger *slog.Logger) error {
	_, err := storage.Load("tasks.json")
	if err != nil {
		logger.Error("command failed", slog.Any("err", err))
		return err
	}
	return nil
}

Como resultado, tienes un único Error-log claro, pero dentro de él ya está toda la cadena de contexto: «command failed: load "tasks.json": open ...: no such file».

Mini tabla: qué registrar y dónde

Para no tener que mantenerlo todo en la cabeza, conviene fijar un contrato simple. Incluso puedes ponerlo literalmente en el README del proyecto.

Evento Dónde ocurre Qué hacemos Nivel
El comando se inició (add/list) Borde de CLI (run) log de «start» con operation Info (moderado)
El comando terminó correctamente Borde de CLI log de «ok» + duration Info
Argumentos incorrectos Borde de CLI imprimir usage en stderr, log opcional normalmente sin Error
No se pudo leer el archivo/BD el borde decide devolver error hacia arriba, en el borde: un solo Error Error
Problema «esperado» (not found, vacío) app/storage devolver error/resultado de dominio normalmente sin Error
La petición HTTP terminó con 500 Borde HTTP un Error con operation, duration, err Error
La petición HTTP terminó con 400 Borde HTTP normalmente sin Error, se puede usar Info/Warn Info/Warn

Un punto importante: la tabla no dice «qué escribir en el mensaje». Fija el lugar de la responsabilidad. Esa es la clave para que los logs no se conviertan en una cámara de eco.

8. Cuándo todavía es aceptable registrar dentro de las capas

La frase «registrar solo en el borde» es una excelente regla por defecto, pero no una ley de la física. A veces, registrar dentro de una capa está justificado. El problema es que el principiante suele empezar con «a veces» y acaba en «siempre»; y entonces volvemos otra vez al mundo de la sopa de texto.

El registro dentro de una capa suele estar justificado cuando ocurre un evento importante que no se refleja en el error y que realmente es útil para el diagnóstico. Por ejemplo, has activado una caché y quieres saber si fue hit o miss. O haces reintentos y quieres saber que hubo un segundo intento. O «tragas» un error a propósito (rara vez, pero pasa) y quieres dejar constancia de que ocurrió.

Pero incluso en esos casos conviene mantener disciplina: los logs internos casi siempre deben ser de nivel Debug (o a veces Info), porque si consideras que el evento es tan serio como para ser Error, probablemente debería ser un error que haya que devolver hacia arriba.

Y aquí ayuda la configuración del nivel de registro: en el desarrollo local puedes activar Debug, y en la ejecución normal dejar Info. Así, las «costuras» internas del sistema solo son visibles cuando realmente estás depurando, no permanentemente.

9. Errores típicos

Error n.º 1: registrar en cada función «por si acaso».
Normalmente empieza con buenas intenciones: «tengo miedo de perder el contexto». Pero al final pierdes lo principal: la capacidad de ver rápidamente dónde exactamente el error se volvió fatal para el comando o la petición. Añade contexto mediante wrapping de errores, y deja el Error-log en el borde, donde se toma la decisión.

Error n.º 2: duplicar el mismo error en varios niveles.
Escenario muy frecuente: storage registró «cannot open file», app registró «load failed», main registró «command failed». Al final, tres registros Error, y todos hablan de lo mismo. La regla «un problema, un Error-log» lo cura casi por completo: las capas internas devuelven el error, el borde registra.

Error n.º 3: intentar «explicar al usuario» a través de los logs.
Los logs son para el desarrollador. El usuario necesita mensajes breves y claros en stderr (CLI) o en la respuesta HTTP (HTTP). Si mezclas esos mundos, obtendrás o bien una filtración de detalles internos, o «ruido» en la salida del usuario, que rompe scripts y pruebas. Y sí, si alguna vez imprimiste JSON en stdout y luego añadiste un log de «processing…», ya sabes lo doloroso que es.

Error n.º 4: «arreglar» la falta de contexto con más logger.Error(...) en vez de wrapping.
Si en el nivel superior no está claro qué ha salido mal exactamente, eso no es una razón para insertar un log en medio. Es una razón para añadir contexto al error devuelto (fmt.Errorf("...: %w", err)). Ese enfoque escala tanto en CLI como en HTTP, y no convierte el sistema en un generador de duplicados.

Error n.º 5: registrar todos los 4xx como Error en HTTP.
Una entrada incorrecta del cliente suele no ser «una avería del sistema», sino simplemente parte de la vida. Si escribes cada error de usuario como Error, tus fallos reales (500) se hundirán en un flujo de «el cliente envió tonterías». Separa el sentido: dónde está el error del cliente y dónde el del servidor, y elige el nivel en el borde.

1
Tarea
Go SELF, nivel 52, lección 3
Bloqueada
División sin duplicados
División sin duplicados
1
Tarea
Go SELF, nivel 52, lección 3
Bloqueada
Uso sin tragedias
Uso sin tragedias
1
Tarea
Go SELF, nivel 52, lección 3
Bloqueada
Contexto en el error
Contexto en el error
1
Tarea
Go SELF, nivel 52, lección 3
Bloqueada
Depuración dentro de la capa
Depuración dentro de la capa
Comentarios
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION