Files
mats 8f670dfa2f
Build and Publish / Metadata (push) Successful in 2s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 1m26s
better otel i think
2026-09-19 19:14:51 +02:00

228 lines
6.2 KiB
Go

package collector
import (
"context"
"encoding/json"
"fmt"
"log"
"math"
"net/http"
"sort"
"strings"
"time"
"go.opentelemetry.io/collector/pdata/pcommon"
"go.opentelemetry.io/collector/pdata/plog"
)
const (
maxVitalsBody = 32 << 10
maxVitalsString = 300
maxAttribution = 24
)
// vitalThresholds are Google's Core Web Vitals boundaries: [good, poor].
// Ratings are recomputed here so a client cannot report a misleading one.
var vitalThresholds = map[string][2]float64{
"LCP": {2500, 4000},
"INP": {200, 500},
"CLS": {0.1, 0.25},
"FCP": {1800, 3000},
"TTFB": {800, 1800},
}
func vitalRating(name string, v float64) string {
t := vitalThresholds[name]
switch {
case v <= t[0]:
return "good"
case v <= t[1]:
return "needs-improvement"
default:
return "poor"
}
}
func formatVital(name string, v float64) string {
switch name {
case "CLS":
return fmt.Sprintf("%.3f", v)
case "INP", "TTFB":
return fmt.Sprintf("%dms", int(math.Round(v)))
default:
return fmt.Sprintf("%.2fs", v/1000)
}
}
type vitalMetric struct {
Value float64 `json:"value"`
Delta float64 `json:"delta,omitempty"`
ID string `json:"id,omitempty"`
Attribution map[string]any `json:"attribution,omitempty"`
}
// vitalsBeacon is the payload otel-rum.js sends once per page view (and again,
// as a fuller snapshot, if the page is hidden more than once, e.g. INP
// changing after a tab switch). The same pageView id marks snapshots of one load.
type vitalsBeacon struct {
Service string `json:"service"`
Environment string `json:"environment"`
Release string `json:"release"`
Session string `json:"session"`
PageView string `json:"pageView"`
URL string `json:"url"`
UserAgent string `json:"ua"`
Viewport string `json:"viewport"`
Navigation string `json:"nav"`
Connection string `json:"conn"`
Metrics map[string]vitalMetric `json:"metrics"`
}
func clip(s string) string {
if len(s) > maxVitalsString {
return s[:maxVitalsString]
}
return s
}
// cleanAttribution keeps only bounded scalar values.
func cleanAttribution(in map[string]any) map[string]any {
out := make(map[string]any, len(in))
keys := make([]string, 0, len(in))
for k := range in {
keys = append(keys, k)
}
sort.Strings(keys)
for _, k := range keys {
if len(out) >= maxAttribution {
break
}
switch v := in[k].(type) {
case string:
out[clip(k)] = clip(v)
case float64:
if !math.IsNaN(v) && !math.IsInf(v, 0) {
out[clip(k)] = v
}
case bool:
out[clip(k)] = v
}
}
return out
}
// HandleVitals ingests a Web Vitals beacon and stores it as one INFO log
// record (scope "otel-rum", rum.event=page_vitals), so search, retention and
// the UI treat it like any other frontend telemetry.
func (c *Collector) HandleVitals(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
if !c.checkIngest(w, r) {
return
}
// sendBeacon posts text/plain, so decode regardless of content type.
var b vitalsBeacon
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, maxVitalsBody)).Decode(&b); err != nil {
http.Error(w, "invalid vitals payload", http.StatusBadRequest)
return
}
logs, ok := b.toLogs(time.Now())
if !ok {
http.Error(w, "no valid metrics", http.StatusBadRequest)
return
}
if err := c.ConsumeLogs(context.Background(), logs); err != nil {
log.Printf("failed to consume vitals: %v", err)
http.Error(w, "failed to store vitals", http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusNoContent)
}
// toLogs converts the beacon into a single log record; ok is false when it
// carries no recognised, finite metric.
func (b *vitalsBeacon) toLogs(now time.Time) (plog.Logs, bool) {
metrics := map[string]map[string]any{}
names := make([]string, 0, len(b.Metrics))
for name, m := range b.Metrics {
if _, known := vitalThresholds[name]; !known || math.IsNaN(m.Value) || math.IsInf(m.Value, 0) || m.Value < 0 {
continue
}
entry := map[string]any{"value": m.Value, "rating": vitalRating(name, m.Value)}
if m.ID != "" {
entry["id"] = clip(m.ID)
}
if len(m.Attribution) > 0 {
entry["attribution"] = cleanAttribution(m.Attribution)
}
metrics[name] = entry
names = append(names, name)
}
if len(names) == 0 {
return plog.Logs{}, false
}
sort.Strings(names)
parts := make([]string, 0, len(names))
worst := "good"
for _, n := range names {
parts = append(parts, n+" "+formatVital(n, b.Metrics[n].Value))
switch metrics[n]["rating"] {
case "poor":
worst = "poor"
case "needs-improvement":
if worst == "good" {
worst = "needs-improvement"
}
}
}
message := strings.Join(parts, " · ")
body, _ := json.Marshal(map[string]any{"message": message, "metrics": metrics})
service := clip(b.Service)
if service == "" {
service = "frontend"
}
ld := plog.NewLogs()
rl := ld.ResourceLogs().AppendEmpty()
rl.Resource().Attributes().PutStr("service.name", service)
sl := rl.ScopeLogs().AppendEmpty()
sl.Scope().SetName("otel-rum")
rec := sl.LogRecords().AppendEmpty()
rec.SetTimestamp(pcommon.NewTimestampFromTime(now))
rec.SetSeverityText("INFO")
rec.SetSeverityNumber(plog.SeverityNumberInfo)
rec.Body().SetStr(string(body))
a := rec.Attributes()
a.PutStr("source", "frontend")
a.PutStr("rum.event", "page_vitals")
a.PutStr("vitals.rating", worst)
for k, v := range map[string]string{
"page_view.id": b.PageView,
"session.id": b.Session,
"browser.url": b.URL,
"browser.userAgent": b.UserAgent,
"browser.viewport": b.Viewport,
"navigation.type": b.Navigation,
"network.connection": b.Connection,
"service.release": b.Release,
"service.environment": b.Environment,
} {
if v != "" {
a.PutStr(k, clip(v))
}
}
// Flat, individually searchable values alongside the structured body.
for _, n := range names {
lower := strings.ToLower(n)
a.PutDouble("vitals."+lower, b.Metrics[n].Value)
a.PutStr("vitals."+lower+".rating", metrics[n]["rating"].(string))
}
return ld, true
}