En Go, un error es simplemente una interfaz con un método Error() string. A diferencia de lenguajes como Java o Python, donde cada excepción arrastra automáticamente un volcado de la pila, un error en Go es solo un valor, a menudo un simple texto. Esta decisión de diseño responde a la filosofía de rendimiento del lenguaje: capturar un stack trace implica recorrer la pila de llamadas actual, inspeccionar los frame pointers y asignar memoria en el heap para almacenar esa información. Si Go hiciera esto por defecto para cada error, el overhead en CPU y la presión sobre el Garbage Collector (GC) en sistemas de alta concurrencia sería prohibitivo.
Sin embargo, esto genera un problema en arquitecturas de microservicios o librerías con múltiples capas de abstracción. Si un error llega al punto de logueo final con el mensaje "connection refused", sabrás qué pasó, pero no dónde se originó la llamada en tu compleja jerarquía de servicios. Necesitas el stack trace para depurar fallos críticos en producción sin depender de un debugger. El riesgo es claro: si implementas una captura de stack manual y la ejecutas en cada error dentro de un bucle de alta frecuencia, acabarás degradando el rendimiento de tu aplicación por el constante ciclo de asignaciones de memoria y escaneo de la pila.
package main
import (
"errors"
"fmt"
"runtime/debug"
)
// TraceableError envuelve un error común añadiendo el contexto de la pila de llamadas.
type TraceableError struct {
err error
stack []byte
}
// Error implementa la interfaz error.
func (e *TraceableError) Error() string {
return e.err.Error()
}
// Unwrap permite que errors.Is y errors.As funcionen con el error original.
func (e *TraceableError) Unwrap() error {
return e.err
}
// StackTrace devuelve la captura de la pila guardada en el momento de la creación.
func (e *TraceableError) StackTrace() []byte {
return e.stack
}
// wrapWithStack captura el stack trace actual y lo envuelve en un TraceableError.
func wrapWithStack(err error) error {
if err == nil {
return nil
}
return &TraceableError{
err: err,
stack: debug.Stack(), // Captura la pila en este punto exacto.
}
}
func repository() error {
// Simulamos un error de base de datos.
return wrapWithStack(errors.New("dial error: connection timeout"))
}
func service(err error) error {
if err != nil {
// Envolvemos para añadir contexto de la capa de servicio.
return wrapWithStack(fmt.Errorf("service layer failed: %w", err))
}
return nil
}
func main() {
// Ejecución de la cadena de llamadas.
err := service(repository())
if err != nil {
fmt.Printf("Error detectado: %v\n", err)
// Buscamos el TraceableError en la cadena de errores envueltos.
var te *TraceableError
if errors.As(err, &te) {
fmt.Println("\n--- Stack Trace Original ---")
fmt.Printf("%s", te.StackTrace())
}
}
}
En el ejemplo anterior, hemos implementado un patrón similar al que utilizan librerías robustas como github.com/cockroachdb/errors. La clave reside en la estructura TraceableError. Cuando repository() detecta el error, llamamos a wrapWithStack, que invoca a debug.Stack(). Esta función de la librería estándar captura la pila de llamadas de la goroutine actual y la devuelve como un slice de bytes. Es vital que este slice se guarde dentro de la estructura para que el trace sea estático y represente el momento exacto del error, y no el momento en que se loguea.
Para que esto sea útil con la biblioteca estándar, implementamos el método Unwrap(). Esto permite que, aunque el error pase por múltiples capas de fmt.Errorf con el verbo %w, podamos usar errors.As en la capa superior para extraer nuestra estructura personalizada y acceder al método StackTrace(). Si no implementáramos Unwrap, el error sería una caja negra y errors.As no encontraría la información técnica que necesitamos.
El flujo de main demuestra cómo recuperamos la información. Aunque el error se ha “enuelto” varias veces en la capa de service, la inspección mediante errors.As descompone la cadena de errores hasta encontrar el primer TraceableError que contiene el volcado de memoria.
El error frecuente
Un error común es intentar capturar el stack trace en cada retorno de error para “estar seguros”, incluso cuando el error no es crítico. Fíjate en este patrón erróneo:
func processData() error {
err := doSomething()
if err != nil {
// ERROR: Capturar el stack trace en cada error trivial
// aumenta drásticamente el uso de CPU y genera basura en el heap.
return wrapWithStack(fmt.Errorf("failed: %w", err))
}
return nil
}
Si doSomething() devuelve errores de tipo “not found” o errores de validación de input que ocurren miles de veces por segundo, estarás ejecutando debug.Stack() innecesariamente. Esto no solo ralentiza el flujo de ejecución, sino que genera una cantidad masiva de objetos en el heap que el recolector de basura tendrá que limpiar, causando latencias impredecibles (p99 spikes). La regla de oro: usa stack traces para errores que representan condiciones excepcionales o fallos sistémicos, no para la lógica de control de flujo de tu aplicación.
N° 210