diff --git a/README.md b/README.md index f73b339..35454d2 100644 --- a/README.md +++ b/README.md @@ -359,6 +359,13 @@ either is set to anything else, including a bare number or a zero or negative duration, dnswatcher refuses to start with an error naming the variable and the value. +**`DNSWATCHER_SENTRY_DSN` reports crashes in HTTP requests to Sentry.** When it +is set, a panic in an HTTP request handler is sent to Sentry, and the request +still gets a `500 Internal Server Error` answer. Nothing else is sent to Sentry: +DNS, port and TLS problems are reported as notifications. A value Sentry cannot +parse stops dnswatcher at startup. At shutdown, reports not yet sent are sent, +waiting at most 2 seconds. + ### Example `.env` ```sh diff --git a/TODO.md b/TODO.md index 8dc1484..6043a58 100644 --- a/TODO.md +++ b/TODO.md @@ -19,6 +19,8 @@ nameserver IP address changes: https://git.eeqj.de/sneak/dnswatcher/issues/105 # Completed Steps +- 2026-10-01: `DNSWATCHER_SENTRY_DSN` reports panics in HTTP handlers to Sentry, + and a DSN Sentry cannot parse stops startup (closes #107). - 2026-10-01: a port or TLS check that shutdown cuts short saves nothing and sends no notification, as a cut-short DNS lookup already did (closes #185). - 2026-10-01: the client address from `X-Forwarded-For` is the last entry that @@ -101,8 +103,6 @@ nameserver IP address changes: https://git.eeqj.de/sneak/dnswatcher/issues/105 # Future Steps -- `DNSWATCHER_SENTRY_DSN` does nothing: - https://git.eeqj.de/sneak/dnswatcher/issues/107 - trial run of the finished image: https://git.eeqj.de/sneak/dnswatcher/issues/149 - 1.0 readiness: run it with a real config and read the logs: diff --git a/go.mod b/go.mod index a9012dd..da8e0b7 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,7 @@ go 1.25.5 require ( github.com/99designs/basicauth-go v0.0.0-20230316000542-bf6f9cbbf0f8 + github.com/getsentry/sentry-go v0.49.0 github.com/go-chi/chi/v5 v5.2.5 github.com/go-chi/cors v1.2.2 github.com/go-chi/httprate v0.16.0 @@ -13,20 +14,20 @@ require ( github.com/spf13/viper v1.21.0 github.com/stretchr/testify v1.11.1 go.uber.org/fx v1.24.0 - golang.org/x/net v0.50.0 - golang.org/x/sync v0.19.0 + golang.org/x/net v0.56.0 + golang.org/x/sync v0.21.0 ) require ( github.com/beorn7/perks v1.0.1 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect - github.com/davecgh/go-spew v1.1.1 // indirect + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/go-viper/mapstructure/v2 v2.4.0 // indirect github.com/klauspost/cpuid/v2 v2.2.10 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/pelletier/go-toml/v2 v2.2.4 // indirect - github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/prometheus/client_model v0.6.2 // indirect github.com/prometheus/common v0.66.1 // indirect github.com/prometheus/procfs v0.16.1 // indirect @@ -42,10 +43,10 @@ require ( go.uber.org/zap v1.26.0 // indirect go.yaml.in/yaml/v2 v2.4.2 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect - golang.org/x/mod v0.32.0 // indirect - golang.org/x/sys v0.41.0 // indirect - golang.org/x/text v0.34.0 // indirect - golang.org/x/tools v0.41.0 // indirect + golang.org/x/mod v0.37.0 // indirect + golang.org/x/sys v0.46.0 // indirect + golang.org/x/text v0.39.0 // indirect + golang.org/x/tools v0.47.0 // indirect google.golang.org/protobuf v1.36.8 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 1570cf1..eb081e2 100644 --- a/go.sum +++ b/go.sum @@ -4,18 +4,22 @@ github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= -github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= -github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/getsentry/sentry-go v0.49.0 h1:Ehejknu1l023Ub7QoRBVLAI7g3Jnhqku4oWx4B4Sh5s= +github.com/getsentry/sentry-go v0.49.0/go.mod h1:nuMJAoCfe1u0Bts2ocyNI+TW8HT84vRMqwA5Qq/SKUI= github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug= github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0= github.com/go-chi/cors v1.2.2 h1:Jmey33TE+b+rB7fT8MUy1u0I4L+NARQlK6LhzKPSyQE= github.com/go-chi/cors v1.2.2/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58= github.com/go-chi/httprate v0.16.0 h1:8V5DH9j6pSK6UQoBsTpvMyFxycqaKEIToyPKzHJjUa8= github.com/go-chi/httprate v0.16.0/go.mod h1:A8lo+qRhk+s9LiuP5saS7XCGDXRXMcrueq0NfIuCa/I= +github.com/go-errors/errors v1.4.2 h1:J6MZopCL4uSllY1OfXM374weqZFFItUbrImctkmUxIA= +github.com/go-errors/errors v1.4.2/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og= github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= @@ -38,8 +42,12 @@ github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= -github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= -github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pingcap/errors v0.11.4 h1:lFuQV/oaUMGcD2tqt+01ROSmJs75VG1ToEOkZIZ4nE4= +github.com/pingcap/errors v0.11.4/go.mod h1:Oi8TUi2kEtXXLMJk9l1cGmz20kV3TaQ0usTwv5KuLY8= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o= github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= @@ -48,8 +56,8 @@ github.com/prometheus/common v0.66.1 h1:h5E0h5/Y8niHc5DlaLlWLArTQI7tMrsfQjHV+d9Z github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA= github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg= github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is= -github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= -github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc= github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik= github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw= @@ -84,18 +92,18 @@ go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI= go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= -golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c= -golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU= -golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60= -golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM= -golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= -golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= -golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= -golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= -golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= -golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= -golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc= -golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg= +golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ= +golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0= +golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o= +golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec= +golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= +golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= +golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.39.0 h1:UbZz4pLOvn600D6Oh6GGEI6VAmndrEBLv8/6BEXzyus= +golang.org/x/text v0.39.0/go.mod h1:3UwRclnC2g0TU9x8PZiyfOajCd1zaUNHF9cvqcQZ+ZM= +golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q= +golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA= google.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc= google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/internal/server/export_test.go b/internal/server/export_test.go index 5a1bec7..acd4d8c 100644 --- a/internal/server/export_test.go +++ b/internal/server/export_test.go @@ -3,6 +3,8 @@ package server import ( "net/http" "time" + + "github.com/go-chi/chi/v5" ) // RequestTimeout exports the handler execution budget applied by @@ -22,3 +24,15 @@ func SetListenPort(s *Server, port int) { func HTTPServerOf(s *Server) *http.Server { return s.httpServer } + +// EnableSentry runs the Sentry setup that the start hook runs, without +// starting the HTTP server. +func EnableSentry(s *Server) error { + return s.enableSentry() +} + +// RouterOf returns the router SetupRoutes built, so a test can add a +// route that panics. +func RouterOf(s *Server) *chi.Mux { + return s.router +} diff --git a/internal/server/routes.go b/internal/server/routes.go index 0a8e8ed..805ff21 100644 --- a/internal/server/routes.go +++ b/internal/server/routes.go @@ -4,6 +4,7 @@ import ( "net/http" "time" + sentryhttp "github.com/getsentry/sentry-go/http" "github.com/go-chi/chi/v5" chimw "github.com/go-chi/chi/v5/middleware" "github.com/prometheus/client_golang/prometheus/promhttp" @@ -25,6 +26,16 @@ func (s *Server) SetupRoutes() { s.router.Use(s.mw.Logging()) s.router.Use(chimw.Timeout(requestTimeout)) + // Report panics in handlers to Sentry when DNSWATCHER_SENTRY_DSN is + // set. Repanic passes each panic on to chimw.Recoverer above, which + // still answers the request. + if s.sentryEnabled { + sentryHandler := sentryhttp.New(sentryhttp.Options{ + Repanic: true, + }) + s.router.Use(sentryHandler.Handle) + } + // Public, unauthenticated, read-only routes, the only ones // REPO_POLICIES.md allows wildcard CORS on. CORS is middleware of // this whole router, not of a Group, so that it also answers diff --git a/internal/server/sentry_test.go b/internal/server/sentry_test.go new file mode 100644 index 0000000..1525d3f --- /dev/null +++ b/internal/server/sentry_test.go @@ -0,0 +1,190 @@ +package server_test + +import ( + "io" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "sync" + "testing" + "time" + + "github.com/getsentry/sentry-go" + "github.com/spf13/viper" + "go.uber.org/fx" + + "sneak.berlin/go/dnswatcher/internal/server" +) + +// The tests below set env vars and touch the global state of viper and +// of Sentry, so they cannot use t.Parallel. + +// standInDelay is how long the Sentry stand-in takes to answer. It +// records a report only then, so a report that Shutdown did not wait +// for has not been recorded yet when Shutdown returns. +const standInDelay = 100 * time.Millisecond + +// sentryStandIn is a local HTTP server in place of Sentry's, so that +// nothing a test reports leaves the host. It keeps the body of every +// request it receives. +type sentryStandIn struct { + server *httptest.Server + mu sync.Mutex + bodies []string +} + +func newSentryStandIn(t *testing.T) *sentryStandIn { + t.Helper() + + standIn := &sentryStandIn{} + standIn.server = httptest.NewServer(http.HandlerFunc( + func(_ http.ResponseWriter, r *http.Request) { + body, err := io.ReadAll(r.Body) + if err != nil { + t.Errorf("reading request to the Sentry stand-in: %v", err) + } + + time.Sleep(standInDelay) + + standIn.mu.Lock() + defer standIn.mu.Unlock() + + standIn.bodies = append(standIn.bodies, string(body)) + }, + )) + t.Cleanup(standIn.server.Close) + + return standIn +} + +// dsn returns a DSN that points Sentry at the stand-in. +func (s *sentryStandIn) dsn(t *testing.T) string { + t.Helper() + + dsn, err := url.Parse(s.server.URL) + if err != nil { + t.Fatalf("parsing the stand-in URL: %v", err) + } + + dsn.User = url.User("public-key") + dsn.Path = "/1" + + return dsn.String() +} + +// received reports whether a request to the stand-in contained text. +func (s *sentryStandIn) received(text string) bool { + s.mu.Lock() + defer s.mu.Unlock() + + for _, body := range s.bodies { + if strings.Contains(body, text) { + return true + } + } + + return false +} + +func TestSentryUnsetDoesNothing(t *testing.T) { + viper.Reset() + t.Setenv("DNSWATCHER_TARGETS", "example.com") + t.Setenv("DNSWATCHER_SENTRY_DSN", "") + + srv := buildServer(t) + + err := server.EnableSentry(srv) + if err != nil { + t.Fatalf("Sentry setup with no DSN: %v", err) + } + + if sentry.CurrentHub().Client() != nil { + t.Error("Sentry was set up with no DSN configured") + } +} + +// TestSentryReportsHandlerPanic checks that with a valid DSN a panic in +// a handler is reported to Sentry, still reaches chimw.Recoverer, and +// has been sent by the time Shutdown returns, and that nothing else is +// sent to Sentry. +func TestSentryReportsHandlerPanic(t *testing.T) { + standIn := newSentryStandIn(t) + + viper.Reset() + t.Setenv("DNSWATCHER_TARGETS", "example.com") + t.Setenv("DNSWATCHER_SENTRY_DSN", standIn.dsn(t)) + + srv := buildServer(t) + + err := server.EnableSentry(srv) + if err != nil { + t.Fatalf("Sentry setup with a valid DSN: %v", err) + } + + // Sentry's client is global: close it so later tests find none. + t.Cleanup(func() { + sentry.CurrentHub().Client().Close() + sentry.CurrentHub().BindClient(nil) + }) + + const panicMessage = "handler panic in the Sentry test" + + srv.SetupRoutes() + server.RouterOf(srv).Get( + "/panic", + func(http.ResponseWriter, *http.Request) { + panic(panicMessage) + }, + ) + + // An ordinary request first: with client reports on, Sentry would + // add a count of its dropped transaction to the panic report. + serve(srv, httptest.NewRequestWithContext( + t.Context(), http.MethodGet, "/.well-known/healthcheck", nil, + )) + + rec := serve(srv, httptest.NewRequestWithContext( + t.Context(), http.MethodGet, "/panic", nil, + )) + if rec.Code != http.StatusInternalServerError { + t.Errorf( + "status %d, want %d from the recoverer", + rec.Code, http.StatusInternalServerError, + ) + } + + err = srv.Shutdown(t.Context()) + if err != nil { + t.Fatalf("Shutdown: %v", err) + } + + if !standIn.received(panicMessage) { + t.Error("the panic had not been sent to Sentry when Shutdown returned") + } + + if standIn.received("client_report") { + t.Error("Sentry was sent a client report, not only the panic") + } +} + +func TestSentryInvalidDSNStopsStartup(t *testing.T) { + viper.Reset() + t.Setenv("DNSWATCHER_TARGETS", "example.com") + t.Setenv("DNSWATCHER_DATA_DIR", t.TempDir()) + // Sentry cannot parse this: it has no public key before the host. + t.Setenv("DNSWATCHER_SENTRY_DSN", "https://sentry.test/1") + + app := newServerApp(fx.Invoke(func(*server.Server) {})) + + err := app.Start(t.Context()) + if err == nil { + _ = app.Stop(t.Context()) + + t.Fatal("startup succeeded with an invalid DSN") + } + + if !strings.Contains(err.Error(), "invalid DNSWATCHER_SENTRY_DSN") { + t.Errorf("startup error does not name the setting: %v", err) + } +} diff --git a/internal/server/server.go b/internal/server/server.go index f9385eb..e18a4d3 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -9,6 +9,7 @@ import ( "net/http" "time" + "github.com/getsentry/sentry-go" "github.com/go-chi/chi/v5" "go.uber.org/fx" @@ -33,6 +34,10 @@ type Params struct { // shutdownTimeout is how long to wait for graceful shutdown. const shutdownTimeout = 30 * time.Second +// sentryFlushTimeout is how long shutdown waits for Sentry to send the +// error reports it still holds. +const sentryFlushTimeout = 2 * time.Second + // Socket-level timeouts for the HTTP server. // // These bound time spent on the connection itself and are a distinct @@ -82,14 +87,15 @@ const ( // Server is the HTTP server. type Server struct { - startupTime time.Time - port int - log *slog.Logger - router *chi.Mux - httpServer *http.Server - params Params - mw *middleware.Middleware - handlers *handlers.Handlers + startupTime time.Time + port int + sentryEnabled bool + log *slog.Logger + router *chi.Mux + httpServer *http.Server + params Params + mw *middleware.Middleware + handlers *handlers.Handlers } // New creates a new Server instance. @@ -108,6 +114,12 @@ func New( lifecycle.Append(fx.Hook{ OnStart: func(_ context.Context) error { srv.startupTime = time.Now() + + err := srv.enableSentry() + if err != nil { + return err + } + go srv.Run() return nil @@ -152,8 +164,11 @@ func (s *Server) Run() { } } -// Shutdown gracefully shuts down the server. +// Shutdown gracefully shuts down the server, then sends the error +// reports Sentry still holds. func (s *Server) Shutdown(ctx context.Context) error { + defer s.flushSentry() + if s.httpServer == nil { return nil } @@ -184,3 +199,45 @@ func (s *Server) ServeHTTP( ) { s.router.ServeHTTP(writer, request) } + +// enableSentry turns on Sentry error reporting when +// DNSWATCHER_SENTRY_DSN is set, and does nothing when it is not. A DSN +// that Sentry cannot parse is an error, so that startup stops instead +// of running without the error reporting the operator asked for. +func (s *Server) enableSentry() error { + if s.params.Config.SentryDSN == "" { + return nil + } + + err := sentry.Init(sentry.ClientOptions{ + Dsn: s.params.Config.SentryDSN, + Release: s.params.Globals.Appname + "-" + s.params.Globals.Version, + // Use the transport that queues each report as it is made. With + // the default one, Flush can return before sending a report made + // just before it, such as one from the last request at shutdown. + DisableTelemetryBuffer: true, + // Send panic reports only, not Sentry's counts of what it dropped, + // such as the transaction it starts for every request. + DisableClientReports: true, + }) + if err != nil { + return fmt.Errorf("invalid DNSWATCHER_SENTRY_DSN: %w", err) + } + + s.log.Info("sentry error reporting activated") + s.sentryEnabled = true + + return nil +} + +// flushSentry sends the error reports Sentry still holds, waiting at +// most sentryFlushTimeout. +func (s *Server) flushSentry() { + if !s.sentryEnabled { + return + } + + if !sentry.Flush(sentryFlushTimeout) { + s.log.Warn("sentry flush timed out; some error reports were not sent") + } +} diff --git a/internal/server/server_test.go b/internal/server/server_test.go index 6cd036c..8aa5f2d 100644 --- a/internal/server/server_test.go +++ b/internal/server/server_test.go @@ -17,18 +17,13 @@ import ( "sneak.berlin/go/dnswatcher/internal/state" ) -// buildServer wires a *server.Server exactly as cmd/dnswatcher does, -// minus the watcher/resolver subtree that would touch live DNS. fx -// builds the object graph but the lifecycle is never started, so no -// OnStart hook runs and nothing listens or resolves. The caller must -// first configure viper (config.New reads it), which is also why the -// caller cannot run in parallel. -func buildServer(t *testing.T) *server.Server { - t.Helper() - - var srv *server.Server - - app := fx.New( +// newServerApp builds an fx app holding a *server.Server wired exactly +// as cmd/dnswatcher wires it, minus the watcher/resolver subtree that +// would touch live DNS, plus the given option. config.New reads viper, +// so the caller must first configure it, which is also why the caller +// cannot run in parallel. +func newServerApp(option fx.Option) *fx.App { + return fx.New( fx.NopLogger, fx.Provide( globals.New, @@ -41,8 +36,18 @@ func buildServer(t *testing.T) *server.Server { handlers.New, server.New, ), - fx.Populate(&srv), + option, ) +} + +// buildServer builds the server without starting the app's lifecycle, +// so no OnStart hook runs and nothing listens or resolves. +func buildServer(t *testing.T) *server.Server { + t.Helper() + + var srv *server.Server + + app := newServerApp(fx.Populate(&srv)) err := app.Err() if err != nil {