veza/veza-backend-api/internal/handlers/web_vitals_handler_test.go
senke 54af2bc851 feat(observability): RUM Web Vitals beacons + alert rules (v1.0.10 ops item 9)
Real User Monitoring closes the gap between synthetic probes (which
already cover server-side latency) and what users actually see in
their browsers. Slow CDN edges, third-party scripts, mobile-CPU
regressions, and bundle bloat all surface here but stay invisible
to backend-side dashboards.

Frontend (apps/web) :
- web-vitals@^4.2.4 dep
- src/observability/webVitals.ts collects LCP / CLS / INP / FID /
  TTFB via the npm web-vitals package and POSTs to the backend
  using sendBeacon (with fetch keepalive fallback)
- Pageload-level sampling decision (flip a coin once, contribute
  all metrics or none) avoids per-metric histogram bias
- Sample rate via VITE_RUM_SAMPLE_RATE (default 1.0 dev / 0.25 prod)
- main.tsx wires initWebVitals() right after initSentry()
- Route slug derived client-side (strips uuid-ish + numeric ids
  to keep cardinality low)

Backend :
- internal/handlers/web_vitals_handler.go : POST
  /api/v1/observability/web-vitals — anonymous, IP rate-limited
  (reuses FrontendLogRateLimit), validates value ranges, normalizes
  route + device labels for cardinality
- internal/monitoring/web_vitals.go : Prometheus histograms with
  buckets aligned to Google's good/needs-improvement/poor
  thresholds, plus beacons-received / beacons-rejected counters
- Tests : 6 handler tests + 3 helper-function tests + 10 frontend
  vitest tests (all pass)

Alerts in alert_rules.yml veza_rum group :
- WebVitalsLCPP75Poor (p75 LCP > 4s on a route+device for 30m)
- WebVitalsCLSP75Poor (p75 CLS > 0.25 for 30m)
- WebVitalsINPP75Poor (p75 INP > 500ms for 30m)
- WebVitalsBeaconsStopped (zero beacons for 30m vs yesterday)

Cardinality discipline : labels are bounded to {route, device}
where route is alnum/dash, ≤32 chars, and device is one of
mobile/desktop/tablet/unknown. No per-user labels.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 19:56:44 +02:00

171 lines
5 KiB
Go

package handlers
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
"go.uber.org/zap"
)
// Web Vitals beacon endpoint contract :
// - POST application/json with {metric, value, route}
// - 204 on success, no body
// - 400 on bad JSON / unknown metric / out-of-range value
// - Defensive label normalization (route truncated, device collapsed)
//
// We don't assert the Prometheus side-effects (the histograms are
// global state ; they're exercised in monitoring tests). The handler
// contract is what matters for the API surface.
func TestWebVitalsHandler_AcceptsValidLCP(t *testing.T) {
gin.SetMode(gin.TestMode)
r := gin.New()
r.POST("/web-vitals", WebVitalsHandler(zap.NewNop()))
body, _ := json.Marshal(WebVitalsBeacon{
Metric: "LCP",
Value: 1850, // 1.85s — "good"
Route: "home",
Device: "mobile",
Rating: "good",
})
req := httptest.NewRequest(http.MethodPost, "/web-vitals", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusNoContent, w.Code)
assert.Empty(t, w.Body.String())
}
func TestWebVitalsHandler_AcceptsValidCLS(t *testing.T) {
gin.SetMode(gin.TestMode)
r := gin.New()
r.POST("/web-vitals", WebVitalsHandler(zap.NewNop()))
body, _ := json.Marshal(WebVitalsBeacon{
Metric: "CLS",
Value: 0.05,
Route: "search",
Device: "desktop",
})
req := httptest.NewRequest(http.MethodPost, "/web-vitals", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusNoContent, w.Code)
}
func TestWebVitalsHandler_RejectsUnknownMetric(t *testing.T) {
gin.SetMode(gin.TestMode)
r := gin.New()
r.POST("/web-vitals", WebVitalsHandler(zap.NewNop()))
body, _ := json.Marshal(WebVitalsBeacon{
Metric: "FCP", // First Contentful Paint — not in our histogram set
Value: 500,
Route: "home",
})
req := httptest.NewRequest(http.MethodPost, "/web-vitals", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusBadRequest, w.Code)
assert.Contains(t, w.Body.String(), "unknown metric")
}
func TestWebVitalsHandler_RejectsNegativeValue(t *testing.T) {
gin.SetMode(gin.TestMode)
r := gin.New()
r.POST("/web-vitals", WebVitalsHandler(zap.NewNop()))
body, _ := json.Marshal(WebVitalsBeacon{
Metric: "LCP",
Value: -100, // clock skew
Route: "home",
})
req := httptest.NewRequest(http.MethodPost, "/web-vitals", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusBadRequest, w.Code)
assert.Contains(t, w.Body.String(), "out of range")
}
func TestWebVitalsHandler_RejectsBeyondRange(t *testing.T) {
gin.SetMode(gin.TestMode)
r := gin.New()
r.POST("/web-vitals", WebVitalsHandler(zap.NewNop()))
body, _ := json.Marshal(WebVitalsBeacon{
Metric: "LCP",
Value: 120_000, // 2 minutes — bogus
Route: "home",
})
req := httptest.NewRequest(http.MethodPost, "/web-vitals", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusBadRequest, w.Code)
}
func TestWebVitalsHandler_RejectsMalformedJSON(t *testing.T) {
gin.SetMode(gin.TestMode)
r := gin.New()
r.POST("/web-vitals", WebVitalsHandler(zap.NewNop()))
req := httptest.NewRequest(http.MethodPost, "/web-vitals", bytes.NewReader([]byte("{not json")))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusBadRequest, w.Code)
}
func TestNormalizeRouteLabel(t *testing.T) {
tests := []struct {
in string
want string
}{
{"home", "home"},
{"PLAYER", "player"},
{" search ", "search"},
{"", "unknown"},
{"!!!", "unknown"},
// Path-style is sanitized to slashes-stripped slug
{"track/123/play", "track123play"},
// >32 chars is truncated
{"verylonglonglonglonglonglonglonglongroute", "verylonglonglonglonglonglonglong"},
}
for _, tt := range tests {
got := normalizeRouteLabel(tt.in)
assert.Equal(t, tt.want, got, "input: %q", tt.in)
}
}
func TestNormalizeDeviceLabel(t *testing.T) {
assert.Equal(t, "mobile", normalizeDeviceLabel("Mobile"))
assert.Equal(t, "desktop", normalizeDeviceLabel("desktop"))
assert.Equal(t, "tablet", normalizeDeviceLabel("Tablet"))
assert.Equal(t, "unknown", normalizeDeviceLabel(""))
assert.Equal(t, "unknown", normalizeDeviceLabel("Mobile-Safari/iPhone-13"))
}
func TestIsValidVitalValue(t *testing.T) {
assert.True(t, isValidVitalValue("LCP", 0))
assert.True(t, isValidVitalValue("LCP", 60_000))
assert.False(t, isValidVitalValue("LCP", 60_001))
assert.False(t, isValidVitalValue("LCP", -1))
assert.True(t, isValidVitalValue("CLS", 0.5))
assert.True(t, isValidVitalValue("CLS", 10))
assert.False(t, isValidVitalValue("CLS", 11))
}