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 inicializando 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 compartilhado 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 compartilhado,
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 compartilhado 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 monitoramento 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á comunicando.
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 registro 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.
Monitoramento 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 monitoramento de desempenho do Sentry seria um pipeline duplicado com pior agregação.
Como parece do exterior
Doze serviços, um pacote compartilhado, 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 compartilhado, mais um benchmark, não derivam.
A integração está disponível abertamente no monorepo em pkg/sentryinit/ para qualquer pessoa a gerenciar 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 equipes 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