Quando se tem um serviço Go, o rastreamento de erros é um trabalho de meia hora: adicionar sentry-go, inicializá-lo a partir do SENTRY_DSN, chamar sentry.CaptureException nos poucos pontos que importam e lançar. Quando se têm doze serviços Go, essa mesma decisão de meia hora torna-se um imposto que se acumula - cada serviço desenvolve o seu próprio código de inicialização ligeiramente diferente, o seu próprio middleware ligeiramente diferente, a sua própria opinião sobre o que significa "release tag". Quando acontece um panic em produção, descobre-se que três serviços não estavam a inicializar o SDK de todo porque alguém se esqueceu da variável de ambiente no manifesto de deployment.
Acabámos de concluir essa integração no Elido - doze serviços Go mais uma CLI de backfill da cadeia de auditoria mais três aplicações Next.js mais dois serviços Node, todos a alimentar um GlitchTip auto-hospedado em sentry.elido.app. As partes interessantes não foram as chamadas ao SDK. Foi a forma do pacote partilhado que faz as chamadas ao SDK desaparecerem numa única linha por serviço, e as restrições que decorrem de precisar do middleware no hot path do edge-redirect sem consumir o orçamento de p95 de 15ms.
Este post é um relato completo de como a integração funciona, o que fizemos bem e os dois compromissos que fizemos deliberadamente.
TL;DR#
- Um pacote partilhado,
pkg/sentryinit, substitui doze cópias defunc main. Adicionar um novo serviço é um únicodefer sentryinit.Init(logger, "service-name")()mais uma linha de middleware. ChiMiddleware()captura automaticamente panics e respostas 5xx sem panic nos serviços de warm-path.FastHTTPMiddleware()faz o mesmo para oedge-redirecte é zero-alloc no happy path - verificado por um benchmark incluído no pacote.- Escolhemos GlitchTip (compatível com Sentry, auto-hospedado) em vez do Sentry SaaS por razões de residência na UE. O SDK é idêntico.
- O hot path explicitamente NÃO chama
sentry.CaptureExceptiona partir do código dos handlers. Toda a captura acontece na fronteira do middleware, onde o custo só se materializa quando há algo a reportar.
Por que um pacote partilhado e não doze cópias#
A integração mínima viável do Sentry em Go são seis linhas:
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 linhas, doze serviços. Setenta e duas linhas que divergem ao longo do tempo. O problema não é a contagem - é a deriva. Um serviço esquece o Release. Outro define o Environment a partir de uma variável de ambiente com um nome ligeiramente diferente. Um terceiro tem um flush de um segundo e perde eventos num SIGTERM rápido. O comportamento do rastreamento de erros em toda a frota deixa de ser uma propriedade da plataforma e passa a ser uma propriedade do engenheiro que escreveu o main.go daquele serviço.
pkg/sentryinit é a correção sem sofisticação desnecessária. Reside no workspace Go, cada serviço inclui-o via uma diretiva replace local, e o ponto de chamada é uma linha:
defer sentryinit.Init(logger, "api-core")()
O pacote em si é pequeno. Toda a superfície de runtime é uma função Init, dois middlewares HTTP (chi e net/http), um middleware fasthttp e um endpoint de diagnóstico para provar a integração de ponta a ponta em produção. As partes relevantes da implementação:
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) }
}
Três aspetos nesse trecho que justificam as suas linhas.
Primeiro, o retorno antecipado quando o DSN está vazio. O ambiente de desenvolvimento local não tem um DSN. Os testes de CI também não. Sem o retorno antecipado, cada máquina de desenvolvimento tentaria inicializar um SDK apontado para lado nenhum e emitiria um aviso de "DSN inválido" de cada vez que go run iniciasse. O retorno antecipado significa que o ponto de chamada nunca precisa de ramificar - defer sentryinit.Init(logger, "api-core")() é correto em qualquer ambiente.
Segundo, a tag service fixada no scope global. O GlitchTip já segmenta eventos por projeto (um projeto por serviço), mas a tag permite que pesquisas e dashboards entre projetos filtrem por slug de serviço sem ter de analisar o ID do projeto no DSN. Quando a mesma classe de panic aparece em três serviços numa hora, a tag torna esse padrão localizável numa única query.
Terceiro, IgnoreErrors. context canceled é o que qualquer cliente gRPC retorna quando um pedido downstream é cancelado por um timeout upstream - um evento de fluxo de controlo normal num grafo de microsserviços encadeado, não um bug. http: Server closed é o que o servidor HTTP da stdlib retorna durante o encerramento gracioso. Ambos produzem ruído que encobre o sinal. A lista de exclusão filtra-os antes de chegarem à fila.
Integrar um novo serviço é adicioná-lo a go.work, colocar um require + replace de uma linha no go.mod do serviço e adicionar a linha defer em main.go. Esse é o contrato. Tudo o resto - timeout de flush, taxa de amostragem, padrões de erros ignorados - está centralizado.
O middleware chi#
Nos serviços de warm-path - api-core, analytics-api, billing, domain-manager, search, url-scanner, qr-generator, metadata-fetcher, webhook-dispatcher - a superfície de captura automática é HTTP. Um handler pode entrar em panic, ou pode retornar um 5xx sem entrar em panic, e queremos ambos visíveis.
A abordagem ingénua é usar o middleware Handle incorporado do sentry-go/http. Não o usámos, por dois motivos. Primeiro, esse middleware sempre inicia uma transação mesmo quando EnableTracing é false - uma alocação desperdiçada em cada pedido. Segundo, captura panics mas não respostas 5xx sem panic, o que significa que um handler que retorna 503 porque o Postgres perdeu a ligação continua invisível.
O substituto é pequeno:
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)
})
}
}
O hub é clonado por pedido e armazenado no contexto. Isso permite que os handlers adicionem breadcrumbs específicos do domínio (sentry.GetHubFromContext(r.Context()).AddBreadcrumb(...)) sem vazar para outros pedidos em voo. O WrapResponseWriter interno do chi preserva as interfaces http.Flusher / http.Hijacker / http.Pusher - algum middleware chi a jusante examina essas, e um wrapper feito à mão perde-as. Para serviços que não usam chi (click-ingester e analytics-export montam um http.ServeMux simples), o pacote inclui um gémeo apenas para stdlib chamado HTTPMiddleware().
Um comportamento subtil: http.ErrAbortHandler é re-panicked em vez de capturado. Essa é a convenção da stdlib para "o cliente desligou-se, suprimir a goroutine de forma limpa". Capturá-lo como uma exceção inundaria a fila com não-bugs.
A integração é idêntica em todos os serviços de warm-path:
r := chi.NewRouter()
r.Use(middleware.RequestID)
r.Use(middleware.RealIP)
r.Use(sentryinit.ChiMiddleware())
r.Use(oteltrace.ChiMiddleware("api-core"))
// ... resto da stack de middleware
sentryinit.ChiMiddleware vai antes de oteltrace.ChiMiddleware para que os panics na camada de tracing ainda sejam capturados.
A parte difícil: fasthttp no hot path de redirecionamento#
O edge-redirect é diferente. O seu orçamento é p50 5ms / p95 15ms num cache hit, medido em três POPs de produção. Qualquer coisa que aloque por pedido aparece no perfil de GC e eventualmente no tail do p99. O middleware chi acima é adequado para serviços de warm-path que alocam livremente; no edge seria um problema.
sentry-go/fasthttp.Handle estava fora de questão pelo mesmo motivo que sentry-go/http.Handle: constrói um snapshot de http.Request em cada pedido, incluindo no happy path, mesmo quando não há nada a reportar. Para um serviço a servir milhares de pedidos por segundo por POP, isso são milhares de structs http.Request desnecessários por segundo por POP.
O middleware fasthttp em pkg/sentryinit inverte o modelo de custo: nada aloca até que haja efetivamente algo a 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)
}
}
}
A forma é a mesma que a versão chi, mas a clonagem do hub e a construção do snapshot do pedido estão empurradas para dentro dos ramos recover / 5xx. Numa resposta 302 de cache hit - o caso de longe mais comum - o corpo do defer é executado, recover() retorna nil, a verificação de status retorna false e nada mais é executado. O próprio closure é o que o Go inlinha no stack frame nesta forma de chamada, por isso até o custo da função diferida se amortiza para nada detetável.
Há um benchmark no pacote (fasthttp_test.go) que comprova isto:
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)
}
}
Combinado com BenchmarkFastHTTPHandler_Bare (mesmo handler, sem middleware), o delta numa máquina de desenvolvimento M3 de 2024 está no ruído - a versão com middleware reporta zero alocações adicionais por operação. O middleware Sentry no hot path do edge-redirect não custa nada no happy path. Custa algo apenas quando há um panic ou um 5xx, que é precisamente quando não há problema em pagar.
A integração no main.go do edge-redirect é uma linha:
rootHandler := sentryinit.FastHTTPMiddleware()(h.Route)
O que isto explicitamente NÃO faz: não polvilha chamadas sentry.CaptureException pelo handler de redirecionamento. O handler mantém-se como o orçamento de latência requer - sem consciência do Sentry, sem alocação por pedido para fins de rastreamento de erros. A fronteira do middleware é o único lugar onde a captura acontece, e a fronteira do middleware é estruturalmente gratuita no happy path.
Este é um compromisso deliberado. Se o edge-redirect tiver um bug lógico que produz um URL de destino errado sem crashar ou retornar 5xx - por exemplo, uma regra mal configurada que encaminha tráfego da UE para o fallback errado - o Sentry não o vê. Os dashboards de bots e a monitorização sintética, esses veem. O trade-off é que mantemos o redirecionamento barato; a observabilidade para correção que não seja de erros reside fora do SDK.
Por que GlitchTip e não Sentry SaaS#
Um produto com RGPD em primeiro lugar que escreve dados de clientes num serviço de rastreamento de erros hospedado nos EUA é uma contradição que os auditores notam. Os stack traces do api-core incluem caminhos de URL, ocasionalmente IDs de tenant, por vezes endereços IP (reduzimo-los via o hook BeforeSend do Sentry, mas a redução pode ser contornada por engano). O caminho mais limpo é manter o plano de dados dentro da nossa própria região da UE.
GlitchTip é a escolha. Fala o protocolo wire do Sentry, por isso o SDK é byte-idêntico - sem fork, sem shim, sem segunda biblioteca de autenticação. O dashboard tem a forma do Sentry e está em sentry.elido.app por detrás da nossa VPN wg-easy. O endpoint de ingestão em o<projectId>.sentry.elido.app/api/<id>/store/ é acessível a partir de cada serviço pela internet pública, com limites de taxa na camada nginx. O recente commit fix(ops/nginx): open Sentry ingestion endpoints; keep dashboard VPN-only captura exatamente essa divisão.
O custo de migração do Sentry SaaS para o GlitchTip é aproximadamente uma alteração de DNS, uma troca de DSN por projeto e um deployment de Postgres + Redis por trás do host do dashboard. Nunca trabalhámos com SaaS - integrámos o GlitchTip desde o primeiro dia - mas o caminho está aberto em qualquer direção. O SDK não sabe com que backend está a comunicar.
Há duas ressalvas específicas do GlitchTip que encontrámos e corrigimos durante o rollout. Primeiro, o fluxo de signup do GlitchTip requer que o registo esteja aberto para o convite de administrador inicial funcionar; ativámos durante o bootstrap, enviámos os convites e voltámos a desativar. Segundo, o email de saída do GlitchTip é configurado via Resend, e o domínio de origem tem de ser verificado antes de a verificação de email no signup funcionar - ignoramos a verificação de email até o domínio Resend estar ativo e reativamo-la depois. Ambas estão documentadas no runbook para quem repetir este processo.
O endpoint de debug-panic#
Testar a integração de ponta a ponta em produção sem um novo deployment é o tipo de coisa que silenciosamente nunca é feita - até que aconteça um panic real e se descubra que a integração estava quebrada há três semanas. Adicionámos uma superfície de diagnóstico permanente para exatamente isto.
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 em GET /debug/sentry-panic, protegido por ELIDO_SENTRY_DEBUG_TOKEN. Com a variável de ambiente não definida, a rota retorna 404 - seguro para lançar em produção. Quando a variável está definida e o pedido inclui ?token=<value>, o handler entra em panic propositadamente. O middleware captura-o, o SDK transporta-o para o GlitchTip, o evento aterra no projeto correto. Toda a viagem de ida e volta pode ser verificada em menos de um minuto sem redeployment.
Há um gémeo fasthttp para o 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()))
}
}
Mesma proteção por token, mesmo comportamento oculto quando não configurado. A primeira coisa que acontece após um deployment é o on-call acionar o endpoint de debug no serviço afetado. Se o evento aterrar no GlitchTip dentro de dez segundos, a integração está saudável. Se não, o deployment é revertido antes que a próxima interrupção descubra a integração quebrada da pior forma.
O que não integrámos#
Três coisas que parecem adições óbvias mas que ficaram deliberadamente fora do âmbito.
Tracing. EnableTracing: false em Init. Usamos OpenTelemetry para rastreamento distribuído (o pacote pkg/oteltrace integra-o nos mesmos serviços). Deixar o Sentry fazer tracing em paralelo duplicaria as alocações de transação por pedido e duplicaria o custo da propagação de contexto através do grafo de chamadas. A força do Sentry são os erros; a força do OTel são os spans. Usamos cada um para o que é bom.
CaptureException manual no caminho de redirecionamento. Coberto acima. O hot path não importa sentryinit com o propósito de o chamar a partir dos handlers. O middleware é o único limite de captura.
Monitorização de desempenho (transações). Mesma razão que o tracing. redirect_duration_seconds é um histograma Prometheus com labels region e cache_tier. Essa é a fonte de verdade para latência. Enviar os mesmos dados através da monitorização de desempenho do Sentry seria um pipeline duplicado com pior agregação.
Como parece do exterior#
Doze serviços, um pacote partilhado, uma linha por main.go, uma linha de middleware por router. Quando acontece um panic - e acontece - aparece no GlitchTip no projeto certo com a tag service certa, o Environment certo, o Release certo, e um stack trace suficientemente profundo para encontrar a linha. Quando um 5xx sem panic escapa - e esses também acontecem, normalmente após um soluço da base de dados - aparece da mesma forma.
Os compromissos são explícitos, escritos no comentário de documentação ao nível do pacote e testados com um benchmark. A integração está documentada no mesmo lugar que os runbooks, não em conhecimento tribal. Adicionar o décimo terceiro serviço vai levar quinze minutos - cinco dos quais são escrever o teste, cinco dos quais são integrar o DSN no manifesto de deployment, e cinco dos quais são executar make build e provar com o endpoint de debug.
Essa é a forma que se mantém. Seis linhas por serviço sempre iriam derivar. Uma linha, mais um pacote partilhado, mais um benchmark, não derivam.
A integração está disponível abertamente no monorepo em pkg/sentryinit/ para qualquer pessoa a gerir uma frota Go no Sentry ou GlitchTip que queira uma forma para copiar. O runbook associado cobre o procedimento de rotação de DSNs, as ressalvas de bootstrap do GlitchTip e o caminho de rollback. Para equipas a auto-hospedar toda a stack do Elido, o playbook k3s cobre onde o SDK se encaixa no deployment Kubernetes mais alargado. Para uma análise aprofundada do que "zero-alloc no happy path" significa realmente sob carga, o post de p95 do redirecionamento é o companion piece.
Marius Voß é DevRel e edge infra no Elido. Lançou o pacote sentryinit juntamente com o rollout descrito acima e passou a última semana a observar o dashboard do GlitchTip preencher-se com eventos que anteriormente eram invisíveis.
Experimente Elido
Cole uma URL, obtenha um link curto
Sem cadastro. O link vive 30 dias. Cadastre-se para mantê-lo para sempre.
Grátis, sem necessidade de registo · 2 por dia