diff --git a/src/blocks/observability/app.tsx b/src/blocks/observability/app.tsx new file mode 100644 index 00000000..0d899cd7 --- /dev/null +++ b/src/blocks/observability/app.tsx @@ -0,0 +1,262 @@ +"use client"; + +import { useMemo, useState } from "react"; + +import { Button } from "../../ui/button.js"; +import { + Dialog, + DialogBody, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "../../ui/dialog.js"; +import { EmptyState } from "../../ui/empty-state.js"; +import { Input } from "../../ui/input.js"; +import type { + SeriesPoint, + SessionSummary, + Trace, +} from "../../ui/observability/model.js"; +import { RunList } from "../../ui/observability/elements.js"; +import { Observations } from "../../ui/observability/observations.js"; +import { WeekOverview } from "../../ui/observability/overview.js"; +import { AuditRun } from "./audit-run.js"; + +export type ObservabilityAppProps = { + readonly sessions: readonly SessionSummary[]; + readonly traces: Readonly>; + readonly series: readonly SeriesPoint[]; + readonly failures?: readonly SeriesPoint[]; +}; + +type Screen = "week" | "runs" | "steps" | "run"; + +export function ObservabilityApp({ + sessions, + traces, + series, + failures, +}: ObservabilityAppProps) { + const [screen, setScreen] = useState("week"); + const [query, setQuery] = useState(""); + const [openId, setOpenId] = useState(null); + const [focusSpan, setFocusSpan] = useState(undefined); + const [adding, setAdding] = useState(false); + const [token, setToken] = useState(null); + + const visible = useMemo( + () => + sessions.filter((session) => { + if (query.length === 0) return true; + const haystack = + `${session.name} ${session.actor} ${session.model}`.toLowerCase(); + return haystack.includes(query.toLowerCase()); + }), + [sessions, query], + ); + + const trace = openId === null ? undefined : traces[openId]; + + function openTrace(id: string, spanId?: string) { + setOpenId(id); + setFocusSpan(spanId); + setScreen("run"); + } + + return ( +
+ +
+ {screen === "week" ? ( + setScreen("runs")} + /> + ) : null} + {screen === "runs" ? ( +
+
+

Runs

+ setQuery(event.target.value)} + className="max-w-xs" + /> +
+ {visible.length === 0 ? ( + + ) : ( + { + const run = traces[session.id]; + return run === undefined ? [] : [run]; + })} + onOpen={(id) => openTrace(id)} + /> + )} +
+ ) : null} + {screen === "steps" ? ( + openTrace(traceId, spanId)} + /> + ) : null} + {screen === "run" ? ( + trace === undefined ? ( + + ) : ( +
+
+ +
+ +
+ ) + ) : null} +
+ {adding ? ( + setAdding(false)} + onMint={(name) => { + const slug = name + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-|-$/g, ""); + setToken(`obs_${slug}_${Math.random().toString(36).slice(2, 10)}`); + }} + /> + ) : null} +
+ ); +} + +function NavButton({ + current, + onClick, + children, +}: { + current: boolean; + onClick: () => void; + children: string; +}) { + return ( + + ); +} + +function AddAgent({ + token, + onClose, + onMint, +}: { + token: string | null; + onClose: () => void; + onMint: (name: string) => void; +}) { + const [name, setName] = useState("editor"); + const label = name.trim(); + return ( + { + if (!open) onClose(); + }} + > + + + Add an agent + + A token is shown once. The machine that runs the agent keeps it. + + + + + {token === null ? null : ( +

+ {token} +

+ )} +
+ + + + +
+
+ ); +} diff --git a/src/blocks/observability/audit-run.tsx b/src/blocks/observability/audit-run.tsx new file mode 100644 index 00000000..85c5f3f3 --- /dev/null +++ b/src/blocks/observability/audit-run.tsx @@ -0,0 +1,92 @@ +import { + Brief, + Compaction, + ContextTransform, + Grant, + Input, + Network, + Output, + Payload, + Sidecar, + Signal, + Thinking, + Tool, + ToolResultTransform, +} from "../../ui/observability/elements.js"; +import { TimingBreakdown } from "../../ui/observability/elements.js"; +import type { Trace } from "../../ui/observability/model.js"; +import { TraceHeader } from "../../ui/observability/trace-header.js"; +import { Timing, TraceSteps } from "../../ui/observability/trace-steps.js"; +import { Run } from "../../ui/observability/trace-view.js"; +import { TraceTimeline } from "../../ui/observability/timeline.js"; + +export function AuditRun({ + trace, + focusSpan, +}: { + readonly trace: Trace; + readonly focusSpan?: string; +}) { + return ( + + {(state) => ( + <> + + + {trace.placement === undefined ? null : ( + + )} +
+ + +
+
+ ( + + )} + /> + +
+ + )} +
+ ); +} diff --git a/src/ui/observability/area-chart.tsx b/src/ui/observability/area-chart.tsx new file mode 100644 index 00000000..f8ff7a21 --- /dev/null +++ b/src/ui/observability/area-chart.tsx @@ -0,0 +1,223 @@ +"use client"; + +import { useState } from "react"; + +import { areaPath, linePath, niceTicks } from "../../lib/chart-geometry.js"; +import { seriesColor, seriesDash } from "../../lib/chart-palette.js"; +import { ChartFrame } from "../chart-frame.js"; +import type { SeriesPoint } from "./model.js"; + +export type AreaChartProps = { + readonly title: string; + readonly description?: string; + readonly data: readonly SeriesPoint[]; + /** A second series, same order and labels as `data`. Drawn dashed. */ + readonly compare?: readonly SeriesPoint[]; + readonly valueLabel?: string; + readonly compareLabel?: string; + readonly format?: (value: number) => string; + readonly className?: string; +}; + +const WIDTH = 560; +const HEIGHT = 180; +const PAD_LEFT = 36; +const PAD_RIGHT = 12; +const PAD_TOP = 12; +const PAD_BOTTOM = 22; + +/** Two series over time. The fill is the line colour. Orange stays off the series. */ +export function AreaChart({ + title, + description, + data, + compare, + valueLabel = "Value", + compareLabel = "Compare", + format = (value) => String(value), + className, +}: AreaChartProps) { + const [hover, setHover] = useState(null); + const compareValues = + compare !== undefined && compare.length === data.length + ? compare + : undefined; + const max = Math.max( + 0, + ...data.map((point) => point.value), + ...(compareValues ?? []).map((point) => point.value), + ); + const ticks = niceTicks(max); + const axisMax = ticks[ticks.length - 1] ?? 1; + const innerW = WIDTH - PAD_LEFT - PAD_RIGHT; + const innerH = HEIGHT - PAD_TOP - PAD_BOTTOM; + const steps = Math.max(data.length - 1, 1); + const plot = (value: number, index: number) => ({ + x: PAD_LEFT + (index / steps) * innerW, + y: PAD_TOP + innerH - (axisMax <= 0 ? 0 : value / axisMax) * innerH, + }); + const primary = data.map((point, index) => plot(point.value, index)); + const secondary = compareValues?.map((point, index) => + plot(point.value, index), + ); + const baseline = PAD_TOP + innerH; + const color = seriesColor(0); + const compareColor = seriesColor(1); + const dash = seriesDash(1); + const active = hover === null ? undefined : data[hover]; + const activeCompare = hover === null ? undefined : compareValues?.[hover]; + + function hoverAt(clientX: number, width: number) { + if (data.length === 0 || width <= 0) return; + const x = (clientX / width) * WIDTH; + let best = 0; + let dist = Number.POSITIVE_INFINITY; + primary.forEach((point, index) => { + const delta = Math.abs(point.x - x); + if (delta < dist) { + dist = delta; + best = index; + } + }); + setHover(best); + } + + return ( + { + const row = [point.label, format(point.value)]; + const other = compareValues?.[index]; + if (other !== undefined) row.push(format(other.value)); + return row; + }), + }} + > +
setHover(null)} + onMouseMove={(event) => { + const rect = event.currentTarget.getBoundingClientRect(); + hoverAt(event.clientX - rect.left, rect.width); + }} + > + + {ticks.map((tick) => { + const y = plot(tick, 0).y; + return ( + + + + {format(tick)} + + + ); + })} + {primary.length > 1 ? ( + + ) : null} + {primary.length > 1 ? ( + + ) : null} + {secondary !== undefined && secondary.length > 1 ? ( + + ) : null} + {data.map((point, index) => ( + + {point.label} + + ))} + {hover !== null && primary[hover] !== undefined ? ( + + + + {secondary?.[hover] !== undefined ? ( + + ) : null} + + ) : null} + + {active === undefined ? null : ( +

+ {active.label} · {valueLabel} {format(active.value)} + {activeCompare === undefined + ? "" + : ` · ${compareLabel} ${format(activeCompare.value)}`} +

+ )} +
+
+ ); +} diff --git a/src/ui/observability/cost-bars.tsx b/src/ui/observability/cost-bars.tsx new file mode 100644 index 00000000..a67d3d80 --- /dev/null +++ b/src/ui/observability/cost-bars.tsx @@ -0,0 +1,50 @@ +import { formatCost } from "./format.js"; + +export type CostBarRow = { + readonly key: string; + readonly label: string; + readonly value: number; + readonly detail: string; +}; + +export function CostBars({ + title, + description, + rows, +}: { + readonly title: string; + readonly description: string; + readonly rows: readonly CostBarRow[]; +}) { + const max = Math.max(0.0001, ...rows.map((row) => row.value)); + return ( +
+
+

{title}

+

{description}

+
+
    + {rows.map((row) => ( +
  • +
    +

    {row.label}

    +
    +
    +
    +
    +

    + {formatCost(row.value)} + {row.detail} +

    +
  • + ))} +
+
+ ); +} diff --git a/src/ui/observability/elements.tsx b/src/ui/observability/elements.tsx new file mode 100644 index 00000000..ca974d0a --- /dev/null +++ b/src/ui/observability/elements.tsx @@ -0,0 +1,285 @@ +"use client"; + +import { formatCost, formatDuration } from "./format.js"; +import type { Placement, Span, Trace } from "./model.js"; +import { spanEnd } from "./playback.js"; +import { AgentBrief as BriefPanel } from "./span-inspector.js"; +import { + CompactionBlock, + ContextBlock, + GrantBlock, + NetworkBlock, + TextBlock, + ThoughtBlock, +} from "./span-inspector.js"; +import { stepTitle } from "./wording.js"; + +export function Brief({ + brief, +}: { + readonly brief: Parameters[0]["brief"] | undefined; +}) { + if (brief === undefined) return null; + return ; +} + +export function Input({ span }: { readonly span: Span }) { + if (span.input === undefined) return null; + return ; +} + +export function Output({ span }: { readonly span: Span }) { + if (span.output === undefined) return null; + return ; +} + +export function Thinking({ + span, + timeMs = Number.POSITIVE_INFINITY, + playing = false, +}: { + readonly span: Span; + readonly timeMs?: number; + readonly playing?: boolean; +}) { + if (span.thinking === undefined && span.redacted !== true) return null; + return ; +} + +export function Tool({ span }: { readonly span: Span }) { + if (span.kind !== "tool") return null; + return ( +
+

{stepTitle(span)}

+ + +
+ ); +} + +export function Grant({ span }: { readonly span: Span }) { + if (span.grant === undefined) return null; + return ; +} + +export function Compaction({ span }: { readonly span: Span }) { + if (span.compaction === undefined) return null; + return ; +} + +export function ContextTransform({ span }: { readonly span: Span }) { + if (span.kind === "tool" || span.contextChange === undefined) return null; + return ; +} + +export function ToolResultTransform({ span }: { readonly span: Span }) { + if (span.kind !== "tool" || span.contextChange === undefined) return null; + return ; +} + +export function Signal({ span }: { readonly span: Span }) { + if (span.kind !== "gate" && span.status !== "waiting") return null; + return ( +
+

Signal

+

{stepTitle(span)}

+

+ {span.status} · {formatDuration(span.durationMs)} +

+
+ ); +} + +export function Trigger({ span }: { readonly span: Span }) { + return ( +
+

Trigger

+

{stepTitle(span)}

+
+ ); +} + +export function Network({ span }: { readonly span: Span }) { + if (span.network === undefined || span.network.length === 0) return null; + return ; +} + +export function Payload({ span }: { readonly span: Span }) { + if (span.payload === undefined) return null; + return ; +} + +export function ChildRun({ + name, + onOpen, +}: { + readonly name: string; + readonly onOpen?: () => void; +}) { + return ( +
+

Child run

+ {onOpen === undefined ? ( +

{name}

+ ) : ( + + )} +
+ ); +} + +export function Execution({ + id, + status, +}: { + readonly id: string; + readonly status: string; +}) { + return ( +
+

Execution

+

+ {id} · {status} +

+
+ ); +} + +export function Sidecar({ + placement, + shares, + costUsd, + connectMs, +}: { + readonly placement: Placement; + readonly shares?: readonly string[]; + readonly costUsd?: number; + readonly connectMs?: number; +}) { + return ( +
+

Sidecar

+

+ {placement.sidecarId} · {placement.status} · {placement.size} ·{" "} + {placement.region} + {connectMs === undefined + ? "" + : ` · connect ${formatDuration(connectMs)}`} + {costUsd === undefined ? "" : ` · ${formatCost(costUsd)}`} +

+

+ {placement.host} · {placement.packageName}@{placement.packageVersion} · + generation {placement.generation} +

+ {shares === undefined || shares.length === 0 ? null : ( +

+ Shares this machine with {shares.join(", ")} +

+ )} +
+ ); +} + +const TIMING_BUCKETS = [ + ["provision", "Provision"], + ["gate", "Waiting"], + ["turn", "Model"], + ["reasoning", "Thinking"], + ["tool", "Tools"], + ["compact", "Compaction"], +] as const; + +export function TimingBreakdown({ + spans, +}: { + readonly spans: readonly Span[]; +}) { + const totals = new Map(); + for (const span of spans) { + totals.set(span.kind, (totals.get(span.kind) ?? 0) + span.durationMs); + } + const end = spans.reduce((max, span) => Math.max(max, spanEnd(span)), 1); + return ( +
+

Timing

+
    + {TIMING_BUCKETS.map(([kind, label]) => { + const value = totals.get(kind) ?? 0; + if (value === 0) return null; + return ( +
  • + {label} + + + + + {formatDuration(value)} + +
  • + ); + })} +
+
+ ); +} + +export function RunList({ + traces, + groupBy, + onOpen, +}: { + readonly traces: readonly Trace[]; + readonly groupBy?: "sidecar"; + readonly onOpen?: (id: string) => void; +}) { + const groups = new Map(); + for (const trace of traces) { + const key = + groupBy === "sidecar" + ? (trace.placement?.sidecarId ?? "No sidecar") + : "Runs"; + const list = groups.get(key); + if (list === undefined) groups.set(key, [trace]); + else list.push(trace); + } + return ( +
+ {[...groups.entries()].map(([key, list]) => ( +
+ {groupBy === "sidecar" ? ( +

{key}

+ ) : null} +
    + {list.map((trace) => ( +
  • + {onOpen === undefined ? ( + {trace.name} + ) : ( + + )} + + {trace.kind} · {trace.status} ·{" "} + {formatDuration(trace.durationMs)} + +
  • + ))} +
+
+ ))} +
+ ); +} diff --git a/src/ui/observability/finops.tsx b/src/ui/observability/finops.tsx new file mode 100644 index 00000000..85e0e721 --- /dev/null +++ b/src/ui/observability/finops.tsx @@ -0,0 +1,198 @@ +import { StatGrid, StatGridItem } from "../stat-grid.js"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "../table.js"; +import { CostBars } from "./cost-bars.js"; +import { + formatCost, + formatDuration, + formatSpeed, + formatTokens, +} from "./format.js"; +import { Ledger } from "./ledger.js"; +import { + modelShares, + portfolio, + rollup, + usageOf, + type UsageBreakdown, +} from "./metrics.js"; +import type { Trace } from "./model.js"; +import { UsageMix } from "./usage-mix.js"; + +export function WorkflowsFinOps({ + traces, +}: { + readonly traces: readonly Trace[]; +}) { + const totals = traces.map((trace) => rollup(trace)); + const cost = totals.reduce((sum, row) => sum + row.costUsd, 0); + const tokens = totals.reduce((sum, row) => sum + row.tokens, 0); + const failed = traces.filter( + (trace) => trace.status === "error" || trace.status === "denied", + ).length; + const usage = traces.reduce( + (sum, trace) => { + const part = usageOf(trace); + return { + input: sum.input + part.input, + output: sum.output + part.output, + cacheRead: sum.cacheRead + part.cacheRead, + cacheWrite: sum.cacheWrite + part.cacheWrite, + thinking: sum.thinking + part.thinking, + }; + }, + { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, thinking: 0 }, + ); + const shares = portfolio(traces); + return ( +
+
+

Runs

+

{traces.length} runs

+
+ + + + + 0} + /> + +
+ ({ + key: `${share.provider}/${share.model}`, + label: `${share.provider} / ${share.model}`, + value: share.costUsd, + detail: formatTokens(share.tokens), + }))} + /> + +
+ + + + Run + Cost + Tokens + Duration + Status + + + + {traces.map((trace) => { + const row = rollup(trace); + return ( + + {trace.name} + + {formatCost(row.costUsd)} + + + {formatTokens(row.tokens)} + + + {formatDuration(row.durationMs)} + + + {trace.status} + + + ); + })} + +
+
+ ); +} + +export function WorkflowFinOps({ trace }: { readonly trace: Trace }) { + const totals = rollup(trace); + const perThousand = + totals.tokens === 0 ? 0 : totals.costUsd / (totals.tokens / 1000); + const shares = modelShares(trace); + return ( +
+
+

{trace.name}

+

+ {trace.actor} · {trace.startedAt} +

+
+ + + + + + +
+ ({ + key: `${share.provider}/${share.model}`, + label: `${share.provider} / ${share.model}`, + value: share.costUsd, + detail: formatTokens(share.tokens), + }))} + /> +
+ + +
+
+ +
+ ); +} + +function ProviderNotes({ shares }: { shares: ReturnType }) { + if (shares.length === 0) return null; + return ( +
+

Model notes

+
    + {shares.map((share) => ( +
  • +

    + {share.provider} / {share.model} +

    + {share.quirks.length === 0 ? ( +

    No recorded quirks.

    + ) : ( +

    + {share.quirks.join(" · ")} +

    + )} +
  • + ))} +
+
+ ); +} diff --git a/src/ui/observability/format.ts b/src/ui/observability/format.ts new file mode 100644 index 00000000..62d6a29f --- /dev/null +++ b/src/ui/observability/format.ts @@ -0,0 +1,45 @@ +export function formatDuration(ms: number): string { + if (ms < 1000) return `${Math.round(ms)} ms`; + if (ms < 10_000) return `${(ms / 1000).toFixed(1)} s`; + const totalSeconds = Math.round(ms / 1000); + const minutes = Math.floor(totalSeconds / 60); + const seconds = totalSeconds % 60; + if (minutes === 0) return `${seconds} s`; + if (seconds === 0) return `${minutes}m`; + return `${minutes}m ${seconds}s`; +} + +export function formatClock(ms: number): string { + const sign = ms < 0 ? "−" : "+"; + return `${sign}${formatDuration(Math.abs(ms))}`; +} + +export function formatTokens(value: number): string { + if (value < 1000) return String(value); + if (value < 999_500) return `${(value / 1000).toFixed(1)}k`; + return `${(value / 1_000_000).toFixed(2)}M`; +} + +export function formatSpeed(tokensPerSecond: number | null): string { + if (tokensPerSecond === null || !Number.isFinite(tokensPerSecond)) return "—"; + if (tokensPerSecond < 10) return `${tokensPerSecond.toFixed(1)} tok/s`; + return `${Math.round(tokensPerSecond)} tok/s`; +} + +export function formatCost(usd: number): string { + if (usd === 0) return "$0"; + if (usd < 0.01) return `$${usd.toFixed(4)}`; + return `$${usd.toFixed(2)}`; +} + +export function spanTokens(span: { + usage?: { inputTokens?: number; outputTokens?: number }; +}): number { + return (span.usage?.inputTokens ?? 0) + (span.usage?.outputTokens ?? 0); +} + +export function formatBytes(bytes: number): string { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; +} diff --git a/src/ui/observability/ledger.tsx b/src/ui/observability/ledger.tsx new file mode 100644 index 00000000..8cdc580f --- /dev/null +++ b/src/ui/observability/ledger.tsx @@ -0,0 +1,88 @@ +"use client"; + +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "../table.js"; +import { + formatCost, + formatDuration, + formatSpeed, + formatTokens, +} from "./format.js"; +import { measures, type MeasureSort } from "./metrics.js"; +import type { Trace } from "./model.js"; +import { stepTitle } from "./wording.js"; + +export function Ledger({ + trace, + sort, + selectedId, + onSelect, +}: { + readonly trace: Trace; + readonly sort: MeasureSort; + readonly selectedId?: string; + readonly onSelect?: (id: string) => void; +}) { + const rows = measures(trace, sort); + return ( + + + + Step + Provider + Tokens + Cost + Time + Speed + + + + {rows.map((row) => { + const span = trace.spans.find((item) => item.id === row.id); + const title = span === undefined ? row.name : stepTitle(span); + return ( + + + {onSelect === undefined ? ( + title + ) : ( + + )} + + + {row.provider === "—" ? "—" : `${row.provider}/${row.model}`} + + + {row.tokens === 0 ? "—" : formatTokens(row.tokens)} + + + {row.costUsd === 0 ? "—" : formatCost(row.costUsd)} + + + {formatDuration(row.durationMs)} + + + {formatSpeed(row.tokensPerSecond)} + + + ); + })} + +
+ ); +} diff --git a/src/ui/observability/metrics.ts b/src/ui/observability/metrics.ts new file mode 100644 index 00000000..ebf82fd5 --- /dev/null +++ b/src/ui/observability/metrics.ts @@ -0,0 +1,339 @@ +import { spanTokens } from "./format.js"; +import type { Span, Trace } from "./model.js"; + +export type StepMeasure = { + readonly id: string; + readonly key: string; + readonly name: string; + readonly kind: Span["kind"]; + readonly provider: string; + readonly model: string; + readonly tokens: number; + readonly cachedTokens: number; + readonly costUsd: number; + readonly durationMs: number; + readonly tokensPerSecond: number | null; + readonly status: Span["status"]; + readonly startMs: number; +}; + +export type TraceRollup = { + readonly tokens: number; + readonly cachedTokens: number; + readonly costUsd: number; + readonly durationMs: number; + readonly tokensPerSecond: number | null; + readonly denied: number; + readonly blocked: number; + readonly externalHops: number; + readonly badSignatures: number; + readonly waitingMs: number; + readonly provisionMs: number; +}; + +export type ModelShare = { + readonly provider: string; + readonly model: string; + readonly tokens: number; + readonly costUsd: number; + readonly quirks: readonly string[]; +}; + +export type Finding = { + readonly id: string; + readonly tone: "danger" | "warning"; + readonly title: string; + readonly detail: string; +}; + +export type MeasureSort = "time" | "cost" | "duration"; + +export type UsageBreakdown = { + readonly input: number; + readonly output: number; + readonly cacheRead: number; + readonly cacheWrite: number; + readonly thinking: number; +}; + +export type ObservationRow = { + readonly traceId: string; + readonly traceName: string; + readonly spanId: string; + readonly type: string; + readonly name: string; + readonly model: string; + readonly level: "Default" | "Warning" | "Error" | "Debug"; + readonly latencyMs: number; + readonly tokens: number; + readonly costUsd: number; +}; + +const speed = (tokens: number, durationMs: number): number | null => { + if (tokens <= 0 || durationMs <= 0) return null; + return tokens / (durationMs / 1000); +}; + +/** Stable key for a step. A repeated name gets `.2`, `.3`, and so on. */ +export function stepKeys(spans: readonly Span[]): ReadonlyMap { + const seen = new Map(); + const keys = new Map(); + for (const span of spans) { + const base = `${span.kind}.${span.name}` + .toLowerCase() + .replace(/[^a-z0-9.]+/g, "_") + .replace(/_+/g, "_"); + const count = (seen.get(base) ?? 0) + 1; + seen.set(base, count); + keys.set(span.id, count === 1 ? base : `${base}.${count}`); + } + return keys; +} + +export function measures( + trace: Trace, + sort: MeasureSort = "time", +): readonly StepMeasure[] { + const keys = stepKeys(trace.spans); + const rows = trace.spans.map((span) => { + const tokens = spanTokens(span); + const cachedTokens = span.usage?.cachedTokens ?? 0; + const durationMs = span.durationMs; + return { + id: span.id, + key: keys.get(span.id) ?? span.id, + name: span.name, + kind: span.kind, + provider: span.usage?.provider ?? "—", + model: span.usage?.model ?? "—", + tokens, + cachedTokens, + costUsd: span.usage?.costUsd ?? 0, + durationMs, + tokensPerSecond: speed(tokens, durationMs), + status: span.status, + startMs: span.startMs, + }; + }); + const kept = (row: StepMeasure) => + row.costUsd > 0 || + row.tokens > 0 || + row.status === "error" || + row.status === "denied" || + row.status === "waiting"; + if (sort === "cost") { + return rows + .filter(kept) + .sort((a, b) => b.costUsd - a.costUsd || a.startMs - b.startMs); + } + if (sort === "duration") { + return rows + .filter((row) => row.durationMs >= 1000 || kept(row)) + .sort((a, b) => b.durationMs - a.durationMs || a.startMs - b.startMs); + } + return rows; +} + +export function rollup(trace: Trace): TraceRollup { + let tokens = 0; + let cachedTokens = 0; + let costUsd = 0; + let denied = 0; + let blocked = 0; + let externalHops = 0; + let badSignatures = 0; + let waitingMs = 0; + let provisionMs = 0; + for (const span of trace.spans) { + tokens += spanTokens(span); + cachedTokens += span.usage?.cachedTokens ?? 0; + costUsd += span.usage?.costUsd ?? 0; + if (span.status === "denied") denied += 1; + if (span.grant?.blocked === true) blocked += 1; + externalHops += span.network?.filter((hop) => hop.external).length ?? 0; + if (span.signature === "invalid" || span.signature === "missing") + badSignatures += 1; + if (span.status === "waiting") waitingMs += span.durationMs; + if (span.kind === "provision") provisionMs += span.durationMs; + } + return { + tokens, + cachedTokens, + costUsd, + durationMs: trace.durationMs, + tokensPerSecond: speed(tokens, trace.durationMs), + denied, + blocked, + externalHops, + badSignatures, + waitingMs, + provisionMs, + }; +} + +export function modelShares(trace: Trace): readonly ModelShare[] { + const byKey = new Map(); + for (const span of trace.spans) { + const provider = span.usage?.provider; + const model = span.usage?.model; + if (provider === undefined || model === undefined) continue; + const key = `${provider}/${model}`; + const current = byKey.get(key) ?? { + provider, + model, + tokens: 0, + costUsd: 0, + quirks: quirksFor(trace, provider, model), + }; + byKey.set(key, { + ...current, + tokens: current.tokens + spanTokens(span), + costUsd: current.costUsd + (span.usage?.costUsd ?? 0), + }); + } + return [...byKey.values()].sort((a, b) => b.costUsd - a.costUsd); +} + +function quirksFor( + trace: Trace, + provider: string, + model: string, +): readonly string[] { + const listed = + trace.providers?.find( + (item) => item.provider === provider && item.model === model, + )?.quirks ?? []; + const redacted = trace.spans.some( + (span) => span.redacted === true && modelOf(trace, span) === model, + ); + return redacted ? [...listed, "This run redacted a thinking block"] : listed; +} + +export function usageOf(trace: Trace): UsageBreakdown { + const totals = { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + thinking: 0, + }; + for (const span of trace.spans) { + totals.input += span.usage?.inputTokens ?? 0; + totals.output += span.usage?.outputTokens ?? 0; + totals.cacheRead += span.usage?.cachedTokens ?? 0; + totals.cacheWrite += span.usage?.cacheWriteTokens ?? 0; + totals.thinking += span.usage?.thinkingTokens ?? 0; + } + return totals; +} + +export function usageOfSpan(span: Span): UsageBreakdown { + return { + input: span.usage?.inputTokens ?? 0, + output: span.usage?.outputTokens ?? 0, + cacheRead: span.usage?.cachedTokens ?? 0, + cacheWrite: span.usage?.cacheWriteTokens ?? 0, + thinking: span.usage?.thinkingTokens ?? 0, + }; +} + +export function observationRows( + traces: readonly Trace[], +): readonly ObservationRow[] { + const rows: ObservationRow[] = []; + for (const trace of traces) { + for (const span of trace.spans) { + rows.push({ + traceId: trace.id, + traceName: trace.name, + spanId: span.id, + type: span.type ?? span.kind, + name: span.name, + model: span.usage?.model ?? "—", + level: levelOf(span.status), + latencyMs: span.durationMs, + tokens: spanTokens(span), + costUsd: span.usage?.costUsd ?? 0, + }); + } + } + return rows.sort( + (a, b) => b.costUsd - a.costUsd || b.latencyMs - a.latencyMs, + ); +} + +function levelOf(status: Span["status"]): ObservationRow["level"] { + if (status === "error" || status === "denied") return "Error"; + if (status === "waiting") return "Warning"; + if (status === "running") return "Debug"; + return "Default"; +} + +export function portfolio(traces: readonly Trace[]): readonly ModelShare[] { + const byKey = new Map(); + for (const trace of traces) { + for (const share of modelShares(trace)) { + const key = `${share.provider}/${share.model}`; + const current = byKey.get(key) ?? { ...share, tokens: 0, costUsd: 0 }; + byKey.set(key, { + ...current, + tokens: current.tokens + share.tokens, + costUsd: current.costUsd + share.costUsd, + quirks: + current.quirks.length > share.quirks.length + ? current.quirks + : share.quirks, + }); + } + } + return [...byKey.values()].sort((a, b) => b.costUsd - a.costUsd); +} + +function modelOf(trace: Trace, span: Span): string | undefined { + if (span.usage?.model !== undefined) return span.usage.model; + if (span.parentId === null) return undefined; + const parent = trace.spans.find((item) => item.id === span.parentId); + return parent === undefined ? undefined : modelOf(trace, parent); +} + +export function findings(trace: Trace): readonly Finding[] { + const rows: Finding[] = []; + for (const span of trace.spans) { + if (span.status === "denied" || span.grant?.effect === "deny") { + rows.push({ + id: `${span.id}:deny`, + tone: "danger", + title: span.name, + detail: span.grant?.blockReason ?? "Denied", + }); + } else if (span.grant?.blocked === true) { + rows.push({ + id: `${span.id}:block`, + tone: "warning", + title: span.name, + detail: span.grant.blockReason ?? "Blocked pending approval", + }); + } + for (const hop of span.network ?? []) { + if (!hop.external) continue; + rows.push({ + id: `${span.id}:${hop.url}`, + tone: "warning", + title: `${hop.method} ${hop.url}`, + detail: + hop.status === null + ? "External, no status" + : `External · ${hop.status}`, + }); + } + if (span.signature === "invalid" || span.signature === "missing") { + rows.push({ + id: `${span.id}:sig`, + tone: "danger", + title: span.name, + detail: `Signature ${span.signature}`, + }); + } + } + return rows; +} diff --git a/src/ui/observability/model.ts b/src/ui/observability/model.ts new file mode 100644 index 00000000..ff7202aa --- /dev/null +++ b/src/ui/observability/model.ts @@ -0,0 +1,262 @@ +/** A recorded session. An agent run and a workflow run share this header. */ +export type TraceKind = "agent" | "workflow"; + +export type SpanKind = + | "turn" + | "reasoning" + | "tool" + | "permission" + | "subagent" + | "compact" + | "error" + | "event" + | "step" + | "gate" + | "mail" + | "provision" + | "fork" + | "reactor" + | "network" + | "attachment" + | "citation" + | "transform" + | "nudge"; + +export type SpanStatus = "running" | "ok" | "error" | "waiting" | "denied"; + +export type GrantEffect = "allow" | "deny" | "ask"; + +export type GrantOrigin = "system" | "role" | "creator" | "invoker"; + +export type GrantRef = { + readonly id: string; + readonly resource: string; + readonly action: string; + readonly effect: GrantEffect; + readonly origin: GrantOrigin; + readonly specificity?: number; +}; + +/** + * The authorization outcome at one moment. `effect: "none"` is a call where + * nothing matched and the runtime failed closed. + */ +export type GrantUse = { + readonly effect: GrantEffect | "none"; + readonly resolvedBy: GrantRef | null; + readonly matching: readonly GrantRef[]; + readonly blocked: boolean; + readonly blockReason?: string; +}; + +export type SignatureStatus = "valid" | "invalid" | "unknown" | "missing"; + +export type MediaItem = { + readonly name: string; + readonly mediaType: string; + readonly bytes: number; + readonly note?: string; +}; + +/** One outbound or inbound hop. `external` means it left the host network. */ +export type NetworkHop = { + readonly method: string; + readonly url: string; + readonly status: number | null; + readonly bytes: number; + readonly external: boolean; +}; + +export type Usage = { + readonly provider?: string; + readonly model?: string; + readonly inputTokens?: number; + readonly outputTokens?: number; + /** Tokens read from the provider cache. A slice of input, not an extra pile. */ + readonly cachedTokens?: number; + readonly cacheWriteTokens?: number; + readonly thinkingTokens?: number; + readonly costUsd?: number; +}; + +export type ProviderInfo = { + readonly provider: string; + readonly model: string; + readonly region?: string; + readonly quirks: readonly string[]; +}; + +export type PrincipalRef = { + readonly id: string; + readonly name: string; + readonly kind: string; + readonly keyId: string; +}; + +export type TenantRef = { + readonly id: string; + readonly name: string; +}; + +export type Attribute = { + readonly label: string; + readonly value: string; +}; + +export type AgentTool = { + readonly name: string; + readonly description: string; +}; + +export type AgentBrief = { + readonly systemPrompt: string; + readonly tools: readonly AgentTool[]; +}; + +export type CompactionTurn = { + readonly role: string; + readonly title: string; + readonly tokens: number; + readonly kept: boolean; + readonly note?: string; +}; + +export type Compaction = { + readonly compactor: string; + readonly version: string; + readonly reason: string; + readonly detail: string; + readonly turnsBefore: number; + readonly turnsAfter: number; + readonly tokensBefore: number; + readonly tokensAfter: number; + readonly turns: readonly CompactionTurn[]; +}; + +/** One context-strategy run. History and the prompt are different files. */ +export type ContextChange = { + readonly strategy: string; + readonly version: string; + readonly trigger: string; + readonly reason: string; + readonly summary: string; +}; + +export type NudgeAction = + | "infer" + | "execute_tools" + | "compact" + | "wait" + | "checkpoint" + | "reply" + | "suspend" + | "done"; + +export type DirectorNudge = { + readonly action: NudgeAction; + readonly because: string; +}; + +/** Where a run was placed. Present once the hub has allocated a sidecar. */ +export type Placement = { + readonly sidecarId: string; + readonly host: string; + readonly region: string; + readonly size: string; + readonly packageName: string; + readonly packageVersion: string; + readonly generation: number; + readonly status: string; +}; + +/** + * One timed step. `startMs` is relative to the trace start. + * + * `kind` is the role this viewer knows how to draw. A caller maps interchange, + * a local session (`~/.corbits/code`, `~/.claude/projects`, `~/.codex`), or an + * SDK trace (Vercel AI SDK, Mastra) onto that role. `type` is the source's own + * step type and `label` is optional display copy. Both are shown as passed. + */ +export type Span = { + readonly id: string; + readonly parentId: string | null; + readonly kind: SpanKind; + /** Source step type, shown beside the row. Omit to show `kind`. */ + readonly type?: string; + /** Row title. Omit to show what the step did, then `name`. */ + readonly label?: string; + readonly name: string; + readonly status: SpanStatus; + readonly startMs: number; + readonly durationMs: number; + readonly input?: string; + readonly output?: string; + /** Chain of thought for a reasoning span, or a turn that streamed thinking. */ + readonly thinking?: string; + readonly redacted?: boolean; + readonly grant?: GrantUse; + readonly media?: readonly MediaItem[]; + readonly network?: readonly NetworkHop[]; + readonly signature?: SignatureStatus; + readonly usage?: Usage; + readonly attributes?: readonly Attribute[]; + /** Host payload, secrets already scrubbed. Shown when the raw layer is on. */ + readonly payload?: string; + readonly compaction?: Compaction; + readonly contextChange?: ContextChange; + readonly nudge?: DirectorNudge; +}; + +export type SessionSummary = { + readonly id: string; + readonly kind: TraceKind; + readonly name: string; + readonly actor: string; + readonly model: string; + readonly startedAt: string; + readonly durationMs: number; + readonly tokens: number; + readonly costUsd: number; + readonly steps: number; + readonly status: SpanStatus; +}; + +export type Trace = { + readonly id: string; + readonly kind: TraceKind; + readonly name: string; + readonly actor: string; + readonly status: SpanStatus; + readonly startedAt: string; + readonly durationMs: number; + readonly spans: readonly Span[]; + readonly placement?: Placement; + readonly principal?: PrincipalRef; + readonly tenant?: TenantRef; + readonly providers?: readonly ProviderInfo[]; + readonly brief?: AgentBrief; +}; + +export type SeriesPoint = { + readonly label: string; + readonly value: number; +}; + +/** What the viewer is willing to show. Every layer is a caller-owned toggle. */ +export type TraceLayers = { + readonly reasoning: boolean; + readonly grants: boolean; + readonly io: boolean; + readonly media: boolean; + readonly network: boolean; + readonly raw: boolean; +}; + +export const DEFAULT_LAYERS: TraceLayers = { + reasoning: true, + grants: true, + io: true, + media: true, + network: true, + raw: false, +}; diff --git a/src/ui/observability/observations.tsx b/src/ui/observability/observations.tsx new file mode 100644 index 00000000..be2b87fd --- /dev/null +++ b/src/ui/observability/observations.tsx @@ -0,0 +1,150 @@ +"use client"; + +import { useMemo, useState } from "react"; + +import { cn } from "../../lib/utils.js"; +import { Badge } from "../badge.js"; +import { formatCost, formatDuration, formatTokens } from "./format.js"; +import { observationRows, type ObservationRow } from "./metrics.js"; +import type { Trace } from "./model.js"; +import { stepTitle } from "./wording.js"; + +export type ObservationsProps = { + readonly traces: readonly Trace[]; + readonly onOpen: (traceId: string, spanId: string) => void; +}; + +const LEVEL_TONE = { + Default: "neutral", + Warning: "warning", + Error: "danger", + Debug: "info", +} as const; + +export function Observations({ traces, onOpen }: ObservationsProps) { + const rows = useMemo(() => observationRows(traces), [traces]); + const [level, setLevel] = useState<"all" | "Error" | "Warning">("all"); + const shown = rows.filter((row) => level === "all" || row.level === level); + + return ( +
+
+
+

Steps

+

+ Every step, across runs. +

+
+

+ {shown.length} shown +

+
+
+ +
+
+ + + + + + + + + + + + + + + {shown.map((row) => ( + + + + + + + + + + + ))} + +
RunTypeNameModelLevelLatencyTokensCost
+ + + {row.type} + {rowTitle(traces, row)}{row.model} + {row.level} + + {formatDuration(row.latencyMs)} + + {row.tokens === 0 ? "—" : formatTokens(row.tokens)} + + {row.costUsd === 0 ? "—" : formatCost(row.costUsd)} +
+
+
+ ); +} + +function rowTitle(traces: readonly Trace[], row: ObservationRow): string { + const trace = traces.find((item) => item.id === row.traceId); + const span = trace?.spans.find((item) => item.id === row.spanId); + return span === undefined ? row.name : stepTitle(span); +} + +function Filter({ + label, + value, + options, + onChange, +}: { + label: string; + value: T; + options: readonly (readonly [T, string])[]; + onChange: (value: T) => void; +}) { + return ( + + ); +} diff --git a/src/ui/observability/overview.tsx b/src/ui/observability/overview.tsx new file mode 100644 index 00000000..2a83692b --- /dev/null +++ b/src/ui/observability/overview.tsx @@ -0,0 +1,117 @@ +"use client"; + +import { Button } from "../button.js"; +import { StatGrid, StatGridItem } from "../stat-grid.js"; +import { AreaChart } from "./area-chart.js"; +import { formatCost, formatDuration, formatTokens } from "./format.js"; +import { observationRows, portfolio, rollup, usageOf } from "./metrics.js"; +import type { SeriesPoint, SessionSummary, Trace } from "./model.js"; +import { UsageMix } from "./usage-mix.js"; + +export function WeekOverview({ + series, + failures, + sessions, + traces, + onOpen, +}: { + readonly series: readonly SeriesPoint[]; + readonly failures?: readonly SeriesPoint[]; + readonly sessions: readonly SessionSummary[]; + readonly traces: readonly Trace[]; + readonly onOpen: () => void; +}) { + const failed = sessions.filter( + (session) => session.status === "error", + ).length; + const tokens = sessions.reduce((sum, session) => sum + session.tokens, 0); + const cost = traces.reduce((sum, trace) => sum + rollup(trace).costUsd, 0); + const usage = traces.reduce( + (sum, trace) => { + const part = usageOf(trace); + return { + input: sum.input + part.input, + output: sum.output + part.output, + cacheRead: sum.cacheRead + part.cacheRead, + cacheWrite: sum.cacheWrite + part.cacheWrite, + thinking: sum.thinking + part.thinking, + }; + }, + { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, thinking: 0 }, + ); + const shares = portfolio(traces); + const steps = observationRows(traces).length; + return ( +
+
+

This week

+ +
+ + point.value)} + sparklineLabel="Sessions across the week" + /> + + + + +
+ + +
+
+
+

Cost by model

+
    + {shares.map((share) => ( +
  • + + {share.provider} / {share.model} + + + {formatCost(share.costUsd)} · {formatTokens(share.tokens)} + +
  • + ))} +
+
+
+

Latency by run

+
    + {sessions.map((session) => ( +
  • + {session.name} + + {formatDuration(session.durationMs)} ·{" "} + {formatCost(session.costUsd)} + +
  • + ))} +
+
+
+
+ ); +} diff --git a/src/ui/observability/playback.ts b/src/ui/observability/playback.ts new file mode 100644 index 00000000..2d86e9f8 --- /dev/null +++ b/src/ui/observability/playback.ts @@ -0,0 +1,86 @@ +import type { Span, TraceLayers } from "./model.js"; +import { placeSpans } from "./tree.js"; + +/** How long a full play-through takes on screen, whatever the trace length. */ +export const SCAN_MS = 16_000; + +export function spanEnd(span: Span): number { + return span.startMs + Math.max(span.durationMs, 1); +} + +export function contains(span: Span, timeMs: number): boolean { + return timeMs >= span.startMs && timeMs < spanEnd(span); +} + +export function visibleSpans( + spans: readonly Span[], + layers: TraceLayers, +): readonly Span[] { + return spans.filter((span) => { + if (span.kind === "reasoning" && !layers.reasoning) return false; + if (span.kind === "network" && !layers.network) return false; + if (span.kind === "attachment" && !layers.media) return false; + return true; + }); +} + +/** Deepest span that covers `timeMs`. */ +export function deepestAt( + spans: readonly Span[], + timeMs: number, +): Span | undefined { + let best: { readonly span: Span; readonly depth: number } | undefined; + for (const row of placeSpans(spans)) { + if (!contains(row.span, timeMs)) continue; + if (best === undefined || row.depth >= best.depth) best = row; + } + return best?.span; +} + +/** The grant that applies at `timeMs`: the deepest active span that has one. */ +export function grantAt( + spans: readonly Span[], + timeMs: number, +): Span | undefined { + let best: { readonly span: Span; readonly depth: number } | undefined; + for (const row of placeSpans(spans)) { + if (row.span.grant === undefined || !contains(row.span, timeMs)) continue; + if (best === undefined || row.depth >= best.depth) best = row; + } + return best?.span; +} + +/** + * How much of `text` has been produced by `timeMs` inside `span`. + * Cuts on a space so the playhead does not stop mid-word. + */ +export function revealedText(text: string, span: Span, timeMs: number): string { + if (timeMs <= span.startMs) return ""; + if (timeMs >= spanEnd(span)) return text; + const ratio = (timeMs - span.startMs) / Math.max(span.durationMs, 1); + const cut = Math.max(1, Math.floor(text.length * ratio)); + const space = text.lastIndexOf(" ", cut); + return text.slice(0, space > 0 ? space : cut); +} + +export function nextBoundary( + spans: readonly Span[], + timeMs: number, +): number | undefined { + const starts = spans + .map((span) => span.startMs) + .filter((start) => start > timeMs) + .sort((a, b) => a - b); + return starts[0]; +} + +export function previousBoundary( + spans: readonly Span[], + timeMs: number, +): number | undefined { + const starts = spans + .map((span) => span.startMs) + .filter((start) => start < timeMs) + .sort((a, b) => a - b); + return starts[starts.length - 1]; +} diff --git a/src/ui/observability/session-table.tsx b/src/ui/observability/session-table.tsx new file mode 100644 index 00000000..c19c939e --- /dev/null +++ b/src/ui/observability/session-table.tsx @@ -0,0 +1,113 @@ +"use client"; + +import { cn } from "../../lib/utils.js"; +import { Badge } from "../badge.js"; +import { formatCost, formatDuration, formatTokens } from "./format.js"; +import type { SessionSummary, SpanStatus, TraceKind } from "./model.js"; + +export type SessionTableProps = { + readonly sessions: readonly SessionSummary[]; + readonly selectedId?: string; + readonly onSelect: (id: string) => void; + readonly className?: string; +}; + +const STATUS_TONE = { + running: "info", + ok: "success", + error: "danger", + waiting: "warning", + denied: "danger", +} as const; + +const STATUS_LABEL: Record = { + running: "Running", + ok: "Done", + error: "Failed", + waiting: "Waiting", + denied: "Denied", +}; + +const KIND_LABEL: Record = { + agent: "Agent", + workflow: "Workflow", +}; + +export function SessionTable({ + sessions, + selectedId, + onSelect, + className, +}: SessionTableProps) { + return ( +
+ + + + + + + + + + + + + + + + {sessions.map((session) => { + const selected = session.id === selectedId; + return ( + + + + + + + + + + + + ); + })} + +
SessionWhoModelWhenDurationTokensCostStepsStatus
+ + {session.actor} + {session.model} + + {session.startedAt} + + {formatDuration(session.durationMs)} + + {formatTokens(session.tokens)} + + {formatCost(session.costUsd)} + + {session.steps} + + + {STATUS_LABEL[session.status]} + +
+
+ ); +} diff --git a/src/ui/observability/span-inspector.tsx b/src/ui/observability/span-inspector.tsx new file mode 100644 index 00000000..bc0df307 --- /dev/null +++ b/src/ui/observability/span-inspector.tsx @@ -0,0 +1,443 @@ +import { Badge } from "../badge.js"; +import { InspectorKv } from "../inspector-shell.js"; +import { + formatBytes, + formatClock, + formatDuration, + formatSpeed, + formatTokens, + spanTokens, +} from "./format.js"; +import { usageOfSpan, type UsageBreakdown } from "./metrics.js"; +import type { + AgentBrief as AgentBriefModel, + Compaction, + ContextChange, + DirectorNudge, + GrantUse, + MediaItem, + NetworkHop, + SignatureStatus, + Span, + TraceLayers, +} from "./model.js"; +import { revealedText, spanEnd } from "./playback.js"; +import { UsageMix } from "./usage-mix.js"; +import { compactionLine } from "./wording.js"; + +function usageTotal(usage: UsageBreakdown): number { + return ( + usage.input + + usage.output + + usage.cacheRead + + usage.cacheWrite + + usage.thinking + ); +} + +export function SpanInspector({ + traceSpans, + span, + timeMs, + layers, + playing, +}: { + traceSpans: readonly Span[]; + span: Span; + timeMs: number; + layers: TraceLayers; + playing: boolean; +}) { + const thoughts = thoughtSpans(traceSpans, span); + return ( +
+ {usageTotal(usageOfSpan(span)) > 0 ? ( + + ) : null} + 0 + ? spanTokens(span) / (span.durationMs / 1000) + : null, + ), + mono: true, + }, + ]), + ...(span.usage === undefined + ? [] + : [ + { + label: "Tokens", + value: formatTokens(spanTokens(span)), + mono: true, + }, + ]), + ...(span.signature === undefined + ? [] + : [ + { + label: "Signature", + value: , + }, + ]), + ...(span.attributes ?? []).map((attribute) => ({ + label: attribute.label, + value: attribute.value, + mono: true, + })), + ]} + /> + {span.nudge === undefined ? null : } + {span.compaction === undefined ? null : ( + + )} + {span.contextChange === undefined ? null : ( + + )} + {layers.grants && span.grant !== undefined ? ( + + ) : null} + {layers.reasoning + ? thoughts.map((thought) => ( + + )) + : null} + {layers.io && span.input !== undefined ? ( + + ) : null} + {layers.io && span.output !== undefined ? ( + + ) : null} + {layers.media && span.media !== undefined && span.media.length > 0 ? ( + + ) : null} + {layers.network && + span.network !== undefined && + span.network.length > 0 ? ( + + ) : null} + {layers.raw ? ( + + ) : null} +
+ ); +} + +function thoughtSpans(spans: readonly Span[], span: Span): readonly Span[] { + if (span.thinking !== undefined || span.redacted === true) return [span]; + return spans.filter( + (item) => + item.parentId === span.id && + item.kind === "reasoning" && + (item.thinking !== undefined || item.redacted === true), + ); +} + +export function AgentBrief({ brief }: { brief: AgentBriefModel }) { + return ( +
+
+

System prompt

+

+ {brief.systemPrompt} +

+
+
+

Tools

+
    + {brief.tools.map((tool) => ( +
  • + + {tool.name} + + {tool.description} +
  • + ))} +
+
+
+ ); +} + +function NudgeBlock({ nudge }: { nudge: DirectorNudge }) { + return ( +
+

{nudge.action}

+

{nudge.because}

+
+ ); +} + +export function CompactionBlock({ compaction }: { compaction: Compaction }) { + return ( +
+
+

Compaction

+

+ {compaction.turnsBefore} turns down to {compaction.turnsAfter} + {` · ${compactionLine(compaction)}`} +

+

{compaction.detail}

+

+ {compaction.compactor} v{compaction.version} · {compaction.reason} +

+
+ + + + + + + + + + {compaction.turns.map((turn) => ( + + + + + + ))} + +
TurnTokensKept
+ + {turn.title} + + {turn.note === undefined ? null : ( + + {turn.note} + + )} + + {formatTokens(turn.tokens)} + {turn.kept ? "Kept" : "Dropped"}
+
+ ); +} + +export function ContextBlock({ change }: { change: ContextChange }) { + return ( +
+

Context transform

+

{change.summary}

+

+ {change.strategy} v{change.version} · {change.trigger} · {change.reason} +

+
+ ); +} + +export function ThoughtBlock({ + span, + timeMs, + playing, +}: { + span: Span; + timeMs: number; + playing: boolean; +}) { + const started = timeMs > span.startMs; + const done = timeMs >= spanEnd(span); + const streaming = playing && started && !done; + return ( +
+

Thinking

+ {span.redacted === true || span.thinking === undefined ? ( +

Redacted by the model.

+ ) : playing && !started ? ( +

+ Starts at {formatClock(span.startMs)}. +

+ ) : ( +

+ {streaming + ? revealedText(span.thinking, span, timeMs) + : span.thinking} + {streaming ? ( + + ) : null} +

+ )} +
+ ); +} + +export function GrantBlock({ grant }: { grant: GrantUse }) { + return ( +
+
+

Grant

+ + {grant.blocked ? Blocked : null} +
+ {grant.blockReason === undefined ? null : ( +

{grant.blockReason}

+ )} + {grant.resolvedBy === null ? ( +

+ Nothing matched. Failed closed. +

+ ) : ( + + )} + {grant.matching.length === 0 ? null : ( +
    + {grant.matching.map((item) => ( +
  • + +
  • + ))} +
+ )} +
+ ); +} + +function GrantLine({ + label, + grant, +}: { + label: string; + grant: NonNullable; +}) { + const exact = + grant.specificity === undefined + ? "" + : ` · specificity ${grant.specificity}`; + return ( +

+ {label} + {grant.id} · {grant.effect} · {grant.origin} +
+ {grant.resource} {grant.action} + {exact} +

+ ); +} + +function EffectBadge({ effect }: { effect: GrantUse["effect"] }) { + const tone = + effect === "allow" + ? "success" + : effect === "ask" + ? "warning" + : effect === "none" + ? "neutral" + : "danger"; + return {effect}; +} + +function SignatureBadge({ status }: { status: SignatureStatus }) { + const tone = + status === "valid" + ? "success" + : status === "invalid" + ? "danger" + : "warning"; + return {status}; +} + +export function TextBlock({ label, text }: { label: string; text: string }) { + return ( +
+

{label}

+
+        {text}
+      
+
+ ); +} + +function MediaBlock({ items }: { items: readonly MediaItem[] }) { + return ( +
+

Attached

+
    + {items.map((item) => ( +
  • + {item.mediaType.startsWith("image/") ? ( +
    + {item.mediaType} +
    + ) : null} +

    {item.name}

    +

    + {item.mediaType} · {formatBytes(item.bytes)} +

    + {item.note === undefined ? null : ( +

    {item.note}

    + )} +
  • + ))} +
+
+ ); +} + +export function NetworkBlock({ hops }: { hops: readonly NetworkHop[] }) { + return ( +
+

Network

+
    + {hops.map((hop) => ( +
  • +

    + {hop.method} {hop.url} +

    +

    + {hop.status === null ? "no status" : hop.status} + {formatBytes(hop.bytes)} + {hop.external ? ( + External + ) : ( + Internal + )} +

    +
  • + ))} +
+
+ ); +} + +function rawSpan(span: Span): string { + return JSON.stringify(span, null, 2); +} diff --git a/src/ui/observability/timeline.tsx b/src/ui/observability/timeline.tsx new file mode 100644 index 00000000..b2b6fbf4 --- /dev/null +++ b/src/ui/observability/timeline.tsx @@ -0,0 +1,212 @@ +"use client"; + +import { useEffect, useRef } from "react"; + +import { usePrefersReducedMotion } from "../../hooks/use-prefers-reduced-motion.js"; +import { seriesColor } from "../../lib/chart-palette.js"; +import { cn } from "../../lib/utils.js"; +import { Button } from "../button.js"; +import { formatClock } from "./format.js"; +import type { Span, SpanKind } from "./model.js"; +import { nextBoundary, previousBoundary, SCAN_MS } from "./playback.js"; +import { stepTitle } from "./wording.js"; + +export type TraceTimelineProps = { + readonly spans: readonly Span[]; + readonly endMs: number; + readonly timeMs: number; + readonly playing: boolean; + readonly onTimeChange: (timeMs: number) => void; + readonly onPlayingChange: (playing: boolean) => void; + readonly className?: string; +}; + +function ink(amount: number): string { + return `color-mix(in oklab, var(--foreground) ${amount}%, transparent)`; +} + +function seekAt( + node: HTMLDivElement, + clientX: number, + end: number, + onTimeChange: (timeMs: number) => void, + onPlayingChange: (playing: boolean) => void, +): void { + const rect = node.getBoundingClientRect(); + if (rect.width <= 0) return; + const ratio = Math.min(1, Math.max(0, (clientX - rect.left) / rect.width)); + onPlayingChange(false); + onTimeChange(Math.round(ratio * end)); +} + +const KIND_MARK: Record = { + turn: ink(70), + reasoning: ink(40), + tool: ink(80), + permission: "var(--destructive)", + subagent: ink(70), + compact: seriesColor(2), + transform: seriesColor(3), + nudge: ink(60), + error: "var(--destructive)", + event: ink(40), + step: ink(50), + gate: "var(--foreground)", + mail: seriesColor(1), + provision: seriesColor(3), + fork: seriesColor(0), + reactor: seriesColor(2), + network: seriesColor(0), + attachment: seriesColor(1), + citation: ink(40), +}; + +export function TraceTimeline({ + spans, + endMs, + timeMs, + playing, + onTimeChange, + onPlayingChange, + className, +}: TraceTimelineProps) { + const reduce = usePrefersReducedMotion(); + const timeRef = useRef(timeMs); + const spansRef = useRef(spans); + timeRef.current = timeMs; + spansRef.current = spans; + const end = Math.max(endMs, 1); + + useEffect(() => { + if (!playing) return; + if (reduce) { + const id = window.setInterval(() => { + const next = nextBoundary(spansRef.current, timeRef.current); + if (next === undefined) { + onTimeChange(end); + onPlayingChange(false); + return; + } + onTimeChange(next); + }, 700); + return () => window.clearInterval(id); + } + + let frame = 0; + let last = performance.now(); + const tick = (now: number) => { + const dt = now - last; + last = now; + const next = timeRef.current + (dt / SCAN_MS) * end; + if (next >= end) { + onTimeChange(end); + onPlayingChange(false); + return; + } + onTimeChange(next); + frame = window.requestAnimationFrame(tick); + }; + frame = window.requestAnimationFrame(tick); + return () => window.cancelAnimationFrame(frame); + }, [playing, reduce, end, onTimeChange, onPlayingChange]); + + function step(direction: 1 | -1) { + onPlayingChange(false); + const boundary = + direction === 1 + ? nextBoundary(spans, timeMs) + : previousBoundary(spans, timeMs); + onTimeChange(boundary ?? (direction === 1 ? end : 0)); + } + + return ( +
+
+ + + + + {formatClock(timeMs)} + / {formatClock(end)} + +
+
+ + +
+
+ ); +} diff --git a/src/ui/observability/trace-header.tsx b/src/ui/observability/trace-header.tsx new file mode 100644 index 00000000..f448d0a9 --- /dev/null +++ b/src/ui/observability/trace-header.tsx @@ -0,0 +1,92 @@ +import { Badge } from "../badge.js"; +import { + formatCost, + formatDuration, + formatTokens, + spanTokens, +} from "./format.js"; +import type { Span, SpanStatus, Trace } from "./model.js"; +import { stepTitle } from "./wording.js"; + +export const STATUS_LABEL: Record = { + running: "Running", + ok: "Done", + error: "Failed", + waiting: "Waiting", + denied: "Denied", +}; + +export const STATUS_TONE = { + running: "info", + ok: "success", + error: "danger", + waiting: "warning", + denied: "danger", +} as const; + +export function TraceHeader({ trace }: { readonly trace: Trace }) { + const tokens = trace.spans.reduce((sum, span) => sum + spanTokens(span), 0); + const cost = trace.spans.reduce( + (sum, span) => sum + (span.usage?.costUsd ?? 0), + 0, + ); + return ( +
+
+
+
+

+ {trace.name} +

+ + {trace.kind === "agent" ? "Agent" : "Workflow"} + + + {STATUS_LABEL[trace.status]} + +
+

+ {trace.actor} · {trace.startedAt} +

+
+
+ + + + +
+
+
+ ); +} + +export function LiveGrant({ span }: { readonly span: Span | undefined }) { + if (span?.grant === undefined) { + return ( +

+ No grant at the playhead. +

+ ); + } + const grant = span.grant; + const winner = grant.resolvedBy; + const stopped = grant.blocked ? " · blocked" : ""; + const by = winner === null ? "" : ` · ${winner.id}`; + return ( +

+ Grant · + {stepTitle(span)} · {grant.effect} + {stopped} + {by} +

+ ); +} + +function Metric({ label, value }: { label: string; value: string }) { + return ( +
+
{label}
+
{value}
+
+ ); +} diff --git a/src/ui/observability/trace-steps.tsx b/src/ui/observability/trace-steps.tsx new file mode 100644 index 00000000..bf52b3c1 --- /dev/null +++ b/src/ui/observability/trace-steps.tsx @@ -0,0 +1,197 @@ +"use client"; + +import { type ReactNode } from "react"; + +import { seriesColor } from "../../lib/chart-palette.js"; +import { cn } from "../../lib/utils.js"; +import { formatDuration } from "./format.js"; +import type { Span } from "./model.js"; +import { spanEnd } from "./playback.js"; +import type { PlacedSpan } from "./tree.js"; +import { rowMarker, stepTitle } from "./wording.js"; + +export function TraceSteps({ + placed, + end, + selectedId, + onSeek, + rowRef, + renderTrailing, +}: { + readonly placed: readonly PlacedSpan[]; + readonly end: number; + readonly selectedId: string; + readonly onSeek: (id: string, startMs: number) => void; + readonly rowRef?: (id: string, node: HTMLButtonElement | null) => void; + readonly renderTrailing?: (span: Span) => ReactNode; +}) { + const index = placed.findIndex((row) => row.span.id === selectedId); + function move(delta: number) { + const next = placed[index + delta]; + if (next !== undefined) onSeek(next.span.id, next.span.startMs); + } + return ( +
{ + if (event.key === "ArrowDown") { + event.preventDefault(); + move(1); + } + if (event.key === "ArrowUp") { + event.preventDefault(); + move(-1); + } + }} + > +
+ Step + {renderTrailing === undefined ? null : ( + + 0 + {formatDuration(end)} + + )} +
+ {placed.map(({ span, depth }) => ( +
+ onSeek(span.id, span.startMs)} + buttonRef={(node) => rowRef?.(span.id, node)} + /> + {renderTrailing === undefined ? null : renderTrailing(span)} +
+ ))} +
+ ); +} + +export function Step({ + span, + depth = 0, + selected = false, + onSelect, + buttonRef, +}: { + readonly span: Span; + readonly depth?: number; + readonly selected?: boolean; + readonly onSelect?: () => void; + readonly buttonRef?: (node: HTMLButtonElement | null) => void; +}) { + return ( + + ); +} + +export function Timing({ + span, + end, + timeMs, +}: { + readonly span: Span; + readonly end: number; + readonly timeMs: number; +}) { + const played = playedRatio(span, timeMs); + return ( +