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:
clawbot
2026-09-26 21:38:57 +00:00
parent e336f46e34
commit f8ce8cef83
79 changed files with 7131 additions and 0 deletions
+162
View File
@@ -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)
})
}
+152
View File
@@ -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"))
}
+112
View File
@@ -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)
}
}
+43
View File
@@ -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
}