Seed from go-template-repo, renamed to simplexcalc
The template's files at a77fd30, without its history or LICENSE, after script/rename simplexcalc. Model: opus-5-5
This commit is contained in:
@@ -0,0 +1,162 @@
|
||||
package telemetry
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"crypto/subtle"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"github.com/prometheus/client_golang/prometheus/collectors"
|
||||
"github.com/prometheus/client_golang/prometheus/promhttp"
|
||||
"go.uber.org/fx"
|
||||
"sneak.berlin/go/simplexcalc/internal/config"
|
||||
)
|
||||
|
||||
// durationBuckets span a fast in-process handler through a slow
|
||||
// upstream call. Prometheus's defaults top out at 10s, which hides the
|
||||
// tail this service's request timeout permits.
|
||||
//
|
||||
//nolint:gochecknoglobals // a bucket list is a declaration, not mutable state.
|
||||
var durationBuckets = []float64{
|
||||
0.001, 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10, 30,
|
||||
}
|
||||
|
||||
// MetricsParams defines dependencies for Metrics.
|
||||
type MetricsParams struct {
|
||||
fx.In
|
||||
|
||||
Config *config.Config
|
||||
}
|
||||
|
||||
// Metrics owns the registry and the HTTP series. A private registry,
|
||||
// not the global default: what this process exports is then exactly
|
||||
// what this code registered, and a linked library cannot quietly add to
|
||||
// it.
|
||||
type Metrics struct {
|
||||
registry *prometheus.Registry
|
||||
|
||||
requests *prometheus.CounterVec
|
||||
duration *prometheus.HistogramVec
|
||||
inflight prometheus.Gauge
|
||||
|
||||
user string
|
||||
password string
|
||||
}
|
||||
|
||||
// NewMetrics builds the registry and registers the collectors.
|
||||
func NewMetrics(params MetricsParams) (*Metrics, error) {
|
||||
m := &Metrics{
|
||||
registry: prometheus.NewRegistry(),
|
||||
user: params.Config.MetricsUser,
|
||||
password: params.Config.MetricsPassword,
|
||||
}
|
||||
|
||||
m.requests = prometheus.NewCounterVec(
|
||||
prometheus.CounterOpts{
|
||||
Name: "http_requests_total",
|
||||
Help: "Total HTTP requests by method, route pattern and status code.",
|
||||
},
|
||||
// The route PATTERN, never the path: labelling by path turns
|
||||
// every distinct URL into a new time series, and a crawler
|
||||
// then owns the memory of the process.
|
||||
[]string{"method", "route", "code"},
|
||||
)
|
||||
|
||||
m.duration = prometheus.NewHistogramVec(
|
||||
prometheus.HistogramOpts{
|
||||
Name: "http_request_duration_seconds",
|
||||
Help: "HTTP request duration by method and route pattern.",
|
||||
Buckets: durationBuckets,
|
||||
},
|
||||
[]string{"method", "route"},
|
||||
)
|
||||
|
||||
m.inflight = prometheus.NewGauge(prometheus.GaugeOpts{
|
||||
Name: "http_requests_in_flight",
|
||||
Help: "HTTP requests currently being served.",
|
||||
})
|
||||
|
||||
m.registry.MustRegister(
|
||||
m.requests,
|
||||
m.duration,
|
||||
m.inflight,
|
||||
collectors.NewGoCollector(),
|
||||
collectors.NewProcessCollector(collectors.ProcessCollectorOpts{}),
|
||||
)
|
||||
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// Observe records one finished request.
|
||||
func (m *Metrics) Observe(method, route, code string, d time.Duration) {
|
||||
m.requests.WithLabelValues(method, route, code).Inc()
|
||||
m.duration.WithLabelValues(method, route).Observe(d.Seconds())
|
||||
}
|
||||
|
||||
// InFlightAdd adjusts the in-flight gauge.
|
||||
func (m *Metrics) InFlightAdd(delta float64) {
|
||||
m.inflight.Add(delta)
|
||||
}
|
||||
|
||||
// Registry exposes the registry so tests can gather what was recorded.
|
||||
func (m *Metrics) Registry() *prometheus.Registry {
|
||||
return m.registry
|
||||
}
|
||||
|
||||
// AuthRequired reports whether /metrics is credential-gated.
|
||||
func (m *Metrics) AuthRequired() bool {
|
||||
return m.user != "" && m.password != ""
|
||||
}
|
||||
|
||||
// Handler serves the exposition format, behind HTTP basic auth when
|
||||
// credentials are configured.
|
||||
//
|
||||
// Metrics are not public: they leak route names, traffic volume,
|
||||
// version and process memory layout. When no credentials are set the
|
||||
// endpoint is served open, which is correct for a private network and
|
||||
// documented as such in the README; config refuses the half-configured
|
||||
// case, so "open" is always something the operator chose rather than
|
||||
// something a typo produced.
|
||||
func (m *Metrics) Handler() http.Handler {
|
||||
h := promhttp.HandlerFor(m.registry, promhttp.HandlerOpts{
|
||||
// A collector that errors should not take the scrape down
|
||||
// with a 500 the operator has to go and interpret.
|
||||
ErrorHandling: promhttp.ContinueOnError,
|
||||
})
|
||||
|
||||
if !m.AuthRequired() {
|
||||
return h
|
||||
}
|
||||
|
||||
return m.basicAuth(h)
|
||||
}
|
||||
|
||||
// basicAuth gates h. Comparison is over SHA-256 digests through
|
||||
// subtle.ConstantTimeCompare: comparing the raw strings would leak the
|
||||
// credential length and the position of the first wrong byte through
|
||||
// timing, and hashing first makes the comparison fixed-width.
|
||||
func (m *Metrics) basicAuth(h http.Handler) http.Handler {
|
||||
wantUser := sha256.Sum256([]byte(m.user))
|
||||
wantPass := sha256.Sum256([]byte(m.password))
|
||||
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
user, pass, ok := r.BasicAuth()
|
||||
if ok {
|
||||
gotUser := sha256.Sum256([]byte(user))
|
||||
gotPass := sha256.Sum256([]byte(pass))
|
||||
|
||||
userOK := subtle.ConstantTimeCompare(gotUser[:], wantUser[:]) == 1
|
||||
passOK := subtle.ConstantTimeCompare(gotPass[:], wantPass[:]) == 1
|
||||
|
||||
if userOK && passOK {
|
||||
h.ServeHTTP(w, r)
|
||||
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
w.Header().Set("WWW-Authenticate", `Basic realm="metrics", charset="UTF-8"`)
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
package telemetry_test
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"sneak.berlin/go/simplexcalc/internal/config"
|
||||
"sneak.berlin/go/simplexcalc/internal/telemetry"
|
||||
)
|
||||
|
||||
func newMetrics(t *testing.T, user, password string) *telemetry.Metrics {
|
||||
t.Helper()
|
||||
|
||||
m, err := telemetry.NewMetrics(telemetry.MetricsParams{
|
||||
Config: &config.Config{MetricsUser: user, MetricsPassword: password},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("building metrics: %v", err)
|
||||
}
|
||||
|
||||
return m
|
||||
}
|
||||
|
||||
// TestMetricsRequireCredentialsWhenConfigured: an exposition endpoint
|
||||
// leaks route names, traffic volume and process layout, so credentials
|
||||
// have to actually be enforced.
|
||||
func TestMetricsRequireCredentialsWhenConfigured(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
m := newMetrics(t, user, pass)
|
||||
|
||||
if !m.AuthRequired() {
|
||||
t.Fatal("credentials are configured but AuthRequired is false")
|
||||
}
|
||||
|
||||
const (
|
||||
unauthorized = http.StatusUnauthorized
|
||||
ok = http.StatusOK
|
||||
)
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
user, pass string
|
||||
useAuth bool
|
||||
want int
|
||||
}{
|
||||
{name: "no credentials", want: unauthorized},
|
||||
{name: "wrong password", user: user, pass: "no", useAuth: true, want: unauthorized},
|
||||
{name: "wrong user", user: "nobody", pass: pass, useAuth: true, want: unauthorized},
|
||||
{name: "correct", user: user, pass: pass, useAuth: true, want: ok},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
req := scrapeReq(t)
|
||||
if tc.useAuth {
|
||||
req.SetBasicAuth(tc.user, tc.pass)
|
||||
}
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
m.Handler().ServeHTTP(w, req)
|
||||
|
||||
if w.Code != tc.want {
|
||||
t.Errorf("status = %d, want %d", w.Code, tc.want)
|
||||
}
|
||||
|
||||
if tc.want == http.StatusUnauthorized {
|
||||
if w.Header().Get("WWW-Authenticate") == "" {
|
||||
t.Error("a 401 with no WWW-Authenticate gives the client nothing to do")
|
||||
}
|
||||
|
||||
if strings.Contains(w.Body.String(), "http_requests_total") {
|
||||
t.Error("metrics were served in the body of a 401")
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestMetricsOpenWhenNoCredentials documents the other half: with
|
||||
// nothing configured the endpoint is open, which config permits only
|
||||
// when BOTH values are absent.
|
||||
func TestMetricsOpenWhenNoCredentials(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
m := newMetrics(t, "", "")
|
||||
|
||||
if m.AuthRequired() {
|
||||
t.Fatal("no credentials configured but AuthRequired is true")
|
||||
}
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
m.Handler().ServeHTTP(w, scrapeReq(t))
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("status = %d, want 200", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestObservedRequestsAreExported: the middleware records through
|
||||
// Observe, and what it records has to come back out of the endpoint.
|
||||
func TestObservedRequestsAreExported(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
m := newMetrics(t, "", "")
|
||||
|
||||
m.Observe(http.MethodGet, "/widgets/{id}", "200", 25*time.Millisecond)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
m.Handler().ServeHTTP(w, scrapeReq(t))
|
||||
|
||||
body := w.Body.String()
|
||||
|
||||
for _, want := range []string{
|
||||
`http_requests_total{code="200",method="GET",route="/widgets/{id}"} 1`,
|
||||
"http_request_duration_seconds_bucket",
|
||||
"go_goroutines", // the Go collector is registered
|
||||
} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Errorf("exposition output is missing %q", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestSentryDisabledWithoutDSN: every method must be safe with no DSN,
|
||||
// because that is how the service runs in development and in tests.
|
||||
func TestSentryDisabledWithoutDSN(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s, err := telemetry.NewSentry(nil, telemetry.SentryParams{
|
||||
Config: &config.Config{},
|
||||
Globals: testGlobals(),
|
||||
Logger: testLogger(t),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("building sentry: %v", err)
|
||||
}
|
||||
|
||||
if s.Enabled() {
|
||||
t.Error("sentry reports enabled with no DSN")
|
||||
}
|
||||
|
||||
// Must not panic.
|
||||
s.CaptureError(nil)
|
||||
s.CaptureError(errTest)
|
||||
s.CapturePanic("boom", []byte("stack"))
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
// Package telemetry owns error reporting (Sentry) and metrics
|
||||
// (Prometheus). Both are optional at runtime and neither is allowed to
|
||||
// take the process down: a monitoring backend that is unreachable must
|
||||
// not stop the service it monitors.
|
||||
package telemetry
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"github.com/getsentry/sentry-go"
|
||||
"go.uber.org/fx"
|
||||
"sneak.berlin/go/simplexcalc/internal/config"
|
||||
"sneak.berlin/go/simplexcalc/internal/globals"
|
||||
"sneak.berlin/go/simplexcalc/internal/logger"
|
||||
)
|
||||
|
||||
// flushTimeout bounds how long shutdown waits for queued events to
|
||||
// reach Sentry. Exceeding it drops the tail rather than hanging the
|
||||
// stop sequence.
|
||||
const flushTimeout = 2 * time.Second
|
||||
|
||||
// SentryParams defines dependencies for Sentry.
|
||||
type SentryParams struct {
|
||||
fx.In
|
||||
|
||||
Config *config.Config
|
||||
Globals *globals.Globals
|
||||
Logger *logger.Logger
|
||||
}
|
||||
|
||||
// Sentry wraps the client. When SENTRY_DSN is unset the wrapper still
|
||||
// exists and every method is a no-op, so no caller needs a nil check
|
||||
// and no caller behaves differently in development.
|
||||
type Sentry struct {
|
||||
enabled bool
|
||||
log *slog.Logger
|
||||
}
|
||||
|
||||
// NewSentry initialises the client if a DSN is configured. A DSN that
|
||||
// is present but malformed has already failed config parsing; a DSN the
|
||||
// client itself rejects is logged and reporting stays off, because a
|
||||
// telemetry backend is not a reason to refuse to serve.
|
||||
func NewSentry(lc fx.Lifecycle, params SentryParams) (*Sentry, error) {
|
||||
s := &Sentry{log: params.Logger.Get()}
|
||||
|
||||
if params.Config.SentryDSN == "" {
|
||||
s.log.Info("sentry disabled", "reason", "no DSN configured")
|
||||
|
||||
return s, nil
|
||||
}
|
||||
|
||||
err := sentry.Init(sentry.ClientOptions{
|
||||
Dsn: params.Config.SentryDSN,
|
||||
Environment: params.Config.SentryEnvironment,
|
||||
Release: params.Globals.Appname + "@" + params.Globals.Version,
|
||||
// Panics are reported explicitly by the recovery middleware,
|
||||
// which also has to answer the request; letting the SDK
|
||||
// re-raise them would take the process down.
|
||||
Debug: params.Config.Debug,
|
||||
})
|
||||
if err != nil {
|
||||
s.log.Error("sentry init failed; error reporting is off", "error", err)
|
||||
|
||||
return s, nil
|
||||
}
|
||||
|
||||
s.enabled = true
|
||||
|
||||
s.log.Info("sentry enabled", "environment", params.Config.SentryEnvironment)
|
||||
|
||||
lc.Append(fx.Hook{
|
||||
OnStop: func(_ context.Context) error {
|
||||
sentry.Flush(flushTimeout)
|
||||
|
||||
return nil
|
||||
},
|
||||
})
|
||||
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// Enabled reports whether events are actually being sent.
|
||||
func (s *Sentry) Enabled() bool {
|
||||
return s.enabled
|
||||
}
|
||||
|
||||
// CaptureError reports an error, and always logs it. Logging is not
|
||||
// conditional on Sentry being on: the log is the record of record, and
|
||||
// Sentry is a convenience on top of it.
|
||||
func (s *Sentry) CaptureError(err error) {
|
||||
if err == nil {
|
||||
return
|
||||
}
|
||||
|
||||
s.log.Error("captured error", "error", err)
|
||||
|
||||
if s.enabled {
|
||||
sentry.CaptureException(err)
|
||||
}
|
||||
}
|
||||
|
||||
// CapturePanic reports a recovered panic value with its stack.
|
||||
func (s *Sentry) CapturePanic(v any, stack []byte) {
|
||||
s.log.Error("recovered panic", "panic", fmt.Sprint(v), "stack", string(stack))
|
||||
|
||||
if s.enabled {
|
||||
sentry.CurrentHub().Recover(v)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package telemetry_test
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"sneak.berlin/go/simplexcalc/internal/globals"
|
||||
"sneak.berlin/go/simplexcalc/internal/logger"
|
||||
)
|
||||
|
||||
// scrapeReq is a request to /metrics carrying the test's context.
|
||||
func scrapeReq(t *testing.T) *http.Request {
|
||||
t.Helper()
|
||||
|
||||
return httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/metrics", nil)
|
||||
}
|
||||
|
||||
// errTest is a stand-in error for the capture paths.
|
||||
var errTest = errors.New("test error")
|
||||
|
||||
// The metrics credentials used across these tests.
|
||||
const (
|
||||
user = "scraper"
|
||||
pass = "hunter2"
|
||||
)
|
||||
|
||||
func testGlobals() *globals.Globals {
|
||||
return &globals.Globals{Appname: "simplexcalc", Version: "test", Buildarch: "amd64"}
|
||||
}
|
||||
|
||||
func testLogger(t *testing.T) *logger.Logger {
|
||||
t.Helper()
|
||||
|
||||
log, err := logger.New(nil, logger.Params{Globals: testGlobals(), Output: io.Discard})
|
||||
if err != nil {
|
||||
t.Fatalf("building logger: %v", err)
|
||||
}
|
||||
|
||||
return log
|
||||
}
|
||||
Reference in New Issue
Block a user