diff --git a/cmd/server/main.go b/cmd/server/main.go index 23511c0..2aeefe4 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -270,6 +270,7 @@ func main() { otelMux.HandleFunc("/v1/metrics/", withCORS(coll.HandleMetrics)) otelMux.HandleFunc("/v1/logs", withCORS(coll.HandleLogs)) otelMux.HandleFunc("/v1/logs/", withCORS(coll.HandleLogs)) + otelMux.HandleFunc("/v1/vitals", withCORS(coll.HandleVitals)) otelMux.HandleFunc("/health", coll.HandleHealth) // UI and API mux (port 8080) @@ -283,6 +284,7 @@ func main() { uiMux.HandleFunc("/v1/metrics/", withCORS(coll.HandleMetrics)) uiMux.HandleFunc("/v1/logs", withCORS(coll.HandleLogs)) uiMux.HandleFunc("/v1/logs/", withCORS(coll.HandleLogs)) + uiMux.HandleFunc("/v1/vitals", withCORS(coll.HandleVitals)) uiMux.HandleFunc("/health", coll.HandleHealth) uiMux.HandleFunc("GET /internal/honeypot/ips", honeypotTracker.HandleIPs) @@ -352,6 +354,7 @@ func main() { log.Printf(" Traces: http://localhost:8080/v1/traces") log.Printf(" Metrics: http://localhost:8080/v1/metrics") log.Printf(" Logs: http://localhost:8080/v1/logs") + log.Printf(" Vitals: http://localhost:8080/v1/vitals (Web Vitals beacons)") log.Printf("OTLP gRPC endpoint:") log.Printf(" Traces/Metrics/Logs: localhost:4317") log.Printf("WebSocket: ws://localhost:8080/ws") diff --git a/internal/collector/collector_test.go b/internal/collector/collector_test.go index cc3edc5..6cda9ef 100644 --- a/internal/collector/collector_test.go +++ b/internal/collector/collector_test.go @@ -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) + } +} diff --git a/internal/collector/vitals.go b/internal/collector/vitals.go new file mode 100644 index 0000000..b949173 --- /dev/null +++ b/internal/collector/vitals.go @@ -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 +} diff --git a/k8s/deployment.yaml b/k8s/deployment.yaml index bee41eb..84e8b06 100644 --- a/k8s/deployment.yaml +++ b/k8s/deployment.yaml @@ -117,6 +117,9 @@ spec: value: "1h" - name: OTEL_NEEDLE_ADDR value: "http://127.0.0.1:5005" + # ~80% of the memory limit so the GC works harder before an OOMKill. + - name: GOMEMLIMIT + value: "800MiB" volumeMounts: - name: log-dir mountPath: /logs @@ -136,10 +139,10 @@ spec: periodSeconds: 10 resources: requests: - memory: "128Mi" + memory: "512Mi" cpu: "100m" limits: - memory: "256Mi" + memory: "1Gi" cpu: "500m" - name: needle-sidecar image: matst80/needle-sidecar:latest diff --git a/otel-ui/src/App.tsx b/otel-ui/src/App.tsx index c154937..5accd18 100644 --- a/otel-ui/src/App.tsx +++ b/otel-ui/src/App.tsx @@ -74,7 +74,7 @@ function App() { // The Issues tab is the redesigned triage overview: it owns its own filter // bar and cards, so the legacy anomaly banner and volume waveform above it // would just duplicate that UI in a different visual language. - const isTriageOverview = activeTab === "tab0" || activeTab === "tab7"; + const isTriageOverview = activeTab === "tab0" || activeTab === "tab3" || activeTab === "tab7"; if (isCallback) { return ; diff --git a/otel-ui/src/components/LogsVisualizer.tsx b/otel-ui/src/components/LogsVisualizer.tsx new file mode 100644 index 0000000..5836f7b --- /dev/null +++ b/otel-ui/src/components/LogsVisualizer.tsx @@ -0,0 +1,464 @@ +import { useMemo, useState } from "react" +import { Link } from "wouter" +import { GitBranch, Monitor, ScrollText } from "lucide-react" +import { useProcessedTraces } from "../hooks/useProcessedTraces" +import { bodyHeadline, messageTemplate, parseStructured } from "../lib/logParse" +import { hashString } from "../lib/otel-utils" +import { VitalsCard, parseVitalsBody } from "./common/VitalsCard" +import { JsonTree } from "./common/JsonTree" +import { Segmented } from "./common/Segmented" +import { SpanInspector } from "./TraceVisualization" +import { TraceDetail } from "./trace/TraceDetail" +import { useObservabilityData } from "../hooks/useObservabilityData" +import { formatAttributeValue, getAttributeValue, getServiceName } from "../lib/otel-utils" +import { formatRelativeTime } from "../lib/timeUtils" +import { joinPath, shortId, useSubRoute } from "../lib/routes" +import type { LogEntry, LogRecord, OtelAttribute } from "../lib/types" +import { FilterBar, type FilterChip } from "./FilterBar" +import { MasterDetail, type DrillApi } from "./MasterDetail" +import { CodeBlock } from "./common/CodeBlock" +import { CopyRef } from "./common/CopyRef" +import { DetailField, SectionLabel } from "./common/DetailField" + +type Level = "FATAL" | "ERROR" | "WARN" | "INFO" | "DEBUG" +type MinLevel = "all" | "info" | "warn" | "error" + +interface FlatLog { + key: string + record: LogRecord + scope?: string + resource: OtelAttribute[] + service: string + level: Level + timeMs: number + body: string + sessionId: string +} + +const MAX_LISTED = 500 + +const levelOf = (n = 0): Level => (n >= 21 ? "FATAL" : n >= 17 ? "ERROR" : n >= 13 ? "WARN" : n >= 9 ? "INFO" : "DEBUG") +const levelRank: Record = { DEBUG: 0, INFO: 1, WARN: 2, ERROR: 3, FATAL: 4 } +const minRank: Record = { all: 0, info: 1, warn: 2, error: 3 } + +// Colour only where the level is a real state: warn/error/fatal. Info/debug stay neutral. +const levelTone: Record = { + FATAL: "border-ds-critical/40 bg-ds-critical-bg text-ds-critical", + ERROR: "border-ds-critical/40 bg-ds-critical-bg text-ds-critical", + WARN: "border-ds-warn/40 bg-ds-warn-bg text-ds-warn", + INFO: "border-ds-line-strong text-ds-text-2", + DEBUG: "border-ds-line-strong text-ds-text-3", +} + +const LevelPill = ({ level }: { level: Level }) => ( + + {level} + +) + +const flatten = (logs: LogEntry[]): FlatLog[] => + logs.flatMap((log) => + (log.scopeLogs ?? []).flatMap((scopeLog) => + (scopeLog.logRecords ?? []).map((record) => { + const attrs = record.attributes ?? [] + return { + key: record.uid || `${log.id}:${record.timeUnixNano}`, + record, + scope: scopeLog.scope?.name, + resource: log.resource?.attributes ?? [], + service: getServiceName(log.resource?.attributes ?? [], attrs), + level: levelOf(record.severityNumber), + timeMs: Number(BigInt(record.timeUnixNano || "0") / 1_000_000n), + body: record.body?.stringValue ?? "", + sessionId: getAttributeValue(attrs, "session.id"), + } + }), + ), + ) + +const attrsToObject = (attrs: OtelAttribute[]) => Object.fromEntries(attrs.map((a) => [a.key, formatAttributeValue(a.value)])) + +/** One list row: a single record, or a group of records sharing a message template. */ +interface Row { + key: string + latest: FlatLog + members: FlatLog[] // newest first +} + +const groupRows = (logs: FlatLog[]): Row[] => { + const map = new Map() + for (const l of logs) { + const k = `${l.service}|${l.level}|${messageTemplate(l.body)}` + const list = map.get(k) + if (list) list.push(l) + else map.set(k, [l]) + } + return [...map.entries()].map(([k, members]) => ({ key: `g${hashString(k).toString(36)}`, latest: members[0], members })) +} + +const isoTime = (ms: number) => (ms ? new Date(ms).toISOString() : "—") +const clock = (ms: number) => (ms ? new Date(ms).toISOString().slice(11, 23) : "—") + +const LogListItem = ({ row }: { row: Row }) => { + const log = row.latest + const count = row.members.length + return ( +
+
+ + + {count > 1 && ( + + ×{count} + + )} + + {clock(log.timeMs)} +
+ + {bodyHeadline(log.body) || "(empty body)"} + +
+ {log.service} + + {log.sessionId && } + {log.record.traceId && } + +
+
+ ) +} + +const linkClass = + "inline-flex h-8 items-center gap-1.5 rounded-[9px] border border-ds-line-strong px-3 text-[12.5px] font-medium text-ds-accent hover:bg-ds-surface-2" + +const SpanPane = ({ traceId, spanId }: { traceId?: string; spanId: string }) => { + const { byId } = useProcessedTraces(traceId ? [traceId] : []) + const trace = traceId ? byId.get(traceId) : undefined + const span = trace?.spans.find((sp) => sp.spanId === spanId) + return span && trace ? : null +} + +const AttributeTable = ({ attrs, label }: { attrs: OtelAttribute[]; label: string }) => { + if (attrs.length === 0) return

None.

+ return ( +
+ {attrs.map((a) => { + const text = formatAttributeValue(a.value) + const structured = parseStructured(text) + return ( +
+
{a.key}
+
+ {structured ? : text} +
+
+ ) + })} +
+ ) +} + +const LogDetail = ({ row, related, drill }: { row: Row; related: number; drill: DrillApi }) => { + const [pickedKey, setPickedKey] = useState(null) + const log = row.members.find((m) => m.key === pickedKey) ?? row.latest + const { record } = log + const traceId = record.traceId + const structuredBody = useMemo(() => parseStructured(log.body), [log.body]) + const vitals = useMemo( + () => (getAttributeValue(record.attributes ?? [], "rum.event") === "page_vitals" ? parseVitalsBody(log.body) : undefined), + [log.body, record.attributes], + ) + const { byId, loading, failed } = useProcessedTraces(traceId ? [traceId] : []) + const rawPayload = useMemo( + () => + JSON.stringify( + { + timestamp: isoTime(log.timeMs), + severity: log.level, + service_name: log.service, + scope: log.scope, + trace_id: traceId, + span_id: record.spanId, + attributes: attrsToObject(record.attributes ?? []), + resource_attributes: attrsToObject(log.resource), + }, + null, + 2, + ), + [log, record, traceId], + ) + + return ( +
+
+ + {isoTime(log.timeMs)} + {formatRelativeTime(log.timeMs)} + + +
+ +
+ {traceId ? ( + +
+ +
+ Body + {vitals ? ( + + ) : structuredBody ? ( + + ) : ( + + )} +
+ +
+ + + + +
+ +
+ Attributes + +
+ + {traceId && ( + (id ? drill.openExtended(id) : drill.closeExtended())} + /> + )} + + {row.members.length > 1 && ( +
+ + Occurrences ({row.members.length} + {row.members.length > 50 ? ", newest 50 shown" : ""}) + +
    + {row.members.slice(0, 50).map((m) => ( +
  1. + +
  2. + ))} +
+
+ )} + +
+ Raw payload +
+ +
+
+
+ ) +} + +export const LogsVisualizer = ({ logs }: { logs: LogEntry[] }) => { + const { searchQuery, setSearchQuery, selectedServices, toggleService } = useObservabilityData() + const [minLevel, setMinLevel] = useState("all") + const [grouped, setGrouped] = useState(true) + // /logs, /logs/ + const { segments, go } = useSubRoute() + const selectedKey = segments[0] ?? null + + const all = useMemo(() => flatten(logs).sort((a, b) => b.timeMs - a.timeMs), [logs]) + + const filtered = useMemo(() => { + const q = searchQuery.trim().toLowerCase() + return all.filter((l) => { + if (levelRank[l.level] < minRank[minLevel]) return false + if (selectedServices.size > 0 && !selectedServices.has(l.service)) return false + if (!q) return true + return ( + l.body.toLowerCase().includes(q) || + l.service.toLowerCase().includes(q) || + (l.record.traceId ?? "").toLowerCase().includes(q) || + l.sessionId.toLowerCase().includes(q) || + (l.record.attributes ?? []).some((a) => a.key.toLowerCase().includes(q) || formatAttributeValue(a.value).toLowerCase().includes(q)) + ) + }) + }, [all, minLevel, selectedServices, searchQuery]) + + const rows: Row[] = useMemo( + () => (grouped ? groupRows(filtered) : filtered.map((l) => ({ key: l.key, latest: l, members: [l] }))), + [filtered, grouped], + ) + const rowFor = (key: string | null) => (key ? rows.find((r) => r.key === key || r.members.some((m) => m.key === key)) : undefined) + + const listed = useMemo(() => { + const head = rows.slice(0, MAX_LISTED) + // A deep-linked record outside the cap must still resolve. + const linked = selectedKey && !head.some((r) => r.key === selectedKey || r.members.some((m) => m.key === selectedKey)) ? rowFor(selectedKey) : undefined + return linked ? [linked, ...head] : head + }, [rows, selectedKey]) // eslint-disable-line react-hooks/exhaustive-deps + + const byTrace = useMemo(() => { + const map = new Map() + for (const l of all) { + const id = l.record.traceId + if (id) map.set(id, [...(map.get(id) ?? []), l]) + } + return map + }, [all]) + + const serviceOptions = useMemo(() => [...new Set(all.map((l) => l.service))].sort(), [all]) + + const chips: FilterChip[] = [ + ...[...selectedServices].map((s) => ({ + id: `svc:${s}`, + filterKey: "service.name", + value: s, + tone: "neutral" as const, + onRemove: () => toggleService(s), + })), + ...(searchQuery + ? [{ id: "query", filterKey: "query", value: searchQuery, tone: "neutral" as const, onRemove: () => setSearchQuery("") }] + : []), + ] + const activeFilters = chips.length + (minLevel !== "all" ? 1 : 0) + + const clearAll = () => { + setMinLevel("all") + setSearchQuery("") + selectedServices.forEach((s) => toggleService(s)) + } + + const errorCount = filtered.filter((l) => levelRank[l.level] >= levelRank.ERROR).length + const selected = listed.find((r) => r.key === selectedKey || r.members.some((m) => m.key === selectedKey)) ?? null + + return ( +
+ setMinLevel(v as MinLevel), + }} + chips={chips} + addFilterOptions={serviceOptions.filter((s) => !selectedServices.has(s)).map((s) => ({ value: s, label: `service.name=${s}` }))} + onAddFilter={toggleService} + matchText={`Matching ${filtered.length} of ${all.length} logs${errorCount ? ` · ${errorCount} errors` : ""}${ + grouped ? ` · ${rows.length} groups` : ""}${rows.length > MAX_LISTED ? ` · showing newest ${MAX_LISTED}` : "" + } · ${activeFilters} filter${activeFilters === 1 ? "" : "s"}`} + onClearAll={activeFilters > 0 ? clearAll : undefined} + /> + + {all.length === 0 ? ( +
+
+ ) : ( + r.key} + listHeader={ + setGrouped(v === "grouped")} + options={[ + { value: "grouped", label: "Grouped" }, + { value: "flat", label: "Flat" }, + ]} + /> + } + selectedKey={selected?.key ?? null} + onSelect={(k) => go([k])} + listSize={{ default: 400, min: 280, max: 640 }} + renderItem={(r) => } + emptyDetail="Select a log record to see its body, attributes and links." + renderDetail={(r, drill) => ( + + )} + extendedTitle="Trace context" + renderExtended={(row, extKey) => { + const l = row.latest + if (extKey !== "trace-logs") return + return ( +
+
    + {(byTrace.get(l.record.traceId ?? "") ?? []) + .slice() + .sort((a, b) => a.timeMs - b.timeMs) + .map((r) => ( +
  1. + + + + {clock(r.timeMs)} + {r.service} + + {r.body} + +
  2. + ))} +
+
+ ) + }} + /> + )} +
+ ) +} + +export default LogsVisualizer diff --git a/otel-ui/src/components/StructuredLogTerminal.tsx b/otel-ui/src/components/StructuredLogTerminal.tsx deleted file mode 100644 index 4edaee9..0000000 --- a/otel-ui/src/components/StructuredLogTerminal.tsx +++ /dev/null @@ -1,577 +0,0 @@ -import { ArrowRight, Brain, ChevronsDownUp, ChevronsUpDown, Copy, CopyPlus, Eraser, Filter, Link, ListFilter } from "lucide-react" -import React, { useMemo, useState } from "react"; -import type { LogEntry, LogRecord, OtelAttribute } from "../lib/types"; -import { useObservabilityData } from "../hooks/useObservabilityData"; -import { formatAttributeValue, getServiceName } from "../lib/otel-utils"; - -interface FlattenedRecord extends LogRecord { - scope?: string; - resourceAttributes: OtelAttribute[]; - serviceName: string; -} - -export const StructuredLogTerminal: React.FC<{ logs: LogEntry[] }> = ({ logs }) => { - const { setActiveTraceId, setActiveSpanId, setActiveTab, selectedServices, toggleService } = - useObservabilityData(); - - const [expandedRowId, setExpandedRowId] = useState(null); - const [streamFilter, setStreamFilter] = useState(""); - const [wrapText, setWrapText] = useState(false); - const [cleared, setCleared] = useState(false); - - // Severity filters - const [selectedSeverities, setSelectedSeverities] = useState>({ - FATAL: true, - ERROR: true, - WARN: true, - INFO: true, - DEBUG: false, - }); - - const toggleSeverity = (sev: string) => { - setSelectedSeverities((prev) => ({ ...prev, [sev]: !prev[sev] })); - }; - - // Flatten all log records - const allRecords: FlattenedRecord[] = useMemo(() => { - if (cleared) return []; - return logs.flatMap((log) => - (log.scopeLogs ?? []).flatMap((scopeLog) => - (scopeLog.logRecords ?? []).map((record) => { - const serviceName = getServiceName( - log.resource?.attributes ?? [], - record.attributes ?? [], - ); - return { - ...record, - scope: scopeLog.scope?.name, - resourceAttributes: log.resource?.attributes ?? [], - serviceName, - }; - }), - ), - ); - }, [logs, cleared]); - - // Counts by severity - const severityCounts = useMemo(() => { - const counts = { FATAL: 0, ERROR: 0, WARN: 0, INFO: 0, DEBUG: 0 }; - allRecords.forEach((r) => { - const num = r.severityNumber ?? 0; - if (num >= 21) counts.FATAL++; - else if (num >= 17) counts.ERROR++; - else if (num >= 13) counts.WARN++; - else if (num >= 9) counts.INFO++; - else counts.DEBUG++; - }); - return counts; - }, [allRecords]); - - // Service facet counts - const serviceCounts = useMemo(() => { - const counts = new Map(); - allRecords.forEach((r) => { - counts.set(r.serviceName, (counts.get(r.serviceName) || 0) + 1); - }); - return Array.from(counts.entries()).sort((a, b) => b[1] - a[1]); - }, [allRecords]); - - // Real diagnostic heuristic calculated from actual logs - const errorHeuristic = useMemo(() => { - const errorRecords = allRecords.filter((r) => (r.severityNumber ?? 0) >= 17); - if (errorRecords.length === 0) { - return { - text: serviceCounts.length > 0 - ? `All ${serviceCounts.length} active service(s) nominal. 0 error logs recorded in current buffer.` - : "No log signals received yet. Waiting for OTLP logs...", - hasErrors: false, - }; - } - - const errByService: Record = {}; - errorRecords.forEach((r) => { - errByService[r.serviceName] = (errByService[r.serviceName] || 0) + 1; - }); - - const sorted = Object.entries(errByService).sort((a, b) => b[1] - a[1]); - const topService = sorted[0]?.[0] || "unknown"; - const topCount = sorted[0]?.[1] || 0; - const pct = Math.round((topCount / errorRecords.length) * 100); - - return { - text: `${pct}% of current ERROR logs (${topCount} of ${errorRecords.length}) originated in "${topService}".`, - hasErrors: true, - }; - }, [allRecords, serviceCounts.length]); - - // Filter records based on selected severities, services, and local stream search - const visibleRecords = useMemo(() => { - return allRecords.filter((r) => { - const num = r.severityNumber ?? 0; - let sevKey = "DEBUG"; - if (num >= 21) sevKey = "FATAL"; - else if (num >= 17) sevKey = "ERROR"; - else if (num >= 13) sevKey = "WARN"; - else if (num >= 9) sevKey = "INFO"; - - if (!selectedSeverities[sevKey]) return false; - - if (streamFilter.trim()) { - const query = streamFilter.toLowerCase().trim(); - const msg = (r.body?.stringValue ?? "").toLowerCase(); - const srv = r.serviceName.toLowerCase(); - const trace = (r.traceId ?? "").toLowerCase(); - const attrs = (r.attributes ?? []).some( - (a) => - a.key.toLowerCase().includes(query) || - formatAttributeValue(a.value).toLowerCase().includes(query), - ); - if (!msg.includes(query) && !srv.includes(query) && !trace.includes(query) && !attrs) { - return false; - } - } - - return true; - }); - }, [allRecords, selectedSeverities, streamFilter]); - - const copyLogStream = () => { - if (visibleRecords.length === 0) return; - const excerpt = visibleRecords - .slice(0, 50) - .map((r) => { - const time = new Date(Number(BigInt(r.timeUnixNano || "0") / 1_000_000n)).toISOString(); - return `[${time}] ${r.severityText || "LOG"} ${r.serviceName}: ${r.body?.stringValue ?? ""}`; - }) - .join("\n"); - navigator.clipboard.writeText(excerpt); - alert("Copied visible log excerpt to clipboard."); - }; - - const navigateToTraceSpan = (traceId: string, spanId?: string) => { - setActiveTraceId(traceId); - if (spanId) setActiveSpanId(spanId); - setActiveTab("tab1"); - }; - - const formatIsoTimestamp = (nanoStr?: string) => { - if (!nanoStr) return "—"; - const millis = Number(BigInt(nanoStr) / 1_000_000n); - return new Date(millis).toISOString(); - }; - - return ( -
- {/* LEFT PANEL: Faceted Filter Breakdown (3 Cols) */} - - - {/* RIGHT PANEL: High-Density Structured Live Log Stream (9 Cols) */} -
- {/* Stream Controls Bar */} -
-
-
-
-
- -
- - -
- - - - -
-
- - {/* Log Terminal Headers */} -
-
Timestamp (UTC)
-
Level
-
Service & Trace
-
Log Message & Structured Payload
-
- - {/* Realtime Log Entries List */} -
- {visibleRecords.length === 0 ? ( -
- {cleared - ? "Stream buffer cleared. Waiting for new telemetry records..." - : "No log records match the current filters."} -
- ) : ( - visibleRecords.map((record) => { - const num = record.severityNumber ?? 0; - let sevLabel = record.severityText?.toUpperCase() || "INFO"; - let sevClass = "bg-ds-fill text-ds-accent"; - - if (num >= 21) { - sevLabel = "FATAL"; - sevClass = "bg-ds-critical text-ds-critical font-extrabold"; - } else if (num >= 17) { - sevLabel = "ERROR"; - sevClass = "bg-ds-critical-bg text-ds-critical font-bold"; - } else if (num >= 13) { - sevLabel = "WARN"; - sevClass = "bg-ds-fill text-ds-accent font-medium"; - } - - const rowId = record.uid; - const isExpanded = expandedRowId === rowId; - const isoTime = formatIsoTimestamp(record.timeUnixNano); - const bodyMessage = record.body?.stringValue ?? "(empty body)"; - - return ( -
-
setExpandedRowId(isExpanded ? null : rowId)} - className="grid grid-cols-12 px-4 py-2 items-start cursor-pointer group" - > -
- {isoTime} -
- -
- - {sevLabel} - -
- -
- {record.serviceName} - {record.traceId && ( -
- - -
- )} -
- -
-
- {num >= 17 && ( - Exception: - )} - {bodyMessage} -
- - {isExpanded ?
-
- - {/* Structured Payload Drawer (Expanded) */} - {isExpanded && ( -
-
- - OpenTelemetry Attributes & Resource Scope - - {record.traceId && ( - - )} -
- - {/* Attributes Grid */} -
- {record.spanId && ( -
- span_id - - {record.spanId} - -
- )} - {record.traceId && ( -
- trace_id - {record.traceId} -
- )} - {record.scope && ( -
- scope - {record.scope} -
- )} -
- service.name - {record.serviceName} -
-
- - {/* Raw JSON block */} -
-                        
-                          {JSON.stringify(
-                            {
-                              timestamp: isoTime,
-                              severity: sevLabel,
-                              service_name: record.serviceName,
-                              trace_id: record.traceId,
-                              span_id: record.spanId,
-                              body: bodyMessage,
-                              attributes: Object.fromEntries(
-                                (record.attributes ?? []).map((a) => [
-                                  a.key,
-                                  formatAttributeValue(a.value),
-                                ]),
-                              ),
-                              resource_attributes: Object.fromEntries(
-                                (record.resourceAttributes ?? []).map((a) => [
-                                  a.key,
-                                  formatAttributeValue(a.value),
-                                ]),
-                              ),
-                            },
-                            null,
-                            2,
-                          )}
-                        
-                      
-
- )} -
- ); - }) - )} -
- - {/* Log Terminal Footer Status */} -
-
- - Showing {visibleRecords.length} of {allRecords.length} matched events - - - Buffer: Healthy -
- -
- - Auto-scroll: LIVE STREAM -
-
-
-
- ); -}; diff --git a/otel-ui/src/components/Tabs.tsx b/otel-ui/src/components/Tabs.tsx index 25d3ade..dc77a27 100644 --- a/otel-ui/src/components/Tabs.tsx +++ b/otel-ui/src/components/Tabs.tsx @@ -266,11 +266,8 @@ export const LogEntryView = ({ log }: { log: LogEntry }) => { ) } -import { StructuredLogTerminal } from "./StructuredLogTerminal" +import { LogsVisualizer } from "./LogsVisualizer" -const LogVisualizer = ({ logs }: { logs: LogEntry[] }) => { - return -} export const TabsComponent = () => { const { traces, logs, activeTab, setActiveTab } = useObservabilityData() @@ -349,7 +346,7 @@ export const TabsComponent = () => { className="outline-none py-2" value="tab3" > - {activeTab === "tab3" && } + {activeTab === "tab3" && } { + const host = useRef(null) + useEffect(() => { + if (!host.current) return + const view = new EditorView({ + parent: host.current, + state: EditorState.create({ + doc: code, + extensions: [ + EditorState.readOnly.of(true), + EditorView.editable.of(false), + EditorView.lineWrapping, + lineNumbers(), + theme, + ...(lang === "json" ? [javascript(), syntaxHighlighting(highlight)] : []), + ], + }), + }) + return () => view.destroy() + }, [code, lang]) + return
+} diff --git a/otel-ui/src/components/common/JsonTree.tsx b/otel-ui/src/components/common/JsonTree.tsx new file mode 100644 index 0000000..07c0d8e --- /dev/null +++ b/otel-ui/src/components/common/JsonTree.tsx @@ -0,0 +1,65 @@ +import { useState } from "react" +import { ChevronRight } from "lucide-react" + +const mono = "font-[family-name:var(--font-data)] text-[12px]" + +const Scalar = ({ value }: { value: unknown }) => { + if (value === null) return null + if (typeof value === "string") { + return "{value}" + } + return {String(value)} +} + +const isContainer = (v: unknown): v is object => typeof v === "object" && v !== null + +const preview = (v: object) => + Array.isArray(v) ? `[${v.length}]` : `{${Object.keys(v).slice(0, 4).join(", ")}${Object.keys(v).length > 4 ? ", …" : ""}}` + +const Node = ({ name, value, depth }: { name?: string; value: unknown; depth: number }) => { + const container = isContainer(value) + const size = container ? Object.keys(value).length : 0 + // Big arrays (e.g. breadcrumbs) start collapsed so the rest of the payload stays visible. + const [open, setOpen] = useState(depth < 2 && !(Array.isArray(value) && size > 8)) + + const label = name !== undefined && {name}: + if (!container) { + return ( +
+ {label} + +
+ ) + } + const entries = Object.entries(value) + return ( +
+ + {open && size > 0 && ( +
+ {entries.map(([k, v]) => ( + + ))} +
+ )} +
+ ) +} + +/** Collapsible, read-only JSON viewer. Pass already-deep-parsed data. */ +export const JsonTree = ({ value, label }: { value: unknown; label: string }) => ( +
+ +
+) diff --git a/otel-ui/src/components/common/VitalsCard.tsx b/otel-ui/src/components/common/VitalsCard.tsx new file mode 100644 index 0000000..6424e1d --- /dev/null +++ b/otel-ui/src/components/common/VitalsCard.tsx @@ -0,0 +1,73 @@ +import { VITAL_THRESHOLDS, getVitalRating, type VitalRating } from "../../lib/rum-utils" + +interface Metric { + value: number + attribution?: Record +} + +const ORDER = ["LCP", "INP", "CLS", "FCP", "TTFB"] as const + +const tone: Record = { + good: "text-ds-ok", + "needs-improvement": "text-ds-warn", + poor: "text-ds-critical", +} +const bar: Record = { good: "bg-ds-ok", "needs-improvement": "bg-ds-warn", poor: "bg-ds-critical" } + +/** Attribution values ending in these are millisecond timings. */ +const isDuration = (k: string) => /(Delay|Duration|Time|timeToFirstByte|firstByteToFCP)$/.test(k) && !/Target$/.test(k) + +const fmt = (k: string, v: string | number | boolean) => + typeof v === "number" ? (k === "largestShiftValue" ? v.toFixed(3) : isDuration(k) ? `${Math.round(v)}ms` : String(v)) : String(v) + +/** Parses a page_vitals log body; undefined when it isn't one. */ +export const parseVitalsBody = (body: string): Record | undefined => { + try { + const metrics = (JSON.parse(body) as { metrics?: Record }).metrics + return metrics && Object.keys(metrics).length > 0 ? metrics : undefined + } catch { + return undefined + } +} + +/** One card per Core Web Vital: value, rating, position against the good/poor thresholds, and what caused it. */ +export const VitalsCard = ({ metrics }: { metrics: Record }) => ( +
+ {ORDER.filter((n) => metrics[n]).map((name) => { + const m = metrics[name] + const t = VITAL_THRESHOLDS[name] + const rating = getVitalRating(name, m.value) + // Scale so the "poor" boundary sits at 2/3 of the track. + const pct = Math.min(100, (m.value / (t.needsImprovement * 1.5)) * 100) + const attr = Object.entries(m.attribution ?? {}) + return ( +
+
+ {name} + {rating.replace("-", " ")} +
+ {t.format(m.value)} +
+
+ + +
+ {attr.length > 0 && ( +
+ {attr.map(([k, v]) => ( +
+
{k}
+
{fmt(k, v)}
+
+ ))} +
+ )} +
+ ) + })} +
+) diff --git a/otel-ui/src/lib/logParse.ts b/otel-ui/src/lib/logParse.ts new file mode 100644 index 0000000..41faa2f --- /dev/null +++ b/otel-ui/src/lib/logParse.ts @@ -0,0 +1,70 @@ +/** + * Helpers for making log bodies/attributes readable: expanding JSON that was + * encoded inside strings, and reducing a message to a template so similar + * records can be grouped. + */ + +const looksLikeJson = (s: string) => { + const t = s.trim() + return (t.startsWith("{") && t.endsWith("}")) || (t.startsWith("[") && t.endsWith("]")) +} + +/** Recursively parses strings that contain JSON ("JSON in JSON"), at any depth. */ +export const deepParse = (value: unknown, depth = 0): unknown => { + if (depth > 8) return value + if (typeof value === "string") { + if (!looksLikeJson(value)) return value + try { + return deepParse(JSON.parse(value), depth + 1) + } catch { + return value + } + } + if (Array.isArray(value)) return value.map((v) => deepParse(v, depth + 1)) + if (value && typeof value === "object") { + return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, deepParse(v, depth + 1)])) + } + return value +} + +/** Parsed structure for a string when it is (nested) JSON, otherwise undefined. */ +export const parseStructured = (s: string): object | undefined => { + if (!looksLikeJson(s)) return undefined + try { + const parsed = deepParse(JSON.parse(s)) + return parsed && typeof parsed === "object" ? parsed : undefined + } catch { + return undefined + } +} + +const MESSAGE_KEYS = ["message", "msg", "event", "error", "err"] + +/** A one-line summary for list rows: the message field of a JSON body, or the raw text. */ +export const bodyHeadline = (body: string): string => { + const parsed = parseStructured(body) + if (parsed && !Array.isArray(parsed)) { + const obj = parsed as Record + for (const k of MESSAGE_KEYS) if (typeof obj[k] === "string" && obj[k]) return obj[k] as string + } + return body +} + +/** Message with volatile parts (ids, numbers, quoted values) replaced by placeholders. */ +export const messageTemplate = (body: string): string => { + const parsed = parseStructured(body) + let text = body + if (parsed) { + const headline = bodyHeadline(body) + text = headline !== body ? headline : Array.isArray(parsed) ? "[array]" : `{${Object.keys(parsed).sort().join(",")}}` + } + return text + .replace(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi, "") + .replace(/\b0x[0-9a-f]+\b|\b[0-9a-f]{16,}\b/gi, "") + .replace(/https?:\/\/\S+/g, "") + .replace(/"[^"]*"|'[^']*'/g, "") + .replace(/\d+(\.\d+)?/g, "") + .replace(/\s+/g, " ") + .trim() + .slice(0, 200) +} diff --git a/otel-ui/src/lib/rum-utils.ts b/otel-ui/src/lib/rum-utils.ts index ef737a0..96d339b 100644 --- a/otel-ui/src/lib/rum-utils.ts +++ b/otel-ui/src/lib/rum-utils.ts @@ -106,8 +106,8 @@ interface RawFrontendRecord { release?: string environment?: string path?: string - vitalName?: string - vitalValue?: number + /** Metric values from a `page_vitals` beacon record (one per page view snapshot). */ + vitals?: Record isError: boolean isHttpFailure: boolean exceptionType?: string @@ -121,6 +121,20 @@ interface RawFrontendRecord { breadcrumbs: ParsedBreadcrumb[] } +/** Reads `{metrics: {LCP: {value}}}` from a page_vitals record body into name → value. */ +function parseVitals(body?: string): Record { + const out: Record = {} + try { + const metrics = (JSON.parse(body ?? "") as { metrics?: Record }).metrics ?? {} + for (const [name, m] of Object.entries(metrics)) { + if (typeof m?.value === "number") out[name.toUpperCase()] = m.value + } + } catch { + /* not a vitals body */ + } + return out +} + function extractFrontendRecords(logs: LogEntry[]): RawFrontendRecord[] { const out: RawFrontendRecord[] = [] @@ -134,13 +148,13 @@ function extractFrontendRecords(logs: LogEntry[]): RawFrontendRecord[] { ;(sl.logRecords ?? []).forEach((r) => { const attrs = r.attributes ?? [] const source = getAttributeValue(attrs, "source") - const vitalName = getAttributeValue(attrs, "rum.vital") + const isVitals = getAttributeValue(attrs, "rum.event") === "page_vitals" const browserUrl = getAttributeValue(attrs, "browser.url") const sessionId = getAttributeValue(attrs, "session.id") const isRumScope = scopeName === "otel-rum" const isFrontend = - source === "frontend" || isRumScope || Boolean(vitalName) || Boolean(browserUrl) || Boolean(sessionId) + source === "frontend" || isRumScope || isVitals || Boolean(browserUrl) || Boolean(sessionId) if (!isFrontend) return let path: string | undefined @@ -157,7 +171,6 @@ function extractFrontendRecords(logs: LogEntry[]): RawFrontendRecord[] { const sevNum = r.severityNumber ?? 0 const isErr = (sevNum >= 17 || excType !== "") && !isHttp - const vitalValStr = getAttributeValue(attrs, "rum.value") const httpStatusStr = getAttributeValue(attrs, "http.response.status_code") out.push({ @@ -168,8 +181,7 @@ function extractFrontendRecords(logs: LogEntry[]): RawFrontendRecord[] { release: getAttributeValue(attrs, "service.release") || undefined, environment: getAttributeValue(attrs, "service.environment") || undefined, path, - vitalName: vitalName || undefined, - vitalValue: vitalValStr ? parseFloat(vitalValStr) : undefined, + vitals: isVitals ? parseVitals(r.body?.stringValue) : undefined, isError: isErr, isHttpFailure: isHttp, exceptionType: excType || undefined, @@ -227,9 +239,8 @@ export function buildRumSessions(logs: LogEntry[]): RumSession[] { seenUrls.add(r.path) urls.push(r.path) } - if (r.vitalName && r.vitalValue !== undefined) { - vitals[r.vitalName.toUpperCase()] = r.vitalValue - } + // Each beacon is a full snapshot of one page view; later snapshots win. + if (r.vitals) Object.assign(vitals, r.vitals) if (r.isError) { errorCount += 1 errors.push({ diff --git a/otel-ui/vite.config.ts b/otel-ui/vite.config.ts index f85f2a2..559f8db 100644 --- a/otel-ui/vite.config.ts +++ b/otel-ui/vite.config.ts @@ -44,6 +44,7 @@ export default defineConfig({ "/ws": { target: target, ws: true, + changeOrigin: true, }, }, }, diff --git a/static/otel-rum.js b/static/otel-rum.js index 6289977..d143b03 100644 --- a/static/otel-rum.js +++ b/static/otel-rum.js @@ -3,9 +3,10 @@ * * Captures uncaught errors, unhandled promise rejections, failed resource * loads, failing HTTP requests and Web Vitals (LCP/INP/CLS/TTFB/FCP), and - * ships them as OTLP log records to this server's existing /v1/logs endpoint + * ships errors as OTLP log records to this server's existing /v1/logs endpoint * (no separate ingestion path needed - the backend's issue view picks these - * up like any other error log). + * up like any other error log). Web Vitals go out separately: one beacon per + * page view to /v1/vitals, measured with the vendored web-vitals library. * * Also exposes a `traceHeaders()` helper so outgoing `fetch`/XHR calls can * carry a W3C `traceparent` header. If the backend service is itself @@ -114,11 +115,18 @@ endpoint = baseUrl + '/' + endpoint.replace(/^\/+/, ''); } + var vitalsEndpoint = config.vitalsEndpoint || (scriptEl && scriptEl.getAttribute('data-vitals-endpoint')) || baseUrl + '/v1/vitals'; + // The web-vitals build lives next to this script; fall back to the collector's /static. + var vitalsLibUrl = + config.vitalsLibUrl || + (scriptEl && scriptEl.getAttribute('data-vitals-lib')) || + (scriptEl && scriptEl.src ? scriptEl.src.replace(/[^\/]*$/, '') + 'web-vitals.attribution.iife.js' : baseUrl + '/static/web-vitals.attribution.iife.js'); + function isCollectorUrl(u) { if (!u) return false; var s = String(u); if (endpoint && s.indexOf(endpoint) === 0) return true; - if (s.indexOf('/v1/logs') !== -1 || s.indexOf('/v1/traces') !== -1 || s.indexOf('/v1/metrics') !== -1) return true; + if (s.indexOf('/v1/logs') !== -1 || s.indexOf('/v1/traces') !== -1 || s.indexOf('/v1/metrics') !== -1 || s.indexOf('/v1/vitals') !== -1) return true; return false; } @@ -294,92 +302,108 @@ enqueue(buildRecord(severity, message, attrs, traceId)); } - // Web Vitals via PerformanceObserver. Values are reported as INFO logs - // tagged rum.vital=; the server can chart/aggregate them like - // any other telemetry. Each vital fires once when it reaches its - // final value (plus a first-input proxy for INP on older browsers). + // Web Vitals: measured by the vendored `web-vitals` library (attribution + // build, loaded lazily so it never blocks the page) and reported as ONE + // beacon per page view to /v1/vitals when the page is hidden. The + // beacon carries every metric with its attribution (LCP element, INP + // target/breakdown, CLS shift source...). If the page is hidden again + // later (tab switch) and a metric changed - typically INP - a fuller + // snapshot with the same pageView id is sent; the newest one wins. function observeVitals() { - if (typeof global.PerformanceObserver !== 'function') return; + var pageViewId = randomHex(16); + var vitals = {}; + var navType = ''; + var dirty = false; + var sampled = sampleRate >= 1 || Math.random() <= sampleRate; - function report(name, value, extra) { - var attrs = Object.assign({ 'rum.vital': name, 'rum.value': Math.round(value) }, extra || {}); - // Vitals are telemetry, not errors: bypass the error rate limit - // but keep sampling. - if (sampleRate < 1 && Math.random() > sampleRate) return; - enqueue(buildRecord('INFO', name + ' ' + Math.round(value), attrs, '')); + var picks = { + LCP: ['target', 'url', 'timeToFirstByte', 'resourceLoadDelay', 'resourceLoadDuration', 'elementRenderDelay'], + INP: ['interactionTarget', 'interactionType', 'inputDelay', 'processingDuration', 'presentationDelay', 'loadState', 'totalScriptDuration', 'totalStyleAndLayoutDuration', 'totalPaintDuration'], + CLS: ['largestShiftTarget', 'largestShiftValue', 'largestShiftTime', 'loadState'], + FCP: ['timeToFirstByte', 'firstByteToFCP', 'loadState'], + TTFB: ['waitingDuration', 'cacheDuration', 'dnsDuration', 'connectionDuration', 'requestDuration'] + }; + + // Values keep 3 decimals: CLS is a tiny fraction and must not be rounded to an integer. + function round(n) { + return typeof n === 'number' ? Math.round(n * 1000) / 1000 : n; } - function onPaint(list) { - list.getEntries().forEach(function (entry) { - if (entry.name === 'first-contentful-paint') report('FCP', entry.startTime); + function slim(metric) { + var out = {}; + var src = metric.attribution || {}; + (picks[metric.name] || []).forEach(function (k) { + if (src[k] !== undefined && src[k] !== null) out[k] = round(src[k]); }); + var script = src.longestScript && src.longestScript.entry; + if (script && script.sourceURL) { + out.longestScriptUrl = script.sourceURL; + if (script.sourceFunctionName) out.longestScriptFunction = script.sourceFunctionName; + } + return out; } - function onLCP(list) { - var entries = list.getEntries(); - var last = entries[entries.length - 1]; - if (last) report('LCP', last.startTime, { 'rum.element': last.element && last.element.tagName }); + function onMetric(metric) { + vitals[metric.name] = { value: round(metric.value), delta: round(metric.delta), id: metric.id, attribution: slim(metric) }; + navType = metric.navigationType || navType; + dirty = true; } - function onCLS() { - var cls = 0; - var lastReport = 0; - var observer = new global.PerformanceObserver(function (list) { - list.getEntries().forEach(function (entry) { - if (!entry.hadRecentInput) cls += entry.value; - }); - // Throttle: report at most once per 10s, plus a final value on - // pagehide, instead of once per layout shift. - var now = Date.now(); - if (now - lastReport > 10000) { - lastReport = now; - report('CLS', Math.round(cls * 1000) / 1000); - } - clsFinal = function () { - report('CLS', Math.round(cls * 1000) / 1000); - clsFinal = null; - }; + function sendVitals() { + if (!dirty || !sampled) return; + dirty = false; + var body = JSON.stringify({ + service: serviceName, + environment: environment, + release: release, + session: sessionId, + pageView: pageViewId, + url: global.location ? global.location.href : '', + ua: global.navigator ? global.navigator.userAgent : '', + viewport: global.innerWidth ? global.innerWidth + 'x' + global.innerHeight : '', + nav: navType, + conn: (global.navigator && global.navigator.connection && global.navigator.connection.effectiveType) || '', + metrics: vitals }); + var url = vitalsEndpoint; + if (ingestKey) url += (url.indexOf('?') === -1 ? '?' : '&') + 'key=' + encodeURIComponent(ingestKey); + // text/plain keeps it a CORS "simple" request: no preflight, so it survives unload. + var queued = false; try { - observer.observe({ type: 'layout-shift', buffered: true }); - } catch (e) { - /* unsupported */ - clsFinal = null; - } - } - - function onINP(entry) { - var duration = entry.duration; - var interactionId = entry.interactionId; - if (!interactionId) return; - if (duration > (observerState.maxINP || 0)) { - observerState.maxINP = duration; - report('INP', duration); - } - } - - var observerState = {}; - - try { - new global.PerformanceObserver(onPaint).observe({ type: 'paint', buffered: true }); - } catch (e) {} - try { - new global.PerformanceObserver(onLCP).observe({ type: 'largest-contentful-paint', buffered: true }); - } catch (e) {} - onCLS(); - try { - new global.PerformanceObserver(function (list) { - list.getEntries().forEach(onINP); - }).observe({ type: 'event', buffered: true, durationThreshold: 40 }); - } catch (e) {} - - // Navigation timing: TTFB. - try { - var nav = performance.getEntriesByType('navigation')[0]; - if (nav && nav.responseStart) { - report('TTFB', nav.responseStart); - } + queued = + !!(global.navigator && typeof global.navigator.sendBeacon === 'function') && + global.navigator.sendBeacon(url, new global.Blob([body], { type: 'text/plain;charset=UTF-8' })); } catch (e) {} + if (!queued && rawFetch) { + rawFetch.call(global, url, { method: 'POST', mode: 'cors', headers: { 'Content-Type': 'text/plain;charset=UTF-8' }, body: body, keepalive: true }).catch(function () {}); + } + } + + var script = global.document.createElement('script'); + script.async = true; + script.src = vitalsLibUrl; + script.onload = function () { + var wv = global.webVitals; + if (!wv) return; + // INP durationThreshold stays at the library default; one listener per metric. + [wv.onLCP, wv.onINP, wv.onCLS, wv.onFCP, wv.onTTFB].forEach(function (observe) { + if (typeof observe === 'function') observe(onMetric); + }); + // Registered after the library's own hidden listeners, so the + // final LCP/INP/CLS values are already collected when this runs. + global.addEventListener('visibilitychange', function () { + if (global.document.visibilityState === 'hidden') sendVitals(); + }); + global.addEventListener('pagehide', sendVitals); + // bfcache restore is a new page view for vitals purposes. + global.addEventListener('pageshow', function (e) { + if (e.persisted) { + pageViewId = randomHex(16); + vitals = {}; + } + }); + }; + global.document.head.appendChild(script); } // traceHeaders returns a headers object carrying a fresh (or @@ -421,7 +445,6 @@ flush(); // ensure earlier queued events don't mix contexts ambiguously } var contextAttrs = {}; - var clsFinal = null; function onError(event) { // Resource load errors (img/script/link) surface as an ErrorEvent-less @@ -631,12 +654,10 @@ // errors that happen right before navigation are not lost, and emit // the final CLS value. global.addEventListener('pagehide', function () { - if (clsFinal) clsFinal(); flush(); }); global.addEventListener('visibilitychange', function () { if (global.document && global.document.visibilityState === 'hidden') { - if (clsFinal) clsFinal(); flush(); } }); diff --git a/static/web-vitals.attribution.iife.js b/static/web-vitals.attribution.iife.js new file mode 100644 index 0000000..cb83d72 --- /dev/null +++ b/static/web-vitals.attribution.iife.js @@ -0,0 +1 @@ +var webVitals=function(t){"use strict";class n{t;o=0;i=[];l(t){if(t.hadRecentInput)return;const n=this.i[0],e=this.i.at(-1);this.o&&n&&e&&t.startTime-e.startTime<1e3&&t.startTime-n.startTime<5e3?(this.o+=t.value,this.i.push(t)):(this.o=t.value,this.i=[t]),this.t?.(t)}}const e=()=>{const t=performance.getEntriesByType("navigation")[0];if(t&&t.responseStart>0&&t.responseStart{if("loading"===document.readyState)return"loading";const n=e();if(n){if(t{const n=t.nodeName;return 1===t.nodeType?n.toLowerCase():n.toUpperCase().replace(/^#/,"")},a=t=>{let n="";try{for(;9!==t?.nodeType;){const e=t,o=e.id?"#"+e.id:[i(e),...Array.from(e.classList??[]).sort()].join(".");if(n.length+o.length>99)return n||o;if(n=n?o+">"+n:o,e.id)break;t=e.parentNode}}catch{}return n},r=new WeakMap;function s(t,n){let e=r.get(n);return e||(e=new WeakMap,r.set(n,e)),e.get(t)||e.set(t,new n),e.get(t)}let c=-1;const f=()=>c,l=t=>{addEventListener("pageshow",n=>{n.persisted&&(c=n.timeStamp,t(n))},!0)},u=(t,n,e,o)=>{let i,a;return r=>{n.value>=0&&(r||o)&&(a=n.value-(i??0),(a||void 0===i)&&(i=n.value,n.delta=a,n.rating=((t,n)=>t>n[1]?"poor":t>n[0]?"needs-improvement":"good")(n.value,e),t(n)))}},d=t=>{requestAnimationFrame(()=>requestAnimationFrame(()=>t()))},h=()=>e()?.activationStart??0;let g=-1;const v=new Set,p=()=>"hidden"!==document.visibilityState||document.prerendering?1/0:0,m=t=>{if("hidden"===document.visibilityState){if("visibilitychange"===t.type)for(const t of v)t();isFinite(g)||(g="visibilitychange"===t.type?t.timeStamp:0,removeEventListener("prerenderingchange",m,!0))}},y=(t=!1)=>{if(t&&(g=1/0),g<0){const t=h(),n=document.prerendering?void 0:globalThis.performance.getEntriesByType("visibility-state").find(n=>"hidden"===n.name&&n.startTime>=t)?.startTime;g=n??p(),addEventListener("visibilitychange",m,!0),addEventListener("prerenderingchange",m,!0),l(()=>{setTimeout(()=>{g=p()})})}return{get firstHiddenTime(){return g},onHidden(t){v.add(t)}}},b=(t,n=-1,o,i=0,a,r,s)=>{const c=e(),l=c?.navigationId||0;let u="navigate";o?u=o:f()>=0?u="back-forward-cache":c&&(document.prerendering||h()>0?u="prerender":document.wasDiscarded?u="restore":c.type&&(u=c.type.replace(/_/g,"-")));return{name:t,value:n,rating:"good",delta:0,entries:[],id:`v6-${Date.now()}-${Math.floor(8999999999999*Math.random())+1e12}`,navigationType:u,navigationId:i||l,navigationInteractionId:a,navigationURL:r||c?.name,navigationStartTime:s||0}},M=(t,n,e={})=>{try{const o=t.filter(t=>PerformanceObserver.supportedEntryTypes.includes(t));if(o.length>0){const t=new PerformanceObserver(t=>{queueMicrotask(()=>{const e=t.getEntries();o.length>1&&e.sort((t,n)=>t.startTime+t.duration-(n.startTime+n.duration)),n(e)})});for(const n of o)t.observe({type:n,buffered:!0,...e});return t}}catch{}},T=t=>globalThis.PerformanceObserver?.supportedEntryTypes?.includes("soft-navigation")&&"function"==typeof globalThis.PerformanceSoftNavigation?.prototype?.getLargestInteractionContentfulPaint&&t&&t.reportSoftNavs,E=(t,n)=>{if(t.set(n.navigationId,n),t.size>2){const n=t.keys().next().value;void 0!==n&&t.delete(n)}},D=t=>{let n=!1;return()=>{n||(t(),n=!0)}};class w{u}const S=t=>{document.prerendering?addEventListener("prerenderingchange",t,!0):t()},k=[1800,3e3],L=(t,n={})=>{const e=T(n);S(()=>{const o=s(n,w),i=y();let a,r=b("FCP");const c=M(["paint"],t=>{for(const n of t)"first-contentful-paint"===n.name&&(c.disconnect(),n.startTime{r=b("FCP",-1,"back-forward-cache",r.navigationId,r.navigationInteractionId,r.navigationURL,f()),a=u(t,r,k,n.reportAllChanges),d(()=>{r.value=performance.now()-e.timeStamp,a(!0)})})),e){M(["soft-navigation"],e=>{e.forEach(e=>{o.u&&e.navigationId&&E(o.u,e);const i=Math.max((e.presentationTime||e.paintTime||0)-e.startTime,0);r=b("FCP",i,"soft-navigation",e.navigationId,e.interactionId,e.name,e.startTime),a=u(t,r,k,n.reportAllChanges),a(!0)})},n)}})},P=[.1,.25],F=t=>t.find(t=>1===t.node?.nodeType)||t[0];let _=0,C=1/0,I=0;const B=t=>{for(const n of t)n.interactionId&&(C=Math.min(C,n.interactionId),I=Math.max(I,n.interactionId),_=I?(I-C)/7+1:0)};let O;const j=()=>O?_:performance.interactionCount??0,A=()=>{"interactionCount"in performance||O||(O=M(["event"],B,{durationThreshold:0}))};class N{h=0;v=[];p=new Map;m;M;T(){return j()-this.h}D(){this.h=j(),this.v.length=0,this.p.clear()}S(t){const n=this.T(),e=Math.min(this.v.length-1,Math.floor(n/50));return!n||-1!==e||"soft-navigation"!==t&&"back-forward-cache"!==t?this.v[e]:{k:8,id:-1,entries:[]}}l(t){if(this.m?.(t),!t.interactionId)return;const n=this.v.at(-1);let e=this.p.get(t.interactionId);if(e||this.v.length<10||t.duration>n.k){if(e?t.duration>e.k?(e.entries=[t],e.k=t.duration):t.duration===e.k&&t.startTime===e.entries[0].startTime&&e.entries.push(t):(e={id:t.interactionId,entries:[t],k:t.duration},this.p.set(e.id,e),this.v.push(e)),this.v.sort((t,n)=>n.k-t.k),this.v.length>10){const t=this.v.splice(10);for(const n of t)this.p.delete(n.id)}this.M?.(e)}}}const q=t=>{const n="requestIdleCallback"in globalThis?1e3:0,e=globalThis.requestIdleCallback||setTimeout,o=globalThis.cancelIdleCallback||clearTimeout;if("hidden"===document.visibilityState)t();else{const i=D(t);let a=-1;const r=()=>{o(a),i()};addEventListener("visibilitychange",r,{once:!0,capture:!0}),a=e(()=>{removeEventListener("visibilitychange",r,{capture:!0}),i()},{timeout:n})}},W=[200,500];class R{m;u;l(t){this.m?.(t)}}const U=[2500,4e3];let V=50;const $=[];M(["resource"],t=>{for(const n of t)$.push(n),$.length>V&&$.shift()});const x=[800,1800],H=t=>{document.prerendering?S(()=>H(t)):"complete"!==document.readyState?addEventListener("load",()=>H(t),!0):setTimeout(t)};return t.CLSThresholds=P,t.FCPThresholds=k,t.INPThresholds=W,t.LCPThresholds=U,t.TTFBThresholds=x,t.onCLS=(t,e={})=>{const i=s(e=Object.assign({},e),n),r=new WeakMap;i.t=t=>{if(t?.sources?.length){const n=F(t.sources),o=n?.node;if(o){const t=e.generateTarget?.(o)??a(o);r.set(n,t)}}};((t,e={})=>{const o=y();L(D(()=>{let i,a=b("CLS",0);const r=s(e,n),c=(n,o,s,c,f)=>{a=b("CLS",0,n,o,s,c,f),r.o=0,i=u(t,a,P,e.reportAllChanges)},h=(t=!1)=>{r.o>a.value&&(a.value=r.o,a.entries=r.i),i(t)},g=t=>{h(!0),c("soft-navigation",t.navigationId,t.interactionId,t.name,t.startTime)},v=t=>{for(const n of t)"soft-navigation"!==n.entryType?r.l(n):g(n);h()},p=["layout-shift"];T(e)&&p.push("soft-navigation");const m=M(p,v);m&&(i=u(t,a,P,e.reportAllChanges),o.onHidden(()=>{v(m.takeRecords()),i(!0)}),l(()=>{c("back-forward-cache",a.navigationId,a.navigationInteractionId,a.navigationURL,f()),d(i)}),setTimeout(i))}))})(n=>{t((t=>{let n={};if(t.entries.length){const e=t.entries.reduce((t,n)=>t.value>n.value?t:n);if(e?.sources?.length){const t=F(e.sources);t&&(n={largestShiftTarget:r.get(t),largestShiftTime:e.startTime,largestShiftValue:e.value,largestShiftSource:t,largestShiftEntry:e,loadState:o(e.startTime)})}}return Object.assign(t,{attribution:n})})(n))},e)},t.onFCP=(t,n={})=>{const i=s(n=Object.assign({},n),w);T(n)&&(i.u=new Map);L(n=>{t((t=>{let n={timeToFirstByte:0,firstByteToFCP:t.value,loadState:o(f())};if("soft-navigation"!==t.navigationType){if(t.entries.length){const i=e(),a=t.entries.at(-1);if(i){const e=i.responseStart,r=i.activationStart||0,s=Math.max(0,e-r);n={timeToFirstByte:s,firstByteToFCP:t.value-s,loadState:o(t.entries[0].startTime),navigationEntry:i,fcpEntry:a}}}}else{const e=i.u?.get(t.navigationId);e&&(n={timeToFirstByte:0,firstByteToFCP:t.value,loadState:"complete",navigationEntry:e})}return Object.assign(t,{attribution:n})})(n))},n)},t.onINP=(t,n={})=>{const e=s(n=Object.assign({},n),N);let i=[],r=[],c=0;const d=new WeakMap,h=new WeakMap;let g=!1;const v=()=>{g||(q(p),g=!0)},p=()=>{const t=new Set(e.v.map(t=>d.get(t.entries[0]))),n=r.length-10;r=r.filter((e,o)=>o>=n||t.has(e));const o=new Set;for(const t of r){const n=m(t.startTime,t.processingEnd);for(const t of n)o.add(t)}const a=i.length-10;i=i.filter((t,n)=>o.has(t)||n>=a&&t.startTime>c),g=!1};e.m=t=>{const e=t.startTime+t.duration;let o;c=Math.max(c,t.processingEnd);for(let i=r.length-1;i>=0;i--){const a=r[i];if(Math.abs(e-a.renderTime)<=8){o=a,o.startTime=Math.min(t.startTime,o.startTime),o.processingStart=Math.min(t.processingStart,o.processingStart),o.processingEnd=Math.max(t.processingEnd,o.processingEnd),n.includeProcessedEventEntries&&o.entries.push(t);break}}o||(o={startTime:t.startTime,processingStart:t.processingStart,processingEnd:t.processingEnd,renderTime:e,entries:n.includeProcessedEventEntries?[t]:[]},r.push(o)),t.interactionId&&d.set(t,o),v()},e.M=t=>{if(!h.get(t)){const e=t.entries.find(t=>t.target)?.target;if(e){const o=n.generateTarget?.(e)??a(e);h.set(t,o)}else{const n=t.entries.find(t=>t.targetSelector)?.targetSelector;n&&h.set(t,n)}}};const m=(t,n)=>{const e=[];for(const o of i)if(!(o.startTime+o.durationn)break;e.push(o)}return e},E=t=>{if(0===t.entries.length){const n=t.navigationStartTime||0,e={processedEventEntries:[],longAnimationFrameEntries:[],inputDelay:0,processingDuration:0,presentationDelay:t.value,loadState:o(n)};return Object.assign(t,{attribution:e})}const n=t.entries[0],i=d.get(n),a=Math.max(i.processingStart,n.startTime),r=Math.max(n.startTime+n.duration,a),s=Math.min(i.processingEnd,r),c=i.entries.sort((t,n)=>t.processingStart-n.processingStart),f=m(n.startTime,s),l=e.p.get(n.interactionId),u={interactionTarget:h.get(l),interactionType:n.name.startsWith("key")?"keyboard":"pointer",interactionTime:n.startTime,nextPaintTime:r,processedEventEntries:c,longAnimationFrameEntries:f,inputDelay:a-n.startTime,processingDuration:s-a,presentationDelay:r-s,loadState:o(n.startTime),longestScript:void 0,totalScriptDuration:void 0,totalStyleAndLayoutDuration:void 0,totalPaintDuration:void 0,totalUnattributedDuration:void 0};return(t=>{const n=t.interactionTime,e=t.nextPaintTime;if(!t.longAnimationFrameEntries?.length||!n||!e)return;const o=t.inputDelay,i=t.processingDuration;let a,r,s=0,c=0,f=0,l=0;for(const e of t.longAnimationFrameEntries){c=c+e.startTime+e.duration-e.styleAndLayoutStart;for(const t of e.scripts){const e=t.startTime+t.duration;if(el&&(r=t.startTime=n+o+i?"presentation-delay":"processing-duration",a=t,l=f)}}const u=t.longAnimationFrameEntries.at(-1),d=u?u.startTime+u.duration:0;d>=n+o+i&&(f=e-d),a&&r&&(t.longestScript={entry:a,subpart:r,intersectingDuration:l}),t.totalScriptDuration=s,t.totalStyleAndLayoutDuration=c,t.totalPaintDuration=f,t.totalUnattributedDuration=e-n-s-c-f})(u),Object.assign(t,{attribution:u})};M(["long-animation-frame"],t=>{i=i.concat(t),v()},n),((t,n={})=>{if(!globalThis.PerformanceEventTiming||!("interactionId"in PerformanceEventTiming.prototype))return;const e=y();S(()=>{A();let o,i=b("INP");const a=s(n,N),r=(e,r,s,c,f)=>{a.D(),i=b("INP",-1,e,r,s,c,f),o=u(t,i,W,n.reportAllChanges)},c=()=>{const t=a.S(i.navigationType);t&&t.k!==i.value&&(i.value=t.k,i.entries=t.entries,o())},d=t=>{c(),o(!0),r("soft-navigation",t.navigationId,t.interactionId,t.name,t.startTime)},h=(t,n=!1)=>{q(()=>{for(const n of t)"soft-navigation"!==n.entryType?a.l(n):d(n);c(),n&&o(!0)})},g=["event","first-input"];T(n)&&g.push("soft-navigation");const v=M(g,h,{...n,durationThreshold:n.durationThreshold??40});o=u(t,i,W,n.reportAllChanges),v&&(e.onHidden(()=>{h(v.takeRecords(),!0)}),l(()=>{r("back-forward-cache",i.navigationId,i.navigationInteractionId,i.navigationURL,f())}))})})(n=>{t(E(n))},n)},t.onLCP=(t,n={})=>{null!=(n=Object.assign({},n)).resourceBufferSize&&(V=n.resourceBufferSize);const o=s(n,R),i=new WeakMap;T(n)&&(o.u=new Map),o.m=t=>{const e=t.element;if(e){const o=n.generateTarget?.(e)??a(e);i.set(t,o)}else t.id&&i.set(t,`#${t.id}`)};((t,n={})=>{let e=!1;const o=T(n);S(()=>{let i,a=y(),r=b("LCP");const c=s(n,R),g=(o,s,c,f,l)=>{r=b("LCP",-1,o,s,c,f,l),i=u(t,r,U,n.reportAllChanges),e=!1,"soft-navigation"===o&&(a=y(!0))},v=t=>{c.u&&t.navigationId&&E(c.u,t),e||i(!0),g("soft-navigation",t.navigationId,t.interactionId,t.name,t.startTime);const n=t.getLargestInteractionContentfulPaint?.();n&&p([n])},p=t=>{n.reportAllChanges||o||(t=t.slice(-1));for(const n of t){if(!n)continue;if("soft-navigation"===n.entryType){v(n);continue}let t=0,e=[],o=n.startTime;if("largest-contentful-paint"===n.entryType)t=Math.max(n.startTime-h(),0),c.l(n),e=[n];else if("interaction-contentful-paint"===n.entryType){const i=n;if(!r.navigationId)continue;if("interactionId"in i&&i.interactionId!=r.navigationInteractionId)continue;o=i.largestContentfulPaint?.renderTime||0,t=Math.max(o-n.startTime,0),i.largestContentfulPaint&&(c.l(i.largestContentfulPaint),e=[i.largestContentfulPaint])}o{if(t.isTrusted&&!e){const t=r.id;q(()=>{if(!e){if(!o){T.disconnect();for(const t of a)removeEventListener(t,s,{capture:!0})}t===r.id&&(e=!0,i(!0))}})}};for(const t of a)addEventListener(t,s,{capture:!0});l(o=>{g("back-forward-cache",r.navigationId,r.navigationInteractionId,r.navigationURL,f()),i=u(t,r,U,n.reportAllChanges),d(()=>{r.value=performance.now()-o.timeStamp,e=!0,i(!0)})})}})})(n=>{t((t=>{let n={timeToFirstByte:0,resourceLoadDelay:0,resourceLoadDuration:0,elementRenderDelay:t.value};if(t.entries.length){const a=t.entries.at(-1),r=a.url&&($.findLast(t=>t.name===a.url)||performance.getEntriesByType("resource").findLast(t=>t.name===a.url));let s;n.target=i.get(a),n.lcpEntry=a,a.url&&(n.url=a.url),r&&(n.lcpResourceEntry=r);let c=0,f=0;if("soft-navigation"!==t.navigationType?(s=e(),c=s?.activationStart??0,f=s?.responseStart??0):(c=t.navigationStartTime||0,s=o.u?.get(t.navigationId)),s){const e=Math.max(0,f-c),o=Math.max(e,r?(r.requestStart||r.startTime)-c:0),i=Math.min(t.value,Math.max(o,r?r.responseEnd-c:0));n={...n,timeToFirstByte:e,resourceLoadDelay:o-e,resourceLoadDuration:i-o,elementRenderDelay:t.value-i,navigationEntry:s}}}return Object.assign(t,{attribution:n})})(n))},n)},t.onTTFB=(t,n={})=>{((t,n={})=>{const o=T(n);let i=b("TTFB"),a=u(t,i,x,n.reportAllChanges);H(()=>{const r=e();if(r){const e=r.responseStart;i.value=Math.max(e-h(),0),i.entries=[r],a(!0),l(()=>{i=b("TTFB",0,"back-forward-cache",i.navigationId,i.navigationInteractionId,i.navigationURL,f()),a=u(t,i,x,n.reportAllChanges),a(!0)}),o&&M(["soft-navigation"],e=>{e.forEach(e=>{e.navigationId&&(i=b("TTFB",0,"soft-navigation",e.navigationId,e.interactionId,e.name,e.startTime),i.entries=[e],a=u(t,i,x,n.reportAllChanges),a(!0))})},n)}})})(n=>{t((t=>{const n=t.entries[0];let e={waitingDuration:0,cacheDuration:0,dnsDuration:0,connectionDuration:0,requestDuration:0,navigationEntry:n};if(t.entries.length&&n instanceof PerformanceNavigationTiming){const o=n.activationStart||0,i=Math.max((n.workerStart||n.fetchStart||0)-o,0),a=Math.max(n.domainLookupStart-o,0),r=Math.max(n.connectStart-o,0),s=Math.max(n.connectEnd-o,0);e={waitingDuration:i,cacheDuration:a-i,dnsDuration:r-a,connectionDuration:s-r,requestDuration:t.value-s,navigationEntry:n}}return Object.assign(t,{attribution:e})})(n))},n)},t}({});