Quando hai un solo servizio Go, il tracciamento degli errori è un lavoro da mezz'ora: includi sentry-go, inizializzalo da SENTRY_DSN, chiama sentry.CaptureException nei pochi punti che contano, pubblica. Quando hai dodici servizi Go, quella stessa decisione da mezz'ora diventa una tassa che si compone - ogni servizio sviluppa il suo codice di inizializzazione leggermente diverso, il suo middleware leggermente diverso, la sua opinione su cosa significa "release tag". Quando avviene un panic in produzione, scopri che tre servizi non stanno inizializzando affatto l'SDK perché qualcuno ha dimenticato la variabile d'ambiente nel manifest di deployment.
Abbiamo appena completato quel cablaggio su Elido - dodici servizi Go più un CLI di backfill per l'audit-chain più tre app Next.js più due servizi Node, tutti che alimentano un GlitchTip self-hosted su sentry.elido.app. Le parti interessanti non erano le chiamate all'SDK. Erano la forma del pacchetto condiviso che fa sparire le chiamate all'SDK in una sola riga per servizio, e i vincoli che derivano dall'avere il middleware sull'hot path di edge-redirect senza bruciare il budget p95 da 15ms.
Questo post è un resoconto completo di come funziona il cablaggio, cosa abbiamo fatto bene e i due compromessi che abbiamo fatto deliberatamente.
TL;DR#
- Un pacchetto condiviso,
pkg/sentryinit, sostituisce dodici copie difunc main. Aggiungere un nuovo servizio è una singola rigadefer sentryinit.Init(logger, "service-name")()più una riga di middleware. ChiMiddleware()cattura automaticamente panic e risposte 5xx non-panicking sui servizi warm-path.FastHTTPMiddleware()fa lo stesso peredge-redirected è zero-alloc sull'happy path - verificato da un benchmark che viene rilasciato nel pacchetto.- Abbiamo scelto GlitchTip (compatibile con Sentry, self-hosted) rispetto a Sentry SaaS per la residenza UE. L'SDK è invariato.
- L'hot path esplicitamente NON chiama
sentry.CaptureExceptiondal codice handler. Tutta la cattura avviene al confine del middleware, dove il costo si materializza solo quando c'è qualcosa da segnalare.
Perché un pacchetto condiviso, non dodici copie#
Il cablaggio minimo indispensabile di Sentry in Go è sei righe:
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)
Sei righe, dodici servizi. Settantadue righe che divergono nel tempo. Il problema non è il conteggio - è la deriva. Un servizio dimentica Release. Un altro imposta Environment da una variabile d'ambiente con un nome leggermente diverso. Un terzo ha un flush di un secondo e perde eventi su un SIGTERM veloce. Il comportamento del tracciamento degli errori in tutto il fleet smette di essere una proprietà della piattaforma e diventa una proprietà di qualunque ingegnere abbia scritto il main.go di quel servizio.
pkg/sentryinit è la correzione senza pretese. Vive nel workspace Go, ogni servizio lo require tramite una direttiva replace locale, e il call site è una riga:
defer sentryinit.Init(logger, "api-core")()
Il pacchetto stesso è piccolo. L'intera superficie runtime è una funzione Init, due middleware HTTP (chi e net/http), un middleware fasthttp e un endpoint di debug per dimostrare il cablaggio end-to-end in produzione. I bit rilevanti dell'implementazione:
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) }
}
Tre cose in quel frammento che guadagnano le loro righe.
Primo, il return anticipato con DSN vuoto. Lo sviluppo locale non ha un DSN. I test CI nemmeno. Senza il return anticipato, ogni dev box cercherebbe di inizializzare un SDK che punta a nessuna parte ed emetterebbe un avviso "invalid DSN" ogni volta che go run si avviasse. Il return anticipato significa che il call site non deve mai fare branch - defer sentryinit.Init(logger, "api-core")() è corretto in ogni ambiente.
Secondo, il tag service fissato sullo scope globale. GlitchTip segmenta già gli eventi per progetto (un progetto per servizio), ma il tag permette ricerche e dashboard cross-progetto di filtrare per slug del servizio senza dover analizzare l'ID progetto del DSN. Quando la stessa classe di panic appare in tre servizi entro un'ora, il tag rende quel pattern trovabile in una sola query.
Terzo, IgnoreErrors. context canceled è ciò che ogni client gRPC restituisce quando una richiesta downstream viene annullata da un timeout upstream - un normale evento di control-flow in un grafo di microservizi a catena, non un bug. http: Server closed è ciò che il server HTTP stdlib restituisce durante lo shutdown graceful. Entrambi producono rumore che annega il segnale. La deny-list li filtra prima che raggiungano la coda.
Il cablaggio di un nuovo servizio è aggiungerlo a go.work, inserire un require + replace di una riga nel go.mod del servizio e aggiungere la riga defer in main.go. Questo è il contratto. Tutto il resto - timeout del flush, sample rate, pattern di errori ignorati - è centralizzato.
Il middleware chi#
Sui servizi warm-path - api-core, analytics-api, billing, domain-manager, search, url-scanner, qr-generator, metadata-fetcher, webhook-dispatcher - la superficie di cattura automatica è HTTP. Un handler può avere un panic, o può restituire un 5xx senza fare panic, e vogliamo che entrambi siano visibili.
L'approccio naïve è usare il middleware Handle integrato di sentry-go/http. Non lo abbiamo fatto, per due motivi. Primo, quel middleware avvia sempre una transazione anche quando EnableTracing è false - allocazione sprecata per ogni richiesta. Secondo, cattura i panic ma non le risposte 5xx non-panicking, il che significa che un handler che restituisce 503 perché Postgres ha perso la connessione rimane invisibile.
Il sostituto è piccolo:
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)
})
}
}
L'hub viene clonato per richiesta e archiviato nel context. Questo permette agli handler di allegare breadcrumb specifici del dominio (sentry.GetHubFromContext(r.Context()).AddBreadcrumb(...)) senza propagarsi in altre richieste in volo. Il WrapResponseWriter interno a chi preserva le interfacce http.Flusher / http.Hijacker / http.Pusher - qualche middleware chi a valle le ispeziona, e un wrapper artigianale le perde. Per i servizi che non usano chi (click-ingester e analytics-export montano http.ServeMux semplice), il pacchetto include un gemello solo-stdlib chiamato HTTPMiddleware().
Un comportamento sottile: http.ErrAbortHandler viene ri-panickato piuttosto che catturato. Questa è la convenzione stdlib per "il client si è disconnesso, sopprimi la goroutine in modo pulito". Catturarlo come eccezione allagherebbe la coda con non-bug.
Il cablaggio è identico tra i servizi warm-path:
r := chi.NewRouter()
r.Use(middleware.RequestID)
r.Use(middleware.RealIP)
r.Use(sentryinit.ChiMiddleware())
r.Use(oteltrace.ChiMiddleware("api-core"))
// ... resto dello stack middleware
sentryinit.ChiMiddleware va prima di oteltrace.ChiMiddleware così i panic nel layer di tracing vengono comunque catturati.
La parte difficile: fasthttp sull'hot path del redirect#
edge-redirect è un animale diverso. Il suo budget è p50 5ms / p95 15ms su un cache hit, misurato in tre POP di produzione. Qualsiasi cosa che allochi per richiesta appare nel profilo GC e alla fine nel tail p99. Il middleware chi sopra va bene per i servizi warm-path che allocano liberamente; sull'edge sarebbe un problema.
sentry-go/fasthttp.Handle era fuori discussione per lo stesso motivo per cui lo era sentry-go/http.Handle: costruisce uno snapshot http.Request per ogni richiesta, incluso l'happy path, anche quando non c'è nulla da segnalare. Per un servizio che serve migliaia di richieste al secondo per POP, questo equivale a migliaia di struct http.Request non necessarie al secondo per POP.
Il middleware fasthttp in pkg/sentryinit capovolge il modello dei costi: nulla alloca finché non c'è effettivamente qualcosa da catturare.
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 è la stessa della versione chi, ma la clonazione dell'hub e la costruzione dello snapshot della richiesta sono spostate dentro i branch di recover / 5xx. Su una risposta 302 cache-hit - il caso di gran lunga più comune - il corpo del defer si attiva, recover() restituisce nil, il controllo dello status restituisce false e non viene eseguito nient'altro. La closure stessa è ciò che Go inline nel frame dello stack a questa forma di chiamata, quindi anche il costo della funzione differita si ammortizza a nulla di rilevabile.
C'è un benchmark nel pacchetto (fasthttp_test.go) che lo verifica:
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)
}
}
Abbinato a BenchmarkFastHTTPHandler_Bare (stesso handler, senza middleware), il delta su un dev box M3 del 2024 è nel rumore - la versione wrapped riporta zero allocazioni aggiuntive per operazione. Il middleware Sentry sull'hot path di edge-redirect non costa nulla sull'happy path. Costa qualcosa solo quando c'è un panic o un 5xx, che è esattamente quando non ti dispiace pagare.
Il cablaggio nel main.go di edge-redirect è una riga:
rootHandler := sentryinit.FastHTTPMiddleware()(h.Route)
Cosa questo esplicitamente NON fa: non dissemina chiamate sentry.CaptureException attraverso l'handler del redirect stesso. L'handler rimane come il budget di latenza richiede - nessuna consapevolezza di Sentry, nessuna allocazione per richiesta per scopi di error-tracking. Il confine del middleware è l'unico punto di cattura, e il confine del middleware è strutturalmente gratuito sull'happy path.
Questo è un compromesso deliberato. Se edge-redirect ha un bug logico che produce un URL di destinazione errato senza crashare o restituire 5xx - diciamo, una regola mal configurata che instrada il traffico UE al fallback sbagliato - Sentry non lo vedrà. Lo vedranno i dashboard bot e il monitoraggio sintetico. Il compromesso è che manteniamo il redirect economico; l'osservabilità per la correttezza non-errore vive fuori dall'SDK.
Perché GlitchTip e non Sentry SaaS#
Un prodotto GDPR-first che scrive i dati dei clienti in un servizio di tracciamento errori ospitato negli USA è una contraddizione che gli auditor notano. I tracce di stack di api-core includono path URL, occasionalmente ID tenant, a volte indirizzi IP (li redighiamo tramite l'hook BeforeSend di Sentry, ma la redazione può essere aggirata per errore). Il percorso più pulito è mantenere il piano dati all'interno della nostra regione UE.
GlitchTip è la scelta. Parla il protocollo wire di Sentry, quindi l'SDK è byte-identico - nessun fork, nessuno shim, nessuna seconda libreria auth. Il dashboard è in stile Sentry e vive su sentry.elido.app dietro la nostra VPN wg-easy. L'endpoint di ingestione su o<projectId>.sentry.elido.app/api/<id>/store/ è raggiungibile da ogni servizio tramite internet pubblico, con rate limit al layer nginx. Il recente commit fix(ops/nginx): open Sentry ingestion endpoints; keep dashboard VPN-only cattura esattamente quella suddivisione.
Il costo di migrazione da Sentry SaaS a GlitchTip è approssimativamente un cambio DNS, uno swap DSN per progetto e un deployment Postgres + Redis dietro l'host del dashboard. Non abbiamo mai usato SaaS - abbiamo cablato GlitchTip dal primo giorno - ma il percorso è aperto in entrambe le direzioni. L'SDK non sa con quale backend sta parlando.
Ci sono due caveat specifici di GlitchTip che abbiamo incontrato e risolto durante il rollout. Primo, il flusso di registrazione di GlitchTip richiede che la registrazione sia aperta per far funzionare il primo invito dell'amministratore; l'abbiamo aperta durante il bootstrap, inviato gli inviti e richiusa. Secondo, la posta in uscita di GlitchTip si registra tramite Resend, e il dominio mittente deve essere verificato prima che la verifica email alla registrazione funzioni - saltiamo la verifica email fino a quando il dominio Resend non è verde e la riattiviamo dopo. Entrambi sono documentati nel runbook per chiunque ripeta questa procedura.
L'endpoint debug-panic#
Il test end-to-end del cablaggio in produzione senza un nuovo deployment è il tipo di cosa che tranquillamente non viene mai fatto - finché non avviene un panic reale e si scopre che il cablaggio era rotto tre settimane fa. Abbiamo aggiunto una superficie diagnostica permanente esattamente per questo.
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)
}
}
Montato su GET /debug/sentry-panic, protetto da ELIDO_SENTRY_DEBUG_TOKEN. Con la variabile d'ambiente non impostata, la route risponde con 404 - sicuro da rilasciare in produzione. Quando la variabile è impostata e la richiesta porta ?token=<value>, l'handler va in panic di proposito. Il middleware lo cattura, l'SDK lo trasporta a GlitchTip, l'evento arriva nel progetto corretto. L'intero round-trip può essere verificato in meno di un minuto senza ridistribuire.
C'è un gemello fasthttp per 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()))
}
}
Stesso gate del token, stesso comportamento nascosto-quando-non-configurato. La prima cosa che accade dopo un deployment è che l'on-call colpisce l'endpoint di debug sul servizio interessato. Se l'evento arriva a GlitchTip entro dieci secondi, il cablaggio è sano. Se non arriva, il deployment viene rollback prima che il prossimo outage scopra il cablaggio rotto nel modo peggiore.
Cosa non abbiamo cablato#
Tre cose che sembrano aggiunte ovvie ma rimangono deliberatamente fuori scope.
Tracing. EnableTracing: false in Init. Usiamo OpenTelemetry per il tracing distribuito (il pacchetto pkg/oteltrace lo cabla attraverso gli stessi servizi). Lasciare che Sentry faccia il tracing in parallelo raddoppierebbe le allocazioni di transazione per richiesta e raddoppierebbe il costo della propagazione del contesto attraverso il call graph. Il punto di forza di Sentry sono gli errori; il punto di forza di OTel sono gli span. Usiamo ciascuno per ciò in cui eccelle.
CaptureException manuale sul percorso di redirect. Coperto sopra. L'hot path non importa sentryinit allo scopo di chiamarlo dagli handler. Il middleware è l'unico confine di cattura.
Performance monitoring (transazioni). Stesso motivo del tracing. redirect_duration_seconds è un istogramma Prometheus con label region e cache_tier. Questa è la fonte di verità per la latenza. Spingere gli stessi dati attraverso il performance monitoring di Sentry sarebbe una pipeline duplicata con aggregazione peggiore.
Com'è dall'esterno#
Dodici servizi, un pacchetto condiviso, una riga per main.go, una riga di middleware per router. Quando avviene un panic - e accade - appare in GlitchTip sotto il progetto corretto con il tag service corretto, l'Environment corretto, il Release corretto e una traccia di stack abbastanza profonda da trovare la riga. Quando un 5xx non-panicking sfugge - e anche quello accade, di solito dopo un problema del database - appare allo stesso modo.
I compromessi sono espliciti, scritti nel commento del pacchetto a livello di package e testati con un benchmark. Il cablaggio è documentato nello stesso posto dei runbook, non nella conoscenza tribale. Aggiungere il tredicesimo servizio richiederà quindici minuti - cinque dei quali per scrivere il test, cinque per cablare il DSN nel manifest di deployment e cinque per eseguire make build e dimostrarlo con l'endpoint di debug.
Questa è la forma che regge. Sei righe per servizio sarebbero sempre andate in deriva. Una riga, più un pacchetto condiviso, più un benchmark, no.
Il cablaggio è disponibile nel monorepo su pkg/sentryinit/ per chiunque gestisca un fleet Go su Sentry o GlitchTip e voglia una forma da copiare. Il runbook associato copre la procedura di rotazione per i DSN, i caveat di bootstrap di GlitchTip e il percorso di rollback. Per i team che self-hostano l'intero stack di Elido, il playbook k3s copre dove l'SDK si inserisce nel deployment Kubernetes più ampio. Per un'analisi approfondita di cosa significa realmente "zero-alloc sull'happy path" sotto carico, il post sul p95 dei redirect è il companion piece.
Marius Voß è DevRel e infra edge di Elido. Ha rilasciato il pacchetto sentryinit insieme al rollout descritto sopra e ha trascorso l'ultima settimana a guardare il dashboard di GlitchTip riempirsi di eventi che prima erano invisibili.
Prova Elido
Incolla un URL, ottieni un link breve
Senza registrazione. Il link vive 30 giorni. Iscriviti per conservarlo.
Gratis, nessuna registrazione richiesta · 2 al giorno