14 min de lectureIngénierie
Pilier

Câbler Sentry/GlitchTip à travers 12 services Go sans casser le hot path

Comment Elido a livré un package sentryinit partagé qui donne à chaque service Go la même capture automatique des panics et 5xx - et reste zéro-alloc sur le budget p95 15 ms d'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

Lorsque vous avez un service Go, le suivi des erreurs est un travail d'une demi-heure : déposez sentry-go, initialisez depuis SENTRY_DSN, appelez sentry.CaptureException aux quelques endroits qui comptent, livrez. Lorsque vous avez douze services Go, cette même décision d'une demi-heure devient une taxe qui se cumule - chaque service développe son propre code d'init légèrement différent, son propre middleware légèrement différent, sa propre opinion sur ce que signifie « release tag ». Au moment où un panic en production survient, vous découvrez que trois services n'initialisent pas du tout le SDK parce que quelqu'un a oublié la variable d'environnement dans le manifeste de déploiement.

Nous venons de terminer ce câblage chez Elido - douze services Go plus un CLI de backfill audit-chain plus trois apps Next.js plus deux services Node, le tout alimentant un GlitchTip auto-hébergé sur sentry.elido.app. Les parties intéressantes n'étaient pas les appels SDK. C'était la forme du package partagé qui fait disparaître les appels SDK en une ligne par service, et les contraintes qui découlent du besoin du middleware sur le hot path d'edge-redirect sans brûler le budget p95 15 ms.

Cet article est un récit complet de la façon dont le câblage fonctionne, ce que nous avons bien fait, et les deux compromis que nous avons faits délibérément.

TL;DR#

  • Un package partagé, pkg/sentryinit, remplace douze copies de func main. Ajouter un nouveau service est un seul defer sentryinit.Init(logger, "service-name")() plus une ligne de middleware.
  • ChiMiddleware() capture automatiquement les panics et les réponses 5xx sans panic sur les services warm-path. FastHTTPMiddleware() fait de même pour edge-redirect et est zéro-alloc sur le happy path - vérifié par un benchmark livré dans le package.
  • Nous avons choisi GlitchTip (compatible Sentry, auto-hébergé) plutôt que Sentry SaaS pour la résidence UE. Le SDK est inchangé.
  • Le hot path n'appelle explicitement PAS sentry.CaptureException depuis le code handler. Toute la capture se produit à la frontière du middleware, où le coût ne se matérialise que lorsqu'il y a quelque chose à rapporter.

Pourquoi un package partagé, pas douze copies#

Le package partagé sentryinit exposant Init, ChiMiddleware et FastHTTPMiddleware, cablant les services warm-path chi et le service edge-redirect fasthttp depuis une seule ligne par main.go

Le câblage Sentry minimum viable en Go est de six lignes :

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)

Six lignes, douze services. Soixante-douze lignes qui divergent avec le temps. Le problème n'est pas le compte - c'est la dérive. Un service oublie Release. Un autre définit Environment à partir d'une variable d'env nommée légèrement différemment. Un troisième a un flush d'une seconde et perd des événements lors d'un SIGTERM rapide. Le comportement du suivi des erreurs à travers la flotte cesse d'être une propriété de la plateforme et devient une propriété de l'ingénieur qui a écrit le main.go de ce service.

pkg/sentryinit est la correction non clever. Il vit dans le workspace Go, chaque service le require via une directive replace locale, et le site d'appel est une ligne :

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

Le package lui-même est petit. Toute la surface runtime est une fonction Init, deux middlewares HTTP (chi et net/http), un middleware fasthttp et un point de terminaison debug pour prouver le câblage de bout en bout en production. Les bits pertinents de l'implémentation :

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) }
}

Trois choses dans cet extrait qui méritent leurs lignes.

D'abord, le retour anticipé sur DSN vide. Le développement local n'a pas de DSN. Les tests CI non plus. Sans le retour anticipé, chaque machine de dev essaierait d'initialiser un SDK pointant nulle part et émettrait un avertissement « DSN invalide » à chaque démarrage de go run. Le retour anticipé signifie que le site d'appel n'a jamais à brancher - defer sentryinit.Init(logger, "api-core")() est correct dans chaque environnement.

Deuxièmement, le tag service épinglé sur le scope global. GlitchTip segmente déjà les événements par projet (un projet par service), mais le tag permet aux recherches et tableaux de bord inter-projets de filtrer par slug de service sans avoir à parser l'ID de projet du DSN. Lorsque la même classe de panic apparaît dans trois services en moins d'une heure, le tag rend ce motif trouvable en une seule requête.

Troisièmement, IgnoreErrors. context canceled est ce que chaque client gRPC retourne lorsqu'une requête downstream est annulée par un timeout upstream - un événement de flux de contrôle normal dans un graphe de microservices en chaîne, pas un bug. http: Server closed est ce que le serveur HTTP de la stdlib retourne pendant l'arrêt gracieux. Les deux produisent du bruit qui noie le signal. La deny-list les filtre avant qu'ils n'atteignent la file.

Câbler un nouveau service consiste à l'ajouter à go.work, déposer un require + replace d'une ligne dans le go.mod du service et ajouter la ligne defer dans main.go. C'est le contrat. Tout le reste - timeout de flush, taux d'échantillonnage, motifs d'erreur ignorés - est centralisé.

Le middleware chi#

Sur les services warm-path - api-core, analytics-api, billing, domain-manager, search, url-scanner, qr-generator, metadata-fetcher, webhook-dispatcher - la surface de capture auto est HTTP. Un handler peut paniquer, ou il peut retourner un 5xx sans paniquer, et nous voulons les deux visibles.

L'approche naïve est d'utiliser le middleware Handle intégré de sentry-go/http. Nous ne l'avons pas fait, pour deux raisons. Premièrement, ce middleware démarre toujours une transaction même lorsque EnableTracing est faux - allocation gaspillée à chaque requête. Deuxièmement, il capture les panics mais pas les réponses 5xx sans panic, ce qui signifie qu'un handler qui retourne 503 parce que Postgres a coupé la connexion reste invisible.

Une requete entre dans le routeur et le middleware ; sur une reponse 2xx ou 3xx normale rien n'est capture, tandis qu'un panic ou un 5xx sans panic est recupere, scope et transporte vers GlitchTip en tant qu'evenement

Le remplacement est petit :

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)
        })
    }
}

Le hub est cloné par requête et stocké sur le contexte. Cela permet aux handlers d'attacher des breadcrumbs spécifiques au domaine (sentry.GetHubFromContext(r.Context()).AddBreadcrumb(...)) sans fuir dans d'autres requêtes en cours. Le WrapResponseWriter interne de chi préserve les interfaces http.Flusher / http.Hijacker / http.Pusher - certains middlewares chi downstream y jettent un œil, et un wrapper fait main les perd. Pour les services qui n'utilisent pas chi (click-ingester et analytics-export montent un http.ServeMux plat), le package livre un jumeau stdlib uniquement appelé HTTPMiddleware().

Un comportement subtil : http.ErrAbortHandler est re-paniqué plutôt que capturé. C'est la convention stdlib pour « le client s'est déconnecté, supprimez la goroutine proprement ». Le capturer comme une exception inonderait la file de non-bugs.

Le câblage est identique à travers les services 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 avant oteltrace.ChiMiddleware pour que les panics dans la couche de tracing soient toujours capturés.

La partie difficile : fasthttp sur le hot path de redirection#

edge-redirect est une autre bête. Son budget est p50 5 ms / p95 15 ms sur un cache hit, mesuré sur trois POPs de production. Tout ce qui alloue par requête apparaît dans le profil GC et finalement dans la queue p99. Le middleware chi ci-dessus convient pour les services warm-path qui allouent librement ; sur l'edge ce serait un problème.

sentry-go/fasthttp.Handle était un non-démarreur pour la même raison que sentry-go/http.Handle : il construit un snapshot http.Request à chaque requête, y compris sur le happy path, même quand il n'y a rien à rapporter. Pour un service servant des milliers de requêtes par seconde par POP, ce sont des milliers de structs http.Request inutiles par seconde par POP.

Le middleware fasthttp dans pkg/sentryinit inverse le modèle de coût : rien n'alloue jusqu'à ce qu'il y ait réellement quelque chose à capturer.

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 forme est la même que la version chi, mais le clonage du hub et la construction du snapshot de requête sont poussés à l'intérieur des branches recover / 5xx. Sur une réponse 302 cache-hit - le cas extrêmement courant - le corps du defer se déclenche, recover() retourne nil, la vérification de statut retourne false, et rien d'autre ne s'exécute. La closure elle-même est ce que Go inline dans le cadre de pile à cette forme d'appel, donc même le coût de la fonction différée s'amortit à rien de détectable.

Il y a un benchmark dans le package (fasthttp_test.go) qui l'épingle :

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)
    }
}

Apparié avec BenchmarkFastHTTPHandler_Bare (même handler, sans middleware), le delta sur une machine de dev M3 2024 est dans le bruit - la version wrapped rapporte zéro allocation supplémentaire par op. Le middleware Sentry sur le hot path edge-redirect ne coûte rien sur le happy path. Il ne coûte quelque chose que lorsqu'il y a un panic ou un 5xx, ce qui est précisément quand vous ne dérangez pas de payer.

Le câblage dans le main.go d'edge-redirect est une ligne :

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

Ce que cela ne fait explicitement PAS : il ne parsème pas des appels sentry.CaptureException à travers le handler de redirection lui-même. Le handler reste comme le budget de latence en a besoin - pas de conscience de Sentry, pas d'allocation par requête à des fins de suivi d'erreurs. La frontière du middleware est le seul endroit où la capture se produit, et la frontière du middleware est structurellement gratuite sur le happy path.

C'est un compromis délibéré. Si edge-redirect a un bug de logique qui produit une mauvaise URL de destination sans crasher ni retourner 5xx - disons, une règle mal configurée qui route le trafic UE vers le mauvais fallback - Sentry ne le verra pas. Les tableaux de bord de bot et le monitoring synthétique le verront. Le compromis est que nous gardons la redirection bon marché ; l'observabilité pour la correction sans erreur vit en dehors du SDK.

Pourquoi GlitchTip, pas Sentry SaaS#

Un produit GDPR-first qui écrit des données clients vers un service de suivi d'erreurs hébergé aux US est une contradiction que les auditeurs remarquent. Les stack traces d'api-core incluent les chemins d'URL, parfois les ID de tenant, parfois les adresses IP (nous les rédigeons via le hook BeforeSend de Sentry, mais la rédaction peut être contournée par erreur). Le chemin le plus propre est de garder le plan de données à l'intérieur de notre propre région UE.

GlitchTip est le choix. Il parle le protocole de fil Sentry, donc le SDK est byte-identique - pas de fork, pas de shim, pas de seconde bibliothèque d'auth. Le tableau de bord est en forme de Sentry et vit sur sentry.elido.app derrière notre VPN wg-easy. Le point de terminaison d'ingestion sur o<projectId>.sentry.elido.app/api/<id>/store/ est accessible depuis chaque service sur l'internet public, avec des limites de débit à la couche nginx. Le récent commit fix(ops/nginx): open Sentry ingestion endpoints; keep dashboard VPN-only capture exactement cette séparation.

Le coût de migration de Sentry SaaS à GlitchTip est d'environ un changement DNS, un swap de DSN par projet et un déploiement Postgres + Redis derrière l'hôte du tableau de bord. Nous n'avons jamais tourné sur SaaS - nous avons câblé GlitchTip depuis le premier jour - mais le chemin est ouvert dans les deux directions. Le SDK ne sait pas avec quel backend il parle.

Il y a deux caveats spécifiques à GlitchTip que nous avons rencontrés et corrigés pendant le déploiement. Premièrement, le flux d'inscription de GlitchTip nécessite que l'inscription soit ouverte pour que l'invitation admin initiale fonctionne ; nous l'avons basculée ouverte pendant le bootstrap, envoyé les invitations et basculée fermée à nouveau. Deuxièmement, l'email sortant de GlitchTip s'inscrit via Resend, et le from-domain doit être vérifié avant que la vérification d'email à l'inscription puisse réussir - nous sautons la vérification d'email jusqu'à ce que le domaine Resend soit vert et réactivons après. Les deux sont documentés dans le runbook pour quiconque répétant cela.

Le point de terminaison debug-panic#

Tester le câblage de bout en bout en production sans un déploiement frais est le genre de chose qui ne se fait silencieusement jamais - jusqu'à ce qu'un vrai panic survienne et que vous découvriez que le câblage était cassé il y a trois semaines. Nous avons ajouté une surface diagnostique permanente pour exactement cela.

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)
    }
}

Monté sur GET /debug/sentry-panic, gated par ELIDO_SENTRY_DEBUG_TOKEN. Avec la variable d'env non définie, la route fait 404 - sûr à livrer en production. Lorsque la variable est définie et que la requête porte ?token=<value>, le handler panique exprès. Le middleware l'attrape, le SDK le transporte vers GlitchTip, l'événement atterrit dans le bon projet. Tout l'aller-retour peut être vérifié en moins d'une minute sans redéploiement.

Il y a un jumeau fasthttp pour l'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()))
    }
}

Même garde de jeton, même comportement caché-quand-non-configuré. La première chose qui se passe après un déploiement est que l'astreinte frappe le point de terminaison debug sur le service affecté. Si l'événement atterrit dans GlitchTip dans les dix secondes, le câblage est sain. Sinon, le déploiement est annulé avant que la panne suivante ne découvre le câblage cassé à la dure.

Ce que nous n'avons pas câblé#

Trois choses qui ressemblent à des ajouts évidents mais qui restent délibérément hors périmètre.

Tracing. EnableTracing: false dans Init. Nous utilisons OpenTelemetry pour le tracing distribué (le package pkg/oteltrace le câble à travers les mêmes services). Laisser Sentry faire le tracing en parallèle doublerait les allocations de transaction par requête et doublerait le coût de propagation de contexte à travers le graphe d'appels. La force de Sentry est les erreurs ; la force d'OTel est les spans. Nous utilisons chacun pour ce dans quoi il est bon.

CaptureException manuel sur le chemin de redirection. Couvert ci-dessus. Le hot path n'importe pas sentryinit dans le but de l'appeler depuis les handlers. Le middleware est la seule frontière de capture.

Monitoring de performance (transactions). Même raison que le tracing. redirect_duration_seconds est un histogramme Prometheus avec les labels region et cache_tier. C'est la source de vérité pour la latence. Pousser les mêmes données à travers le monitoring de performance de Sentry serait un pipeline en double avec une agrégation moins bonne.

À quoi cela ressemble de l'extérieur#

Douze services Go dont edge-redirect convergeant vers un seul GlitchTip auto-heberge sur sentry.elido.app, chaque evenement portant un tag service, un environnement et une release pour la recherche inter-projets

Douze services, un package partagé, une ligne par main.go, une ligne de middleware par routeur. Lorsqu'un panic survient - et ils surviennent - il apparaît dans GlitchTip sous le bon projet avec le bon tag service, le bon Environment, le bon Release et une stack trace assez profonde pour trouver la ligne. Lorsqu'un 5xx sans panic s'échappe - et ceux-là arrivent aussi, généralement après un hoquet de base de données - il apparaît de la même manière.

Les compromis sont explicites, écrits dans le commentaire de doc au niveau du package, et testés avec un benchmark. Le câblage est documenté au même endroit que les runbooks, pas dans la connaissance tribale. Ajouter le treizième service prendra quinze minutes - cinq desquelles à écrire le test, cinq à câbler le DSN dans le manifeste de déploiement, et cinq à exécuter make build et le prouver avec le point de terminaison debug.

C'est la forme qui tient. Six lignes par service allaient toujours dériver. Une ligne, plus un package partagé, plus un benchmark, ne le fait pas.


Le câblage est ouvert dans le monorepo sur pkg/sentryinit/ pour quiconque exploitant une flotte Go sur Sentry ou GlitchTip et qui veut une forme à copier. Le runbook associé couvre la procédure de rotation pour les DSN, les caveats de bootstrap GlitchTip et le chemin de rollback. Pour les équipes auto-hébergeant toute la pile Elido, le playbook k3s couvre où le SDK s'intègre dans le déploiement Kubernetes plus large. Pour une plongée profonde sur ce que « zéro-alloc sur le happy path » signifie réellement sous charge, l'article p95 redirection est la pièce compagnon.


Marius Voß est DevRel et edge infra chez Elido. Il a livré le package sentryinit aux côtés du déploiement décrit ci-dessus et a passé la semaine dernière à regarder le tableau de bord GlitchTip se remplir d'événements qui étaient auparavant invisibles.

Essayer Elido

Collez une URL, obtenez un lien court

Sans inscription. Lien actif 30 jours. Inscrivez-vous pour le garder pour toujours.

Gratuit, sans inscription · 2 par jour

Essayer Elido

Raccourcisseur d'URL hébergé en UE : domaines personnalisés, analyses approfondies et API ouverte. Forfait gratuit - sans carte bancaire.

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

Lire la suite