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>
171 lines
5 KiB
Go
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))
|
|
}
|