Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 55 additions & 0 deletions src/ui/observability/format.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { describe, expect, test } from "bun:test";

import {
formatReportedCost,
formatStarted,
formatStepCount,
} from "./format.js";

describe("formatReportedCost", () => {
test("is a dash when no span reports a cost", () => {
expect(formatReportedCost([{}, { usage: {} }])).toBe("—");
});

test("shows zero when a span reports zero", () => {
expect(formatReportedCost([{ usage: { costUsd: 0 } }])).toBe("$0");
});

test("sums the costs that were reported", () => {
expect(
formatReportedCost([
{ usage: { costUsd: 1.5 } },
{ usage: {} },
{ usage: { costUsd: 2.25 } },
]),
).toBe("$3.75");
});
});

describe("formatStepCount", () => {
test("uses the run total when the page is shorter", () => {
expect(formatStepCount({ stepCount: 12, spans: [{}, {}] })).toBe("12");
});

test("counts the loaded spans when no total was passed", () => {
expect(formatStepCount({ spans: [{}, {}, {}] })).toBe("3");
});
});

describe("formatStarted", () => {
test("returns an unparseable value unchanged", () => {
expect(formatStarted("not-a-time")).toBe("not-a-time");
});

test("formats a timestamp as a local date and time", () => {
const shown = formatStarted("2026-10-24T15:04:00.000Z");
expect(shown.includes("T")).toBe(false);
expect(shown.includes("2026-10-24")).toBe(false);
expect(/\bat\b/i.test(shown)).toBe(false);
expect(shown.includes(" · ")).toBe(true);
});

test("leaves a clock time unchanged", () => {
expect(formatStarted("09:14:02")).toBe("09:14:02");
});
});
38 changes: 38 additions & 0 deletions src/ui/observability/format.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,44 @@ export function formatCost(usd: number): string {
return `$${usd.toFixed(2)}`;
}

export function formatReportedCost(
spans: readonly { usage?: { costUsd?: number } }[],
): string {
let total = 0;
let known = false;
for (const span of spans) {
const cost = span.usage?.costUsd;
if (cost === undefined) continue;
known = true;
total += cost;
}
if (!known) return "—";
return formatCost(total);
}

export function formatStepCount(trace: {
stepCount?: number;
spans: readonly unknown[];
}): string {
if (trace.stepCount !== undefined) return String(trace.stepCount);
return String(trace.spans.length);
}

export function formatStarted(value: string): string {
const parsed = Date.parse(value);
if (Number.isNaN(parsed)) return value;
const date = new Date(parsed);
const day = new Intl.DateTimeFormat(undefined, {
month: "short",
day: "numeric",
}).format(date);
const time = new Intl.DateTimeFormat(undefined, {
hour: "numeric",
minute: "2-digit",
}).format(date);
return `${day} · ${time}`;
}

export function spanTokens(span: {
usage?: { inputTokens?: number; outputTokens?: number };
}): number {
Expand Down
2 changes: 2 additions & 0 deletions src/ui/observability/model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -230,6 +230,8 @@ export type Trace = {
readonly startedAt: string;
readonly durationMs: number;
readonly spans: readonly Span[];
/** Steps in the whole run. Omit to count `spans`. */
readonly stepCount?: number;
readonly placement?: Placement;
readonly principal?: PrincipalRef;
readonly tenant?: TenantRef;
Expand Down
14 changes: 6 additions & 8 deletions src/ui/observability/trace-header.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import { Badge } from "../badge.js";
import {
formatCost,
formatDuration,
formatReportedCost,
formatStarted,
formatStepCount,
formatTokens,
spanTokens,
} from "./format.js";
Expand All @@ -26,10 +28,6 @@ export const STATUS_TONE = {

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 (
<header className="flex flex-col gap-3 border-b border-border px-4 py-3">
<div className="flex flex-wrap items-end justify-between gap-4">
Expand All @@ -46,14 +44,14 @@ export function TraceHeader({ trace }: { readonly trace: Trace }) {
</Badge>
</div>
<p className="font-mono text-xs text-muted-foreground">
{trace.actor} · {trace.startedAt}
{trace.actor} · {formatStarted(trace.startedAt)}
</p>
</div>
<dl className="flex gap-6 text-right">
<Metric label="Duration" value={formatDuration(trace.durationMs)} />
<Metric label="Tokens" value={formatTokens(tokens)} />
<Metric label="Cost" value={formatCost(cost)} />
<Metric label="Steps" value={String(trace.spans.length)} />
<Metric label="Cost" value={formatReportedCost(trace.spans)} />
<Metric label="Steps" value={formatStepCount(trace)} />
</dl>
</div>
</header>
Expand Down
14 changes: 14 additions & 0 deletions stories/observability/views/run.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,20 @@ export const NameOnly = () => (
);
NameOnly.storyName = "Name only";

export const ParseableStart = () => {
const run = {
...review,
startedAt: "2026-10-24T15:04:00.000Z",
spans: [span("turn", review), span("sub", review)],
};
return (
<div className="border border-border">
<TraceHeader trace={run} />
</div>
);
};
ParseableStart.storyName = "Parseable start";

export const WithSteps = () => (
<div className="h-[640px] border border-border">
<Run trace={review}>
Expand Down
Loading