better otel i think
This commit is contained in:
1 parent
6edbdc340d
commit
8f670dfa2f
16 files changed
+1129
-675
No files matched your search
@@ -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")
|
||||
|
||||
@@ -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
|
||||
}
|
||||
+5
-2
@@ -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
|
||||
|
||||
+1
-1
@@ -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 <AuthCallbackView />;
|
||||
|
||||
@@ -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<Level, number> = { DEBUG: 0, INFO: 1, WARN: 2, ERROR: 3, FATAL: 4 }
|
||||
const minRank: Record<MinLevel, number> = { 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<Level, string> = {
|
||||
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 }) => (
|
||||
<span
|
||||
className={`inline-flex shrink-0 items-center whitespace-nowrap rounded-[6px] border px-1.5 font-[family-name:var(--font-data)] text-[10.5px] font-semibold ${levelTone[level]}`}
|
||||
>
|
||||
{level}
|
||||
</span>
|
||||
)
|
||||
|
||||
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<string, FlatLog[]>()
|
||||
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 (
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="flex items-center gap-2">
|
||||
<LevelPill level={log.level} />
|
||||
{count > 1 && (
|
||||
<span className="rounded-[6px] bg-ds-surface-2 px-1.5 font-[family-name:var(--font-data)] text-[10.5px] font-semibold text-ds-text-2">
|
||||
×{count}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span className="font-[family-name:var(--font-data)] text-[11px] text-ds-text-3">{clock(log.timeMs)}</span>
|
||||
</div>
|
||||
<span className="line-clamp-2 break-words font-[family-name:var(--font-data)] text-[12px] text-ds-text">
|
||||
{bodyHeadline(log.body) || "(empty body)"}
|
||||
</span>
|
||||
<div className="flex items-center justify-between gap-2 font-[family-name:var(--font-data)] text-[10.5px] text-ds-text-3">
|
||||
<span className="truncate">{log.service}</span>
|
||||
<span className="flex shrink-0 items-center gap-2">
|
||||
{log.sessionId && <Monitor className="h-3 w-3" aria-label="Has RUM session" />}
|
||||
{log.record.traceId && <GitBranch className="h-3 w-3" aria-label="Has trace" />}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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 ? <SpanInspector span={span} traceTotalRange={trace.duration} /> : null
|
||||
}
|
||||
|
||||
const AttributeTable = ({ attrs, label }: { attrs: OtelAttribute[]; label: string }) => {
|
||||
if (attrs.length === 0) return <p className="text-[12px] text-ds-text-3">None.</p>
|
||||
return (
|
||||
<dl className="grid grid-cols-[minmax(0,max-content)_1fr] gap-x-4 gap-y-1.5 rounded-[10px] border border-ds-line bg-ds-bg p-3" aria-label={label}>
|
||||
{attrs.map((a) => {
|
||||
const text = formatAttributeValue(a.value)
|
||||
const structured = parseStructured(text)
|
||||
return (
|
||||
<div key={a.key} className="contents">
|
||||
<dt className="break-all font-[family-name:var(--font-data)] text-[12px] text-ds-accent">{a.key}</dt>
|
||||
<dd className="min-w-0 break-words font-[family-name:var(--font-data)] text-[12px] text-ds-text-2">
|
||||
{structured ? <JsonTree value={structured} label={a.key} /> : text}
|
||||
</dd>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</dl>
|
||||
)
|
||||
}
|
||||
|
||||
const LogDetail = ({ row, related, drill }: { row: Row; related: number; drill: DrillApi }) => {
|
||||
const [pickedKey, setPickedKey] = useState<string | null>(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 (
|
||||
<article className="flex flex-col gap-4 rounded-[14px] border border-ds-line bg-ds-surface p-5">
|
||||
<header className="flex flex-wrap items-center gap-2">
|
||||
<LevelPill level={log.level} />
|
||||
<span className="font-[family-name:var(--font-data)] text-[12px] text-ds-text-2">{isoTime(log.timeMs)}</span>
|
||||
<span className="font-[family-name:var(--font-data)] text-[12px] text-ds-text-3">{formatRelativeTime(log.timeMs)}</span>
|
||||
<span className="flex-grow" />
|
||||
<CopyRef id={log.key} path={joinPath("/logs", [log.key])} />
|
||||
</header>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{traceId ? (
|
||||
<Link href={joinPath("/traces", record.spanId ? [traceId, record.spanId] : [traceId])} className={linkClass}>
|
||||
<GitBranch className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
Open trace {shortId(traceId)}
|
||||
</Link>
|
||||
) : (
|
||||
<span className="text-[12px] text-ds-text-3">No trace attached to this record.</span>
|
||||
)}
|
||||
{log.sessionId && (
|
||||
<Link href={joinPath("/rum", ["session", log.sessionId])} className={linkClass}>
|
||||
<Monitor className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
RUM session {shortId(log.sessionId)}
|
||||
</Link>
|
||||
)}
|
||||
{traceId && related > 0 && (
|
||||
<button type="button" onClick={() => drill.openExtended("trace-logs")} className={linkClass}>
|
||||
<ScrollText className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
{related} other log{related === 1 ? "" : "s"} in this trace
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<section className="flex flex-col gap-2">
|
||||
<SectionLabel>Body</SectionLabel>
|
||||
{vitals ? (
|
||||
<VitalsCard metrics={vitals} />
|
||||
) : structuredBody ? (
|
||||
<JsonTree value={structuredBody} label="Log body" />
|
||||
) : (
|
||||
<CodeBlock code={log.body || "(empty body)"} lang="text" label="Log body" />
|
||||
)}
|
||||
</section>
|
||||
|
||||
<dl className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||
<DetailField label="service" value={log.service} />
|
||||
<DetailField label="scope" value={log.scope} />
|
||||
<DetailField label="trace_id" value={traceId} />
|
||||
<DetailField label="span_id" value={record.spanId} />
|
||||
</dl>
|
||||
|
||||
<section className="flex flex-col gap-2">
|
||||
<SectionLabel>Attributes</SectionLabel>
|
||||
<AttributeTable attrs={record.attributes ?? []} label="Log attributes" />
|
||||
</section>
|
||||
|
||||
{traceId && (
|
||||
<TraceDetail
|
||||
traceId={traceId}
|
||||
trace={byId.get(traceId)}
|
||||
loading={loading && !failed.has(traceId)}
|
||||
spanId={record.spanId}
|
||||
label="Trace"
|
||||
selectedSpanId={drill.extendedKey === "trace-logs" ? null : drill.extendedKey}
|
||||
onSelectSpan={(id) => (id ? drill.openExtended(id) : drill.closeExtended())}
|
||||
/>
|
||||
)}
|
||||
|
||||
{row.members.length > 1 && (
|
||||
<section className="flex flex-col gap-2">
|
||||
<SectionLabel>
|
||||
Occurrences ({row.members.length}
|
||||
{row.members.length > 50 ? ", newest 50 shown" : ""})
|
||||
</SectionLabel>
|
||||
<ol className="flex max-h-64 flex-col gap-1 overflow-auto">
|
||||
{row.members.slice(0, 50).map((m) => (
|
||||
<li key={m.key}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPickedKey(m.key)}
|
||||
aria-current={m.key === log.key ? "true" : undefined}
|
||||
className={`flex w-full items-center gap-2 rounded-[9px] border px-2.5 py-1 text-left font-[family-name:var(--font-data)] text-[11px] hover:border-ds-accent/40 ${
|
||||
m.key === log.key ? "border-ds-accent/50 bg-ds-surface-2" : "border-ds-line"
|
||||
}`}
|
||||
>
|
||||
<span className="text-ds-text-3">{clock(m.timeMs)}</span>
|
||||
<span className="min-w-0 flex-1 truncate text-ds-text-2">{m.body}</span>
|
||||
{m.record.traceId && <GitBranch className="h-3 w-3 shrink-0 text-ds-text-3" aria-hidden="true" />}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<details className="text-[12px] text-ds-text-3">
|
||||
<summary className="cursor-pointer select-none">Raw payload</summary>
|
||||
<div className="mt-2 flex flex-col gap-3">
|
||||
<CodeBlock code={rawPayload} label="Structured payload" />
|
||||
</div>
|
||||
</details>
|
||||
</article>
|
||||
)
|
||||
}
|
||||
|
||||
export const LogsVisualizer = ({ logs }: { logs: LogEntry[] }) => {
|
||||
const { searchQuery, setSearchQuery, selectedServices, toggleService } = useObservabilityData()
|
||||
const [minLevel, setMinLevel] = useState<MinLevel>("all")
|
||||
const [grouped, setGrouped] = useState(true)
|
||||
// /logs, /logs/<logKey>
|
||||
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<string, FlatLog[]>()
|
||||
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 (
|
||||
<div className="flex flex-col gap-5">
|
||||
<FilterBar
|
||||
scope={{
|
||||
label: "Minimum level",
|
||||
options: [
|
||||
{ value: "all", label: "All" },
|
||||
{ value: "info", label: "Info+" },
|
||||
{ value: "warn", label: "Warn+" },
|
||||
{ value: "error", label: "Errors" },
|
||||
],
|
||||
value: minLevel,
|
||||
onChange: (v) => 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 ? (
|
||||
<div className="flex flex-col items-center justify-center gap-2 rounded-[14px] border border-dashed border-ds-line py-20 text-center">
|
||||
<ScrollText className="h-8 w-8 text-ds-text-3" aria-hidden="true" />
|
||||
<p className="text-[14px] font-medium text-ds-text">No log records yet</p>
|
||||
<p className="max-w-sm text-[12.5px] text-ds-text-3">Logs appear here as soon as the collector receives OTLP log records.</p>
|
||||
</div>
|
||||
) : (
|
||||
<MasterDetail
|
||||
label="Logs"
|
||||
storageKey="logs"
|
||||
items={listed}
|
||||
getKey={(r) => r.key}
|
||||
listHeader={
|
||||
<Segmented
|
||||
label="Group similar logs"
|
||||
value={grouped ? "grouped" : "flat"}
|
||||
onChange={(v) => 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) => <LogListItem row={r} />}
|
||||
emptyDetail="Select a log record to see its body, attributes and links."
|
||||
renderDetail={(r, drill) => (
|
||||
<LogDetail key={r.key} row={r} related={(byTrace.get(r.latest.record.traceId ?? "")?.length ?? 1) - 1} drill={drill} />
|
||||
)}
|
||||
extendedTitle="Trace context"
|
||||
renderExtended={(row, extKey) => {
|
||||
const l = row.latest
|
||||
if (extKey !== "trace-logs") return <SpanPane traceId={l.record.traceId} spanId={extKey} />
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<ol className="flex flex-col gap-1.5">
|
||||
{(byTrace.get(l.record.traceId ?? "") ?? [])
|
||||
.slice()
|
||||
.sort((a, b) => a.timeMs - b.timeMs)
|
||||
.map((r) => (
|
||||
<li key={r.key}>
|
||||
<Link
|
||||
href={joinPath("/logs", [r.key])}
|
||||
aria-current={r.key === l.key ? "true" : undefined}
|
||||
className={`flex flex-col gap-1 rounded-[9px] border px-2.5 py-1.5 hover:border-ds-accent/40 ${
|
||||
r.key === l.key ? "border-ds-accent/50 bg-ds-surface-2" : "border-ds-line bg-ds-surface"
|
||||
}`}
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<LevelPill level={r.level} />
|
||||
<span className="font-[family-name:var(--font-data)] text-[11px] text-ds-text-3">{clock(r.timeMs)}</span>
|
||||
<span className="truncate font-[family-name:var(--font-data)] text-[11px] text-ds-text-3">{r.service}</span>
|
||||
</span>
|
||||
<span className="line-clamp-2 break-words font-[family-name:var(--font-data)] text-[12px] text-ds-text">{r.body}</span>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default LogsVisualizer
|
||||
@@ -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<string | null>(null);
|
||||
const [streamFilter, setStreamFilter] = useState("");
|
||||
const [wrapText, setWrapText] = useState(false);
|
||||
const [cleared, setCleared] = useState(false);
|
||||
|
||||
// Severity filters
|
||||
const [selectedSeverities, setSelectedSeverities] = useState<Record<string, boolean>>({
|
||||
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<string, number>();
|
||||
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<string, number> = {};
|
||||
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 (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-12 gap-4 items-start font-body">
|
||||
{/* LEFT PANEL: Faceted Filter Breakdown (3 Cols) */}
|
||||
<aside className="lg:col-span-3 flex flex-col gap-4">
|
||||
{/* Severity Breakdown Card */}
|
||||
<div className="bg-ds-bg p-4 rounded-xl flex flex-col gap-2 border border-ds-line">
|
||||
<div className="flex items-center justify-between pb-1 border-b border-ds-line">
|
||||
<span className="font-mono text-[10px] text-ds-text-3 uppercase tracking-wider font-semibold">
|
||||
Severity Breakdown
|
||||
</span>
|
||||
<span className="font-mono text-xs text-ds-text-3">{allRecords.length} Total</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
{/* FATAL */}
|
||||
<label className="flex items-center justify-between p-1.5 rounded-lg bg-ds-surface hover:bg-ds-surface-2 transition-colors cursor-pointer group">
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedSeverities.FATAL}
|
||||
onChange={() => toggleSeverity("FATAL")}
|
||||
className="rounded bg-ds-fill border-ds-line-strong text-ds-critical focus:ring-0"
|
||||
/>
|
||||
<span className="font-mono text-xs text-ds-critical font-bold tracking-tight">FATAL</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="font-mono text-[10px] px-1.5 py-0.5 rounded bg-ds-critical-bg text-ds-critical font-semibold">
|
||||
{severityCounts.FATAL}
|
||||
</span>
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-ds-critical" />
|
||||
</div>
|
||||
</label>
|
||||
|
||||
{/* ERROR */}
|
||||
<label className="flex items-center justify-between p-1.5 rounded-lg bg-ds-surface hover:bg-ds-surface-2 transition-colors cursor-pointer group">
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedSeverities.ERROR}
|
||||
onChange={() => toggleSeverity("ERROR")}
|
||||
className="rounded bg-ds-fill border-ds-line-strong text-ds-critical focus:ring-0"
|
||||
/>
|
||||
<span className="font-mono text-xs text-ds-critical font-medium">ERROR</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="font-mono text-[10px] px-1.5 py-0.5 rounded bg-ds-critical-bg text-ds-critical font-semibold">
|
||||
{severityCounts.ERROR}
|
||||
</span>
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-ds-critical" />
|
||||
</div>
|
||||
</label>
|
||||
|
||||
{/* WARN */}
|
||||
<label className="flex items-center justify-between p-1.5 rounded-lg bg-ds-surface hover:bg-ds-surface-2 transition-colors cursor-pointer group">
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedSeverities.WARN}
|
||||
onChange={() => toggleSeverity("WARN")}
|
||||
className="rounded bg-ds-fill border-ds-line-strong text-ds-accent focus:ring-0"
|
||||
/>
|
||||
<span className="font-mono text-xs text-ds-accent">WARN</span>
|
||||
</div>
|
||||
<span className="font-mono text-[10px] px-1.5 py-0.5 rounded bg-ds-fill text-ds-text font-medium">
|
||||
{severityCounts.WARN}
|
||||
</span>
|
||||
</label>
|
||||
|
||||
{/* INFO */}
|
||||
<label className="flex items-center justify-between p-1.5 rounded-lg bg-ds-surface hover:bg-ds-surface-2 transition-colors cursor-pointer group">
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedSeverities.INFO}
|
||||
onChange={() => toggleSeverity("INFO")}
|
||||
className="rounded bg-ds-fill border-ds-line-strong text-ds-accent focus:ring-0"
|
||||
/>
|
||||
<span className="font-mono text-xs text-ds-accent">INFO</span>
|
||||
</div>
|
||||
<span className="font-mono text-[10px] px-1.5 py-0.5 rounded bg-ds-fill text-ds-text-2">
|
||||
{severityCounts.INFO}
|
||||
</span>
|
||||
</label>
|
||||
|
||||
{/* DEBUG */}
|
||||
<label className="flex items-center justify-between p-1.5 rounded-lg bg-ds-surface hover:bg-ds-surface-2 transition-colors cursor-pointer group">
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedSeverities.DEBUG}
|
||||
onChange={() => toggleSeverity("DEBUG")}
|
||||
className="rounded bg-ds-fill border-ds-line-strong text-ds-text-3 focus:ring-0"
|
||||
/>
|
||||
<span className="font-mono text-xs text-ds-text-3">DEBUG</span>
|
||||
</div>
|
||||
<span className="font-mono text-[10px] px-1.5 py-0.5 rounded bg-ds-fill text-ds-text-3">
|
||||
{severityCounts.DEBUG}
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Service Facet Card */}
|
||||
<div className="bg-ds-bg p-4 rounded-xl flex flex-col gap-2 border border-ds-line">
|
||||
<div className="flex items-center justify-between pb-1 border-b border-ds-line">
|
||||
<span className="font-mono text-[10px] text-ds-text-3 uppercase tracking-wider font-semibold">
|
||||
Service Name
|
||||
</span>
|
||||
<Filter className="text-ds-text-3 h-[16px] w-[16px] shrink-0" aria-hidden="true" />
|
||||
</div>
|
||||
|
||||
<div className="space-y-1 font-mono text-xs">
|
||||
{serviceCounts.slice(0, 6).map(([serviceName, count]) => {
|
||||
const isSelected = selectedServices.has(serviceName);
|
||||
return (
|
||||
<div
|
||||
key={serviceName}
|
||||
onClick={() => toggleService(serviceName)}
|
||||
className={`flex items-center justify-between px-2.5 py-1.5 rounded-lg cursor-pointer transition-colors ${
|
||||
isSelected
|
||||
? "bg-ds-surface-2 text-ds-accent font-semibold"
|
||||
: "hover:bg-ds-surface-2 text-ds-text-2"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<span
|
||||
className={`w-1.5 h-1.5 rounded-full shrink-0 ${
|
||||
isSelected ? "bg-ds-accent" : "bg-ds-fill"
|
||||
}`}
|
||||
/>
|
||||
<span className="truncate">{serviceName}</span>
|
||||
</div>
|
||||
<span className="font-mono text-[10px] text-ds-text-3">{count}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{serviceCounts.length === 0 && (
|
||||
<p className="text-center py-2 text-ds-text-3 text-xs italic">No services detected</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* AI Root Cause Diagnostic Hint Box */}
|
||||
<div className="bg-ds-surface p-4 rounded-xl flex flex-col gap-1.5 border border-ds-line">
|
||||
<div className="flex items-center gap-2 text-ds-accent">
|
||||
<Brain className="h-[18px] w-[18px] shrink-0" aria-hidden="true" />
|
||||
<span className="font-[family-name:var(--font-display)] font-semibold text-xs leading-none">
|
||||
Diagnostic Heuristic
|
||||
</span>
|
||||
</div>
|
||||
<p className="font-body text-xs text-ds-text-2 leading-relaxed">
|
||||
{errorHeuristic.text}
|
||||
</p>
|
||||
<div className="pt-1 flex items-center justify-between">
|
||||
<button
|
||||
onClick={() => {
|
||||
setSelectedSeverities({ FATAL: true, ERROR: true, WARN: false, INFO: false, DEBUG: false });
|
||||
}}
|
||||
className="font-mono text-[10px] text-ds-accent hover:underline uppercase flex items-center gap-1 font-semibold"
|
||||
>
|
||||
<span>Filter Only Error Signatures</span>
|
||||
<ArrowRight className="h-[14px] w-[14px] shrink-0" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* RIGHT PANEL: High-Density Structured Live Log Stream (9 Cols) */}
|
||||
<main className="lg:col-span-9 flex flex-col bg-ds-bg rounded-xl overflow-hidden border border-ds-line">
|
||||
{/* Stream Controls Bar */}
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 p-2.5 bg-ds-surface border-b border-ds-line">
|
||||
<div className="flex items-center gap-2 flex-1 min-w-[240px]">
|
||||
<div className="relative flex-1">
|
||||
<ListFilter className="absolute left-2.5 top-1/2 -translate-y-1/2 text-ds-text-3 h-[16px] w-[16px] pointer-events-none shrink-0" aria-hidden="true" />
|
||||
<input
|
||||
type="text"
|
||||
value={streamFilter}
|
||||
onChange={(e) => setStreamFilter(e.target.value)}
|
||||
placeholder="Filter current buffer (e.g. 'timeout', 'status:504')..."
|
||||
className="w-full h-7 pl-8 pr-3 rounded bg-ds-surface-2 text-ds-text font-mono text-xs placeholder:text-ds-text-3 focus:outline-none focus:bg-ds-fill transition-colors border border-ds-line"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="flex items-center gap-1.5 font-mono text-xs text-ds-text-3 cursor-pointer select-none">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={wrapText}
|
||||
onChange={(e) => setWrapText(e.target.checked)}
|
||||
className="rounded bg-ds-surface-2 border-ds-line-strong text-ds-accent focus:ring-0"
|
||||
/>
|
||||
<span>Wrap Text</span>
|
||||
</label>
|
||||
|
||||
<div className="h-3 w-px bg-ds-fill" />
|
||||
|
||||
<button
|
||||
onClick={() => setCleared(true)}
|
||||
className="text-ds-text-3 hover:text-ds-text p-1 rounded hover:bg-ds-surface-2 transition-colors"
|
||||
title="Clear Buffer"
|
||||
>
|
||||
<Eraser className="h-[18px] w-[18px] shrink-0" aria-hidden="true" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={copyLogStream}
|
||||
className="text-ds-text-3 hover:text-ds-text p-1 rounded hover:bg-ds-surface-2 transition-colors"
|
||||
title="Copy Visible Buffer"
|
||||
>
|
||||
<Copy className="h-[18px] w-[18px] shrink-0" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Log Terminal Headers */}
|
||||
<div className="grid grid-cols-12 px-4 py-1.5 bg-ds-surface-2 text-ds-text-3 font-mono uppercase tracking-wider select-none text-[11px] border-b border-ds-line">
|
||||
<div className="col-span-2">Timestamp (UTC)</div>
|
||||
<div className="col-span-1">Level</div>
|
||||
<div className="col-span-2">Service & Trace</div>
|
||||
<div className="col-span-7">Log Message & Structured Payload</div>
|
||||
</div>
|
||||
|
||||
{/* Realtime Log Entries List */}
|
||||
<div className="divide-y divide-ds-line font-mono text-xs max-h-[640px] overflow-y-auto select-text">
|
||||
{visibleRecords.length === 0 ? (
|
||||
<div className="p-8 text-center text-ds-text-3 text-xs">
|
||||
{cleared
|
||||
? "Stream buffer cleared. Waiting for new telemetry records..."
|
||||
: "No log records match the current filters."}
|
||||
</div>
|
||||
) : (
|
||||
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 (
|
||||
<div
|
||||
key={rowId}
|
||||
className="flex flex-col bg-ds-bg hover:bg-ds-surface-2 transition-colors"
|
||||
>
|
||||
<div
|
||||
onClick={() => setExpandedRowId(isExpanded ? null : rowId)}
|
||||
className="grid grid-cols-12 px-4 py-2 items-start cursor-pointer group"
|
||||
>
|
||||
<div className="col-span-2 text-ds-text-3 whitespace-nowrap pt-0.5 text-[11px]">
|
||||
{isoTime}
|
||||
</div>
|
||||
|
||||
<div className="col-span-1">
|
||||
<span
|
||||
className={`font-mono text-[10px] px-1.5 py-0.5 rounded tracking-tight ${sevClass}`}
|
||||
>
|
||||
{sevLabel}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="col-span-2 flex flex-col gap-0.5 min-w-0 pr-2">
|
||||
<span className="text-ds-accent truncate font-medium">{record.serviceName}</span>
|
||||
{record.traceId && (
|
||||
<div className="flex items-center gap-1">
|
||||
<Link className="h-[12px] w-[12px] text-ds-accent shrink-0" aria-hidden="true" />
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
navigateToTraceSpan(record.traceId!, record.spanId);
|
||||
}}
|
||||
className="text-ds-accent hover:underline truncate font-mono text-[10px]"
|
||||
>
|
||||
{record.traceId.slice(0, 12)}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="col-span-7 flex items-start justify-between min-w-0">
|
||||
<div
|
||||
className={`text-ds-text font-normal ${
|
||||
wrapText ? "break-words" : "truncate"
|
||||
}`}
|
||||
>
|
||||
{num >= 17 && (
|
||||
<span className="text-ds-critical font-medium mr-1">Exception:</span>
|
||||
)}
|
||||
{bodyMessage}
|
||||
</div>
|
||||
|
||||
{isExpanded ? <ChevronsDownUp className="text-ds-text-3 h-[16px] w-[16px] group-hover:text-ds-text transition-transform shrink-0 ml-2 shrink-0" aria-hidden="true" /> : <ChevronsUpDown className="text-ds-text-3 h-[16px] w-[16px] group-hover:text-ds-text transition-transform shrink-0 ml-2 shrink-0" aria-hidden="true" />}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Structured Payload Drawer (Expanded) */}
|
||||
{isExpanded && (
|
||||
<div className="px-4 py-3 bg-ds-surface text-xs flex flex-col gap-2 border-t border-ds-line">
|
||||
<div className="flex items-center justify-between border-b border-ds-line pb-1">
|
||||
<span className="font-mono text-[10px] text-ds-text-3 uppercase tracking-wider">
|
||||
OpenTelemetry Attributes & Resource Scope
|
||||
</span>
|
||||
{record.traceId && (
|
||||
<button
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(
|
||||
`trace:${record.traceId} span:${record.spanId || ""}`,
|
||||
);
|
||||
alert("Copied trace context to clipboard.");
|
||||
}}
|
||||
className="flex items-center gap-1 font-mono text-[10px] text-ds-accent hover:text-ds-accent transition-colors"
|
||||
>
|
||||
<CopyPlus className="h-[14px] w-[14px] shrink-0" aria-hidden="true" />
|
||||
<span>Copy Trace Context</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Attributes Grid */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-4 gap-2 font-mono text-xs">
|
||||
{record.spanId && (
|
||||
<div className="p-1.5 rounded bg-ds-surface-2 flex flex-col">
|
||||
<span className="text-ds-text-3 text-[10px] uppercase">span_id</span>
|
||||
<span className="text-ds-accent truncate font-medium">
|
||||
{record.spanId}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{record.traceId && (
|
||||
<div className="p-1.5 rounded bg-ds-surface-2 flex flex-col">
|
||||
<span className="text-ds-text-3 text-[10px] uppercase">trace_id</span>
|
||||
<span className="text-ds-text truncate">{record.traceId}</span>
|
||||
</div>
|
||||
)}
|
||||
{record.scope && (
|
||||
<div className="p-1.5 rounded bg-ds-surface-2 flex flex-col">
|
||||
<span className="text-ds-text-3 text-[10px] uppercase">scope</span>
|
||||
<span className="text-ds-accent truncate">{record.scope}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="p-1.5 rounded bg-ds-surface-2 flex flex-col">
|
||||
<span className="text-ds-text-3 text-[10px] uppercase">service.name</span>
|
||||
<span className="text-ds-accent truncate">{record.serviceName}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Raw JSON block */}
|
||||
<pre className="p-2.5 rounded bg-ds-surface-2 text-ds-text overflow-x-auto text-[11px] leading-relaxed border border-ds-line">
|
||||
<code>
|
||||
{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,
|
||||
)}
|
||||
</code>
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Log Terminal Footer Status */}
|
||||
<div className="flex items-center justify-between px-4 py-2 bg-ds-surface text-ds-text-3 font-mono text-xs border-t border-ds-line">
|
||||
<div className="flex items-center gap-3">
|
||||
<span>
|
||||
Showing {visibleRecords.length} of {allRecords.length} matched events
|
||||
</span>
|
||||
<span className="h-3 w-px bg-ds-fill" />
|
||||
<span>Buffer: Healthy</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-ds-accent" />
|
||||
<span className="text-ds-text">Auto-scroll: LIVE STREAM</span>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -266,11 +266,8 @@ export const LogEntryView = ({ log }: { log: LogEntry }) => {
|
||||
)
|
||||
}
|
||||
|
||||
import { StructuredLogTerminal } from "./StructuredLogTerminal"
|
||||
import { LogsVisualizer } from "./LogsVisualizer"
|
||||
|
||||
const LogVisualizer = ({ logs }: { logs: LogEntry[] }) => {
|
||||
return <StructuredLogTerminal logs={logs} />
|
||||
}
|
||||
|
||||
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" && <ViewErrorBoundary><LogVisualizer logs={logs} /></ViewErrorBoundary>}
|
||||
{activeTab === "tab3" && <ViewErrorBoundary><LogsVisualizer logs={logs} /></ViewErrorBoundary>}
|
||||
</Tabs.Content>
|
||||
<Tabs.Content
|
||||
className="outline-none py-2"
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { useEffect, useRef } from "react"
|
||||
import { EditorState } from "@codemirror/state"
|
||||
import { EditorView, lineNumbers } from "@codemirror/view"
|
||||
import { HighlightStyle, syntaxHighlighting } from "@codemirror/language"
|
||||
import { javascript } from "@codemirror/lang-javascript"
|
||||
import { tags as t } from "@lezer/highlight"
|
||||
|
||||
const highlight = HighlightStyle.define([
|
||||
{ tag: [t.keyword, t.modifier, t.operatorKeyword], color: "var(--ds-accent)" },
|
||||
{ tag: [t.propertyName], color: "var(--ds-accent)" },
|
||||
{ tag: [t.string, t.regexp], color: "var(--ds-ok)" },
|
||||
{ tag: [t.number, t.bool, t.null], color: "var(--ds-warn)" },
|
||||
{ tag: [t.comment], color: "var(--ds-text-3)", fontStyle: "italic" },
|
||||
])
|
||||
|
||||
const theme = EditorView.theme({
|
||||
"&": { backgroundColor: "var(--ds-bg)", color: "var(--ds-text-2)", fontSize: "12px" },
|
||||
".cm-content": { fontFamily: "var(--font-data, ui-monospace, monospace)", padding: "6px 0" },
|
||||
".cm-gutters": { backgroundColor: "var(--ds-bg)", color: "var(--ds-text-3)", border: "none" },
|
||||
".cm-scroller": { overflow: "auto", maxHeight: "360px" },
|
||||
})
|
||||
|
||||
/**
|
||||
* Read-only CodeMirror view. JSON is valid JavaScript, so the JS grammar
|
||||
* highlights it; `lang="text"` skips highlighting for plain log lines.
|
||||
*/
|
||||
export const CodeBlock = ({ code, lang = "json", label }: { code: string; lang?: "json" | "text"; label: string }) => {
|
||||
const host = useRef<HTMLDivElement>(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 <div ref={host} role="group" aria-label={label} className="overflow-hidden rounded-[10px] border border-ds-line" />
|
||||
}
|
||||
@@ -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 <span className="text-ds-warn">null</span>
|
||||
if (typeof value === "string") {
|
||||
return <span className="whitespace-pre-wrap break-words text-ds-ok">"{value}"</span>
|
||||
}
|
||||
return <span className="text-ds-warn">{String(value)}</span>
|
||||
}
|
||||
|
||||
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 && <span className="text-ds-accent">{name}: </span>
|
||||
if (!container) {
|
||||
return (
|
||||
<div className={`${mono} pl-4`}>
|
||||
{label}
|
||||
<Scalar value={value} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
const entries = Object.entries(value)
|
||||
return (
|
||||
<div className={mono}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(!open)}
|
||||
aria-expanded={open}
|
||||
className="flex items-start gap-1 text-left hover:text-ds-text"
|
||||
>
|
||||
<ChevronRight className={`mt-0.5 h-3 w-3 shrink-0 text-ds-text-3 transition-transform ${open ? "rotate-90" : ""}`} aria-hidden="true" />
|
||||
<span>
|
||||
{label}
|
||||
{(!open || size === 0) && <span className="text-ds-text-3">{size === 0 ? (Array.isArray(value) ? "[]" : "{}") : preview(value)}</span>}
|
||||
</span>
|
||||
</button>
|
||||
{open && size > 0 && (
|
||||
<div className="ml-1.5 border-l border-ds-line pl-2">
|
||||
{entries.map(([k, v]) => (
|
||||
<Node key={k} name={Array.isArray(value) ? `${k}` : k} value={v} depth={depth + 1} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** Collapsible, read-only JSON viewer. Pass already-deep-parsed data. */
|
||||
export const JsonTree = ({ value, label }: { value: unknown; label: string }) => (
|
||||
<div role="tree" aria-label={label} className="max-h-[420px] overflow-auto rounded-[10px] border border-ds-line bg-ds-bg p-2 text-ds-text-2">
|
||||
<Node value={value} depth={0} />
|
||||
</div>
|
||||
)
|
||||
@@ -0,0 +1,73 @@
|
||||
import { VITAL_THRESHOLDS, getVitalRating, type VitalRating } from "../../lib/rum-utils"
|
||||
|
||||
interface Metric {
|
||||
value: number
|
||||
attribution?: Record<string, string | number | boolean>
|
||||
}
|
||||
|
||||
const ORDER = ["LCP", "INP", "CLS", "FCP", "TTFB"] as const
|
||||
|
||||
const tone: Record<VitalRating, string> = {
|
||||
good: "text-ds-ok",
|
||||
"needs-improvement": "text-ds-warn",
|
||||
poor: "text-ds-critical",
|
||||
}
|
||||
const bar: Record<VitalRating, string> = { 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<string, Metric> | undefined => {
|
||||
try {
|
||||
const metrics = (JSON.parse(body) as { metrics?: Record<string, Metric> }).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<string, Metric> }) => (
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
{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 (
|
||||
<div key={name} className="flex flex-col gap-2 rounded-[10px] border border-ds-line bg-ds-bg p-3">
|
||||
<div className="flex items-baseline justify-between">
|
||||
<span className="text-[10.5px] font-bold uppercase tracking-widest text-ds-text-3">{name}</span>
|
||||
<span className={`text-[11px] font-semibold ${tone[rating]}`}>{rating.replace("-", " ")}</span>
|
||||
</div>
|
||||
<span className={`font-[family-name:var(--font-data)] text-[22px] font-semibold ${tone[rating]}`}>{t.format(m.value)}</span>
|
||||
<div
|
||||
className="relative h-1.5 rounded-full bg-ds-surface-2"
|
||||
role="img"
|
||||
aria-label={`${name} ${t.format(m.value)}, good up to ${t.format(t.good)}, poor above ${t.format(t.needsImprovement)}`}
|
||||
>
|
||||
<div className={`h-full rounded-full ${bar[rating]}`} style={{ width: `${pct}%` }} />
|
||||
<span className="absolute top-0 h-full w-px bg-ds-line-strong" style={{ left: `${(t.good / (t.needsImprovement * 1.5)) * 100}%` }} />
|
||||
<span className="absolute top-0 h-full w-px bg-ds-line-strong" style={{ left: "66.7%" }} />
|
||||
</div>
|
||||
{attr.length > 0 && (
|
||||
<dl className="grid grid-cols-[max-content_1fr] gap-x-3 gap-y-0.5 font-[family-name:var(--font-data)] text-[11px]">
|
||||
{attr.map(([k, v]) => (
|
||||
<div key={k} className="contents">
|
||||
<dt className="text-ds-text-3">{k}</dt>
|
||||
<dd className="break-all text-ds-text-2">{fmt(k, v)}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
@@ -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<string, unknown>
|
||||
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, "<uuid>")
|
||||
.replace(/\b0x[0-9a-f]+\b|\b[0-9a-f]{16,}\b/gi, "<hex>")
|
||||
.replace(/https?:\/\/\S+/g, "<url>")
|
||||
.replace(/"[^"]*"|'[^']*'/g, "<str>")
|
||||
.replace(/\d+(\.\d+)?/g, "<n>")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim()
|
||||
.slice(0, 200)
|
||||
}
|
||||
@@ -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<string, number>
|
||||
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<string, number> {
|
||||
const out: Record<string, number> = {}
|
||||
try {
|
||||
const metrics = (JSON.parse(body ?? "") as { metrics?: Record<string, { value?: number }> }).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({
|
||||
|
||||
@@ -44,6 +44,7 @@ export default defineConfig({
|
||||
"/ws": {
|
||||
target: target,
|
||||
ws: true,
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
+101
-80
@@ -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=<name>; 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();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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<performance.now())return t},o=t=>{if("loading"===document.readyState)return"loading";const n=e();if(n){if(t<n.domInteractive)return"loading";if(0===n.domContentLoadedEventStart||t<n.domContentLoadedEventStart)return"dom-interactive";if(0===n.domComplete||t<n.domComplete)return"dom-content-loaded"}return"complete"},i=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<i.firstHiddenTime&&(r.value=Math.max(n.startTime-h(),0),r.entries.push(n),r.navigationId=n.navigationId||r.navigationId,a(!0)))});if(c&&(a=u(t,r,k,n.reportAllChanges),l(e=>{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.Line truncated
|
||||
Reference in new issue
Block a user