Коли у вас один сервіс Go, налаштування відстеження помилок займає пів години: додаєте sentry-go, ініціалізуєте через SENTRY_DSN, викликаєте sentry.CaptureException у кількох важливих місцях і деплоїте. Коли у вас дванадцять сервісів Go, те саме рішення перетворюється на податок, що накопичується — кожен сервіс обростає власним кодом ініціалізації, специфічним middleware та власною логікою "release tag". До моменту виникнення panic у продакшені ви з'ясовуєте, що три сервіси взагалі не ініціалізують SDK, бо хтось забув додати env var у маніфест деплою.
Ми щойно завершили це підключення в Elido — дванадцять сервісів Go плюс CLI для бекфілу audit-chain, три додатки Next.js та два сервіси Node, які всі надсилають дані у self-hosted GlitchTip на sentry.elido.app. Цікавим був не сам виклик SDK, а структура спільного пакета, завдяки якому виклики SDK зводяться до одного рядка на сервіс, та обмеження, спричинені необхідністю використовувати middleware на hot path сервісу edge-redirect без виходу за межі бюджету p95 15ms.
Цей допис — повний звіт про те, як працює це підключення, що ми зробили правильно і на які два компроміси ми пішли свідомо.
TL;DR#
- Один спільний пакет,
pkg/sentryinit, замінює дванадцять копійfunc main. Додавання нового сервісу — це один рядокdefer sentryinit.Init(logger, "service-name")()плюс один рядок для middleware. ChiMiddleware()автоматично захоплює panics та 5xx відповіді (без паніки) у сервісах на warm-path.FastHTTPMiddleware()робить те саме дляedge-redirectі є zero-alloc на happy path — це підтверджено бенчмарком, який іде у пакеті.- Ми обрали GlitchTip (Sentry-сумісний, self-hosted) замість Sentry SaaS для дотримання вимог щодо зберігання даних в EU. SDK залишається незмінним.
- Hot path явно НЕ викликає
sentry.CaptureExceptionіз коду хендлера. Усе захоплення відбувається на рівні middleware, де витрати виникають лише тоді, коли є про що звітувати.
Чому спільний пакет, а не дванадцять копій#
Мінімально життєздатне підключення Sentry у Go — це шість рядків:
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)
Шість рядків, дванадцять сервісів. Сімдесят два рядки, які з часом починають розходитися. Проблема не в кількості, а в дрейфі. Один сервіс забуває Release. Інший бере Environment з env var з іншою назвою. Третій має односекундний flush і втрачає події під час швидкого SIGTERM. Поведінка відстеження помилок у всьому флоті перестає бути властивістю платформи і стає результатом роботи інженера, який писав main.go цього сервісу.
pkg/sentryinit — це просте рішення. Він живе у Go workspace, кожен сервіс підключає його через локальну директиву replace, а виклик займає один рядок:
defer sentryinit.Init(logger, "api-core")()
Сам пакет невеликий. Уся поверхня рантайму — це одна функція Init, два HTTP middleware (chi та net/http), один fasthttp middleware та debug endpoint для перевірки підключення end-to-end у продакшені. Важливі частини реалізації:
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) }
}
Три речі в цьому фрагменті коду варті свого місця.
По-перше, раннє повернення при порожньому DSN. Локальна розробка не має DSN. Тести CI теж. Без раннього повернення кожна машина розробника намагалася б ініціалізувати SDK, що вказує в нікуди, і видавала б попередження "invalid DSN" під час кожного запуску go run. Раннє повернення означає, що на місці виклику ніколи не потрібно робити розгалуження — defer sentryinit.Init(logger, "api-core")() є правильним у будь-якому оточенні.
По-друге, тег service, закріплений у глобальному scope. GlitchTip уже сегментує події за проектами (один проект на сервіс), але тег дозволяє фільтрувати за slug сервісу в пошуку по декількох проектах та на дашбордах без необхідності парсити ID проекту з DSN. Коли один і той самий тип паніки з'являється у трьох сервісах протягом години, тег дозволяє знайти цей патерн одним запитом.
По-третє, IgnoreErrors. context canceled — це те, що повертає кожен клієнт gRPC, коли запит скасовується через таймаут вище за ланцюжком — звичайна подія керування потоком (control-flow) у графі мікросервісів, а не баг. http: Server closed — це те, що повертає stdlib HTTP сервер під час graceful shutdown. Обидва створюють шум, який заглушає корисний сигнал. Deny-list фільтрує їх до того, як вони потраплять у чергу.
Підключення нового сервісу — це додавання його до go.work, один рядок require + replace у go.mod сервісу та додавання рядка з defer у main.go. Це контракт. Усе інше — таймаут flush, sample rate, ігноровані патерни помилок — централізовано.
Chi middleware#
У сервісах на warm-path — api-core, analytics-api, billing, domain-manager, search, url-scanner, qr-generator, metadata-fetcher, webhook-dispatcher — автоматичне захоплення відбувається на рівні HTTP. Хендлер може впасти у panic або повернути 5xx без паніки, і ми хочемо бачити обидва випадки.
Наївний підхід — використати вбудований middleware Handle із sentry-go/http. Ми цього не зробили з двох причин. По-перше, цей middleware завжди створює транзакцію, навіть якщо EnableTracing вимкнено — зайві алокації на кожному запиті. По-друге, він захоплює panics, але не захоплює 5xx відповіді без паніки, що означає, що хендлер, який повертає 503 через розірване з'єднання з Postgres, залишається невидимим.
Заміна невелика:
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)
})
}
}
Hub клонується для кожного запиту і зберігається в context. Це дозволяє хендлерам додавати специфічні для домену breadcrumbs (sentry.GetHubFromContext(r.Context()).AddBreadcrumb(...)) без витоку в інші запити. Внутрішній для chi WrapResponseWriter зберігає інтерфейси http.Flusher / http.Hijacker / http.Pusher — деякі middleware chi далі по ланцюжку використовують їх, а самописна обгортка їх втрачає. Для сервісів, які не використовують chi (click-ingester та analytics-export використовують звичайний http.ServeMux), пакет містить аналог на базі stdlib під назвою HTTPMiddleware().
Тонкий момент: http.ErrAbortHandler знову викликає panic замість захоплення. Це конвенція stdlib для випадку "клієнт відключився, завершити goroutine". Захоплення цього як exception переповнило б чергу подіями, що не є багами.
Підключення ідентичне для всіх сервісів на 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 ставиться перед oteltrace.ChiMiddleware, щоб panics у шарі трасування також захоплювалися.
Важка частина: fasthttp на hot path redirect#
edge-redirect — це зовсім інша справа. Його бюджет — p50 5ms / p95 15ms при попаданні в кеш, виміряно на трьох продуктових POPs. Будь-що, що робить алокації на кожен запит, відображається у профілі GC і зрештою у хвості p99. Middleware для chi вище цілком підходить для сервісів warm-path, які вільно використовують алокації; на edge це стало б проблемою.
sentry-go/fasthttp.Handle нам не підійшов з тієї ж причини, що й sentry-go/http.Handle: він створює snapshot http.Request на кожному запиті, включаючи happy path, навіть коли повідомляти ні про що. Для сервісу, що обробляє тисячі запитів на секунду на кожному POP, це тисячі непотрібних структур http.Request на секунду на кожен POP.
Fasthttp middleware у pkg/sentryinit змінює модель витрат: нічого не алокується, доки справді не виникне потреба у захопленні.
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)
}
}
}
Структура така ж, як і у версії для chi, але клонування hub та створення snapshot запиту винесені всередину гілок recover / 5xx. У разі відповіді 302 (cache-hit) — найпоширенішого випадку — тіло defer виконується, recover() повертає nil, перевірка статусу повертає false, і більше нічого не запускається. Сама замикаюча функція (closure) — це те, що Go інлайнить у стек при такій структурі виклику, тому навіть витрати на deferred функцію амортизуються до невидимих значень.
У пакеті є бенчмарк (fasthttp_test.go), який це підтверджує:
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)
}
}
У парі з BenchmarkFastHTTPHandler_Bare (той самий хендлер без middleware) різниця на розробницькій машині M3 2024 року в межах похибки — версія з обгорткою показує нуль додаткових алокацій на операцію. Middleware Sentry на hot path сервісу edge-redirect нічого не коштує на happy path. Витрати з'являються лише під час panic або 5xx, тобто саме тоді, коли ви готові за це платити.
Підключення в main.go сервісу edge-redirect займає один рядок:
rootHandler := sentryinit.FastHTTPMiddleware()(h.Route)
Чого це явно НЕ робить: воно не розкидає виклики sentry.CaptureException по самому хендлеру redirect. Хендлер залишається таким, якого вимагає бюджет затримки — без знань про Sentry і без алокацій на кожен запит для відстеження помилок. Межа middleware — єдине місце, де відбувається захоплення, і вона структурно безкоштовна на happy path.
Це свідомий компроміс. Якщо в edge-redirect є логічний баг, який видає неправильний цільовий URL без падіння чи повернення 5xx — наприклад, неправильно налаштоване правило, що спрямовує трафік з EU не туди — Sentry цього не побачить. Це побачать дашборди ботів та синтетичний моніторинг. Компроміс полягає в тому, що ми зберігаємо redirect швидким; обсервабільність для коректності (не помилок) живе поза SDK.
Чому GlitchTip, а не Sentry SaaS#
Продукт, що фокусується на GDPR і записує дані клієнтів у сервіс відстеження помилок у США — це суперечність, яку помічають аудитори. Stack traces з api-core включають шляхи URL, іноді ID тенантів, часом IP-адреси (ми маскуємо їх через BeforeSend hook у Sentry, але маскування можна випадково обійти). Найчистіший шлях — тримати data plane у нашому власному регіоні EU.
Наш вибір — GlitchTip. Він підтримує протокол Sentry, тому SDK ідентичний — ніяких форків, прошарків чи сторонніх бібліотек автентифікації. Дашборд виглядає як у Sentry і працює на sentry.elido.app через наш wg-easy VPN. Ingestion endpoint на o<projectId>.sentry.elido.app/api/<id>/store/ доступний для кожного сервісу через інтернет з обмеженнями (rate limits) на рівні nginx. Нещодавній коміт fix(ops/nginx): open Sentry ingestion endpoints; keep dashboard VPN-only відображає саме цей поділ.
Вартість міграції з Sentry SaaS на GlitchTip — це приблизно одна зміна DNS, одна заміна DSN на проект та один деплой Postgres + Redis для хоста дашборду. Ми ніколи не працювали на SaaS — ми налаштували GlitchTip з першого дня — але шлях відкритий в обох напрямках. SDK не знає, з яким бекендом він спілкується.
Є два нюанси GlitchTip, з якими ми зіткнулися і які виправили під час розгортання. По-перше, процес реєстрації в GlitchTip вимагає, щоб реєстрація була відкритою для створення першого адміна; ми відкрили її під час bootstrap, надіслали запрошення і знову закрили. По-друге, вихідна пошта GlitchTip налаштовується через Resend, і домен відправника має бути підтверджений для успішної верифікації пошти при реєстрації — ми пропускаємо верифікацію пошти, доки домен у Resend не стане "зеленим", і вмикаємо її після цього. Обидва моменти задокументовані в runbook.
Debug-panic endpoint#
Тестування підключення end-to-end у продакшені без нового деплою — це те, що зазвичай ніхто не робить, поки не станеться справжня паніка і не з'ясується, що підключення зламалося три тижні тому. Ми додали постійну діагностичну поверхню саме для цього.
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)
}
}
Маршрут GET /debug/sentry-panic, захищений ELIDO_SENTRY_DEBUG_TOKEN. Якщо env var не встановлено, маршрут повертає 404 — безпечно для продакшену. Коли змінна встановлена і запит містить ?token=<value>, хендлер навмисно викликає panic. Middleware ловить її, SDK передає в GlitchTip, подія потрапляє у потрібний проект. Увесь цикл можна перевірити менш ніж за хвилину без передеплою.
Є аналог fasthttp для 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()))
}
}
Той самий захист токеном, та сама поведінка "приховано, якщо не налаштовано". Перше, що робить черговий після деплою — звертається до debug endpoint відповідного сервісу. Якщо подія з'являється у GlitchTip протягом десяти секунд — підключення в порядку. Якщо ні — деплой відкочується до того, як наступна аварія виявить поломку складним шляхом.
Що ми не підключали#
Три речі, які здаються очевидними доповненнями, але ми свідомо залишили їх поза межами проекту.
Tracing. EnableTracing: false у Init. Ми використовуємо OpenTelemetry для розподіленого трасування (пакет pkg/oteltrace підключає його в тих самих сервісах). Дозволити Sentry робити трасування паралельно означало б подвоїти алокації транзакцій на кожен запит і подвоїти витрати на прокидання context через граф викликів. Сила Sentry — у помилках, сила OTel — у spans. Ми використовуємо кожен інструмент для того, у чому він найкращий.
Ручний CaptureException на шляху redirect. Описано вище. Hot path не імпортує sentryinit для виклику з хендлерів. Middleware — єдина межа захоплення.
Моніторинг продуктивності (транзакції). Та сама причина, що й з tracing. redirect_duration_seconds — це гістограма Prometheus з мітками region та cache_tier. Це джерело істини для затримок. Передача тих самих даних через моніторинг продуктивності Sentry була б дублюванням пайплайну з гіршою агрегацією.
Як це виглядає ззовні#
Дванадцять сервісів, один спільний пакет, один рядок у main.go, один рядок middleware на роутер. Коли стається panic — а вони стаються — вона з'являється у GlitchTip у потрібному проекті з правильним тегом service, Environment, Release та stack trace, достатнім для пошуку потрібного рядка коду. Коли проскакує 5xx без паніки — а таке теж буває, зазвичай після проблем із базою даних — це відображається так само.
Компроміси є явними, описаними в коментарі до пакета та перевіреними бенчмарком. Підключення задокументовано там само, де й runbooks, а не в усних переказах. Додавання тринадцятого сервісу займе п'ятнадцять хвилин — п'ять на написання тесту, п'ять на додавання DSN у маніфест деплою і ще п'ять на запуск make build та перевірку через debug endpoint.
Це структура, яка працює. Шість рядків на сервіс неминуче призвели б до розбіжностей. Один рядок плюс спільний пакет та бенчмарк — ні.
Код підключення відкритий у монорепозиторії в pkg/sentryinit/ для тих, хто використовує флот на Go із Sentry або GlitchTip і хоче скопіювати структуру. Відповідний runbook описує процедуру ротації DSN, нюанси bootstrap GlitchTip та шлях відкату. Для команд, які самостійно хостять увесь стек Elido, k3s playbook пояснює, як SDK вписується в загальне розгортання Kubernetes. Для детального розбору того, що насправді означає "zero-alloc на happy path" під навантаженням, перегляньте допис про redirect p95.
Маріус Фосс (Marius Voß) — DevRel та edge infra в Elido. Він впровадив пакет sentryinit разом із розгортанням, описаним вище, і провів останній тиждень, спостерігаючи, як дашборд GlitchTip наповнюється подіями, які раніше були невидимими.