better otel i think
Build and Publish / Metadata (push) Successful in 2s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 1m26s

This commit is contained in:
mats committed 2026-09-19 19:14:51 +02:00
1 parent 6edbdc340d
commit 8f670dfa2f
16 files changed
+1129 -675

No files matched your search

+3
View File
@@ -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")
+47
View File
@@ -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)
}
}
+227
View File
@@ -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
View File
@@ -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
View File
@@ -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 />;
+464
View File
@@ -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 &amp; Trace</div>
<div className="col-span-7">Log Message &amp; 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 &amp; 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>
);
};
+2 -5
View File
@@ -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">&quot;{value}&quot;</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>
)
+70
View File
@@ -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)
}
+21 -10
View File
@@ -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({
+1
View File
@@ -44,6 +44,7 @@ export default defineConfig({
"/ws": {
target: target,
ws: true,
changeOrigin: true,
},
},
},
+101 -80
View File
@@ -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();
}
});
+1
View File
@@ -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