better otel i think
This commit is contained in:
1 parent
6edbdc340d
commit
8f670dfa2f
16 files changed
+1129
-675
No files matched your search
@@ -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>
|
||||
)
|
||||
Reference in new issue
Block a user