Когда у вас один Go-сервис, трекинг ошибок занимает полчаса: подключаете sentry-go, инициализируете через SENTRY_DSN, вызываете sentry.CaptureException в паре важных мест и деплоите. Когда у вас двенадцать Go-сервисов, это же получасовое решение превращается в налог с нарастающим итогом — в каждом сервисе появляется свой, слегка отличающийся код инициализации, свой middleware и свое мнение о том, что значит "release tag". К тому моменту, когда в продакшене случается panic, вы обнаруживаете, что три сервиса вообще не инициализируют SDK, потому что кто-то забыл добавить env var в манифест деплоя.
Мы в Elido только что закончили эту работу — двенадцать Go-сервисов плюс CLI для бэкфилла цепочки аудита, три приложения Next.js и два сервиса Node, все они отправляют данные в self-hosted GlitchTip по адресу sentry.elido.app. Самым интересным были не вызовы SDK. Это была структура общего пакета, благодаря которому вызовы SDK сводятся к одной строке на сервис, и ограничения, возникшие из-за необходимости использовать middleware на hot path сервиса edge-redirect, не выходя за рамки бюджета p95 в 15 мс.
Этот пост — подробный отчет о том, как устроено это подключение, что у нас получилось и на какие два компромисса мы пошли сознательно.
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 middlewares (chi и net/http), одного fasthttp middleware и диагностического эндпоинта для проверки всей цепочки в продакшене. Ключевые фрагменты реализации:
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. Когда в течение часа в трех сервисах появляется один и тот же класс panic, тег позволяет обнаружить эту закономерность одним запросом.
В-третьих, IgnoreErrors. context canceled — это то, что возвращает каждый gRPC-клиент, когда нижестоящий запрос отменяется вышестоящим таймаутом. Это нормальное событие управления потоком в цепочке микросервисов, а не баг. http: Server closed возвращается стандартным HTTP-сервером при корректном завершении работы. Оба этих события создают шум, который заглушает полезный сигнал. Deny-list фильтрует их до того, как они попадут в очередь.
Подключение нового сервиса — это добавление его в go.work, одна строка require + replace в go.mod сервиса и добавление строки с defer в main.go. Это весь контракт. Все остальное — таймаут flush, sample rate, шаблоны игнорируемых ошибок — централизовано.
Middleware для chi#
В сервисах на warm-path — api-core, analytics-api, billing, domain-manager, search, url-scanner, qr-generator, metadata-fetcher, webhook-dispatcher — автозахват происходит на уровне HTTP. Хендлер может упасть с panic или вернуть 5xx без panic, и мы хотим видеть оба случая.
Наивный подход — использовать встроенный 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 (click-ingester и analytics-export используют обычный http.ServeMux), пакет поставляет аналогичный middleware HTTPMiddleware(), работающий только со стандартной библиотекой.
Тонкий момент: http.ErrAbortHandler пробрасывается дальше (re-panic), а не захватывается. Это стандартное соглашение библиотеки: "клиент отключился, корректно завершаем goroutine". Захват этого события как исключения завалил бы очередь ложными ошибками.
Подключение идентично во всех сервисах 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 редиректов#
edge-redirect — это совсем другой случай. Его бюджет составляет p50 5 мс / p95 15 мс при попадании в кэш, измеренный в трех производственных POPs. Любые аллокации на каждый запрос отражаются на профиле GC и, в конечном итоге, на p99. Middleware для chi выше вполне подходит для сервисов warm-path, которые свободно выделяют память, но на edge это стало бы проблемой.
sentry-go/fasthttp.Handle не рассматривался по той же причине, что и sentry-go/http.Handle: он создает снимок http.Request при каждом запросе, включая happy path, даже если сообщать не о чем. Для сервиса, обрабатывающего тысячи запросов в секунду на каждый POP, это тысячи ненужных структур http.Request в секунду на каждый POP.
Middleware для fasthttp в 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 и создание снимка запроса вынесены внутрь веток recover / 5xx. При ответе 302 из кэша — а это подавляющее большинство случаев — тело defer выполняется, recover() возвращает nil, проверка статуса дает false, и больше ничего не происходит. Само замыкание — это то, что Go инлайнит в стек при такой структуре вызова, поэтому даже затраты на отложенную функцию в среднем сводятся к нулю.
В пакете есть бенчмарк (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 показывает ноль дополнительных аллокаций на операцию. Middleware Sentry на hot path сервиса edge-redirect ничего не стоит на happy path. Затраты возникают только при panic или 5xx, то есть именно тогда, когда вы готовы за это платить.
Подключение в main.go сервиса edge-redirect занимает одну строку:
rootHandler := sentryinit.FastHTTPMiddleware()(h.Route)
Чего это решение явно НЕ делает: оно не добавляет вызовы sentry.CaptureException в сам хендлер редиректов. Хендлер остается таким, каким его требует бюджет задержек — без зависимостей от Sentry и без аллокаций на каждый запрос для целей трекинга ошибок. Граница middleware — единственное место, где происходит захват, и эта граница структурно бесплатна на happy path.
Это сознательный компромисс. Если в edge-redirect возникнет логический баг, который приведет к неправильному целевому URL без падения сервиса или возврата 5xx (например, из-за неверного правила трафик из EU уйдет не туда), Sentry этого не увидит. Это заметят дашборды ботов и синтетический мониторинг. Мы выбрали дешевые редиректы; наблюдаемость для проверки корректности без ошибок вынесена за пределы SDK.
Почему GlitchTip, а не Sentry SaaS#
Использование US-сервиса трекинга ошибок для продукта, ориентированного на GDPR и обрабатывающего данные клиентов — это противоречие, которое заметят аудиторы. Стек-трейсы из api-core содержат пути URL, иногда ID арендаторов, иногда IP-адреса (мы удаляем их через хук BeforeSend в Sentry, но удаление можно случайно сломать). Самый чистый путь — держать data plane в нашем собственном регионе EU.
Наш выбор — GlitchTip. Он поддерживает протокол Sentry, поэтому SDK идентичен на уровне байтов — никаких форков, прослоек или вторых библиотек аутентификации. Дашборд выглядит так же, как в Sentry, и доступен по адресу sentry.elido.app через наш VPN (wg-easy). Эндпоинт для приема данных o<projectId>.sentry.elido.app/api/<id>/store/ доступен из каждого сервиса через публичный интернет с ограничениями на уровне nginx. Коммит fix(ops/nginx): open Sentry ingestion endpoints; keep dashboard VPN-only как раз фиксирует это разделение.
Затраты на миграцию из Sentry SaaS в GlitchTip — это примерно одно изменение DNS, одна замена DSN на проект и один деплой Postgres + Redis за хостом дашборда. Мы никогда не использовали SaaS — мы подключили GlitchTip с первого дня — но путь открыт в обоих направлениях. SDK не знает, с каким бэкендом он работает.
При развертывании мы столкнулись с двумя особенностями GlitchTip и исправили их. Во-первых, процесс регистрации в GlitchTip требует, чтобы регистрация была открыта для принятия приглашения первого администратора; мы открыли ее на время настройки, отправили приглашения и снова закрыли. Во-вторых, GlitchTip отправляет почту через Resend, и домен отправителя должен быть подтвержден, чтобы верификация почты при регистрации сработала. Мы пропускаем верификацию почты, пока домен Resend не станет активным, и включаем ее после. Обе эти особенности задокументированы в runbook.
Эндпоинт debug-panic#
Сквозное тестирование подключения в продакшене без нового деплоя — это то, что обычно тихо откладывается в долгий ящик, пока не случится реальный panic и вы не обнаружите, что подключение сломалось три недели назад. Мы добавили постоянный диагностический интерфейс именно для этого.
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, и событие попадает в нужный проект. Весь цикл проверки занимает меньше минуты без передеплоя.
Для edge-сервиса есть аналог на fasthttp:
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()))
}
}
Та же защита токеном, то же поведение "скрыт, если не настроен". Первое, что делает дежурный после деплоя — обращается к эндпоинту отладки в соответствующем сервисе. Если событие появляется в GlitchTip в течение десяти секунд, подключение исправно. Если нет — деплой откатывается до того, как следующая авария вскроет проблему с трекингом ошибок более болезненным способом.
Что мы не стали подключать#
Три вещи, которые кажутся очевидными дополнениями, но намеренно оставлены за рамками проекта.
Трассировка. EnableTracing: false в Init. Мы используем OpenTelemetry для распределенной трассировки (пакет pkg/oteltrace подключает ее в тех же сервисах). Использование Sentry для трассировки параллельно удвоило бы аллокации транзакций на каждый запрос и стоимость проброса контекста через граф вызовов. Сила Sentry — в ошибках, сила OTel — в spans. Мы используем каждый инструмент для того, в чем он хорош.
Ручной вызов CaptureException на пути редиректов. Описано выше. Hot path не импортирует sentryinit для вызова из хендлеров. Единственная граница захвата — middleware.
Мониторинг производительности (транзакции). По той же причине, что и трассировка. redirect_duration_seconds — это гистограмма Prometheus с метками region и cache_tier. Это наш источник истины по задержкам. Отправка тех же данных через мониторинг производительности Sentry была бы дублирующим конвейером с худшей агрегацией.
Как это выглядит со стороны#
Двенадцать сервисов, один общий пакет, по одной строке в каждом main.go, по одной строке middleware в каждом роутере. Когда случается panic — а они случаются — он появляется в GlitchTip в нужном проекте с правильным тегом service, верными Environment, Release и стек-трейсом, достаточно глубоким, чтобы найти нужную строку. Когда проскакивает обычный 5xx — и такое бывает, обычно при сбоях в базе данных — он отображается так же.
Все компромиссы прописаны явно, зафиксированы в комментарии к пакету и проверены бенчмарком. Настройка задокументирована там же, где и runbooks, а не передается как тайное знание. Добавление тринадцатого сервиса займет пятнадцать минут: пять — на написание теста, пять — на добавление DSN в манифест деплоя и пять — на запуск make build и проверку через эндпоинт отладки.
Такая структура жизнеспособна. Шесть строк на сервис неизбежно привели бы к дрифту. Одна строка, один общий пакет и один бенчмарк — нет.
Код подключения доступен в монорепозитории в pkg/sentryinit/ для всех, кто использует Go-сервисы с Sentry или GlitchTip и ищет готовый шаблон. Соответствующий runbook описывает процедуру ротации DSN, особенности настройки GlitchTip и путь отката. Для команд, самостоятельно разворачивающих весь стек Elido, в k3s playbook описано, как SDK вписывается в общий деплой в Kubernetes. Для глубокого погружения в то, что на самом деле означает "zero-alloc на happy path" под нагрузкой, рекомендуем пост про p95 редиректов.
Мариус Фосс (Marius Voß) — DevRel и инженер edge-инфраструктуры в Elido. Он выпустил пакет sentryinit одновременно с развертыванием, описанным выше, и последнюю неделю наблюдал, как дашборд GlitchTip наполняется событиями, которые раньше были невидимы.