Elido
12 min readengineering
Cornerstone

Подключение Sentry/GlitchTip в 12 Go-сервисах без замедления hot path

Как Elido выпустила общий пакет sentryinit, который обеспечивает во всех Go-сервисах одинаковый автозахват panic + 5xx — и сохраняет zero-alloc при бюджете p95 в 15 мс для edge-redirect.

Marius Voß
DevRel · edge infra
Diagram of 12 Go service tiles each emitting events into a central GlitchTip ingest, with the edge-redirect tile labelled zero-alloc on the happy path

Когда у вас один 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 наполняется событиями, которые раньше были невидимы.

Try Elido

EU-hosted URL shortener with custom domains, deep analytics, and an open API. Free tier — no credit card.

Tags
sentry go middleware
glitchtip self-hosted
observability url shortener
fasthttp panic recovery
chi middleware
go error tracking

Continue reading

Подключение Sentry/GlitchTip в 12 Go-сервисах без замедления hot path · Elido