241 lines
6.4 KiB
Go
241 lines
6.4 KiB
Go
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
|
|||
|
|
// SPDX-License-Identifier: MIT
|
||
|
|
|
||
|
|
//go:build linux || freebsd
|
||
|
|
|
||
|
|
package handler
|
||
|
|
|
||
|
|
import (
|
||
|
|
"crypto/subtle"
|
||
|
|
"encoding/json"
|
||
|
|
"log/slog"
|
||
|
|
"net/http"
|
||
|
|
"strings"
|
||
|
|
"sync"
|
||
|
|
|
||
|
|
"sourcedock.dev/petrbalvin/nuntius/internal/config"
|
||
|
|
)
|
||
|
|
|
||
|
|
// Canonical metric names. The same identifiers serve as counter keys in
|
||
|
|
// bump() and as JSON field names in GET /metrics responses.
|
||
|
|
const (
|
||
|
|
metricReceived = "received"
|
||
|
|
metricHoneypotBlocked = "honeypot_blocked"
|
||
|
|
metricRateLimited = "rate_limited"
|
||
|
|
metricOriginBlocked = "origin_blocked"
|
||
|
|
metricBodyTooLarge = "body_too_large"
|
||
|
|
metricInvalidBody = "invalid_body"
|
||
|
|
metricValidationFailed = "validation_failed"
|
||
|
|
metricSendFailed = "send_failed"
|
||
|
|
metricPersistFailed = "persist_failed"
|
||
|
|
metricDuplicateSignup = "duplicate_signup"
|
||
|
|
metricConfirmationSent = "confirmation_sent"
|
||
|
|
metricConfirmed = "confirmed"
|
||
|
|
metricConfirmFailed = "confirmation_failed"
|
||
|
|
metricSent = "sent"
|
||
|
|
metricAutoReplyFailed = "auto_reply_failed"
|
||
|
|
metricTelegramFailed = "telegram_failed"
|
||
|
|
)
|
||
|
|
|
||
|
|
// metricNames lists every counter in stable order so totals and snapshots
|
||
|
|
// cannot drift from the struct fields.
|
||
|
|
var metricNames = []string{
|
||
|
|
metricReceived,
|
||
|
|
metricHoneypotBlocked,
|
||
|
|
metricRateLimited,
|
||
|
|
metricOriginBlocked,
|
||
|
|
metricBodyTooLarge,
|
||
|
|
metricInvalidBody,
|
||
|
|
metricValidationFailed,
|
||
|
|
metricSendFailed,
|
||
|
|
metricPersistFailed,
|
||
|
|
metricDuplicateSignup,
|
||
|
|
metricConfirmationSent,
|
||
|
|
metricConfirmed,
|
||
|
|
metricConfirmFailed,
|
||
|
|
metricSent,
|
||
|
|
metricAutoReplyFailed,
|
||
|
|
metricTelegramFailed,
|
||
|
|
}
|
||
|
|
|
||
|
|
// FormStats holds lifetime counters for one form. Every field counts
|
||
|
|
// outcomes of requests routed to that form's endpoints.
|
||
|
|
type FormStats struct {
|
||
|
|
Received int64 `json:"received"`
|
||
|
|
HoneypotBlocked int64 `json:"honeypot_blocked"`
|
||
|
|
RateLimited int64 `json:"rate_limited"`
|
||
|
|
OriginBlocked int64 `json:"origin_blocked"`
|
||
|
|
BodyTooLarge int64 `json:"body_too_large"`
|
||
|
|
InvalidBody int64 `json:"invalid_body"`
|
||
|
|
ValidationFailed int64 `json:"validation_failed"`
|
||
|
|
SendFailed int64 `json:"send_failed"`
|
||
|
|
PersistFailed int64 `json:"persist_failed"`
|
||
|
|
DuplicateSignup int64 `json:"duplicate_signup"`
|
||
|
|
ConfirmationSent int64 `json:"confirmation_sent"`
|
||
|
|
Confirmed int64 `json:"confirmed"`
|
||
|
|
ConfirmFailed int64 `json:"confirmation_failed"`
|
||
|
|
Sent int64 `json:"sent"`
|
||
|
|
AutoReplyFailed int64 `json:"auto_reply_failed"`
|
||
|
|
TelegramFailed int64 `json:"telegram_failed"`
|
||
|
|
}
|
||
|
|
|
||
|
|
// incByIndex increments the counter at metricNames[i]; indexes outside the
|
||
|
|
// known set are ignored.
|
||
|
|
func (f *FormStats) incByIndex(i int) {
|
||
|
|
switch i {
|
||
|
|
case 0:
|
||
|
|
f.Received++
|
||
|
|
case 1:
|
||
|
|
f.HoneypotBlocked++
|
||
|
|
case 2:
|
||
|
|
f.RateLimited++
|
||
|
|
case 3:
|
||
|
|
f.OriginBlocked++
|
||
|
|
case 4:
|
||
|
|
f.BodyTooLarge++
|
||
|
|
case 5:
|
||
|
|
f.InvalidBody++
|
||
|
|
case 6:
|
||
|
|
f.ValidationFailed++
|
||
|
|
case 7:
|
||
|
|
f.SendFailed++
|
||
|
|
case 8:
|
||
|
|
f.PersistFailed++
|
||
|
|
case 9:
|
||
|
|
f.DuplicateSignup++
|
||
|
|
case 10:
|
||
|
|
f.ConfirmationSent++
|
||
|
|
case 11:
|
||
|
|
f.Confirmed++
|
||
|
|
case 12:
|
||
|
|
f.ConfirmFailed++
|
||
|
|
case 13:
|
||
|
|
f.Sent++
|
||
|
|
case 14:
|
||
|
|
f.AutoReplyFailed++
|
||
|
|
case 15:
|
||
|
|
f.TelegramFailed++
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// add sums another snapshot into f.
|
||
|
|
func (f *FormStats) add(other FormStats) {
|
||
|
|
for i := range metricNames {
|
||
|
|
switch i {
|
||
|
|
case 0:
|
||
|
|
f.Received += other.Received
|
||
|
|
case 1:
|
||
|
|
f.HoneypotBlocked += other.HoneypotBlocked
|
||
|
|
case 2:
|
||
|
|
f.RateLimited += other.RateLimited
|
||
|
|
case 3:
|
||
|
|
f.OriginBlocked += other.OriginBlocked
|
||
|
|
case 4:
|
||
|
|
f.BodyTooLarge += other.BodyTooLarge
|
||
|
|
case 5:
|
||
|
|
f.InvalidBody += other.InvalidBody
|
||
|
|
case 6:
|
||
|
|
f.ValidationFailed += other.ValidationFailed
|
||
|
|
case 7:
|
||
|
|
f.SendFailed += other.SendFailed
|
||
|
|
case 8:
|
||
|
|
f.PersistFailed += other.PersistFailed
|
||
|
|
case 9:
|
||
|
|
f.DuplicateSignup += other.DuplicateSignup
|
||
|
|
case 10:
|
||
|
|
f.ConfirmationSent += other.ConfirmationSent
|
||
|
|
case 11:
|
||
|
|
f.Confirmed += other.Confirmed
|
||
|
|
case 12:
|
||
|
|
f.ConfirmFailed += other.ConfirmFailed
|
||
|
|
case 13:
|
||
|
|
f.Sent += other.Sent
|
||
|
|
case 14:
|
||
|
|
f.AutoReplyFailed += other.AutoReplyFailed
|
||
|
|
case 15:
|
||
|
|
f.TelegramFailed += other.TelegramFailed
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// formStatsRegistry guards the per-form counters shared between request
|
||
|
|
// goroutines and the /metrics endpoint.
|
||
|
|
type formStatsRegistry struct {
|
||
|
|
mu sync.Mutex
|
||
|
|
stats map[string]*FormStats
|
||
|
|
}
|
||
|
|
|
||
|
|
func newFormStatsRegistry(forms map[string]*config.Form) *formStatsRegistry {
|
||
|
|
r := &formStatsRegistry{stats: make(map[string]*FormStats, len(forms))}
|
||
|
|
for path := range forms {
|
||
|
|
r.stats[path] = &FormStats{}
|
||
|
|
}
|
||
|
|
return r
|
||
|
|
}
|
||
|
|
|
||
|
|
// bump increments the named counter for a form. Unknown paths or metrics
|
||
|
|
// are dropped silently so logging can never fail a request.
|
||
|
|
func (r *formStatsRegistry) bump(path, metric string) {
|
||
|
|
if r == nil {
|
||
|
|
return
|
||
|
|
}
|
||
|
|
r.mu.Lock()
|
||
|
|
defer r.mu.Unlock()
|
||
|
|
fs, ok := r.stats[path]
|
||
|
|
if !ok {
|
||
|
|
return
|
||
|
|
}
|
||
|
|
for i, name := range metricNames {
|
||
|
|
if name == metric {
|
||
|
|
fs.incByIndex(i)
|
||
|
|
return
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// snapshot returns a copy of every form's counters plus their sum.
|
||
|
|
func (r *formStatsRegistry) snapshot() (map[string]FormStats, FormStats) {
|
||
|
|
out := make(map[string]FormStats, len(r.stats))
|
||
|
|
var total FormStats
|
||
|
|
r.mu.Lock()
|
||
|
|
defer r.mu.Unlock()
|
||
|
|
for path, fs := range r.stats {
|
||
|
|
out[path] = *fs
|
||
|
|
total.add(*fs)
|
||
|
|
}
|
||
|
|
return out, total
|
||
|
|
}
|
||
|
|
|
||
|
|
// Metrics serves GET /metrics: lifetime counters per form and combined
|
||
|
|
// totals, as JSON. The endpoint is exempt from rate limiting and sends no
|
||
|
|
// CORS headers, so third-party pages cannot read submission volumes. With
|
||
|
|
// server.metrics_token set it requires that token as a bearer credential.
|
||
|
|
func (h *ContactHandler) Metrics(w http.ResponseWriter, r *http.Request) {
|
||
|
|
if h.metricsToken != "" {
|
||
|
|
token, ok := strings.CutPrefix(r.Header.Get("Authorization"), "Bearer ")
|
||
|
|
if !ok || subtle.ConstantTimeCompare([]byte(token), []byte(h.metricsToken)) != 1 {
|
||
|
|
w.Header().Set("WWW-Authenticate", `Bearer realm="nuntius metrics"`)
|
||
|
|
respondError(w, http.StatusUnauthorized, "unauthorized", "A valid bearer token is required.")
|
||
|
|
return
|
||
|
|
}
|
||
|
|
}
|
||
|
|
if h.stats == nil {
|
||
|
|
w.WriteHeader(http.StatusNotFound)
|
||
|
|
return
|
||
|
|
}
|
||
|
|
formSnapshots, total := h.stats.snapshot()
|
||
|
|
|
||
|
|
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||
|
|
if err := json.NewEncoder(w).Encode(map[string]any{
|
||
|
|
"totals": total,
|
||
|
|
"forms": formSnapshots,
|
||
|
|
}); err != nil {
|
||
|
|
slog.Error("failed to encode metrics response", "err", err)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// bump records one occurrence of metric for the given form path.
|
||
|
|
func (h *ContactHandler) bump(path, metric string) {
|
||
|
|
h.stats.bump(path, metric)
|
||
|
|
}
|