@spunto/design-system 0.33.0 → 0.35.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +32 -1
- package/package.json +5 -1
- package/src/components/observability/analytics.ts +153 -0
- package/src/components/observability/index.ts +39 -0
- package/src/components/observability/latency.ts +47 -0
- package/src/components/observability/log-lines.tsx +117 -0
- package/src/components/observability/observability.test.tsx +244 -0
- package/src/components/observability/span-waterfall.tsx +493 -0
- package/src/components/observability/trace-filters.tsx +213 -0
- package/src/components/observability/trace-list.tsx +193 -0
- package/src/components/observability/traces-overview.tsx +284 -0
- package/src/components/observability/types.ts +29 -0
- package/src/components/tasks/event-stream.tsx +9 -2
- package/src/components/tasks/types.ts +1 -1
package/README.md
CHANGED
|
@@ -188,7 +188,7 @@ const nextConfig = { transpilePackages: ["@spunto/design-system"] }
|
|
|
188
188
|
provider is also the umbrella that mounts the tooltip delay group and the overlay
|
|
189
189
|
portal container the dialogs/selects render into.
|
|
190
190
|
|
|
191
|
-
## Domain components — `/workers`, `/devcontainer`, `/projects`, `/tasks`
|
|
191
|
+
## Domain components — `/workers`, `/devcontainer`, `/projects`, `/tasks`, `/observability`
|
|
192
192
|
|
|
193
193
|
Everything above is domain-free: a `Button` knows nothing about Spunto. Components
|
|
194
194
|
that **do** know a Spunto concept live behind their own entry point, so the root
|
|
@@ -202,6 +202,7 @@ import { WorkerCard } from "@spunto/design-system/workers"
|
|
|
202
202
|
import { ImageCard, FeatureCard, ExtensionCard } from "@spunto/design-system/devcontainer" // domain
|
|
203
203
|
import { ProjectForm, ProjectPanel } from "@spunto/design-system/projects" // domain
|
|
204
204
|
import { TaskList, TaskCockpit, TaskEventStream } from "@spunto/design-system/tasks" // domain
|
|
205
|
+
import { SpanWaterfall, TracesOverview } from "@spunto/design-system/observability" // domain
|
|
205
206
|
import { Section, ProductHeader } from "@spunto/design-system/marketing" // not a domain — see below
|
|
206
207
|
```
|
|
207
208
|
|
|
@@ -330,6 +331,36 @@ review. Every surface that shows one:
|
|
|
330
331
|
Same rules as `/workers`: nothing fetches, types are structural and lax
|
|
331
332
|
(`TaskItem`, `TaskEvent`, `TaskDiff`…), links go through `render.link`.
|
|
332
333
|
|
|
334
|
+
### `@spunto/design-system/observability`
|
|
335
|
+
|
|
336
|
+
Traces — what an OpenTelemetry backend hands back, drawn. Every surface that shows one:
|
|
337
|
+
|
|
338
|
+
- **`SpanWaterfall`** — a trace as a tree of spans on a shared time axis (children under
|
|
339
|
+
their parent, not the backend's start-time order), colour by nature (http → build, db →
|
|
340
|
+
ship, agent RPC / job → run, error → destructive), attributes of the clicked span. Any
|
|
341
|
+
span with children folds (chevron, Collapse/Expand all, ←/→; a folded row shows `+N`, red
|
|
342
|
+
when an error is hidden under it; `defaultCollapsedDepth`, or controlled
|
|
343
|
+
`collapsedSpanIds`). Hand it `logs` and a Logs section appears underneath, linked by the
|
|
344
|
+
span id the logger stamped: a line selects its span, unfolding its ancestors if needed.
|
|
345
|
+
`SpanWaterfallSkeleton` for the fetch.
|
|
346
|
+
- **`TraceList`** — one row per request (method, route, latency bar, when), or
|
|
347
|
+
`variant="compact"` for root spans that aren't necessarily HTTP. **`TraceFiltersBar`** next
|
|
348
|
+
to it: window and min latency are meant for the backend query, status / method / route are
|
|
349
|
+
applied client-side by `filterTraces`; `traceFilterOptions` derives the select options.
|
|
350
|
+
- **`TracesOverview`** — stat tiles, requests and p95 over time, per-service cards
|
|
351
|
+
(`showServices`), slowest routes. **`TraceRouteDetail`** — one route's stats and example
|
|
352
|
+
traces. Both are *derived* from the traces they're handed, so a list and its dashboard
|
|
353
|
+
can't disagree and no aggregation endpoint is needed.
|
|
354
|
+
- **`LogLines`** — log records with severity and trace/span ids: under a waterfall
|
|
355
|
+
(`origin`, `onSelectSpan`) or as a service's feed (`onOpenTrace`).
|
|
356
|
+
- Pure helpers: `computeStats`, `byRoute`, `byService`, `timeBuckets`, `exampleTraces`,
|
|
357
|
+
`percentile`, `methodOf`/`routeOf`, `orderSpans`, `spanColor`; and **one** latency scale —
|
|
358
|
+
`formatLatency` (sub-millisecond precise), `latencyTone`/`latencyColor`/`latencyTextClass`.
|
|
359
|
+
|
|
360
|
+
Same rules as `/workers`: nothing fetches (no URL, no query client — the org's traces view,
|
|
361
|
+
a deployment's Observability tab and the admin each pass their own data), types are
|
|
362
|
+
structural and lax (`TraceItem`, `TraceSpan`, `TelemetryLogLine`).
|
|
363
|
+
|
|
333
364
|
### `@spunto/design-system/projects`
|
|
334
365
|
|
|
335
366
|
- **`ProjectForm`** — the whole "compose a dev environment" screen: identity,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@spunto/design-system",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.35.0",
|
|
4
4
|
"description": "Spunto's shared design system — warm/flame tokens, color constants, and UI primitives.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -45,6 +45,10 @@
|
|
|
45
45
|
"types": "./src/components/tasks/index.ts",
|
|
46
46
|
"import": "./src/components/tasks/index.ts"
|
|
47
47
|
},
|
|
48
|
+
"./observability": {
|
|
49
|
+
"types": "./src/components/observability/index.ts",
|
|
50
|
+
"import": "./src/components/observability/index.ts"
|
|
51
|
+
},
|
|
48
52
|
"./projects": {
|
|
49
53
|
"types": "./src/components/projects/index.ts",
|
|
50
54
|
"import": "./src/components/projects/index.ts"
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
// Client-side aggregations over a set of traces. Everything is derived from the list a backend
|
|
2
|
+
// already returned, so a list and its dashboard stay driven by exactly the same filters — and the
|
|
3
|
+
// dashboard never needs an endpoint of its own. Pure functions: no Date.now() unless defaulted.
|
|
4
|
+
|
|
5
|
+
import type { TraceItem } from "./types"
|
|
6
|
+
|
|
7
|
+
const METHODS = ["GET", "POST", "PATCH", "PUT", "DELETE", "HEAD", "OPTIONS"]
|
|
8
|
+
|
|
9
|
+
/** HTTP method of a root span name ("GET /x" → "GET"), "—" when it isn't one. */
|
|
10
|
+
export function methodOf(name: string): string {
|
|
11
|
+
const m = name.split(" ")[0]
|
|
12
|
+
return METHODS.includes(m) ? m : "—"
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Route template of a root span name ("GET /x" → "/x"); non-HTTP names come back whole. */
|
|
16
|
+
export function routeOf(name: string): string {
|
|
17
|
+
const m = name.split(" ")[0]
|
|
18
|
+
return METHODS.includes(m) ? name.slice(m.length + 1) || name : name
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Percentile, linear interpolation, over an ascending-sorted array. */
|
|
22
|
+
export function percentile(sortedAsc: number[], p: number): number {
|
|
23
|
+
if (sortedAsc.length === 0) return 0
|
|
24
|
+
if (sortedAsc.length === 1) return sortedAsc[0]
|
|
25
|
+
const idx = (p / 100) * (sortedAsc.length - 1)
|
|
26
|
+
const lo = Math.floor(idx)
|
|
27
|
+
const hi = Math.ceil(idx)
|
|
28
|
+
if (lo === hi) return sortedAsc[lo]
|
|
29
|
+
return sortedAsc[lo] + (sortedAsc[hi] - sortedAsc[lo]) * (idx - lo)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface TraceStats {
|
|
33
|
+
count: number
|
|
34
|
+
errorCount: number
|
|
35
|
+
errorRate: number
|
|
36
|
+
p50: number
|
|
37
|
+
p95: number
|
|
38
|
+
p99: number
|
|
39
|
+
max: number
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function computeStats(traces: TraceItem[]): TraceStats {
|
|
43
|
+
const durations = traces.map((t) => t.durationMs).sort((a, b) => a - b)
|
|
44
|
+
const errorCount = traces.filter((t) => t.error).length
|
|
45
|
+
return {
|
|
46
|
+
count: traces.length,
|
|
47
|
+
errorCount,
|
|
48
|
+
errorRate: traces.length ? errorCount / traces.length : 0,
|
|
49
|
+
p50: percentile(durations, 50),
|
|
50
|
+
p95: percentile(durations, 95),
|
|
51
|
+
p99: percentile(durations, 99),
|
|
52
|
+
max: durations.length ? durations[durations.length - 1] : 0,
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function groupBy(traces: TraceItem[], key: (t: TraceItem) => string): Map<string, TraceItem[]> {
|
|
57
|
+
const groups = new Map<string, TraceItem[]>()
|
|
58
|
+
for (const t of traces) {
|
|
59
|
+
const k = key(t)
|
|
60
|
+
const arr = groups.get(k)
|
|
61
|
+
if (arr) arr.push(t)
|
|
62
|
+
else groups.set(k, [t])
|
|
63
|
+
}
|
|
64
|
+
return groups
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export interface RouteStat {
|
|
68
|
+
/** Full root span name ("GET /api/orgs/:orgId/…") — the grouping key. */
|
|
69
|
+
name: string
|
|
70
|
+
route: string
|
|
71
|
+
method: string
|
|
72
|
+
count: number
|
|
73
|
+
p50: number
|
|
74
|
+
p95: number
|
|
75
|
+
p99: number
|
|
76
|
+
errorRate: number
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Per route (method + template), slowest p95 first. */
|
|
80
|
+
export function byRoute(traces: TraceItem[]): RouteStat[] {
|
|
81
|
+
const rows: RouteStat[] = []
|
|
82
|
+
for (const [name, items] of groupBy(traces, (t) => t.name)) {
|
|
83
|
+
const s = computeStats(items)
|
|
84
|
+
rows.push({ name, route: routeOf(name), method: methodOf(name), count: s.count, p50: s.p50, p95: s.p95, p99: s.p99, errorRate: s.errorRate })
|
|
85
|
+
}
|
|
86
|
+
return rows.sort((a, b) => b.p95 - a.p95)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export interface ServiceStat {
|
|
90
|
+
service: string
|
|
91
|
+
count: number
|
|
92
|
+
errorRate: number
|
|
93
|
+
p95: number
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Per emitting service, busiest first. A trace without a service lands in "(unknown)". */
|
|
97
|
+
export function byService(traces: TraceItem[]): ServiceStat[] {
|
|
98
|
+
const rows: ServiceStat[] = []
|
|
99
|
+
for (const [service, items] of groupBy(traces, (t) => t.service || "(unknown)")) {
|
|
100
|
+
const s = computeStats(items)
|
|
101
|
+
rows.push({ service, count: s.count, errorRate: s.errorRate, p95: s.p95 })
|
|
102
|
+
}
|
|
103
|
+
return rows.sort((a, b) => b.count - a.count)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export interface TimeBucket {
|
|
107
|
+
start: number
|
|
108
|
+
total: number
|
|
109
|
+
errors: number
|
|
110
|
+
p95: number
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Splits the traces into `bucketCount` equal intervals over the last `lookbackMinutes`, ending at
|
|
115
|
+
* `now`. A trace outside the window is clamped into the first/last bucket rather than dropped:
|
|
116
|
+
* the list next to the chart shows it, so the chart must count it.
|
|
117
|
+
*/
|
|
118
|
+
export function timeBuckets(
|
|
119
|
+
traces: TraceItem[],
|
|
120
|
+
lookbackMinutes: number,
|
|
121
|
+
bucketCount = 24,
|
|
122
|
+
now: number = Date.now()
|
|
123
|
+
): TimeBucket[] {
|
|
124
|
+
const windowMs = lookbackMinutes * 60_000
|
|
125
|
+
const start = now - windowMs
|
|
126
|
+
const size = windowMs / bucketCount
|
|
127
|
+
const buckets = Array.from({ length: bucketCount }, (_, i) => ({ start: start + i * size, items: [] as TraceItem[] }))
|
|
128
|
+
for (const t of traces) {
|
|
129
|
+
const i = Math.min(bucketCount - 1, Math.max(0, Math.floor((t.startTimeMs - start) / size)))
|
|
130
|
+
buckets[i].items.push(t)
|
|
131
|
+
}
|
|
132
|
+
return buckets.map((b) => {
|
|
133
|
+
const s = computeStats(b.items)
|
|
134
|
+
return { start: b.start, total: s.count, errors: s.errorCount, p95: s.p95 }
|
|
135
|
+
})
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Up to `limit` example traces for a set: errors first, then the slowest, then the most recent,
|
|
140
|
+
* de-duplicated. What a route's detail shows to click into.
|
|
141
|
+
*/
|
|
142
|
+
export function exampleTraces(traces: TraceItem[], limit = 12): TraceItem[] {
|
|
143
|
+
const slowest = [...traces].sort((a, b) => b.durationMs - a.durationMs).slice(0, Math.ceil(limit / 2))
|
|
144
|
+
const recent = [...traces].sort((a, b) => b.startTimeMs - a.startTimeMs).slice(0, Math.ceil(limit / 2))
|
|
145
|
+
const seen = new Set<string>()
|
|
146
|
+
return [...traces.filter((t) => t.error), ...slowest, ...recent]
|
|
147
|
+
.filter((t) => {
|
|
148
|
+
if (seen.has(t.traceId)) return false
|
|
149
|
+
seen.add(t.traceId)
|
|
150
|
+
return true
|
|
151
|
+
})
|
|
152
|
+
.slice(0, limit)
|
|
153
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// @spunto/design-system/observability — traces: span waterfall, list, filters, overview dashboard.
|
|
2
|
+
//
|
|
3
|
+
// Domain sub-export (see the README's "Domain components" rule): these know what a trace, a span
|
|
4
|
+
// and a route are. They know nothing of a backend — every component takes the traces or spans the
|
|
5
|
+
// app already fetched (Cloud's per-org `/traces`, the admin's global Cloud Trace view, Jaeger…),
|
|
6
|
+
// and the aggregations are pure functions exported alongside, so a number can be derived without
|
|
7
|
+
// drawing anything.
|
|
8
|
+
|
|
9
|
+
export type { TraceItem, TraceSpan } from "./types"
|
|
10
|
+
|
|
11
|
+
export { SpanWaterfall, SpanWaterfallSkeleton, orderSpans, spanColor, spanTree } from "./span-waterfall"
|
|
12
|
+
export type { SpanTreeInfo, SpanWaterfallProps } from "./span-waterfall"
|
|
13
|
+
|
|
14
|
+
export { LogLines, severityClass, stripAnsi } from "./log-lines"
|
|
15
|
+
export type { LogLinesProps, TelemetryLogLine } from "./log-lines"
|
|
16
|
+
|
|
17
|
+
export { TracesOverview, TraceStatTile, RequestsOverTime, LatencyOverTime } from "./traces-overview"
|
|
18
|
+
export type { TracesOverviewProps } from "./traces-overview"
|
|
19
|
+
|
|
20
|
+
export { TraceList, TraceListSkeleton, TraceRouteDetail } from "./trace-list"
|
|
21
|
+
export type { TraceListProps, TraceRouteDetailProps } from "./trace-list"
|
|
22
|
+
|
|
23
|
+
export { TraceFiltersBar, DEFAULT_TRACE_FILTERS, filterTraces, traceFilterOptions } from "./trace-filters"
|
|
24
|
+
export type { TraceFilters, TraceFiltersBarProps } from "./trace-filters"
|
|
25
|
+
|
|
26
|
+
export {
|
|
27
|
+
byRoute,
|
|
28
|
+
byService,
|
|
29
|
+
computeStats,
|
|
30
|
+
exampleTraces,
|
|
31
|
+
methodOf,
|
|
32
|
+
percentile,
|
|
33
|
+
routeOf,
|
|
34
|
+
timeBuckets,
|
|
35
|
+
} from "./analytics"
|
|
36
|
+
export type { RouteStat, ServiceStat, TimeBucket, TraceStats } from "./analytics"
|
|
37
|
+
|
|
38
|
+
export { formatLatency, latencyColor, latencyTextClass, latencyTone } from "./latency"
|
|
39
|
+
export type { LatencyTone } from "./latency"
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// Latency formatting and tone — the one place that decides what "slow" looks like. The dashboard
|
|
2
|
+
// had this threshold copied four times (overview, route sheet, list bar, list text) across two
|
|
3
|
+
// apps, in two notations (a CSS colour and a Tailwind class).
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* A latency, precise enough for a span: "1.24s", "38ms", "0.42ms". `formatDuration` (root entry)
|
|
7
|
+
* stops at whole milliseconds and one decimal of a second — right for a setup phase, too coarse
|
|
8
|
+
* for a 0.3 ms database call next to a 1.2 s request.
|
|
9
|
+
*/
|
|
10
|
+
export function formatLatency(ms: number): string {
|
|
11
|
+
if (ms >= 1000) return `${(ms / 1000).toFixed(2)}s`
|
|
12
|
+
if (ms >= 1) return `${Math.round(ms)}ms`
|
|
13
|
+
return `${Math.max(0, ms).toFixed(2)}ms`
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export type LatencyTone = "fast" | "slow" | "critical"
|
|
17
|
+
|
|
18
|
+
/** ≥ 1 s is critical, ≥ 300 ms is slow. The thresholds of an interactive HTTP request. */
|
|
19
|
+
export function latencyTone(ms: number): LatencyTone {
|
|
20
|
+
if (ms >= 1000) return "critical"
|
|
21
|
+
if (ms >= 300) return "slow"
|
|
22
|
+
return "fast"
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Inline colours (SVG fills, bar widths set by style) — amber-600 / emerald-600, the same the
|
|
26
|
+
// Tailwind classes below resolve to in light mode.
|
|
27
|
+
const TONE_COLOR: Record<LatencyTone, string> = {
|
|
28
|
+
critical: "var(--destructive)",
|
|
29
|
+
slow: "#d97706",
|
|
30
|
+
fast: "#059669",
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const TONE_TEXT: Record<LatencyTone, string> = {
|
|
34
|
+
critical: "text-destructive",
|
|
35
|
+
slow: "text-amber-600 dark:text-amber-400",
|
|
36
|
+
fast: "text-emerald-600 dark:text-emerald-400",
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** CSS colour of a latency, for a `style` or an SVG `fill`. */
|
|
40
|
+
export function latencyColor(ms: number): string {
|
|
41
|
+
return TONE_COLOR[latencyTone(ms)]
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Tailwind text classes of a latency, dark mode included. */
|
|
45
|
+
export function latencyTextClass(ms: number): string {
|
|
46
|
+
return TONE_TEXT[latencyTone(ms)]
|
|
47
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import { cn } from "../../utils"
|
|
4
|
+
import { formatLatency } from "./latency"
|
|
5
|
+
|
|
6
|
+
// ── Log lines ─────────────────────────────────────────────────────────────
|
|
7
|
+
// Log records as an OTLP pipeline stores them: a body, a severity, and — when the logger wrote
|
|
8
|
+
// them inside a span — the exact trace/span ids. That id is the whole point: a line is tied to a
|
|
9
|
+
// span by what the logger stamped, not by "same container, same second".
|
|
10
|
+
|
|
11
|
+
/** One log record. Structural and lax: only `timestamp` and `body` are required. */
|
|
12
|
+
export interface TelemetryLogLine {
|
|
13
|
+
/** ISO timestamp (or anything `Date.parse` reads). */
|
|
14
|
+
timestamp: string
|
|
15
|
+
body: string
|
|
16
|
+
service?: string | null
|
|
17
|
+
severity?: string | null
|
|
18
|
+
traceId?: string | null
|
|
19
|
+
spanId?: string | null
|
|
20
|
+
attributes?: Record<string, string>
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Tailwind text classes of an OTel severity text ("ERROR", "WARN", "INFO2"…). */
|
|
24
|
+
export function severityClass(severity: string | null | undefined): string {
|
|
25
|
+
const s = (severity ?? "").toUpperCase()
|
|
26
|
+
if (s.startsWith("ERROR") || s.startsWith("FATAL")) return "text-destructive"
|
|
27
|
+
if (s.startsWith("WARN")) return "text-amber-600 dark:text-amber-400"
|
|
28
|
+
return "text-muted-foreground"
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Drops ANSI colour codes a logger meant for a terminal. */
|
|
32
|
+
export function stripAnsi(text: string): string {
|
|
33
|
+
// eslint-disable-next-line no-control-regex
|
|
34
|
+
return text.replace(/\u001b\[[0-9;]*[A-Za-z]/g, "")
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function clock(ms: number): string {
|
|
38
|
+
return new Date(ms).toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit", second: "2-digit" })
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface LogLinesProps {
|
|
42
|
+
lines: TelemetryLogLine[]
|
|
43
|
+
/**
|
|
44
|
+
* Time column. A number is the start of a trace: each line shows "+12ms" from it — what
|
|
45
|
+
* `SpanWaterfall` does when handed `logs`. Omitted: wall-clock time.
|
|
46
|
+
*/
|
|
47
|
+
origin?: number
|
|
48
|
+
/**
|
|
49
|
+
* Makes a line whose `spanId` is in this set a button that selects that span, and highlights
|
|
50
|
+
* the lines of `selectedSpanId`. For the logs under a waterfall.
|
|
51
|
+
*/
|
|
52
|
+
spanIds?: Set<string>
|
|
53
|
+
selectedSpanId?: string | null
|
|
54
|
+
onSelectSpan?: (spanId: string | null) => void
|
|
55
|
+
/** A "trace" link at the end of each line that carries a trace id. For a service's log feed. */
|
|
56
|
+
onOpenTrace?: (traceId: string) => void
|
|
57
|
+
/** Service name when a line has none (e.g. resolved from a resource attribute). */
|
|
58
|
+
serviceOf?: (line: TelemetryLogLine) => string | null | undefined
|
|
59
|
+
className?: string
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function LogLines({
|
|
63
|
+
lines,
|
|
64
|
+
origin,
|
|
65
|
+
spanIds,
|
|
66
|
+
selectedSpanId,
|
|
67
|
+
onSelectSpan,
|
|
68
|
+
onOpenTrace,
|
|
69
|
+
serviceOf,
|
|
70
|
+
className,
|
|
71
|
+
}: LogLinesProps) {
|
|
72
|
+
const cols = onOpenTrace ? "grid-cols-[5.5rem_7rem_3.5rem_1fr_auto]" : "grid-cols-[5.5rem_7rem_3.5rem_1fr]"
|
|
73
|
+
return (
|
|
74
|
+
<div className={cn("max-h-96 overflow-auto rounded-lg border border-border", className)}>
|
|
75
|
+
{lines.map((l, i) => {
|
|
76
|
+
const at = Date.parse(l.timestamp)
|
|
77
|
+
// A row holding a "trace" button can't itself be a button: the two uses don't mix.
|
|
78
|
+
const linked = !onOpenTrace && !!onSelectSpan && !!l.spanId && (!spanIds || spanIds.has(l.spanId))
|
|
79
|
+
const isSel = linked && l.spanId === selectedSpanId
|
|
80
|
+
const cells = (
|
|
81
|
+
<>
|
|
82
|
+
<span className="tabular-nums text-muted-foreground">
|
|
83
|
+
{origin !== undefined ? `+${formatLatency(Math.max(0, at - origin))}` : clock(at)}
|
|
84
|
+
</span>
|
|
85
|
+
<span className="truncate text-muted-foreground">{l.service ?? serviceOf?.(l) ?? "—"}</span>
|
|
86
|
+
<span className={cn("truncate", severityClass(l.severity))}>{l.severity ?? ""}</span>
|
|
87
|
+
<span className="whitespace-pre-wrap break-all text-foreground">{stripAnsi(l.body)}</span>
|
|
88
|
+
{onOpenTrace &&
|
|
89
|
+
(l.traceId ? (
|
|
90
|
+
<button type="button" onClick={() => onOpenTrace(l.traceId!)} className="text-primary hover:underline">
|
|
91
|
+
trace
|
|
92
|
+
</button>
|
|
93
|
+
) : (
|
|
94
|
+
<span />
|
|
95
|
+
))}
|
|
96
|
+
</>
|
|
97
|
+
)
|
|
98
|
+
const rowClass = cn("grid w-full items-start gap-3 border-b border-border/60 px-3 py-1 text-left font-mono text-[11px] last:border-b-0", cols)
|
|
99
|
+
return linked ? (
|
|
100
|
+
<button
|
|
101
|
+
key={i}
|
|
102
|
+
type="button"
|
|
103
|
+
aria-pressed={isSel}
|
|
104
|
+
onClick={() => onSelectSpan!(isSel ? null : l.spanId!)}
|
|
105
|
+
className={cn(rowClass, "hover:bg-muted/50", isSel && "bg-primary/10")}
|
|
106
|
+
>
|
|
107
|
+
{cells}
|
|
108
|
+
</button>
|
|
109
|
+
) : (
|
|
110
|
+
<div key={i} className={rowClass}>
|
|
111
|
+
{cells}
|
|
112
|
+
</div>
|
|
113
|
+
)
|
|
114
|
+
})}
|
|
115
|
+
</div>
|
|
116
|
+
)
|
|
117
|
+
}
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest"
|
|
2
|
+
import { fireEvent, render, screen } from "@testing-library/react"
|
|
3
|
+
import {
|
|
4
|
+
byRoute,
|
|
5
|
+
byService,
|
|
6
|
+
computeStats,
|
|
7
|
+
exampleTraces,
|
|
8
|
+
filterTraces,
|
|
9
|
+
formatLatency,
|
|
10
|
+
latencyTone,
|
|
11
|
+
methodOf,
|
|
12
|
+
orderSpans,
|
|
13
|
+
percentile,
|
|
14
|
+
routeOf,
|
|
15
|
+
SpanWaterfall,
|
|
16
|
+
spanTree,
|
|
17
|
+
timeBuckets,
|
|
18
|
+
TracesOverview,
|
|
19
|
+
type TraceItem,
|
|
20
|
+
type TraceSpan,
|
|
21
|
+
} from "./index"
|
|
22
|
+
|
|
23
|
+
const NOW = Date.parse("2026-09-28T12:00:00Z")
|
|
24
|
+
const MIN = 60_000
|
|
25
|
+
|
|
26
|
+
function trace(id: string, name: string, durationMs: number, agoMs: number, extra: Partial<TraceItem> = {}): TraceItem {
|
|
27
|
+
return { traceId: id, name, service: "spunto-api", startTimeMs: NOW - agoMs, durationMs, ...extra }
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
describe("analytics", () => {
|
|
31
|
+
it("sépare méthode et route, et laisse entier un nom qui n'est pas HTTP", () => {
|
|
32
|
+
expect(methodOf("GET /api/orgs/:orgId")).toBe("GET")
|
|
33
|
+
expect(routeOf("GET /api/orgs/:orgId")).toBe("/api/orgs/:orgId")
|
|
34
|
+
expect(methodOf("job.worker.create")).toBe("—")
|
|
35
|
+
expect(routeOf("job.worker.create")).toBe("job.worker.create")
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it("interpole les percentiles", () => {
|
|
39
|
+
expect(percentile([], 95)).toBe(0)
|
|
40
|
+
expect(percentile([10], 95)).toBe(10)
|
|
41
|
+
expect(percentile([0, 100], 50)).toBe(50)
|
|
42
|
+
expect(percentile([1, 2, 3, 4, 5], 100)).toBe(5)
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it("compte les erreurs et le max", () => {
|
|
46
|
+
const s = computeStats([trace("a", "GET /x", 10, 0), trace("b", "GET /x", 30, 0, { error: true })])
|
|
47
|
+
expect(s).toMatchObject({ count: 2, errorCount: 1, errorRate: 0.5, max: 30 })
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
it("trie les routes par p95 décroissant, les services par volume", () => {
|
|
51
|
+
const set = [
|
|
52
|
+
trace("a", "GET /fast", 5, 0),
|
|
53
|
+
trace("b", "GET /fast", 7, 0),
|
|
54
|
+
trace("c", "POST /slow", 900, 0, { service: "spunto-proxy" }),
|
|
55
|
+
]
|
|
56
|
+
expect(byRoute(set).map((r) => r.name)).toEqual(["POST /slow", "GET /fast"])
|
|
57
|
+
expect(byService(set).map((s) => s.service)).toEqual(["spunto-api", "spunto-proxy"])
|
|
58
|
+
expect(byService([trace("x", "GET /", 1, 0, { service: null })])[0].service).toBe("(unknown)")
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
it("répartit dans la fenêtre, et ramène au bord ce qui en déborde au lieu de le perdre", () => {
|
|
62
|
+
const b = timeBuckets([trace("old", "GET /", 1, 90 * MIN), trace("new", "GET /", 1, 1 * MIN)], 60, 6, NOW)
|
|
63
|
+
expect(b).toHaveLength(6)
|
|
64
|
+
expect(b[0].total).toBe(1)
|
|
65
|
+
expect(b[5].total).toBe(1)
|
|
66
|
+
expect(b.reduce((n, x) => n + x.total, 0)).toBe(2)
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
it("choisit les exemples : erreurs, puis lentes, puis récentes, sans doublon", () => {
|
|
70
|
+
const set = [trace("e", "GET /", 1, 5, { error: true }), trace("slow", "GET /", 500, 10), trace("recent", "GET /", 2, 0)]
|
|
71
|
+
const ids = exampleTraces(set, 4).map((t) => t.traceId)
|
|
72
|
+
expect(ids[0]).toBe("e")
|
|
73
|
+
expect(new Set(ids).size).toBe(ids.length)
|
|
74
|
+
expect(ids).toContain("slow")
|
|
75
|
+
expect(ids).toContain("recent")
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
it("filtre côté client sur statut, méthode et route", () => {
|
|
79
|
+
const set = [trace("a", "GET /x", 1, 0), trace("b", "POST /x", 1, 0, { error: true }), trace("c", "GET /y", 1, 0)]
|
|
80
|
+
expect(filterTraces(set, { status: "errors", method: "all", route: "all" }).map((t) => t.traceId)).toEqual(["b"])
|
|
81
|
+
expect(filterTraces(set, { status: "all", method: "GET", route: "/x" }).map((t) => t.traceId)).toEqual(["a"])
|
|
82
|
+
})
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
describe("latence", () => {
|
|
86
|
+
it("garde la précision d'un span sous la milliseconde", () => {
|
|
87
|
+
expect(formatLatency(0.42)).toBe("0.42ms")
|
|
88
|
+
expect(formatLatency(38.4)).toBe("38ms")
|
|
89
|
+
expect(formatLatency(1234)).toBe("1.23s")
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
it("a un seul jeu de seuils", () => {
|
|
93
|
+
expect(latencyTone(299)).toBe("fast")
|
|
94
|
+
expect(latencyTone(300)).toBe("slow")
|
|
95
|
+
expect(latencyTone(1000)).toBe("critical")
|
|
96
|
+
})
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
const SPANS: TraceSpan[] = [
|
|
100
|
+
{ spanId: "db", parentSpanId: "root", name: "pg.query", startTimeMs: 20, durationMs: 5, attributes: { "db.system": "postgresql" } },
|
|
101
|
+
{ spanId: "root", parentSpanId: null, name: "GET /api/x", startTimeMs: 0, durationMs: 100, service: "spunto-api" },
|
|
102
|
+
{ spanId: "rpc", parentSpanId: "root", name: "agent.cmd spawn", startTimeMs: 10, durationMs: 50 },
|
|
103
|
+
{ spanId: "leaf", parentSpanId: "rpc", name: "docker.create", startTimeMs: 12, durationMs: 30 },
|
|
104
|
+
{ spanId: "orphan", parentSpanId: "not-in-trace", name: "late", startTimeMs: 90, durationMs: 1 },
|
|
105
|
+
]
|
|
106
|
+
|
|
107
|
+
describe("orderSpans", () => {
|
|
108
|
+
it("met chaque enfant sous son parent, les frères par heure de début", () => {
|
|
109
|
+
expect(orderSpans(SPANS).map((o) => `${o.span.spanId}:${o.depth}`)).toEqual([
|
|
110
|
+
"root:0",
|
|
111
|
+
"rpc:1",
|
|
112
|
+
"leaf:2",
|
|
113
|
+
"db:1",
|
|
114
|
+
"orphan:0",
|
|
115
|
+
])
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
it("ne boucle pas sur un span qui est son propre parent", () => {
|
|
119
|
+
const loop: TraceSpan[] = [{ spanId: "a", parentSpanId: "a", name: "a", startTimeMs: 0, durationMs: 1 }]
|
|
120
|
+
// Son parent est dans le jeu, donc ce n'est pas une racine : rien à dessiner, mais pas de boucle.
|
|
121
|
+
expect(orderSpans(loop)).toEqual([])
|
|
122
|
+
})
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
describe("SpanWaterfall", () => {
|
|
126
|
+
it("dessine les spans, et montre les attributs d'un span cliqué", () => {
|
|
127
|
+
render(<SpanWaterfall traceId="abc123" spans={SPANS} />)
|
|
128
|
+
expect(screen.getByText("abc123")).toBeTruthy()
|
|
129
|
+
expect(screen.getByText("5 spans")).toBeTruthy()
|
|
130
|
+
expect(screen.queryByText("postgresql")).toBeNull()
|
|
131
|
+
fireEvent.click(screen.getByText("pg.query"))
|
|
132
|
+
expect(screen.getByText("postgresql")).toBeTruthy()
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
it("relie une ligne de log à son span", () => {
|
|
136
|
+
render(
|
|
137
|
+
<SpanWaterfall
|
|
138
|
+
spans={SPANS}
|
|
139
|
+
logs={[{ timestamp: new Date(25).toISOString(), body: "\u001b[31mslow query\u001b[0m", spanId: "db", severity: "WARN" }]}
|
|
140
|
+
/>
|
|
141
|
+
)
|
|
142
|
+
expect(screen.getByText("1 line with this trace id")).toBeTruthy()
|
|
143
|
+
fireEvent.click(screen.getByText("slow query"))
|
|
144
|
+
// La ligne a sélectionné le span : son détail est ouvert.
|
|
145
|
+
expect(screen.getByText("postgresql")).toBeTruthy()
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
it("dit quand aucun log ne porte ce trace id", () => {
|
|
149
|
+
render(<SpanWaterfall spans={SPANS} logs={[]} />)
|
|
150
|
+
expect(screen.getByText(/No log line carries this trace id/)).toBeTruthy()
|
|
151
|
+
})
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
describe("TracesOverview", () => {
|
|
155
|
+
it("n'a de lignes cliquables que si on lui donne onRouteClick", () => {
|
|
156
|
+
const set = [trace("a", "GET /x", 10, MIN)]
|
|
157
|
+
const { rerender } = render(<TracesOverview traces={set} lookbackMinutes={60} now={NOW} />)
|
|
158
|
+
expect(screen.getByText("/x").closest("button")).toBeNull()
|
|
159
|
+
let clicked = ""
|
|
160
|
+
rerender(<TracesOverview traces={set} lookbackMinutes={60} now={NOW} onRouteClick={(n) => (clicked = n)} />)
|
|
161
|
+
fireEvent.click(screen.getByText("/x"))
|
|
162
|
+
expect(clicked).toBe("GET /x")
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
it("n'affiche les services que sur demande", () => {
|
|
166
|
+
const set = [trace("a", "GET /x", 10, MIN, { service: "spunto-proxy" })]
|
|
167
|
+
const { rerender } = render(<TracesOverview traces={set} lookbackMinutes={60} now={NOW} />)
|
|
168
|
+
expect(screen.queryByText("spunto-proxy")).toBeNull()
|
|
169
|
+
rerender(<TracesOverview traces={set} lookbackMinutes={60} now={NOW} showServices />)
|
|
170
|
+
expect(screen.getByText("spunto-proxy")).toBeTruthy()
|
|
171
|
+
})
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
describe("SpanWaterfall — repli", () => {
|
|
175
|
+
it("sait ce que replier cacherait, erreurs comprises", () => {
|
|
176
|
+
const t = spanTree(orderSpans([...SPANS, { spanId: "boom", parentSpanId: "leaf", name: "x", startTimeMs: 13, durationMs: 1, error: true }]))
|
|
177
|
+
expect(t.get("root")).toMatchObject({ parentId: null, descendants: 4, descendantError: true })
|
|
178
|
+
expect(t.get("rpc")).toMatchObject({ parentId: "root", descendants: 2, descendantError: true })
|
|
179
|
+
expect(t.get("db")).toMatchObject({ parentId: "root", descendants: 0, descendantError: false })
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
it("replie un sous-arbre au chevron, sans sélectionner le span", () => {
|
|
183
|
+
render(<SpanWaterfall spans={SPANS} />)
|
|
184
|
+
expect(screen.getByText("docker.create")).toBeTruthy()
|
|
185
|
+
fireEvent.click(screen.getByLabelText("Collapse agent.cmd spawn"))
|
|
186
|
+
expect(screen.queryByText("docker.create")).toBeNull()
|
|
187
|
+
expect(screen.getByText("+1")).toBeTruthy()
|
|
188
|
+
// Le clic sur le chevron n'a pas ouvert le détail du span.
|
|
189
|
+
expect(screen.queryByText("kind")).toBeNull()
|
|
190
|
+
fireEvent.click(screen.getByLabelText("Expand agent.cmd spawn"))
|
|
191
|
+
expect(screen.getByText("docker.create")).toBeTruthy()
|
|
192
|
+
})
|
|
193
|
+
|
|
194
|
+
it("replie et déplie tout", () => {
|
|
195
|
+
render(<SpanWaterfall spans={SPANS} />)
|
|
196
|
+
fireEvent.click(screen.getByText("Collapse all"))
|
|
197
|
+
expect(screen.queryByText("pg.query")).toBeNull()
|
|
198
|
+
expect(screen.getByText("GET /api/x")).toBeTruthy()
|
|
199
|
+
expect(screen.getByText("late")).toBeTruthy()
|
|
200
|
+
fireEvent.click(screen.getByText("Expand all"))
|
|
201
|
+
expect(screen.getByText("docker.create")).toBeTruthy()
|
|
202
|
+
})
|
|
203
|
+
|
|
204
|
+
it("part replié à partir d'une profondeur", () => {
|
|
205
|
+
render(<SpanWaterfall spans={SPANS} defaultCollapsedDepth={1} />)
|
|
206
|
+
expect(screen.getByText("agent.cmd spawn")).toBeTruthy()
|
|
207
|
+
expect(screen.queryByText("docker.create")).toBeNull()
|
|
208
|
+
})
|
|
209
|
+
|
|
210
|
+
it("se replie au clavier", () => {
|
|
211
|
+
render(<SpanWaterfall spans={SPANS} />)
|
|
212
|
+
const row = screen.getByText("agent.cmd spawn").closest("[role=button]") as HTMLElement
|
|
213
|
+
fireEvent.keyDown(row, { key: "ArrowLeft" })
|
|
214
|
+
expect(screen.queryByText("docker.create")).toBeNull()
|
|
215
|
+
fireEvent.keyDown(row, { key: "ArrowRight" })
|
|
216
|
+
expect(screen.getByText("docker.create")).toBeTruthy()
|
|
217
|
+
fireEvent.keyDown(row, { key: "Enter" })
|
|
218
|
+
expect(row.getAttribute("aria-pressed")).toBe("true")
|
|
219
|
+
})
|
|
220
|
+
|
|
221
|
+
it("déplie les ancêtres d'un span choisi depuis une ligne de log", () => {
|
|
222
|
+
render(
|
|
223
|
+
<SpanWaterfall
|
|
224
|
+
spans={SPANS}
|
|
225
|
+
defaultCollapsedDepth={0}
|
|
226
|
+
logs={[{ timestamp: new Date(20).toISOString(), body: "creating container", spanId: "leaf" }]}
|
|
227
|
+
/>
|
|
228
|
+
)
|
|
229
|
+
expect(screen.queryByText("docker.create")).toBeNull()
|
|
230
|
+
fireEvent.click(screen.getByText("creating container"))
|
|
231
|
+
expect(screen.getAllByText("docker.create").length).toBeGreaterThan(0)
|
|
232
|
+
expect(screen.getByText("agent.cmd spawn")).toBeTruthy()
|
|
233
|
+
})
|
|
234
|
+
|
|
235
|
+
it("se laisse contrôler", () => {
|
|
236
|
+
let last: string[] = []
|
|
237
|
+
render(<SpanWaterfall spans={SPANS} collapsedSpanIds={["root"]} onCollapsedSpanIdsChange={(ids) => (last = ids)} />)
|
|
238
|
+
expect(screen.queryByText("agent.cmd spawn")).toBeNull()
|
|
239
|
+
fireEvent.click(screen.getByLabelText("Expand GET /api/x"))
|
|
240
|
+
expect(last).toEqual([])
|
|
241
|
+
// Contrôlé : tant que le parent ne change pas la prop, rien ne bouge.
|
|
242
|
+
expect(screen.queryByText("agent.cmd spawn")).toBeNull()
|
|
243
|
+
})
|
|
244
|
+
})
|