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 }