mreo ui
This commit is contained in:
1 parent
49f9bb3458
commit
7436966689
22 files changed
+989
-125
No files matched your search
@@ -223,3 +223,23 @@ plus `AuthServer.spec.deviceFlow`.
|
||||
(a non-fatal "spec.issuer: Required value" warning for the partial AuthServer
|
||||
apply is expected).
|
||||
- Never commit tokens, persist them in a production build, or log them.
|
||||
|
||||
### Drill-down screens: `MasterDetail` + `ResizablePanes`
|
||||
|
||||
Navigation here drills list -> detail -> extended detail, so use the shared
|
||||
pane components instead of laying out columns by hand.
|
||||
|
||||
- `components/panes/ResizablePanes.tsx` - generic stack of panes with
|
||||
draggable dividers (arrow keys when focused, double-click resets, widths
|
||||
persisted per `storageKey`). Fixed panes take a `size`; exactly one pane is
|
||||
flexible. Below `lg` panes stack and dividers disappear.
|
||||
- `components/MasterDetail.tsx` - list | detail [| extended] on top of it.
|
||||
The caller owns selection; `renderDetail(item, drill)` gets
|
||||
`drill.openExtended(key)` / `closeExtended()` and `renderExtended(item, key)`
|
||||
fills the third pane, which resets when the selection changes.
|
||||
`layout="stacked"` gives a full-width table with the detail below.
|
||||
- Building blocks: `trace/TraceListItem` (narrow rows), `trace/TraceOverviewRow`
|
||||
(table rows), `trace/TraceDetail`, `common/*` (Stat, StatHeader, Segmented,
|
||||
DetailField, BackButton). The waterfall takes `selectedSpanId`/`onSelectSpan`
|
||||
to drive an external inspector pane.
|
||||
- Local UI against a local server: `OTEL_URL=http://localhost:8080 npm run dev`.
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"git.tornberg.me/go-otel/internal/httpattr"
|
||||
"git.tornberg.me/go-otel/internal/issues"
|
||||
"go.opentelemetry.io/collector/pdata/pcommon"
|
||||
"go.opentelemetry.io/collector/pdata/plog"
|
||||
@@ -183,12 +184,11 @@ func firstAttr(attrs pcommon.Map, keys ...string) string {
|
||||
// as a generic router mount. This keeps API endpoints distinct in issue
|
||||
// fingerprints and avoids collapsing unrelated requests into one root route.
|
||||
func httpRoute(attrs pcommon.Map) string {
|
||||
route := firstAttr(attrs, "http.route")
|
||||
path := firstAttr(attrs, "url.path", "http.target")
|
||||
if path != "" && (route == "" || route == "/" && path != "/") {
|
||||
return path
|
||||
ep := httpattr.Extract(func(keys ...string) string { return firstAttr(attrs, keys...) })
|
||||
if ep.Route != "" {
|
||||
return ep.Route
|
||||
}
|
||||
return route
|
||||
return ep.Path
|
||||
}
|
||||
|
||||
// getHTTPStatusCode extracts an HTTP status code from attributes, supporting
|
||||
@@ -332,7 +332,7 @@ func spanErrorTitle(span ptrace.Span) string {
|
||||
}
|
||||
|
||||
method := firstAttr(span.Attributes(), "http.request.method", "http.method")
|
||||
route := firstAttr(span.Attributes(), "http.route", "http.target", "url.path")
|
||||
route := httpRoute(span.Attributes())
|
||||
|
||||
var endpoint string
|
||||
if method != "" && route != "" {
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
// Package httpattr collapses the many attribute spellings used for "which
|
||||
// HTTP endpoint was this" (OTel stable semconv, legacy semconv, nginx, Go
|
||||
// net/http, browser RUM) into one Endpoint, so spans from different
|
||||
// instrumentations can be compared and joined.
|
||||
package httpattr
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Endpoint is the normalized identity of one HTTP request.
|
||||
type Endpoint struct {
|
||||
Method string // upper-case, e.g. "GET"
|
||||
Host string // host without port, lower-case; "" if unknown
|
||||
Path string // concrete path, no query/fragment, no trailing slash
|
||||
Route string // instrumentation-provided template ("/orders/:id"), may be ""
|
||||
}
|
||||
|
||||
// Key is the join key between a client-side (RUM) request and a server-side
|
||||
// span: method plus the concrete path. Route is deliberately not part of it -
|
||||
// a browser only knows the concrete URL.
|
||||
func (e Endpoint) Key() string {
|
||||
if e.Path == "" {
|
||||
return ""
|
||||
}
|
||||
return e.Method + " " + e.Path
|
||||
}
|
||||
|
||||
// Lookup returns the first non-empty value among keys.
|
||||
type Lookup func(keys ...string) string
|
||||
|
||||
// Extract reads an Endpoint out of span/log attributes.
|
||||
//
|
||||
// Sources, in order of preference:
|
||||
//
|
||||
// method: http.request.method, http.method
|
||||
// path: url.path, http.target (path+query), url.full, http.url
|
||||
// host: server.address, http.host, net.host.name, url.full/http.url host
|
||||
// route: http.route
|
||||
//
|
||||
// nginx's OTel module emits the stable keys; older nginx/Go/Java agents emit
|
||||
// http.target/http.url; browsers only know url.full (absolute or relative).
|
||||
func Extract(get Lookup) Endpoint {
|
||||
e := Endpoint{
|
||||
Method: strings.ToUpper(get("http.request.method", "http.method")),
|
||||
Route: get("http.route"),
|
||||
}
|
||||
e.Host = stripPort(strings.ToLower(get("server.address", "http.host", "net.host.name", "server.host")))
|
||||
if p := get("url.path"); p != "" {
|
||||
e.Path = CleanPath(p)
|
||||
} else if t := get("http.target"); t != "" {
|
||||
e.Path = CleanPath(t)
|
||||
}
|
||||
if full := get("url.full", "http.url"); full != "" {
|
||||
host, path := SplitURL(full)
|
||||
if e.Path == "" {
|
||||
e.Path = path
|
||||
}
|
||||
if e.Host == "" {
|
||||
e.Host = host
|
||||
}
|
||||
}
|
||||
// Some instrumentations report the router mount ("/") as the route.
|
||||
if e.Route == "/" && e.Path != "" && e.Path != "/" {
|
||||
e.Route = ""
|
||||
}
|
||||
return e
|
||||
}
|
||||
|
||||
// SplitURL splits an absolute or relative URL into (host without port, clean path).
|
||||
func SplitURL(raw string) (host, path string) {
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil {
|
||||
return "", CleanPath(raw)
|
||||
}
|
||||
return stripPort(strings.ToLower(u.Host)), CleanPath(u.Path)
|
||||
}
|
||||
|
||||
// CleanPath drops query/fragment and a trailing slash, and guarantees a
|
||||
// leading slash. "" stays "".
|
||||
func CleanPath(p string) string {
|
||||
if i := strings.IndexAny(p, "?#"); i >= 0 {
|
||||
p = p[:i]
|
||||
}
|
||||
p = strings.TrimSpace(p)
|
||||
if p == "" {
|
||||
return ""
|
||||
}
|
||||
if !strings.HasPrefix(p, "/") {
|
||||
p = "/" + p
|
||||
}
|
||||
if len(p) > 1 {
|
||||
p = strings.TrimRight(p, "/")
|
||||
if p == "" {
|
||||
p = "/"
|
||||
}
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
// StripPrefix removes a reverse-proxy mount prefix (e.g. nginx
|
||||
// `location /api/ { proxy_pass http://backend/; }` makes the browser see
|
||||
// /api/orders while the backend sees /orders). The longest matching prefix wins.
|
||||
func StripPrefix(path string, prefixes []string) string {
|
||||
best := ""
|
||||
for _, p := range prefixes {
|
||||
p = CleanPath(p)
|
||||
if p == "" || p == "/" {
|
||||
continue
|
||||
}
|
||||
if (path == p || strings.HasPrefix(path, p+"/")) && len(p) > len(best) {
|
||||
best = p
|
||||
}
|
||||
}
|
||||
if best == "" {
|
||||
return path
|
||||
}
|
||||
if rest := path[len(best):]; rest != "" {
|
||||
return rest
|
||||
}
|
||||
return "/"
|
||||
}
|
||||
|
||||
func stripPort(host string) string {
|
||||
if strings.HasPrefix(host, "[") { // IPv6 literal
|
||||
if i := strings.Index(host, "]"); i > 0 {
|
||||
return host[:i+1]
|
||||
}
|
||||
return host
|
||||
}
|
||||
if i := strings.LastIndexByte(host, ':'); i >= 0 {
|
||||
return host[:i]
|
||||
}
|
||||
return host
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package httpattr
|
||||
|
||||
import "testing"
|
||||
|
||||
func lookup(m map[string]string) Lookup {
|
||||
return func(keys ...string) string {
|
||||
for _, k := range keys {
|
||||
if v := m[k]; v != "" {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractVariants(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
attrs map[string]string
|
||||
want string
|
||||
}{
|
||||
{"stable semconv", map[string]string{"http.request.method": "get", "url.path": "/api/orders/"}, "GET /api/orders"},
|
||||
{"legacy nginx target", map[string]string{"http.method": "GET", "http.target": "/api/orders?x=1"}, "GET /api/orders"},
|
||||
{"go http.url", map[string]string{"http.method": "POST", "http.url": "https://shop.example.com:8443/api/cart?a=b"}, "POST /api/cart"},
|
||||
{"browser url.full", map[string]string{"http.request.method": "GET", "url.full": "https://shop.example.com/api/orders#x"}, "GET /api/orders"},
|
||||
{"relative url.full", map[string]string{"http.request.method": "GET", "url.full": "api/orders"}, "GET /api/orders"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := Extract(lookup(c.attrs)).Key(); got != c.want {
|
||||
t.Errorf("%s: got %q want %q", c.name, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractHostAndMountRoute(t *testing.T) {
|
||||
e := Extract(lookup(map[string]string{"http.route": "/", "url.path": "/x", "server.address": "API.example.com:443"}))
|
||||
if e.Host != "api.example.com" || e.Route != "" {
|
||||
t.Fatalf("got %+v", e)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStripPrefix(t *testing.T) {
|
||||
p := []string{"/api", "/api/v2"}
|
||||
for in, want := range map[string]string{"/api/orders": "/orders", "/api/v2/orders": "/orders", "/api": "/", "/apix": "/apix", "/other": "/other"} {
|
||||
if got := StripPrefix(in, p); got != want {
|
||||
t.Errorf("%s: got %q want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"git.tornberg.me/go-otel/internal/httpattr"
|
||||
"git.tornberg.me/go-otel/internal/issues"
|
||||
)
|
||||
|
||||
@@ -397,14 +398,13 @@ func defaultString(value, fallback string) string {
|
||||
func normalizeMessage(message string) string { return issues.NormalizeTitle(message) }
|
||||
|
||||
func normalizeHTTPRoute(attrs []attribute) string {
|
||||
route := stringAttr(attrs, "http.route")
|
||||
path := stringAttr(attrs, "url.path", "http.target")
|
||||
// Some HTTP instrumentations report the router mount point (often "/")
|
||||
// as http.route while url.path contains the actual endpoint. Prefer the
|
||||
// specific path in that case so unrelated API calls do not collapse into
|
||||
// one pattern.
|
||||
if path != "" && (route == "" || route == "/" && path != "/") {
|
||||
route = path
|
||||
ep := httpattr.Extract(func(keys ...string) string { return stringAttr(attrs, keys...) })
|
||||
// Prefer the template when the instrumentation gave a meaningful one,
|
||||
// otherwise the concrete path. A mount-point route ("/") is dropped by
|
||||
// Extract so unrelated API calls do not collapse into one pattern.
|
||||
route := ep.Route
|
||||
if route == "" {
|
||||
route = ep.Path
|
||||
}
|
||||
return issues.NormalizeRoute(route)
|
||||
}
|
||||
|
||||
+23
-11
@@ -1,9 +1,12 @@
|
||||
# Single-node ClickHouse for the otel-debug-receiver, applied as one file:
|
||||
# kubectl apply -f k8s/clickhouse.yaml
|
||||
#
|
||||
# A single instance (per request): one StatefulSet replica with a PVC so
|
||||
# telemetry survives pod restarts. Resources sized for a small cluster;
|
||||
# adjust before production use.
|
||||
# A single instance (per request): one StatefulSet replica. Data lives on
|
||||
# serber's local disk at /srv/monitoring-db (hostPath), so the pod is pinned to
|
||||
# that node. It used to be an nfs-client PVC on a spinning-disk NAS, where
|
||||
# synchronous writes cost ~120 ms and every query paid that latency; see
|
||||
# k8s/migrate/clickhouse-nfs-to-local.yaml for the one-off data move.
|
||||
# Resources sized for a small cluster; adjust before production use.
|
||||
apiVersion: v1
|
||||
kind: Namespace
|
||||
metadata:
|
||||
@@ -56,8 +59,21 @@ spec:
|
||||
labels:
|
||||
app: clickhouse
|
||||
spec:
|
||||
nodeSelector:
|
||||
kubernetes.io/hostname: serber
|
||||
securityContext:
|
||||
fsGroup: 101
|
||||
initContainers:
|
||||
# hostPath directories are created root-owned and fsGroup does not apply
|
||||
# to them; ClickHouse runs as uid/gid 101.
|
||||
- name: fix-data-perms
|
||||
image: busybox:1.36
|
||||
command: ["sh", "-c", "chown -R 101:101 /var/lib/clickhouse"]
|
||||
securityContext:
|
||||
runAsUser: 0
|
||||
volumeMounts:
|
||||
- name: data
|
||||
mountPath: /var/lib/clickhouse
|
||||
containers:
|
||||
- name: clickhouse
|
||||
image: clickhouse/clickhouse-server:24.8
|
||||
@@ -101,20 +117,16 @@ spec:
|
||||
memory: "2Gi"
|
||||
cpu: "1"
|
||||
volumes:
|
||||
- name: data
|
||||
hostPath:
|
||||
path: /srv/monitoring-db
|
||||
type: DirectoryOrCreate
|
||||
- name: user-config
|
||||
configMap:
|
||||
name: clickhouse-config
|
||||
items:
|
||||
- key: users.d-otel.xml
|
||||
path: users.d-otel.xml
|
||||
volumeClaimTemplates:
|
||||
- metadata:
|
||||
name: data
|
||||
spec:
|
||||
accessModes: ["ReadWriteOnce"]
|
||||
resources:
|
||||
requests:
|
||||
storage: 20Gi
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
# One-off: copy ClickHouse data from the old NFS PVC to serber:/srv/monitoring-db.
|
||||
# NOT applied by `kubectl apply -f k8s/` (subdirectory). Run it while the
|
||||
# StatefulSet is scaled to 0 so the copy is consistent:
|
||||
#
|
||||
# kubectl scale sts clickhouse -n monitoring --replicas=0
|
||||
# kubectl apply -f k8s/migrate/clickhouse-nfs-to-local.yaml
|
||||
# kubectl wait --for=condition=complete job/clickhouse-nfs-to-local -n monitoring --timeout=30m
|
||||
# kubectl delete sts clickhouse -n monitoring # PVC is retained (rollback copy)
|
||||
# kubectl apply -f k8s/clickhouse.yaml # new hostPath StatefulSet
|
||||
# kubectl delete job clickhouse-nfs-to-local -n monitoring
|
||||
#
|
||||
# The old PVC (data-clickhouse-0) is left untouched until you delete it.
|
||||
apiVersion: batch/v1
|
||||
kind: Job
|
||||
metadata:
|
||||
name: clickhouse-nfs-to-local
|
||||
namespace: monitoring
|
||||
spec:
|
||||
backoffLimit: 0
|
||||
template:
|
||||
spec:
|
||||
restartPolicy: Never
|
||||
nodeSelector:
|
||||
kubernetes.io/hostname: serber
|
||||
containers:
|
||||
- name: copy
|
||||
image: busybox:1.36
|
||||
securityContext:
|
||||
runAsUser: 0
|
||||
command:
|
||||
- sh
|
||||
- -c
|
||||
- |
|
||||
set -eu
|
||||
if [ -n "$(ls -A /new 2>/dev/null)" ]; then
|
||||
echo "refusing to copy: /new (serber:/srv/monitoring-db) is not empty"; exit 1
|
||||
fi
|
||||
du -sh /old
|
||||
cp -a /old/. /new/
|
||||
chown -R 101:101 /new
|
||||
echo "old: $(find /old -type f | wc -l) files, new: $(find /new -type f | wc -l) files"
|
||||
[ "$(find /old -type f | wc -l)" = "$(find /new -type f | wc -l)" ]
|
||||
volumeMounts:
|
||||
- name: old
|
||||
mountPath: /old
|
||||
readOnly: true
|
||||
- name: new
|
||||
mountPath: /new
|
||||
volumes:
|
||||
- name: old
|
||||
persistentVolumeClaim:
|
||||
claimName: data-clickhouse-0
|
||||
readOnly: true
|
||||
- name: new
|
||||
hostPath:
|
||||
path: /srv/monitoring-db
|
||||
type: DirectoryOrCreate
|
||||
@@ -4,7 +4,9 @@ import { BackButton } from "./common/BackButton"
|
||||
import { SectionLabel } from "./common/DetailField"
|
||||
import { Stat, StatHeader } from "./common/Stat"
|
||||
import { TraceDetail } from "./trace/TraceDetail"
|
||||
import { TraceOverviewHeader, TraceOverviewRow } from "./trace/TraceOverviewRow"
|
||||
import { SpanInspector } from "./TraceVisualization"
|
||||
import { Segmented } from "./common/Segmented"
|
||||
import { TraceListItem } from "./trace/TraceListItem"
|
||||
import { OccurrenceDetail } from "./issues/OccurrenceDetail"
|
||||
import { useIssueTraces } from "../hooks/useIssueTraces"
|
||||
import { useProcessedTraces } from "../hooks/useProcessedTraces"
|
||||
@@ -37,6 +39,8 @@ export const IssueOccurrences = ({
|
||||
}) => {
|
||||
const { traces, untraced, total, complete, hasMore, loading, error, loadMore } = useIssueTraces(issue.id)
|
||||
|
||||
const [sort, setSort] = useState<"recent" | "slowest" | "errors">("recent")
|
||||
|
||||
const rows: Row[] = useMemo(() => {
|
||||
const traced = traces.map((t) => ({
|
||||
key: t.traceId,
|
||||
@@ -47,8 +51,13 @@ export const IssueOccurrences = ({
|
||||
summary: t.summary,
|
||||
}))
|
||||
const loose = untraced.map((s, i) => ({ key: `s-${s.timestamp}-${i}`, samples: [s], ts: s.timestamp, hits: 1 }))
|
||||
return [...traced, ...loose].sort((a, b) => b.ts - a.ts)
|
||||
}, [traces, untraced])
|
||||
const all = [...traced, ...loose]
|
||||
const dur = (r: Row) => r.summary?.duration ?? 0
|
||||
const errs = (r: Row) => r.summary?.errorCount ?? 0
|
||||
return all.sort((a, b) =>
|
||||
sort === "slowest" ? dur(b) - dur(a) : sort === "errors" ? errs(b) - errs(a) || b.ts - a.ts : b.ts - a.ts,
|
||||
)
|
||||
}, [traces, untraced, sort])
|
||||
|
||||
const [picked, setPicked] = useState<string | null>(initialTraceId ?? null)
|
||||
const selected = rows.find((r) => r.key === picked) ?? rows[0]
|
||||
@@ -94,15 +103,28 @@ export const IssueOccurrences = ({
|
||||
{loading && rows.length === 0 && <p className="text-[13px] text-ds-text-3">Loading traces…</p>}
|
||||
|
||||
<MasterDetail
|
||||
layout="stacked"
|
||||
items={rows}
|
||||
getKey={(r) => r.key}
|
||||
selectedKey={selected?.key ?? null}
|
||||
onSelect={setPicked}
|
||||
label="Traces containing this issue"
|
||||
listHeader={<TraceOverviewHeader />}
|
||||
listHeader={
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<SectionLabel>Traces ({rows.length}{hasMore ? "+" : ""})</SectionLabel>
|
||||
<Segmented
|
||||
label="Sort traces"
|
||||
value={sort}
|
||||
onChange={setSort}
|
||||
options={[
|
||||
{ value: "recent", label: "Newest" },
|
||||
{ value: "slowest", label: "Slowest" },
|
||||
{ value: "errors", label: "Errors" },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
renderItem={(r) => (
|
||||
<TraceOverviewRow
|
||||
<TraceListItem
|
||||
summary={r.summary}
|
||||
traceId={r.traceId}
|
||||
timestampNs={r.ts}
|
||||
@@ -111,7 +133,16 @@ export const IssueOccurrences = ({
|
||||
hits={r.hits}
|
||||
/>
|
||||
)}
|
||||
renderDetail={(r) => (
|
||||
storageKey="issue-traces"
|
||||
extendedTitle="Span"
|
||||
renderExtended={(r, spanId) => {
|
||||
const trace = r.traceId ? byId.get(r.traceId) : undefined
|
||||
const span = trace?.spans.find((sp) => sp.spanId === spanId)
|
||||
return span && trace ? (
|
||||
<SpanInspector span={span} traceTotalRange={trace.duration} />
|
||||
) : null
|
||||
}}
|
||||
renderDetail={(r, drill) => (
|
||||
<div className="flex flex-col gap-4">
|
||||
{r.traceId && (
|
||||
<TraceDetail
|
||||
@@ -119,6 +150,8 @@ export const IssueOccurrences = ({
|
||||
trace={byId.get(r.traceId)}
|
||||
loading={spansLoading && !failed.has(r.traceId)}
|
||||
spanId={r.samples[0]?.spanId}
|
||||
selectedSpanId={drill.extendedKey}
|
||||
onSelectSpan={(id) => (id ? drill.openExtended(id) : drill.closeExtended())}
|
||||
/>
|
||||
)}
|
||||
<SectionLabel>
|
||||
|
||||
@@ -1,4 +1,13 @@
|
||||
import { useEffect, useRef, type KeyboardEvent, type ReactNode } from "react"
|
||||
import { useEffect, useRef, useState, type KeyboardEvent, type ReactNode } from "react"
|
||||
import { ResizablePanes, type PaneSize } from "./panes/ResizablePanes"
|
||||
|
||||
/** Handed to renderDetail so the detail can open (and close) the extended pane. */
|
||||
export interface DrillApi {
|
||||
/** Key of the currently open extended item, or null. */
|
||||
extendedKey: string | null
|
||||
openExtended: (key: string) => void
|
||||
closeExtended: () => void
|
||||
}
|
||||
|
||||
interface MasterDetailProps<T> {
|
||||
items: T[]
|
||||
@@ -6,28 +15,33 @@ interface MasterDetailProps<T> {
|
||||
selectedKey: string | null
|
||||
onSelect: (key: string) => void
|
||||
renderItem: (item: T, selected: boolean) => ReactNode
|
||||
renderDetail: (item: T) => ReactNode
|
||||
/** Accessible name for the list, e.g. "Occurrences". */
|
||||
renderDetail: (item: T, drill: DrillApi) => ReactNode
|
||||
/**
|
||||
* Third pane, opened from inside the detail via drill.openExtended(key).
|
||||
* Omit for a plain two-pane list/detail.
|
||||
*/
|
||||
renderExtended?: (item: T, key: string) => ReactNode
|
||||
extendedTitle?: ReactNode
|
||||
/** Accessible name for the list, e.g. "Traces". */
|
||||
label: string
|
||||
/** Optional heading rendered above the list. */
|
||||
/** Optional heading/controls rendered above the list. */
|
||||
listHeader?: ReactNode
|
||||
emptyDetail?: ReactNode
|
||||
/**
|
||||
* "split": list left, detail right (lists of cards). "stacked": full-width
|
||||
* list on top (tables/overviews), detail below.
|
||||
* "split": resizable list | detail [| extended] panes (default).
|
||||
* "stacked": full-width list on top (tables), detail below.
|
||||
*/
|
||||
layout?: "split" | "stacked"
|
||||
/** Split layout: Tailwind width class for the master column. */
|
||||
listWidth?: string
|
||||
/** Tailwind max-height class for the list (and, in split, the detail). */
|
||||
maxHeight?: string
|
||||
listSize?: PaneSize
|
||||
extendedSize?: PaneSize
|
||||
/** Persists pane widths per screen. */
|
||||
storageKey?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Reusable master-detail layout: a selectable list on the left, the selected
|
||||
* item's detail on the right (stacked on narrow screens). Arrow keys move the
|
||||
* selection. Callers own selection state so it can be driven from elsewhere
|
||||
* (e.g. a deep link or "focus this trace").
|
||||
* Reusable drill-down layout. Selection is owned by the caller so it can be
|
||||
* driven from elsewhere (deep links, "focus this trace"); the extended pane's
|
||||
* state lives here and resets whenever the selection changes.
|
||||
*/
|
||||
export function MasterDetail<T>({
|
||||
items,
|
||||
@@ -36,78 +50,112 @@ export function MasterDetail<T>({
|
||||
onSelect,
|
||||
renderItem,
|
||||
renderDetail,
|
||||
renderExtended,
|
||||
extendedTitle = "Details",
|
||||
label,
|
||||
listHeader,
|
||||
emptyDetail,
|
||||
layout = "split",
|
||||
listWidth = "lg:w-[380px]",
|
||||
maxHeight,
|
||||
listSize = { default: 320, min: 240, max: 560 },
|
||||
extendedSize = { default: 380, min: 280, max: 720 },
|
||||
storageKey,
|
||||
}: MasterDetailProps<T>) {
|
||||
const stacked = layout === "stacked"
|
||||
const maxH = maxHeight ?? (stacked ? "max-h-[300px]" : "lg:max-h-[calc(100vh-260px)]")
|
||||
const listRef = useRef<HTMLDivElement>(null)
|
||||
const [extendedKey, setExtendedKey] = useState<string | null>(null)
|
||||
const selected = items.find((item) => getKey(item) === selectedKey) ?? null
|
||||
|
||||
useEffect(() => {
|
||||
listRef.current
|
||||
?.querySelector<HTMLElement>('[aria-selected="true"]')
|
||||
?.scrollIntoView({ block: "nearest" })
|
||||
listRef.current?.querySelector<HTMLElement>('[aria-selected="true"]')?.scrollIntoView({ block: "nearest" })
|
||||
}, [selectedKey])
|
||||
|
||||
// A different selection invalidates whatever was open in the extended pane.
|
||||
useEffect(() => setExtendedKey(null), [selectedKey])
|
||||
|
||||
const select = (key: string) => {
|
||||
setExtendedKey(null)
|
||||
onSelect(key)
|
||||
}
|
||||
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key !== "ArrowDown" && e.key !== "ArrowUp") return
|
||||
e.preventDefault()
|
||||
const idx = items.findIndex((item) => getKey(item) === selectedKey)
|
||||
const next = items[Math.min(items.length - 1, Math.max(0, idx + (e.key === "ArrowDown" ? 1 : -1)))]
|
||||
if (next) onSelect(getKey(next))
|
||||
if (next) select(getKey(next))
|
||||
}
|
||||
|
||||
const list = (
|
||||
<div className="flex flex-col gap-2">
|
||||
{listHeader}
|
||||
<div
|
||||
ref={listRef}
|
||||
role="listbox"
|
||||
aria-label={label}
|
||||
tabIndex={0}
|
||||
onKeyDown={onKeyDown}
|
||||
className={`flex flex-col gap-1.5 overflow-auto rounded-[14px] border border-ds-line bg-ds-surface p-1.5 focus:outline-none focus-visible:border-ds-accent ${
|
||||
stacked ? "max-h-[300px]" : "lg:max-h-[calc(100vh-240px)]"
|
||||
}`}
|
||||
>
|
||||
{items.map((item) => {
|
||||
const key = getKey(item)
|
||||
const isSelected = key === selectedKey
|
||||
return (
|
||||
<div
|
||||
key={key}
|
||||
role="option"
|
||||
aria-selected={isSelected}
|
||||
onClick={() => select(key)}
|
||||
className={`cursor-pointer rounded-[9px] border px-3 py-2 ${
|
||||
isSelected ? "border-ds-accent/40 bg-ds-surface-2" : "border-transparent hover:bg-ds-surface-2"
|
||||
}`}
|
||||
>
|
||||
{renderItem(item, isSelected)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
const drill: DrillApi = { extendedKey, openExtended: setExtendedKey, closeExtended: () => setExtendedKey(null) }
|
||||
const detail = selected ? (
|
||||
renderDetail(selected, drill)
|
||||
) : (
|
||||
<p className="py-10 text-center text-[13px] text-ds-text-3">{emptyDetail ?? "Select an item to see its details."}</p>
|
||||
)
|
||||
|
||||
if (stacked) {
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
{list}
|
||||
<section aria-live="polite">{detail}</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`flex flex-col gap-4 ${stacked ? "" : "lg:flex-row lg:items-start"}`}>
|
||||
<div className={`flex w-full shrink-0 flex-col gap-2 ${stacked ? "" : listWidth}`}>
|
||||
{listHeader}
|
||||
<div
|
||||
ref={listRef}
|
||||
role="listbox"
|
||||
aria-label={label}
|
||||
tabIndex={0}
|
||||
onKeyDown={onKeyDown}
|
||||
className={`flex max-h-[360px] flex-col gap-1.5 overflow-auto rounded-[14px] border border-ds-line bg-ds-surface p-1.5 focus:outline-none focus-visible:border-ds-accent ${maxH}`}
|
||||
>
|
||||
{items.map((item) => {
|
||||
const key = getKey(item)
|
||||
const isSelected = key === selectedKey
|
||||
return (
|
||||
<div
|
||||
key={key}
|
||||
role="option"
|
||||
aria-selected={isSelected}
|
||||
onClick={() => onSelect(key)}
|
||||
className={`cursor-pointer rounded-[9px] border px-3 py-2 ${
|
||||
isSelected
|
||||
? "border-ds-accent/40 bg-ds-surface-2"
|
||||
: "border-transparent hover:bg-ds-surface-2"
|
||||
}`}
|
||||
>
|
||||
{renderItem(item, isSelected)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<section
|
||||
aria-live="polite"
|
||||
className={`min-w-0 flex-1 ${stacked ? "" : `overflow-auto rounded-[14px] border border-ds-line bg-ds-surface p-5 ${maxH}`}`}
|
||||
>
|
||||
{selected ? (
|
||||
renderDetail(selected)
|
||||
) : (
|
||||
<p className="py-10 text-center text-[13px] text-ds-text-3">
|
||||
{emptyDetail ?? "Select an item to see its details."}
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
<ResizablePanes
|
||||
storageKey={storageKey}
|
||||
panes={[
|
||||
{ id: "list", label, node: list, size: listSize, sticky: true },
|
||||
{ id: "detail", label: "Details", node: detail },
|
||||
...(selected && renderExtended && extendedKey
|
||||
? [
|
||||
{
|
||||
id: "extended",
|
||||
label: "Extended details",
|
||||
node: renderExtended(selected, extendedKey),
|
||||
size: extendedSize,
|
||||
sticky: true,
|
||||
title: extendedTitle,
|
||||
onClose: drill.closeExtended,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
]}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,12 @@ import {
|
||||
} from "lucide-react"
|
||||
import { useObservabilityData } from "../hooks/useObservabilityData"
|
||||
import { formatRelativeTime } from "../lib/timeUtils"
|
||||
import {
|
||||
backendRequestsFromTraces,
|
||||
linkRumRequest,
|
||||
parseRequestCrumb,
|
||||
type RumBackendLink,
|
||||
} from "../lib/http-endpoint"
|
||||
import {
|
||||
VITAL_THRESHOLDS,
|
||||
aggregateRum,
|
||||
@@ -162,7 +168,8 @@ const SessionRow = ({
|
||||
}
|
||||
|
||||
const SessionDetail = ({ session }: { session: RumSession | null }) => {
|
||||
const { setActiveTraceId, setActiveSpanId, setActiveTab } = useObservabilityData()
|
||||
const { setActiveTraceId, setActiveSpanId, setActiveTab, allTraces } = useObservabilityData()
|
||||
const backend = useMemo(() => backendRequestsFromTraces(allTraces), [allTraces])
|
||||
const [copied, setCopied] = useState(false)
|
||||
|
||||
if (!session) {
|
||||
@@ -174,6 +181,28 @@ const SessionDetail = ({ session }: { session: RumSession | null }) => {
|
||||
)
|
||||
}
|
||||
|
||||
// Match each browser request to the backend span that served it. Falls back
|
||||
// to method+path+time when the trace id doesn't survive the hop (e.g. an
|
||||
// uninstrumented nginx in front of the API).
|
||||
const links = new Map<number, RumBackendLink>()
|
||||
session.timeline.forEach((entry, idx) => {
|
||||
if (entry.category !== "fetch" && entry.category !== "xhr") return
|
||||
const req = parseRequestCrumb(entry.message)
|
||||
if (!req) return
|
||||
const link = linkRumRequest(
|
||||
{
|
||||
// Breadcrumbs are stamped on completion; the request started duration earlier.
|
||||
timestamp: entry.timestamp - (entry.durationMs ?? 0),
|
||||
durationMs: entry.durationMs,
|
||||
traceId: entry.traceId,
|
||||
method: req.method,
|
||||
url: req.url,
|
||||
},
|
||||
backend,
|
||||
)
|
||||
if (link) links.set(idx, link)
|
||||
})
|
||||
|
||||
const latestTracedError = session.errors.find((e) => e.traceId)
|
||||
|
||||
const copySessionId = () => {
|
||||
@@ -266,6 +295,22 @@ const SessionDetail = ({ session }: { session: RumSession | null }) => {
|
||||
<span className="break-all font-[family-name:var(--font-data)] text-[12px] text-ds-text-2">
|
||||
{entry.message}
|
||||
</span>
|
||||
{links.get(idx) && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => viewTrace(links.get(idx)!.backend.traceId)}
|
||||
className="inline-flex w-fit items-center gap-1 text-[12px] text-ds-accent"
|
||||
title={
|
||||
links.get(idx)!.confidence === "trace-id"
|
||||
? "Linked by trace id"
|
||||
: "Inferred from method, path and timing - no trace id reached the backend"
|
||||
}
|
||||
>
|
||||
<ExternalLink className="h-3 w-3" aria-hidden="true" />
|
||||
{links.get(idx)!.backend.service || "backend"} trace
|
||||
{links.get(idx)!.confidence === "path+time" && " (inferred)"}
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
|
||||
@@ -148,13 +148,14 @@ const getOperationDisplayName = (span: ProcessedSpan) => {
|
||||
};
|
||||
|
||||
// Span Detail Inspector Drawer
|
||||
const SpanInspector = ({
|
||||
export const SpanInspector = ({
|
||||
span,
|
||||
onClose,
|
||||
traceTotalRange,
|
||||
}: {
|
||||
span: ProcessedSpan;
|
||||
onClose: () => void;
|
||||
/** Omit when the host (e.g. a pane) already provides a close control. */
|
||||
onClose?: () => void;
|
||||
traceTotalRange: number;
|
||||
}) => {
|
||||
const [tab, setTab] = useState<"overview" | "attributes" | "events" | "logs">("overview");
|
||||
@@ -219,13 +220,15 @@ const SpanInspector = ({
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="rounded-xl p-1.5 text-ds-text-2 hover:bg-ds-fill hover:text-ds-text transition-colors"
|
||||
title="Close Inspector"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
{onClose && (
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="rounded-xl p-1.5 text-ds-text-2 hover:bg-ds-fill hover:text-ds-text transition-colors"
|
||||
title="Close Inspector"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Navigation Tabs */}
|
||||
@@ -678,7 +681,7 @@ const VirtualizedSpanWaterfall = ({
|
||||
{/* Virtualized Span Rows Container */}
|
||||
<div
|
||||
ref={parentRef}
|
||||
className="relative h-[560px] overflow-y-auto divide-y divide-ds-line select-none"
|
||||
className="relative max-h-[560px] overflow-y-auto divide-y divide-ds-line select-none"
|
||||
>
|
||||
{visibleItems.length === 0 ? (
|
||||
<div className="flex h-full flex-col items-center justify-center text-sm text-ds-text-3">
|
||||
@@ -823,10 +826,21 @@ const VirtualizedSpanWaterfall = ({
|
||||
// Main Trace Box with Header, Visual Time Distribution Bar, View Toggle, and Detail Drawer
|
||||
export const SingleTraceView = ({
|
||||
trace,
|
||||
selectedSpanId: controlledSpanId,
|
||||
onSelectSpan,
|
||||
externalInspector = false,
|
||||
}: {
|
||||
trace: ProcessedTrace;
|
||||
/** Controlled span selection; omit (with onSelectSpan) for the built-in inline inspector. */
|
||||
selectedSpanId?: string | null;
|
||||
onSelectSpan?: (spanId: string | null) => void;
|
||||
/** Don't render the inspector inline: the caller shows it elsewhere (e.g. an extended pane). */
|
||||
externalInspector?: boolean;
|
||||
}) => {
|
||||
const [selectedSpanId, setSelectedSpanId] = useState<string | null>(null);
|
||||
const [internalSpanId, setInternalSpanId] = useState<string | null>(null);
|
||||
const controlled = onSelectSpan !== undefined;
|
||||
const selectedSpanId = controlled ? (controlledSpanId ?? null) : internalSpanId;
|
||||
const setSelectedSpanId = controlled ? onSelectSpan : setInternalSpanId;
|
||||
const [viewMode, setViewMode] = useState<"waterfall" | "graph">("waterfall");
|
||||
const [filterService, setFilterService] = useState<string | null>(null);
|
||||
|
||||
@@ -867,11 +881,7 @@ export const SingleTraceView = ({
|
||||
return (
|
||||
<section
|
||||
id={trace.id}
|
||||
className={`rounded-3xl border transition-all ${
|
||||
trace.hasError
|
||||
? "border-ds-critical/20 bg-ds-critical-bg"
|
||||
: "border-ds-line bg-ds-surface-2"
|
||||
} p-5 space-y-4`}
|
||||
className="rounded-[14px] border border-ds-line bg-ds-surface p-5 space-y-4"
|
||||
>
|
||||
{/* Trace Card Header */}
|
||||
<header className="flex flex-wrap items-center justify-between gap-4 border-b border-ds-line pb-4">
|
||||
@@ -1004,17 +1014,17 @@ export const SingleTraceView = ({
|
||||
) : (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-12 gap-4 items-start">
|
||||
{/* Virtualized Waterfall Table */}
|
||||
<div className={selectedSpan ? "lg:col-span-7 xl:col-span-8" : "col-span-12"}>
|
||||
<div className={selectedSpan && !externalInspector ? "lg:col-span-7 xl:col-span-8" : "col-span-12"}>
|
||||
<VirtualizedSpanWaterfall
|
||||
trace={trace}
|
||||
selectedSpanId={selectedSpanId}
|
||||
onSelectSpan={(id) => setSelectedSpanId((prev) => (prev === id ? null : id))}
|
||||
onSelectSpan={(id) => setSelectedSpanId(selectedSpanId === id ? null : id)}
|
||||
filterService={filterService}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Span Detail Inspector Panel */}
|
||||
{selectedSpan && (
|
||||
{selectedSpan && !externalInspector && (
|
||||
<div className="lg:col-span-5 xl:col-span-4 sticky top-6">
|
||||
<SpanInspector
|
||||
span={selectedSpan}
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
/** Small segmented control for switching between a few mutually exclusive options (sort, range, ...). */
|
||||
export function Segmented<T extends string>({
|
||||
value,
|
||||
options,
|
||||
onChange,
|
||||
label,
|
||||
}: {
|
||||
value: T
|
||||
options: { value: T; label: string }[]
|
||||
onChange: (v: T) => void
|
||||
label: string
|
||||
}) {
|
||||
return (
|
||||
<div role="group" aria-label={label} className="inline-flex rounded-[9px] border border-ds-line bg-ds-surface-2 p-0.5">
|
||||
{options.map((o) => (
|
||||
<button
|
||||
key={o.value}
|
||||
type="button"
|
||||
aria-pressed={o.value === value}
|
||||
onClick={() => onChange(o.value)}
|
||||
className={`rounded-[7px] px-2.5 py-1 text-[11.5px] font-semibold ${
|
||||
o.value === value ? "bg-ds-accent-bg text-ds-accent" : "text-ds-text-3 hover:text-ds-text-2"
|
||||
}`}
|
||||
>
|
||||
{o.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
import { Fragment, useCallback, useRef, useState, type KeyboardEvent, type PointerEvent, type ReactNode } from "react"
|
||||
import { X } from "lucide-react"
|
||||
|
||||
export interface PaneSize {
|
||||
default: number
|
||||
min: number
|
||||
max: number
|
||||
}
|
||||
|
||||
export interface PaneSpec {
|
||||
/** Stable id: keys the pane and its persisted width. */
|
||||
id: string
|
||||
/** Accessible name for the pane's region. */
|
||||
label: string
|
||||
node: ReactNode
|
||||
/** Fixed-width, user-resizable pane. Omit for the one flexible pane that takes the remaining space. */
|
||||
size?: PaneSize
|
||||
/** Pin the pane while the page scrolls (lists, inspectors). Its content scrolls internally. */
|
||||
sticky?: boolean
|
||||
/** Renders the pane as a titled card with a close button when either is set. */
|
||||
title?: ReactNode
|
||||
onClose?: () => void
|
||||
}
|
||||
|
||||
const STEP = 16
|
||||
const stickyMax = "lg:max-h-[calc(100vh-190px)]"
|
||||
|
||||
function loadWidths(key: string | undefined): Record<string, number> {
|
||||
if (!key) return {}
|
||||
try {
|
||||
return JSON.parse(localStorage.getItem(`otel.panes.${key}`) ?? "{}") as Record<string, number>
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
function saveWidths(key: string | undefined, widths: Record<string, number>) {
|
||||
if (!key) return
|
||||
try {
|
||||
localStorage.setItem(`otel.panes.${key}`, JSON.stringify(widths))
|
||||
} catch {
|
||||
/* storage unavailable: widths just don't persist */
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Horizontal stack of panes with draggable dividers, for drill-down screens
|
||||
* (list -> detail -> extended detail). Panes come and go with the `panes`
|
||||
* array, so a third pane simply appears when something is selected.
|
||||
*
|
||||
* - Fixed panes (`size`) are resizable: drag a divider, or focus it and use
|
||||
* the arrow keys; double-click resets to the default. Widths persist per
|
||||
* `storageKey`.
|
||||
* - One pane without `size` is flexible and absorbs the remaining space.
|
||||
* - Below the `lg` breakpoint panes stack vertically and dividers disappear.
|
||||
*/
|
||||
export function ResizablePanes({ panes, storageKey }: { panes: PaneSpec[]; storageKey?: string }) {
|
||||
const [widths, setWidths] = useState<Record<string, number>>(() => loadWidths(storageKey))
|
||||
const drag = useRef<{ index: number; x: number; start: number } | null>(null)
|
||||
|
||||
const widthOf = (p: PaneSpec) => (p.size ? clamp(widths[p.id] ?? p.size.default, p.size) : 0)
|
||||
|
||||
/** The fixed pane a divider before `index` controls, and the direction dx moves it. */
|
||||
const target = (index: number): { pane: PaneSpec; sign: 1 | -1 } | null => {
|
||||
const left = panes[index - 1]
|
||||
const right = panes[index]
|
||||
if (left?.size) return { pane: left, sign: 1 }
|
||||
if (right?.size) return { pane: right, sign: -1 }
|
||||
return null
|
||||
}
|
||||
|
||||
const setWidth = useCallback(
|
||||
(pane: PaneSpec, px: number, persist: boolean) => {
|
||||
setWidths((prev) => {
|
||||
const next = { ...prev, [pane.id]: clamp(px, pane.size!) }
|
||||
if (persist) saveWidths(storageKey, next)
|
||||
return next
|
||||
})
|
||||
},
|
||||
[storageKey],
|
||||
)
|
||||
|
||||
const onPointerDown = (index: number) => (e: PointerEvent<HTMLDivElement>) => {
|
||||
const t = target(index)
|
||||
if (!t) return
|
||||
e.currentTarget.setPointerCapture(e.pointerId)
|
||||
drag.current = { index, x: e.clientX, start: widthOf(t.pane) }
|
||||
}
|
||||
|
||||
const onPointerMove = (e: PointerEvent<HTMLDivElement>) => {
|
||||
const d = drag.current
|
||||
const t = d && target(d.index)
|
||||
if (!d || !t) return
|
||||
setWidth(t.pane, d.start + t.sign * (e.clientX - d.x), false)
|
||||
}
|
||||
|
||||
const onPointerUp = () => {
|
||||
if (drag.current) saveWidths(storageKey, widths)
|
||||
drag.current = null
|
||||
}
|
||||
|
||||
const onKeyDown = (index: number) => (e: KeyboardEvent<HTMLDivElement>) => {
|
||||
const t = target(index)
|
||||
if (!t) return
|
||||
const dir = e.key === "ArrowRight" ? 1 : e.key === "ArrowLeft" ? -1 : 0
|
||||
if (!dir) return
|
||||
e.preventDefault()
|
||||
setWidth(t.pane, widthOf(t.pane) + dir * t.sign * STEP, true)
|
||||
}
|
||||
|
||||
const reset = (index: number) => {
|
||||
const t = target(index)
|
||||
if (t) setWidth(t.pane, t.pane.size!.default, true)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:gap-0">
|
||||
{panes.map((p, i) => {
|
||||
const t = i > 0 ? target(i) : null
|
||||
const boxed = Boolean(p.title || p.onClose)
|
||||
return (
|
||||
<Fragment key={p.id}>
|
||||
{i > 0 && (
|
||||
<div
|
||||
role="separator"
|
||||
aria-orientation="vertical"
|
||||
aria-label={`Resize ${p.label}`}
|
||||
aria-valuenow={t ? widthOf(t.pane) : undefined}
|
||||
tabIndex={t ? 0 : -1}
|
||||
onPointerDown={onPointerDown(i)}
|
||||
onPointerMove={onPointerMove}
|
||||
onPointerUp={onPointerUp}
|
||||
onKeyDown={onKeyDown(i)}
|
||||
onDoubleClick={() => reset(i)}
|
||||
className="group hidden w-3 shrink-0 cursor-col-resize touch-none justify-center self-stretch focus:outline-none lg:flex"
|
||||
>
|
||||
<span className="w-px bg-transparent group-hover:bg-ds-line-strong group-focus-visible:bg-ds-accent" />
|
||||
</div>
|
||||
)}
|
||||
<section
|
||||
aria-label={p.label}
|
||||
style={p.size ? ({ "--pane-w": `${widthOf(p)}px` } as React.CSSProperties) : undefined}
|
||||
className={`min-w-0 ${p.size ? "w-full lg:w-[var(--pane-w)] lg:shrink-0" : "w-full flex-1"} ${
|
||||
p.sticky ? "lg:sticky lg:top-4 lg:self-start" : ""
|
||||
}`}
|
||||
>
|
||||
{boxed ? (
|
||||
<div className={`flex flex-col overflow-hidden rounded-[14px] border border-ds-line bg-ds-surface ${p.sticky ? stickyMax : ""}`}>
|
||||
<header className="flex items-center justify-between gap-2 border-b border-ds-line px-4 py-2.5">
|
||||
<span className="min-w-0 truncate text-[10.5px] font-bold uppercase tracking-widest text-ds-text-3">{p.title}</span>
|
||||
{p.onClose && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={p.onClose}
|
||||
aria-label={`Close ${p.label}`}
|
||||
className="rounded p-1 text-ds-text-3 hover:bg-ds-surface-2 hover:text-ds-text"
|
||||
>
|
||||
<X className="h-4 w-4" aria-hidden="true" />
|
||||
</button>
|
||||
)}
|
||||
</header>
|
||||
<div className="min-h-0 flex-1 overflow-auto p-4">{p.node}</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className={p.sticky ? `${stickyMax} lg:overflow-auto` : ""}>{p.node}</div>
|
||||
)}
|
||||
</section>
|
||||
</Fragment>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function clamp(n: number, s: PaneSize) {
|
||||
return Math.min(s.max, Math.max(s.min, n))
|
||||
}
|
||||
@@ -10,12 +10,17 @@ export const TraceDetail = ({
|
||||
loading,
|
||||
spanId,
|
||||
label = "Selected trace",
|
||||
selectedSpanId,
|
||||
onSelectSpan,
|
||||
}: {
|
||||
traceId: string
|
||||
trace?: ProcessedTrace
|
||||
loading?: boolean
|
||||
spanId?: string
|
||||
label?: string
|
||||
/** Controlled span selection: the inspector is then expected to render elsewhere (extended pane). */
|
||||
selectedSpanId?: string | null
|
||||
onSelectSpan?: (spanId: string | null) => void
|
||||
}) => {
|
||||
const { setActiveTraceId, setActiveSpanId, setActiveTab } = useObservabilityData()
|
||||
const open = () => {
|
||||
@@ -37,7 +42,13 @@ export const TraceDetail = ({
|
||||
</button>
|
||||
</div>
|
||||
{trace ? (
|
||||
<SingleTraceView key={trace.id} trace={trace} />
|
||||
<SingleTraceView
|
||||
key={trace.id}
|
||||
trace={trace}
|
||||
selectedSpanId={selectedSpanId}
|
||||
onSelectSpan={onSelectSpan}
|
||||
externalInspector={onSelectSpan !== undefined}
|
||||
/>
|
||||
) : (
|
||||
<p className="rounded-[14px] border border-dashed border-ds-line py-10 text-center text-[13px] text-ds-text-3">
|
||||
{loading ? "Loading trace…" : "Trace spans are no longer available."}
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import { formatDuration, formatRelativeTime } from "../../lib/timeUtils"
|
||||
import type { TraceSummary } from "../../lib/types"
|
||||
|
||||
/**
|
||||
* Compact trace card for narrow master lists: age and duration up top, name,
|
||||
* a duration bar scaled against the set, then span/error/service counts.
|
||||
* Colour is used only for real state (errors present).
|
||||
*/
|
||||
export const TraceListItem = ({
|
||||
summary,
|
||||
traceId,
|
||||
timestampNs,
|
||||
fallbackTitle,
|
||||
maxDuration,
|
||||
hits,
|
||||
}: {
|
||||
summary?: TraceSummary
|
||||
traceId?: string
|
||||
timestampNs: number
|
||||
fallbackTitle: string
|
||||
maxDuration: number
|
||||
hits?: number
|
||||
}) => {
|
||||
const errored = (summary?.errorCount ?? 0) > 0
|
||||
return (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<div className="flex items-baseline justify-between gap-2">
|
||||
<span className="text-[11.5px] text-ds-text-3">{formatRelativeTime(timestampNs / 1e6)}</span>
|
||||
{summary && (
|
||||
<span className={`font-[family-name:var(--font-data)] text-[12px] font-medium ${errored ? "text-ds-critical" : "text-ds-text-2"}`}>
|
||||
{formatDuration(summary.duration)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<span className="truncate text-[13px] text-ds-text">{summary?.name ?? fallbackTitle}</span>
|
||||
{summary && (
|
||||
<span className="h-1.5 overflow-hidden rounded-full bg-ds-fill">
|
||||
<span
|
||||
className={`block h-full rounded-full ${errored ? "bg-ds-critical" : "bg-ds-accent"}`}
|
||||
style={{ width: `${Math.max(2, (summary.duration / maxDuration) * 100)}%` }}
|
||||
/>
|
||||
</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">
|
||||
{summary
|
||||
? `${summary.spanCount} spans · ${summary.services.length} svc${errored ? ` · ${summary.errorCount} err` : ""}`
|
||||
: traceId
|
||||
? `trace:${traceId.slice(0, 8)}`
|
||||
: "no trace attached"}
|
||||
</span>
|
||||
{hits !== undefined && hits > 1 && <span className="shrink-0">×{hits}</span>}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -746,6 +746,7 @@ export function ObservabilityProvider({
|
||||
logs: filteredLogs,
|
||||
metrics: filteredMetrics,
|
||||
traces: filteredTraces,
|
||||
allTraces: traces,
|
||||
unfocusedLogs,
|
||||
unfocusedTraces,
|
||||
logsByTraceId,
|
||||
|
||||
@@ -29,6 +29,8 @@ interface ObservabilityContextType {
|
||||
logs: LogEntry[];
|
||||
metrics: MetricData[];
|
||||
traces: TraceData[];
|
||||
/** Every loaded trace, ignoring the active filters (used for cross-signal linking). */
|
||||
allTraces: TraceData[];
|
||||
unfocusedLogs: LogEntry[];
|
||||
unfocusedTraces: TraceData[];
|
||||
logsByTraceId: Record<string, LogEntry[]>;
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
import { getAttributeValue } from "./otel-utils"
|
||||
import type { TraceData } from "./types"
|
||||
|
||||
/**
|
||||
* Normalizes the many attribute spellings for "which HTTP endpoint" (stable
|
||||
* semconv, legacy semconv, nginx, Go, browser RUM) into one shape. Mirrors
|
||||
* internal/httpattr in the Go backend - keep the two in sync.
|
||||
*/
|
||||
|
||||
export interface HttpEndpoint {
|
||||
method: string
|
||||
host: string
|
||||
path: string
|
||||
route: string
|
||||
}
|
||||
|
||||
type Get = (...keys: string[]) => string
|
||||
|
||||
export function cleanPath(p: string): string {
|
||||
p = p.split(/[?#]/)[0].trim()
|
||||
if (!p) return ""
|
||||
if (!p.startsWith("/")) p = "/" + p
|
||||
return p.length > 1 ? p.replace(/\/+$/, "") || "/" : p
|
||||
}
|
||||
|
||||
function stripPort(host: string): string {
|
||||
return host.replace(/:\d+$/, "")
|
||||
}
|
||||
|
||||
export function splitUrl(raw: string): { host: string; path: string } {
|
||||
try {
|
||||
const u = new URL(raw, "http://relative.invalid")
|
||||
const host = u.host === "relative.invalid" ? "" : stripPort(u.host.toLowerCase())
|
||||
return { host, path: cleanPath(u.pathname) }
|
||||
} catch {
|
||||
return { host: "", path: cleanPath(raw) }
|
||||
}
|
||||
}
|
||||
|
||||
export function extractEndpoint(get: Get): HttpEndpoint {
|
||||
const e: HttpEndpoint = {
|
||||
method: get("http.request.method", "http.method").toUpperCase(),
|
||||
route: get("http.route"),
|
||||
host: stripPort(get("server.address", "http.host", "net.host.name", "server.host").toLowerCase()),
|
||||
path: cleanPath(get("url.path") || get("http.target")),
|
||||
}
|
||||
const full = get("url.full", "http.url")
|
||||
if (full) {
|
||||
const u = splitUrl(full)
|
||||
e.path ||= u.path
|
||||
e.host ||= u.host
|
||||
}
|
||||
if (e.route === "/" && e.path && e.path !== "/") e.route = ""
|
||||
return e
|
||||
}
|
||||
|
||||
/** Removes a reverse-proxy mount prefix; longest match wins. */
|
||||
export function stripPrefix(path: string, prefixes: string[]): string {
|
||||
let best = ""
|
||||
for (const raw of prefixes) {
|
||||
const p = cleanPath(raw)
|
||||
if (p && p !== "/" && (path === p || path.startsWith(p + "/")) && p.length > best.length) best = p
|
||||
}
|
||||
return best ? path.slice(best.length) || "/" : path
|
||||
}
|
||||
|
||||
export interface BackendRequest {
|
||||
traceId: string
|
||||
spanId: string
|
||||
service: string
|
||||
startMs: number
|
||||
durationMs: number
|
||||
endpoint: HttpEndpoint
|
||||
}
|
||||
|
||||
export interface RumRequest {
|
||||
timestamp: number
|
||||
traceId?: string
|
||||
method?: string
|
||||
url?: string
|
||||
durationMs?: number
|
||||
}
|
||||
|
||||
export type LinkConfidence = "trace-id" | "path+time"
|
||||
|
||||
export interface RumBackendLink {
|
||||
backend: BackendRequest
|
||||
confidence: LinkConfidence
|
||||
}
|
||||
|
||||
export interface LinkOptions {
|
||||
/** Proxy mount prefixes the browser sees but the backend does not, e.g. ["/api"]. */
|
||||
stripPrefixes?: string[]
|
||||
/** How far a backend span start may be from the browser request, in ms. */
|
||||
windowMs?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds the backend span a browser request hit. Exact trace id wins; when the
|
||||
* hop in between (e.g. an uninstrumented nginx that neither propagates
|
||||
* traceparent nor emits spans) breaks the trace, fall back to method + path
|
||||
* inside a time window. Returns null unless exactly one candidate is closest,
|
||||
* so an ambiguous match is never presented as a link.
|
||||
*/
|
||||
export function linkRumRequest(
|
||||
req: RumRequest,
|
||||
backend: BackendRequest[],
|
||||
{ stripPrefixes = [], windowMs = 2000 }: LinkOptions = {},
|
||||
): RumBackendLink | null {
|
||||
if (req.traceId) {
|
||||
const hit = backend.find((b) => b.traceId === req.traceId)
|
||||
if (hit) return { backend: hit, confidence: "trace-id" }
|
||||
}
|
||||
if (!req.url) return null
|
||||
const { path } = splitUrl(req.url)
|
||||
const method = (req.method || "GET").toUpperCase()
|
||||
const wanted = new Set([path, stripPrefix(path, stripPrefixes)])
|
||||
// The request can only reach the server after the browser started it, and
|
||||
// must have started before the browser saw the response.
|
||||
const end = req.timestamp + (req.durationMs ?? 0)
|
||||
const candidates = backend
|
||||
.filter((b) => b.endpoint.method === method && wanted.has(b.endpoint.path))
|
||||
.filter((b) => b.startMs >= req.timestamp - windowMs && b.startMs <= Math.max(end, req.timestamp) + windowMs)
|
||||
.map((b) => ({ b, d: Math.abs(b.startMs - req.timestamp) }))
|
||||
.sort((x, y) => x.d - y.d)
|
||||
if (candidates.length === 0) return null
|
||||
if (candidates.length > 1 && candidates[0].d === candidates[1].d) return null
|
||||
return { backend: candidates[0].b, confidence: "path+time" }
|
||||
}
|
||||
|
||||
/** Server-side HTTP spans (kind SERVER) from loaded traces, normalized for linking. */
|
||||
export function backendRequestsFromTraces(traces: TraceData[]): BackendRequest[] {
|
||||
const out: BackendRequest[] = []
|
||||
for (const trace of traces) {
|
||||
for (const rs of trace.resourceSpans ?? []) {
|
||||
const service = getAttributeValue(rs.resource?.attributes ?? [], "service.name")
|
||||
for (const ss of rs.scopeSpans ?? []) {
|
||||
for (const s of ss.spans ?? []) {
|
||||
if (s.kind !== 2) continue
|
||||
const endpoint = extractEndpoint((...keys) => {
|
||||
for (const k of keys) {
|
||||
const v = getAttributeValue(s.attributes ?? [], k)
|
||||
if (v) return v
|
||||
}
|
||||
return ""
|
||||
})
|
||||
if (!endpoint.path) continue
|
||||
const start = Number(BigInt(s.startTimeUnixNano) / 1_000_000n)
|
||||
const end = Number(BigInt(s.endTimeUnixNano) / 1_000_000n)
|
||||
out.push({ traceId: s.traceId, spanId: s.spanId, service, startMs: start, durationMs: end - start, endpoint })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/** Parses an otel-rum.js fetch/xhr breadcrumb: "GET /api/x -> 200". */
|
||||
export function parseRequestCrumb(message: string): { method: string; url: string } | null {
|
||||
const m = /^([A-Z]+)\s+(\S+)\s+->\s+\S+$/.exec(message)
|
||||
return m ? { method: m[1], url: m[2] } : null
|
||||
}
|
||||
@@ -35,6 +35,10 @@ export interface ParsedBreadcrumb {
|
||||
t: number
|
||||
category: string
|
||||
message: string
|
||||
/** Request duration in ms (fetch/xhr crumbs); the request started at t - d. */
|
||||
d?: number
|
||||
/** Trace id otel-rum.js sent on the request, if it injected traceparent. */
|
||||
tid?: string
|
||||
}
|
||||
|
||||
/** Safely parses the raw breadcrumb JSON attached to a frontend sample. */
|
||||
|
||||
@@ -74,6 +74,8 @@ export interface RumTimelineEntry {
|
||||
category: "session-start" | "navigation" | "fetch" | "xhr" | "console.error" | "js-error" | "http-failure"
|
||||
message: string
|
||||
severe: boolean
|
||||
durationMs?: number
|
||||
traceId?: string
|
||||
}
|
||||
|
||||
export interface RumSession {
|
||||
@@ -273,6 +275,8 @@ export function buildRumSessions(logs: LogEntry[]): RumSession[] {
|
||||
: "navigation",
|
||||
message: crumb.message,
|
||||
severe: crumb.category === "console.error" || isFailedRequestBreadcrumb(crumb.message),
|
||||
durationMs: typeof crumb.d === "number" ? crumb.d : undefined,
|
||||
traceId: crumb.tid || undefined,
|
||||
})
|
||||
}
|
||||
for (const err of errors) {
|
||||
|
||||
@@ -2,7 +2,7 @@ import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import tailwindcss from "@tailwindcss/vite";
|
||||
|
||||
const target = "https://otel.k6n.net";
|
||||
const target = process.env.OTEL_URL ?? "https://otel.k6n.net";
|
||||
// Device-grant issuer for local/agent development (see src/dev/device-auth.ts).
|
||||
// Proxied same-origin so the browser needs no CORS and no issuer URL.
|
||||
const authIssuer = process.env.AUTH_DEV_BASE ?? "https://users.k6n.net";
|
||||
|
||||
+12
-5
@@ -524,16 +524,17 @@
|
||||
requestInit = Object.assign({}, requestInit, { headers: headers });
|
||||
}
|
||||
|
||||
var startedAt = Date.now();
|
||||
return originalFetch.call(global, input, requestInit).then(
|
||||
function (res) {
|
||||
addBreadcrumb('fetch', method + ' ' + url + ' -> ' + res.status);
|
||||
addBreadcrumb('fetch', method + ' ' + url + ' -> ' + res.status, { d: Date.now() - startedAt, tid: requestTraceId });
|
||||
if (reportHttpFailures && res.status >= 400) {
|
||||
httpFailureReport('fetch', method, url, res.status, requestTraceId);
|
||||
}
|
||||
return res;
|
||||
},
|
||||
function (err) {
|
||||
addBreadcrumb('fetch', method + ' ' + url + ' -> error');
|
||||
addBreadcrumb('fetch', method + ' ' + url + ' -> error', { d: Date.now() - startedAt, tid: requestTraceId });
|
||||
if (reportHttpFailures) {
|
||||
httpFailureReport('fetch', method, url, 0, requestTraceId);
|
||||
}
|
||||
@@ -571,12 +572,13 @@
|
||||
global.XMLHttpRequest.prototype.send = function () {
|
||||
var xhr = this;
|
||||
var info = xhr.__rum || {};
|
||||
var xhrStartedAt = Date.now();
|
||||
if (info.url) {
|
||||
var origHandler = xhr.onreadystatechange;
|
||||
xhr.onreadystatechange = function () {
|
||||
try {
|
||||
if (xhr.readyState === 4) {
|
||||
addBreadcrumb('xhr', info.method + ' ' + info.url + ' -> ' + xhr.status);
|
||||
addBreadcrumb('xhr', info.method + ' ' + info.url + ' -> ' + xhr.status, { d: Date.now() - xhrStartedAt, tid: info.traceId });
|
||||
if (reportHttpFailures && (xhr.status === 0 || xhr.status >= 400)) {
|
||||
httpFailureReport('xhr', info.method, info.url, xhr.status, info.traceId);
|
||||
}
|
||||
@@ -644,8 +646,13 @@
|
||||
observeVitals();
|
||||
}
|
||||
|
||||
function addBreadcrumb(category, message) {
|
||||
breadcrumbs.push({ t: Date.now(), category: String(category), message: String(message) });
|
||||
// extra.d = request duration in ms, extra.tid = trace id we sent on the
|
||||
// request. t stays the completion time; the request started at t - d.
|
||||
function addBreadcrumb(category, message, extra) {
|
||||
var crumb = { t: Date.now(), category: String(category), message: String(message) };
|
||||
if (extra && typeof extra.d === 'number') crumb.d = extra.d;
|
||||
if (extra && extra.tid) crumb.tid = String(extra.tid);
|
||||
breadcrumbs.push(crumb);
|
||||
if (breadcrumbs.length > maxBreadcrumbs) {
|
||||
breadcrumbs.shift();
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user