better otel i think
This commit is contained in:
1 parent
6edbdc340d
commit
8f670dfa2f
16 files changed
+1129
-675
No files matched your search
@@ -509,3 +509,50 @@ func TestHandleLogsIngestRateLimited(t *testing.T) {
|
||||
t.Fatalf("expected ingest rate limiting to eventually return 429, last code was %d", lastCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleVitalsStoresOnePageVitalsRecord(t *testing.T) {
|
||||
c := New(noopBroadcaster{}, 100, &mockStore{})
|
||||
body := `{"service":"shop","session":"s1","pageView":"pv1","url":"https://x/y","nav":"navigate",
|
||||
"metrics":{"LCP":{"value":3100.4,"attribution":{"target":"img.hero","nested":{"x":1}}},
|
||||
"CLS":{"value":0.05},"BOGUS":{"value":1},"INP":{"value":-3}}}`
|
||||
req := httptest.NewRequest(http.MethodPost, "/v1/vitals", strings.NewReader(body))
|
||||
req.Header.Set("Content-Type", "text/plain;charset=UTF-8")
|
||||
rr := httptest.NewRecorder()
|
||||
c.HandleVitals(rr, req)
|
||||
if rr.Code != http.StatusNoContent {
|
||||
t.Fatalf("status = %d, body %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
|
||||
vb := vitalsBeacon{}
|
||||
if err := json.Unmarshal([]byte(body), &vb); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
logs, ok := vb.toLogs(time.Unix(1, 0))
|
||||
if !ok {
|
||||
t.Fatal("expected valid logs")
|
||||
}
|
||||
rec := logs.ResourceLogs().At(0).ScopeLogs().At(0).LogRecords().At(0)
|
||||
attrs := rec.Attributes()
|
||||
if v, _ := attrs.Get("vitals.lcp.rating"); v.Str() != "needs-improvement" {
|
||||
t.Errorf("lcp rating = %q", v.Str())
|
||||
}
|
||||
if v, _ := attrs.Get("vitals.cls"); v.Double() != 0.05 {
|
||||
t.Errorf("cls = %v (must not be rounded)", v.Double())
|
||||
}
|
||||
if _, found := attrs.Get("vitals.inp"); found {
|
||||
t.Error("negative INP must be dropped")
|
||||
}
|
||||
if strings.Contains(rec.Body().Str(), "BOGUS") || strings.Contains(rec.Body().Str(), "nested") {
|
||||
t.Errorf("unknown metric or non-scalar attribution leaked: %s", rec.Body().Str())
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleVitalsRejectsEmpty(t *testing.T) {
|
||||
c := New(noopBroadcaster{}, 100, &mockStore{})
|
||||
req := httptest.NewRequest(http.MethodPost, "/v1/vitals", strings.NewReader(`{"metrics":{}}`))
|
||||
rr := httptest.NewRecorder()
|
||||
c.HandleVitals(rr, req)
|
||||
if rr.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d", rr.Code)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
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
|
||||
}
|
||||
Reference in new issue
Block a user