12 min czytaniaInżynieria
Kluczowa

Podłączanie Sentry/GlitchTip do 12 serwisów Go bez psucia hot path

Jak Elido dostarczyło współdzielony pakiet sentryinit, który daje każdemu serwisowi Go identyczne automatyczne przechwytywanie panik i odpowiedzi 5xx - i pozostaje zero-alloc na budżecie p95 15ms serwisu 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

Gdy masz jeden serwis Go, śledzenie błędów to praca na pół godziny: wrzucasz sentry-go, inicjalizujesz go z SENTRY_DSN, wywołujesz sentry.CaptureException w kilku kluczowych miejscach i wysyłasz. Gdy masz dwanaście serwisów Go, ta sama półgodzinna decyzja staje się podatkiem, który kumuluje się z czasem - każdy serwis wyrasta własny, nieco inny kod inicjalizacji, własny, nieco inny middleware, własne zdanie o tym, co oznacza „tag release". Kiedy w produkcji dochodzi do paniki, odkrywasz, że trzy serwisy nie inicjalizują SDK w ogóle, bo ktoś zapomniał zmiennej środowiskowej w manifeście wdrożenia.

Właśnie skończyliśmy to okablowanie w Elido - dwanaście serwisów Go plus CLI do backfillowania łańcucha audytu, plus trzy aplikacje Next.js, plus dwa serwisy Node, wszystkie zasilające self-hosted GlitchTip pod adresem sentry.elido.app. Interesujące fragmenty nie były wywołaniami SDK. Były nimi kształt współdzielonego pakietu, który sprawia, że wywołania SDK znikają w jedną linię na serwis, oraz ograniczenia wynikające z konieczności posiadania middleware na hot path serwisu edge-redirect bez spalenia budżetu p95 15ms.

Ten wpis to kompletny opis działania okablowania, tego, co zrobiliśmy dobrze, i dwóch świadomych kompromisów.

TL;DR#

  • Jeden współdzielony pakiet, pkg/sentryinit, zastępuje dwanaście kopii func main. Dodanie nowego serwisu to jedna linia defer sentryinit.Init(logger, "service-name")() i jedna linia middleware.
  • ChiMiddleware() automatycznie przechwytuje paniki i niepaniczne odpowiedzi 5xx w serwisach warm-path. FastHTTPMiddleware() robi to samo dla edge-redirect i jest zero-alloc na happy path - zweryfikowane przez benchmark dołączony do pakietu.
  • Wybraliśmy GlitchTip (kompatybilny z Sentry, self-hosted) zamiast Sentry SaaS ze względu na rezydencję danych w UE. SDK pozostaje niezmieniony.
  • Hot path celowo NIE wywołuje sentry.CaptureException z kodu handlera. Całe przechwytywanie odbywa się na granicy middleware, gdzie koszt materializuje się tylko wtedy, gdy jest coś do zgłoszenia.

Dlaczego współdzielony pakiet, a nie dwanaście kopii#

Współdzielony pakiet sentryinit udostępniający Init, ChiMiddleware i FastHTTPMiddleware, podłączający serwisy warm-path chi i serwis edge-redirect fasthttp za pomocą jednej linii per main.go

Minimalne okablowanie Sentry w Go to sześć linii:

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)

Sześć linii, dwanaście serwisów. Siedemdziesiąt dwie linie, które z czasem się rozbiegają. Problem nie leży w liczbie - leży w dryfie. Jeden serwis zapomina o Release. Inny ustawia Environment z nieco inaczej nazwanej zmiennej środowiskowej. Trzeci ma jednosekundowy flush i traci zdarzenia przy szybkim SIGTERM. Zachowanie śledzenia błędów w całej flocie przestaje być właściwością platformy, a zaczyna być właściwością inżyniera, który napisał main.go danego serwisu.

pkg/sentryinit to niezbyt pomysłowa naprawa. Mieszka w workspace Go, każdy serwis wymaga go przez lokalną dyrektywę replace, a miejsce wywołania to jedna linia:

defer sentryinit.Init(logger, "api-core")()

Sam pakiet jest mały. Cała powierzchnia uruchomieniowa to jedna funkcja Init, dwa middleware HTTP (chi i net/http), jeden middleware fasthttp i endpoint debugowania do udowadniania okablowania end-to-end w produkcji. Odpowiednie fragmenty implementacji:

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) }
}

Trzy rzeczy w tym fragmencie, które zasługują na swoje linie.

Po pierwsze, wczesny powrót przy pustym DSN. Środowisko lokalne nie ma DSN. Testy CI też nie. Bez wczesnego powrotu każda maszyna dewelopera próbowałaby zainicjalizować SDK wskazujące donikąd i emitowałaby ostrzeżenie „invalid DSN" przy każdym starcie go run. Wczesny powrót sprawia, że miejsce wywołania nigdy nie musi rozgałęziać - defer sentryinit.Init(logger, "api-core")() jest poprawne w każdym środowisku.

Po drugie, tag service przypiętY do globalnego scope'u. GlitchTip już segmentuje zdarzenia według projektu (jeden projekt na serwis), ale tag pozwala wyszukiwaniom i dashboardom między projektami filtrować według sluga serwisu bez konieczności parsowania ID projektu z DSN. Gdy ta sama klasa paniki pojawia się w trzech serwisach w ciągu godziny, tag sprawia, że ten wzorzec jest możliwy do znalezienia jednym zapytaniem.

Po trzecie, IgnoreErrors. context canceled to to, co każdy klient gRPC zwraca, gdy żądanie poniżej jest anulowane przez timeout powyżej - normalny zdarzenie sterowania przepływem w łańcuchu mikroserwisów, a nie błąd. http: Server closed to to, co stdlib HTTP server zwraca podczas graceful shutdown. Oba generują szum, który topi sygnał. Lista odmów filtruje je zanim trafią do kolejki.

Okablowanie nowego serwisu polega na dodaniu go do go.work, wrzuceniu jednolinijkowego require + replace w go.mod serwisu i dodaniu linii defer w main.go. Taka jest umowa. Wszystko inne - timeout flush, współczynnik próbkowania, ignorowane wzorce błędów - jest scentralizowane.

Middleware chi#

W serwisach warm-path - api-core, analytics-api, billing, domain-manager, search, url-scanner, qr-generator, metadata-fetcher, webhook-dispatcher - powierzchnia automatycznego przechwytywania to HTTP. Handler może spanikować, lub może zwrócić 5xx bez panikowania, i chcemy żeby oba były widoczne.

Naiwne podejście to użycie wbudowanego middleware Handle z sentry-go/http. Nie zrobiliśmy tego z dwóch powodów. Po pierwsze, ten middleware zawsze startuje transakcję nawet gdy EnableTracing jest false - zmarnowana alokacja przy każdym żądaniu. Po drugie, przechwytuje paniki, ale nie niepaniczne odpowiedzi 5xx, co oznacza, że handler zwracający 503 z powodu zerwania połączenia z Postgres pozostaje niewidoczny.

Żądanie wchodzi do routera i middleware; przy normalnej odpowiedzi 2xx lub 3xx nic nie jest przechwytywane, podczas gdy panika lub niepaniczny 5xx jest odzyskiwany, zakresowany i transportowany do GlitchTip jako zdarzenie

Zamiennik jest mały:

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 jest klonowany per żądanie i przechowywany w kontekście. Pozwala to handlerom dołączać kontekstowe breadcrumby specyficzne dla domeny (sentry.GetHubFromContext(r.Context()).AddBreadcrumb(...)) bez wycieku do innych obsługiwanych żądań. Wewnętrzny WrapResponseWriter chi zachowuje interfejsy http.Flusher / http.Hijacker / http.Pusher - część middleware chi poniżej zagląda do tych interfejsów, a ręcznie skonstruowany wrapper je traci. Dla serwisów niekorzystających z chi (click-ingester i analytics-export montują zwykłe http.ServeMux), pakiet dostarcza bliźniaka opartego wyłącznie na stdlib o nazwie HTTPMiddleware().

Subtelne zachowanie: http.ErrAbortHandler jest re-panikowany zamiast przechwytywany. Taka jest konwencja stdlib dla „klient się rozłączył, zatrzymaj goroutine bez śladu". Przechwytywanie tego jako wyjątku zalałoby kolejkę nieistotnymi zdarzeniami.

Okablowanie jest identyczne we wszystkich serwisach warm-path:

r := chi.NewRouter()
r.Use(middleware.RequestID)
r.Use(middleware.RealIP)
r.Use(sentryinit.ChiMiddleware())
r.Use(oteltrace.ChiMiddleware("api-core"))
// ... reszta stosu middleware

sentryinit.ChiMiddleware jest umieszczone przed oteltrace.ChiMiddleware, aby paniki w warstwie tracingowej nadal były przechwytywane.

Trudna część: fasthttp na hot path przekierowania#

edge-redirect to inny gatunek. Jego budżet to p50 5ms / p95 15ms na trafienie w cache, mierzone w trzech produkcyjnych POPach. Cokolwiek alokuje per żądanie pojawia się w profilu GC i ostatecznie w ogonie p99. Powyższy middleware chi jest w porządku dla serwisów warm-path, które alokują swobodnie; na edge byłby problemem.

sentry-go/fasthttp.Handle był nie do przyjęcia z tego samego powodu co sentry-go/http.Handle: buduje migawkę http.Request dla każdego żądania, w tym happy path, nawet gdy nie ma nic do zgłoszenia. Dla serwisu obsługującego tysiące żądań na sekundę per POP, to tysiące niepotrzebnych struktur http.Request na sekundę per POP.

Middleware fasthttp w pkg/sentryinit odwraca model kosztów: nic nie alokuje, dopóki nie ma czegoś do przechwycenia.

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)
        }
    }
}

Kształt jest taki sam jak wersja chi, ale klonowanie huba i budowanie migawki żądania są przesunięte wewnątrz gałęzi recover / 5xx. Przy odpowiedzi 302 z cache - przytłaczająco częstym przypadku - ciało defer się uruchamia, recover() zwraca nil, sprawdzenie statusu zwraca false i nic więcej nie działa. Samo zamknięcie to to, co Go inlinuje do ramki stosu przy tym kształcie wywołania, więc nawet koszt odroczonej funkcji amortyzuje się do niczego wykrywalnego.

W pakiecie jest benchmark (fasthttp_test.go), który to potwierdza:

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)
    }
}

W zestawieniu z BenchmarkFastHTTPHandler_Bare (ten sam handler, bez middleware), delta na maszynie deweloperskiej M3 z 2024 roku mieści się w szumie - opakowana wersja raportuje zero dodatkowych alokacji per operację. Middleware Sentry na hot path edge-redirect nie kosztuje nic na happy path. Kosztuje coś tylko wtedy, gdy jest panika lub 5xx, czyli dokładnie wtedy, gdy nie masz nic przeciwko płaceniu.

Okablowanie w main.go serwisu edge-redirect to jedna linia:

rootHandler := sentryinit.FastHTTPMiddleware()(h.Route)

To, czego to celowo NIE robi: nie rozsiewa wywołań sentry.CaptureException przez handler przekierowania. Handler pozostaje taki, jakiego potrzebuje budżet opóźnienia - bez świadomości Sentry, bez alokacji per żądanie do celów śledzenia błędów. Granica middleware to jedyne miejsce, gdzie przechwytywanie się odbywa, a granica middleware jest strukturalnie bezpłatna na happy path.

To jest świadomy kompromis. Jeśli edge-redirect ma błąd logiczny, który generuje zły docelowy URL bez awarii lub zwracania 5xx - powiedzmy, źle skonfigurowana reguła kierująca ruch z UE na zły fallback - Sentry tego nie zobaczy. Zobaczy to dashboardy botów i monitoring syntetyczny. Kompromis polega na tym, że przekierowanie pozostaje tanie; obserwowalność dla poprawności niezwiązanej z błędami mieszka poza SDK.

Dlaczego GlitchTip, a nie Sentry SaaS#

Produkt GDPR-first zapisujący dane klientów do US-hostowanej usługi śledzenia błędów to sprzeczność, którą audytorzy dostrzegają. Stack trace'y z api-core zawierają ścieżki URL, okazjonalnie ID tenantów, czasem adresy IP (redagujemy je przez hook BeforeSend Sentry, ale redakcja może zostać ominięta przez pomyłkę). Najczystszą ścieżką jest utrzymanie płaszczyzny danych w naszym własnym regionie UE.

GlitchTip to nasz wybór. Mówi protokołem wire Sentry, więc SDK jest bajt-identyczny - żaden fork, żaden shim, żadna druga biblioteka auth. Dashboard ma kształt Sentry i mieszka pod sentry.elido.app za naszym VPN wg-easy. Endpoint ingestii pod adresem o<projectId>.sentry.elido.app/api/<id>/store/ jest dostępny z każdego serwisu przez publiczny internet, z limitami szybkości na warstwie nginx. Niedawny commit fix(ops/nginx): open Sentry ingestion endpoints; keep dashboard VPN-only uchwytuje ten dokładny podział.

Koszt migracji z Sentry SaaS do GlitchTip to mniej więcej jedna zmiana DNS, jedna zmiana DSN per projekt i jedno wdrożenie Postgres + Redis za hostem dashboardu. Nigdy nie pracowaliśmy na SaaS - podłączyliśmy GlitchTip od pierwszego dnia - ale ścieżka jest otwarta w obie strony. SDK nie wie, z jakim backendem rozmawia.

Podczas wdrożenia napotkaliśmy i naprawiliśmy dwa zastrzeżenia specyficzne dla GlitchTip. Po pierwsze, przepływ rejestracji GlitchTip wymaga otwartej rejestracji, aby działało pierwsze zaproszenie administratora; otworzyliśmy ją podczas bootstrapowania, wysłaliśmy zaproszenia i z powrotem zamknęliśmy. Po drugie, wychodzące e-maile GlitchTip działają przez Resend, a domena from musi być zweryfikowana, zanim weryfikacja e-mail przy rejestracji zadziała - pomijamy weryfikację e-mail, dopóki domena Resend nie jest zielona i ponownie ją włączamy po. Oba są udokumentowane w runbooku dla każdego powtarzającego ten proces.

Endpoint debug-panic#

Testowanie okablowania end-to-end w produkcji bez nowego wdrożenia to rodzaj pracy, który po cichu nigdy nie jest wykonywany - dopóki nie dojdzie do prawdziwej paniki i nie okazuje się, że okablowanie było zepsute trzy tygodnie temu. Dodaliśmy stałą powierzchnię diagnostyczną właśnie do tego celu.

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)
    }
}

Zamontowany pod GET /debug/sentry-panic, chroniony przez ELIDO_SENTRY_DEBUG_TOKEN. Przy nieustawionej zmiennej środowiskowej, route zwraca 404 - bezpieczne do wdrożenia w produkcji. Gdy zmienna jest ustawiona i żądanie zawiera ?token=<value>, handler celowo panikuje. Middleware to przechwytuje, SDK transportuje to do GlitchTip, zdarzenie ląduje w odpowiednim projekcie. Cały round trip można zweryfikować w mniej niż minutę bez ponownego wdrożenia.

Istnieje bliźniak fasthttp dla 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()))
    }
}

Ta sama bramka tokenowa, to samo zachowanie ukryte-gdy-nieskonfigurowane. Pierwszą rzeczą po wdrożeniu jest uderzenie przez dyżurnego w endpoint debugowania na dotkniętym serwisie. Jeśli zdarzenie ląduje w GlitchTip w ciągu dziesięciu sekund, okablowanie jest sprawne. Jeśli nie, wdrożenie jest wycofywane zanim następna awaria odkryje zepsute okablowanie w trudny sposób.

Czego nie podłączyliśmy#

Trzy rzeczy, które wyglądają jak oczywiste uzupełnienia, ale celowo pozostają poza zakresem.

Tracing. EnableTracing: false w Init. Używamy OpenTelemetry do rozproszonego tracingu (pakiet pkg/oteltrace podłącza go do tych samych serwisów). Zezwolenie Sentry na równoległy tracing podwoiłoby alokacje transakcji per żądanie i podwoiło koszt propagacji kontekstu przez graf wywołań. Mocna strona Sentry to błędy; mocna strona OTel to spany. Używamy każdego do tego, w czym jest dobry.

Ręczne wywołania CaptureException na ścieżce przekierowania. Omówione powyżej. Hot path nie importuje sentryinit w celu wywoływania go z handlerów. Middleware jest jedyną granicą przechwytywania.

Monitorowanie wydajności (transakcje). Z tego samego powodu co tracing. redirect_duration_seconds to histogram Prometheus z etykietami region i cache_tier. To jest źródło prawdy dla opóźnienia. Przekazywanie tych samych danych przez monitorowanie wydajności Sentry byłoby zduplikowanym potokiem z gorszą agregacją.

Jak to wygląda z zewnątrz#

Dwanaście serwisów Go, w tym edge-redirect, zbiegających się do jednego self-hostowanego GlitchTip pod adresem sentry.elido.app, każde zdarzenie nosi tag serwisu, środowisko i wersję release do wyszukiwania między projektami

Dwanaście serwisów, jeden współdzielony pakiet, jedna linia per main.go, jedna linia middleware per router. Gdy dochodzi do paniki - a dochodzi - pojawia się w GlitchTip pod właściwym projektem z właściwym tagiem service, właściwym Environment, właściwym Release i stack trace'em wystarczająco głębokim, by znaleźć linię. Gdy niepaniczny 5xx wycieka - a to też się zdarza, zazwyczaj po hiccupie bazy danych - pojawia się w ten sam sposób.

Kompromisy są wyraźne, zapisane w komentarzu dokumentacyjnym na poziomie pakietu i przetestowane benchmarkiem. Okablowanie jest udokumentowane w tym samym miejscu co runbooki, a nie w wiedzy plemiennej. Dodanie trzynastego serwisu zajmie piętnaście minut - pięć z których to pisanie testu, pięć to okablowanie DSN w manifeście wdrożenia, a pięć to uruchomienie make build i potwierdzenie endpointem debugowania.

To jest kształt, który się trzyma. Sześć linii per serwis zawsze miało driftować. Jedna linia, plus jeden współdzielony pakiet, plus jeden benchmark - nie.


Okablowanie jest otwarte w monorepo pod adresem pkg/sentryinit/ dla każdego, kto prowadzi flotę Go na Sentry lub GlitchTip i szuka kształtu do skopiowania. Powiązany runbook obejmuje procedurę rotacji DSN, zastrzeżenia bootstrapowania GlitchTip i ścieżkę rollbacku. Dla zespołów self-hostujących cały stos Elido, podręcznik k3s omawia, gdzie SDK wpisuje się w szersze wdrożenie Kubernetes. Dla głębokiego zanurzenia w to, co „zero-alloc na happy path" faktycznie oznacza pod obciążeniem, wpis o p95 przekierowania jest uzupełniającym materiałem.


Marius Voß jest DevRel i inżynierem edge infra w Elido. Dostarczył pakiet sentryinit równolegle z opisanym powyżej wdrożeniem i przez ostatni tydzień obserwował wypełniający się dashboard GlitchTip ze zdarzeniami, które wcześniej były niewidoczne.

Wypróbuj Elido

Wklej URL, otrzymaj krótki link

Bez rejestracji. Link działa 30 dni. Zarejestruj się, aby zachować go na zawsze.

Za darmo, bez rejestracji · 2 dziennie

Wypróbuj Elido

Skracarka URL hostowana w UE: własne domeny, głęboka analityka i otwarte API. Darmowy plan - bez karty kredytowej.

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

Czytaj dalej