Elido
14 min de lecturaIngeniería
Esencial

Cableando Sentry/GlitchTip a través de 12 servicios Go sin romper el hot path

Cómo Elido envió un paquete sentryinit compartido que le da a cada servicio Go la misma auto-captura de panic + 5xx - y se mantiene zero-alloc en el presupuesto de p95 15ms de edge-redirect.

Marius Voß
DevRel · edge infra
Diagram of 12 Go service tiles each emitting events into a central GlitchTip ingest, with the edge-redirect tile labelled zero-alloc on the happy path

Cuando tienes un servicio Go, el tracking de errores es trabajo de media hora: dejar caer sentry-go, inicializarlo desde SENTRY_DSN, llamar a sentry.CaptureException en los pocos lugares que importan, enviar. Cuando tienes doce servicios Go, esa misma decisión de media hora se convierte en un impuesto que se compone - cada servicio cultiva su propio código de init ligeramente-diferente, su propio middleware ligeramente-diferente, su propia opinión sobre lo que significa "release tag". Para cuando ocurre un panic de producción, descubres que tres servicios no están inicializando el SDK en absoluto porque alguien olvidó la variable de entorno en el manifiesto de despliegue.

Acabamos de terminar ese cableado en Elido - doce servicios Go más una CLI de backfill de cadena de auditoría más tres apps Next.js más dos servicios Node, todos alimentando un GlitchTip self-hosted en sentry.elido.app. Las partes interesantes no fueron las llamadas al SDK. Fueron la forma del paquete compartido que hace que las llamadas al SDK desaparezcan en una línea por servicio, y las restricciones que caen de necesitar el middleware en el hot path de edge-redirect sin quemar el presupuesto de p95 15ms.

Este post es una cuenta completa de cómo funciona el cableado, qué hicimos bien, y los dos compromisos que hicimos deliberadamente.

TL;DR#

  • Un paquete compartido, pkg/sentryinit, reemplaza doce copias de func main. Añadir un nuevo servicio es un único defer sentryinit.Init(logger, "service-name")() más una línea de middleware.
  • ChiMiddleware() auto-captura panics y respuestas 5xx no-panic en servicios warm-path. FastHTTPMiddleware() hace lo mismo para edge-redirect y es zero-alloc en el happy path - verificado por un benchmark que viene con el paquete.
  • Elegimos GlitchTip (compatible con Sentry, self-hosted) sobre Sentry SaaS por residencia UE. El SDK no cambia.
  • El hot path explícitamente NO llama a sentry.CaptureException desde código de handler. Toda la captura ocurre en la frontera del middleware, donde el coste solo se materializa cuando hay algo que reportar.

Por qué un paquete compartido, no doce copias#

El paquete sentryinit compartido exponiendo Init, ChiMiddleware y FastHTTPMiddleware, cableando los servicios warm-path con chi y el servicio edge-redirect con fasthttp desde una línea por main.go

El cableado mínimo viable de Sentry en Go son seis líneas:

sentry.Init(sentry.ClientOptions{
    Dsn:              os.Getenv("SENTRY_DSN"),
    Environment:      os.Getenv("ENV"),
    Release:          os.Getenv("ELIDO_VERSION"),
    ServerName:       "api-core",
    AttachStacktrace: true,
})
defer sentry.Flush(2 * time.Second)

Seis líneas, doce servicios. Setenta y dos líneas que divergen con el tiempo. El problema no es el conteo - es la deriva. Un servicio olvida Release. Otro configura Environment desde una variable de entorno con nombre ligeramente diferente. Un tercero tiene un flush de un segundo y pierde eventos en un SIGTERM rápido. El comportamiento del tracking de errores a través de la flota deja de ser una propiedad de la plataforma y empieza a ser una propiedad de quien sea que escribió el main.go de ese servicio.

pkg/sentryinit es la solución poco-inteligente. Vive en el workspace Go, cada servicio lo requirea vía una directiva replace local, y el call site es una línea:

defer sentryinit.Init(logger, "api-core")()

El paquete en sí es pequeño. Toda la superficie en tiempo de ejecución es una función Init, dos middlewares HTTP (chi y net/http), un middleware fasthttp, y un endpoint de debug para probar el cableado de extremo a extremo en producción. Los bits relevantes de la implementación:

func Init(logger *zap.Logger, serverName string) func() {
    dsn := os.Getenv("SENTRY_DSN")
    if dsn == "" {
        return func() {}
    }
    env := os.Getenv("ENV")
    if env == "" {
        env = "production"
    }
    release := os.Getenv("ELIDO_VERSION")
    if err := sentry.Init(sentry.ClientOptions{
        Dsn:              dsn,
        Environment:      env,
        Release:          release,
        ServerName:       serverName,
        AttachStacktrace: true,
        EnableTracing:    false,
        SampleRate:       1.0,
        IgnoreErrors: []string{
            "context canceled",
            "http: Server closed",
        },
    }); err != nil {
        if logger != nil {
            logger.Warn("sentry init failed", zap.Error(err), zap.String("service", serverName))
        }
        return func() {}
    }
    sentry.ConfigureScope(func(scope *sentry.Scope) {
        scope.SetTag("service", serverName)
    })
    return func() { sentry.Flush(flushTimeout) }
}

Tres cosas en ese snippet que ganan sus líneas.

Primero, el early return de DSN vacío. El desarrollo local no tiene DSN. Los tests de CI tampoco. Sin el early return, cada caja de dev intentaría inicializar un SDK apuntando a ningún lugar y emitiría una advertencia de "DSN inválido" cada vez que go run empezara. El early return significa que el call site nunca tiene que ramificar - defer sentryinit.Init(logger, "api-core")() es correcto en cada entorno.

Segundo, el tag service fijado en el scope global. GlitchTip ya segmenta los eventos por proyecto (un proyecto por servicio), pero el tag deja que las búsquedas y dashboards cross-project filtren por slug de servicio sin tener que parsear el ID de proyecto del DSN. Cuando la misma clase de panic aparece en tres servicios dentro de una hora, el tag hace ese patrón encontrable en una consulta.

Tercero, IgnoreErrors. context canceled es lo que cada cliente gRPC devuelve cuando una solicitud downstream es cancelada por un timeout upstream - un evento normal de flujo de control en un grafo de microservicios encadenados, no un bug. http: Server closed es lo que el servidor HTTP de la stdlib devuelve durante un apagado gracioso. Ambos producen ruido que ahoga la señal. La deny-list los filtra antes de que alcancen la cola.

Cablear un nuevo servicio es añadirlo a go.work, dejar caer una línea de require + replace en el go.mod del servicio, y añadir la línea defer en main.go. Ese es el contrato. Todo lo demás - timeout de flush, sample rate, patrones de error ignorados - está centralizado.

El middleware chi#

En servicios warm-path - api-core, analytics-api, billing, domain-manager, search, url-scanner, qr-generator, metadata-fetcher, webhook-dispatcher - la superficie de auto-captura es HTTP. Un handler puede hacer panic, o puede devolver un 5xx sin hacer panic, y queremos ambos visibles.

El enfoque ingenuo es usar el middleware Handle built-in de sentry-go/http. No lo hicimos, por dos razones. Primero, ese middleware siempre inicia una transacción incluso cuando EnableTracing es false - asignación desperdiciada en cada solicitud. Segundo, captura panics pero no respuestas 5xx no-panic, lo que significa que un handler que devuelve 503 porque Postgres dejó caer la conexión permanece invisible.

Una solicitud entra en el router y el middleware; en una respuesta normal 2xx o 3xx no se captura nada, mientras que un panic o un 5xx no-panic se recupera, delimita y transporta a GlitchTip como un evento

El reemplazo es pequeño:

func ChiMiddleware() func(http.Handler) http.Handler {
    return func(next http.Handler) http.Handler {
        return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
            hub := sentry.GetHubFromContext(r.Context())
            if hub == nil {
                hub = sentry.CurrentHub().Clone()
                r = r.WithContext(sentry.SetHubOnContext(r.Context(), hub))
            }
            hub.Scope().SetRequest(r)

            ww := chimw.NewWrapResponseWriter(w, r.ProtoMajor)
            defer func() {
                if rvr := recover(); rvr != nil {
                    if rvr == http.ErrAbortHandler {
                        panic(rvr)
                    }
                    hub.RecoverWithContext(r.Context(), rvr)
                    if ww.Status() == 0 {
                        ww.WriteHeader(http.StatusInternalServerError)
                    }
                    return
                }
                if status := ww.Status(); status >= 500 && status < 600 {
                    hub.WithScope(func(scope *sentry.Scope) {
                        scope.SetLevel(sentry.LevelError)
                        scope.SetTag("status_code", strconv.Itoa(status))
                        hub.CaptureMessage(fmt.Sprintf("HTTP %d %s %s", status, r.Method, r.URL.Path))
                    })
                }
            }()

            next.ServeHTTP(ww, r)
        })
    }
}

El hub es clonado por solicitud y almacenado en el contexto. Eso permite que los handlers adjunten breadcrumbs específicas de dominio (sentry.GetHubFromContext(r.Context()).AddBreadcrumb(...)) sin filtrarse a otras solicitudes en vuelo. El WrapResponseWriter interno de chi preserva las interfaces http.Flusher / http.Hijacker / http.Pusher - algún middleware downstream de chi mira esos, y un wrapper hecho a mano los pierde. Para servicios que no usan chi (click-ingester y analytics-export montan http.ServeMux simple), el paquete envía un gemelo solo-stdlib llamado HTTPMiddleware().

Un bit sutil de comportamiento: http.ErrAbortHandler es re-paniqueado en lugar de capturado. Esa es la convención de stdlib para "el cliente se desconectó, suprime la goroutine limpiamente". Capturarlo como una excepción inundaría la cola con no-bugs.

El cableado es idéntico a través de los servicios warm-path:

r := chi.NewRouter()
r.Use(middleware.RequestID)
r.Use(middleware.RealIP)
r.Use(sentryinit.ChiMiddleware())
r.Use(oteltrace.ChiMiddleware("api-core"))
// ... rest of the middleware stack

sentryinit.ChiMiddleware va antes de oteltrace.ChiMiddleware para que los panics en la capa de tracing aún sean capturados.

La parte difícil: fasthttp en el hot path de redirect#

edge-redirect es un animal diferente. Su presupuesto es p50 5ms / p95 15ms en un cache hit, medido a través de tres POPs de producción. Cualquier cosa que asigna por solicitud aparece en el profile de GC y eventualmente en la cola p99. El middleware chi de arriba está bien para servicios warm-path que asignan libremente; en el edge sería un problema.

sentry-go/fasthttp.Handle era un no-starter por la misma razón que sentry-go/http.Handle lo era: construye un snapshot http.Request en cada solicitud, incluyendo el happy path, incluso cuando no hay nada que reportar. Para un servicio sirviendo miles de solicitudes por segundo por POP, eso son miles de structs http.Request innecesarios por segundo por POP.

El middleware fasthttp en pkg/sentryinit invierte el modelo de coste: nada asigna hasta que en realidad hay algo que capturar.

func FastHTTPMiddleware() func(fasthttp.RequestHandler) fasthttp.RequestHandler {
    return func(next fasthttp.RequestHandler) fasthttp.RequestHandler {
        return func(ctx *fasthttp.RequestCtx) {
            defer func() {
                if rvr := recover(); rvr != nil {
                    if rvr == http.ErrAbortHandler {
                        panic(rvr)
                    }
                    hub := sentry.CurrentHub().Clone()
                    req := fasthttpRequestSnapshot(ctx)
                    hub.Scope().SetRequest(req)
                    hub.RecoverWithContext(
                        context.WithValue(context.Background(), sentry.RequestContextKey, req),
                        rvr,
                    )
                    ctx.Response.Reset()
                    ctx.SetStatusCode(fasthttp.StatusInternalServerError)
                    return
                }
                if status := ctx.Response.StatusCode(); status >= 500 && status < 600 {
                    hub := sentry.CurrentHub().Clone()
                    req := fasthttpRequestSnapshot(ctx)
                    hub.WithScope(func(scope *sentry.Scope) {
                        scope.SetRequest(req)
                        scope.SetLevel(sentry.LevelError)
                        scope.SetTag("status_code", strconv.Itoa(status))
                        hub.CaptureMessage("HTTP " + strconv.Itoa(status) + " " + string(ctx.Method()) + " " + string(ctx.Path()))
                    })
                }
            }()
            next(ctx)
        }
    }
}

La forma es la misma que la versión chi, pero el clon del hub y la construcción del request-snapshot están empujados dentro de las ramas de recover / 5xx. En una respuesta 302 de cache-hit - el caso abrumadoramente común - el cuerpo del defer se dispara, recover() devuelve nil, el check de status devuelve false, y nada más corre. El closure mismo es lo que Go inlinea en el marco de la pila a esta forma de call, así que incluso el coste de función diferida se amortiza a nada detectable.

Hay un benchmark en el paquete (fasthttp_test.go) que clava esto:

func BenchmarkFastHTTPMiddleware_HappyPath(b *testing.B) {
    noop := func(ctx *fasthttp.RequestCtx) {
        ctx.SetStatusCode(fasthttp.StatusFound)
    }
    wrapped := FastHTTPMiddleware()(noop)

    ctx := &fasthttp.RequestCtx{}
    ctx.Init(&ctx.Request, &net.TCPAddr{IP: net.ParseIP("127.0.0.1"), Port: 1234}, nil)
    ctx.Request.SetRequestURI("/abc123")
    ctx.Request.Header.SetMethod("GET")
    ctx.Request.Header.SetHost("f.elido.me")

    b.ReportAllocs()
    b.ResetTimer()
    for i := 0; i < b.N; i++ {
        wrapped(ctx)
    }
}

Emparejado con BenchmarkFastHTTPHandler_Bare (mismo handler, sin middleware), el delta en una caja de dev M3 de 2024 está en el ruido - la versión wrapped reporta cero asignaciones adicionales por op. El middleware Sentry en el hot path de edge-redirect cuesta nada en el happy path. Cuesta algo solo cuando hay un panic o un 5xx, que es precisamente cuando no te importa pagar.

El cableado en el main.go de edge-redirect es una línea:

rootHandler := sentryinit.FastHTTPMiddleware()(h.Route)

Lo que esto explícitamente NO hace: no salpica llamadas sentry.CaptureException a través del handler de redirect mismo. El handler permanece como el presupuesto de latencia lo necesita - sin conciencia de Sentry, sin asignación por solicitud para propósitos de tracking de errores. La frontera del middleware es el único lugar donde la captura ocurre, y la frontera del middleware es estructuralmente gratis en el happy path.

Este es un compromiso deliberado. Si edge-redirect tiene un bug lógico que produce una URL de destino incorrecta sin crashear o devolver 5xx - digamos, una regla mal configurada que rutea tráfico UE al fallback incorrecto - Sentry no lo verá. Los dashboards de bots y el monitoreo sintético sí. El trade es que mantenemos el redirect barato; la observabilidad para corrección no-error vive fuera del SDK.

Por qué GlitchTip, no Sentry SaaS#

Un producto GDPR-first escribiendo datos de cliente a un servicio de tracking de errores alojado en US es una contradicción que los auditores notan. Los stack traces de api-core incluyen rutas de URL, ocasionalmente IDs de tenant, a veces direcciones IP (las redactamos vía el hook BeforeSend de Sentry, pero la redacción puede ser bypaseada por error). El camino más limpio es mantener el data plane dentro de nuestra propia región UE.

GlitchTip es la elección. Habla el protocolo de cable de Sentry, así que el SDK es byte-idéntico - sin fork, sin shim, sin segunda librería de auth. El dashboard tiene forma de Sentry y vive en sentry.elido.app detrás de nuestro VPN wg-easy. El endpoint de ingesta en o<projectId>.sentry.elido.app/api/<id>/store/ es alcanzable desde cada servicio sobre internet público, con límites de tasa en la capa de nginx. El reciente commit fix(ops/nginx): open Sentry ingestion endpoints; keep dashboard VPN-only captura esa división exacta.

El coste de migración de Sentry SaaS a GlitchTip es aproximadamente un cambio de DNS, un swap de DSN por proyecto, y un despliegue de Postgres + Redis detrás del host del dashboard. Nunca corrimos en SaaS - cableamos GlitchTip desde el día uno - pero el camino está abierto en cualquier dirección. El SDK no sabe a qué backend está hablando.

Hay dos advertencias específicas de GlitchTip que golpeamos y arreglamos durante el rollout. Primero, el flujo de signup de GlitchTip requiere que el registro esté abierto para que la invitación inicial de admin funcione; lo abrimos durante el bootstrap, enviamos las invitaciones, y lo cerramos de nuevo. Segundo, el email saliente de GlitchTip se registra vía Resend, y el dominio remitente tiene que estar verificado antes de que la verificación de email en el signup tenga éxito - nos saltamos la verificación de email hasta que el dominio Resend esté verde y la re-habilitamos después. Ambos están documentados en el runbook para cualquiera repitiendo esto.

El endpoint de debug-panic#

Testear de extremo a extremo el cableado en producción sin un nuevo despliegue es el tipo de cosa que silenciosamente nunca se hace - hasta que ocurre un panic real y descubres que el cableado estaba roto tres semanas atrás. Añadimos una superficie de diagnóstico permanente para exactamente esto.

func DebugPanicHandler() http.HandlerFunc {
    return func(w http.ResponseWriter, r *http.Request) {
        expected := os.Getenv(debugTokenEnv)
        if expected == "" || r.URL.Query().Get("token") != expected {
            http.NotFound(w, r)
            return
        }
        panic("elido sentry-debug panic: " + r.RemoteAddr + " " + r.URL.RawQuery)
    }
}

Montado en GET /debug/sentry-panic, restringido por ELIDO_SENTRY_DEBUG_TOKEN. Con la variable de entorno sin configurar, la ruta da 404 - seguro para enviar a producción. Cuando la variable está configurada y la solicitud lleva ?token=<value>, el handler hace panic a propósito. El middleware lo captura, el SDK lo transporta a GlitchTip, el evento aterriza en el proyecto correcto. El round trip completo puede ser verificado en menos de un minuto sin redesplegar.

Hay un gemelo fasthttp para el edge:

func DebugPanicFastHTTPHandler() fasthttp.RequestHandler {
    return func(ctx *fasthttp.RequestCtx) {
        expected := os.Getenv(debugTokenEnv)
        if expected == "" || string(ctx.QueryArgs().Peek("token")) != expected {
            ctx.SetStatusCode(fasthttp.StatusNotFound)
            return
        }
        panic("elido sentry-debug panic: " + ctx.RemoteAddr().String() + " " + string(ctx.QueryArgs().QueryString()))
    }
}

Mismo gate de token, mismo comportamiento oculto-cuando-no-configurado. Lo primero que pasa después de un despliegue es que el on-call golpea el endpoint de debug en el servicio afectado. Si el evento aterriza en GlitchTip dentro de diez segundos, el cableado está sano. Si no, el despliegue se revierte antes de que la siguiente caída descubra el cableado roto de la manera difícil.

Lo que no cableamos#

Tres cosas que parecen adiciones obvias pero permanecen deliberadamente fuera de scope.

Tracing. EnableTracing: false en Init. Usamos OpenTelemetry para tracing distribuido (el paquete pkg/oteltrace lo cablea a través de los mismos servicios). Dejar que Sentry haga tracing en paralelo doblaría las asignaciones de transacción por solicitud y doblaría el coste de propagación de contexto a través del grafo de llamadas. La fortaleza de Sentry son los errores; la fortaleza de OTel son los spans. Usamos cada uno para lo que es bueno.

CaptureException manual en el camino de redirect. Cubierto arriba. El hot path no importa sentryinit con el propósito de llamarlo desde handlers. El middleware es la única frontera de captura.

Monitoreo de rendimiento (transacciones). Misma razón que tracing. redirect_duration_seconds es un histograma de Prometheus con etiquetas region y cache_tier. Esa es la fuente de verdad para latencia. Empujar los mismos datos a través del monitoreo de rendimiento de Sentry sería una pipeline duplicada con peor agregación.

Cómo se ve desde fuera#

Doce servicios Go incluyendo edge-redirect convergiendo en un GlitchTip self-hosted en sentry.elido.app, cada evento llevando una etiqueta de servicio, entorno y release para busqueda cross-project

Doce servicios, un paquete compartido, una línea por main.go, una línea de middleware por router. Cuando ocurre un panic - y ocurren - aparece en GlitchTip bajo el proyecto correcto con el tag service correcto, el Environment correcto, el Release correcto, y un stack trace lo suficientemente profundo para encontrar la línea. Cuando un 5xx no-panic escapa - y esos también ocurren, usualmente después de un hipo de base de datos - aparece de la misma manera.

Los compromisos son explícitos, escritos en el comentario de doc a nivel de paquete del paquete, y testeados con un benchmark. El cableado está documentado en el mismo lugar que los runbooks, no en conocimiento tribal. Añadir el decimotercer servicio tomará quince minutos - cinco de los cuales son escribir el test, cinco de los cuales son cablear el DSN en el manifiesto de despliegue, y cinco de los cuales son ejecutar make build y probarlo con el endpoint de debug.

Esa es la forma que se mantiene. Seis líneas por servicio siempre iba a derivar. Una línea, más un paquete compartido, más un benchmark, no.


El cableado está abierto en el monorepo en pkg/sentryinit/ para cualquiera ejecutando una flota Go en Sentry o GlitchTip que quiera una forma para copiar. El runbook asociado cubre el procedimiento de rotación para DSNs, las advertencias de bootstrap de GlitchTip, y el camino de rollback. Para equipos self-hosting el stack completo de Elido, el playbook de k3s cubre dónde encaja el SDK en el despliegue Kubernetes más amplio. Para una inmersión profunda en lo que "zero-alloc en el happy path" realmente significa bajo carga, el post p95 de redirect es la pieza compañera.


Marius Voß es DevRel e edge infra en Elido. Envió el paquete sentryinit junto con el rollout descrito arriba y ha pasado la última semana viendo el dashboard de GlitchTip llenarse de eventos que antes eran invisibles.

Prueba Elido

Pega una URL, obtén un enlace corto

Sin registro. El enlace vive 30 días. Crea una cuenta para conservarlo.

Gratis, sin registro · 2 por día

Prueba Elido

Acortador de URL alojado en la UE: dominios personalizados, análisis profundo y API abierta. Plan gratuito - sin tarjeta de crédito.

Etiquetas
sentry go middleware
glitchtip self-hosted
observability url shortener
fasthttp panic recovery
chi middleware
go error tracking

Seguir leyendo