228 lines
6.2 KiB
Go
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
|
|
}
|