Skip to content
Open
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
34 changes: 14 additions & 20 deletions src/commands/traces/get.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import type { ApiClient, FindingDetail, TraceDetail } from "../../api/client.js"
import { type Writers, colorEnabled, defaultWriters, writeJson } from "../../output.js";
import { createStyler } from "../../render/style.js";
import { renderTree } from "../../render/tree.js";
import { formatDuration, formatTimestamp, parseBackendTime } from "../../util/index.js";
import { elapsedMs, formatDuration, formatTimestamp } from "../../util/index.js";
import { contextFromCommand, requireApiClient } from "../shared.js";

/** Max width for the single-line RCA preview shown inline in `traces get`. */
Expand Down Expand Up @@ -51,23 +51,6 @@ function isLive(spans: Span[]): boolean {
return spans.some((span) => span.span_end_time === null);
}

/** Milliseconds between two ISO timestamps, or null when not derivable. */
function elapsedMs(start: string, end: string | null): number | null {
if (end === null) {
return null;
}
// Parse both as zone-less UTC so the live path (end is a real-UTC ISO) and the
// completed path stay consistent; a bare `new Date(...)` would misread the
// zone-less backend start time as host-local and skew the elapsed math.
const startDate = parseBackendTime(start);
const endDate = parseBackendTime(end);
if (startDate === null || endDate === null) {
return null;
}
const ms = endDate.getTime() - startDate.getTime();
return Number.isFinite(ms) && ms >= 0 ? ms : null;
}

/** Core, network-free logic for `traces get`. Tests inject a fake client. */
export async function runGet(deps: RunGetDeps): Promise<void> {
const { client, json, writers, traceId } = deps;
Expand All @@ -92,12 +75,17 @@ export async function runGet(deps: RunGetDeps): Promise<void> {
const styler = createStyler(writers.out);
const label = (text: string): string => styler.bold(text);
const live = isLive(trace.spans);
// Single "now" instant, reused for both the header's live elapsed duration
// and the per-span tree (via renderTree's `now` option), so a still-running
// span's elapsed-so-far agrees with the header rather than drifting apart
// from a second, later `Date.now()` read.
const nowIso = new Date().toISOString();

// Live traces have no real end yet: show elapsed-so-far and a LIVE marker
// instead of an end time. Completed traces derive end/duration from the spans.
const end = live ? null : latestSpanEnd(trace.spans);
const duration = live
? elapsedMs(trace.trace_start_time, new Date().toISOString())
? elapsedMs(trace.trace_start_time, nowIso)
: elapsedMs(trace.trace_start_time, end);

const lines: string[] = [];
Expand Down Expand Up @@ -135,7 +123,13 @@ export async function runGet(deps: RunGetDeps): Promise<void> {
}
lines.push("");
lines.push(label("Spans:"));
lines.push(renderTree(trace.spans, { color: colorEnabled(writers.out) }));
lines.push(
renderTree(trace.spans, {
color: colorEnabled(writers.out),
width: process.stdout.columns ?? 80,
now: nowIso,
}),
);
if (live) {
// Indicate the tree is incomplete — more spans are still arriving.
lines.push(" *** (live — more spans incoming)");
Expand Down
130 changes: 126 additions & 4 deletions src/render/tree.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { elapsedMs, formatDuration } from "../util/index.js";

/**
* Minimal structural shape a span needs to be rendered as a tree. A superset of
* the fields of `SpanResponse` used here, kept local so the renderer stays a
Expand All @@ -9,6 +11,16 @@ export interface SpanLike {
name: string;
status?: string;
span_start_time?: string;
/** End timestamp, or `null`/absent while the span is still running. */
span_end_time?: string | null;
/** Error detail, shown as its own line beneath an errored span. */
status_message?: string | null;
/** LLM model name; presence triggers the compact model/token/cost detail. */
model_name?: string | null;
input_tokens?: number | null;
output_tokens?: number | null;
total_tokens?: number | null;
cost?: number | null;
}

const ERROR_STATUSES = new Set(["ERROR", "error", "STATUS_CODE_ERROR"]);
Expand All @@ -17,6 +29,12 @@ const ERROR_STATUSES = new Set(["ERROR", "error", "STATUS_CODE_ERROR"]);
// terminals; applied to the whole line, gated behind the `color` option.
const ANSI_RESET = "\x1b[0m";
const ANSI_RED = "\x1b[91m";
// Dim, for the secondary LLM model/token/cost detail line, so it reads as
// supplementary rather than competing with the span line itself.
const ANSI_DIM = "\x1b[2m";

/** Default terminal width assumed when {@link RenderTreeOptions.width} is omitted. */
const DEFAULT_WIDTH = 80;

function isError(status: string | undefined): boolean {
return status !== undefined && ERROR_STATUSES.has(status);
Expand All @@ -30,10 +48,71 @@ function sortKey(span: SpanLike, index: number): [string, number] {
return [span.span_start_time ?? "", index];
}

/**
* Compact token count for the LLM detail line, e.g. `850` → `"850 tok"`,
* `1200` → `"1.2k tok"`. Values at or above 1000 collapse to one decimal of
* thousands (trailing `.0` dropped) so the detail stays a single glanceable
* token.
*/
function formatTokens(count: number): string {
if (count < 1000) {
return `${count} tok`;
}
const thousands = (count / 1000).toFixed(1).replace(/\.0$/, "");
return `${thousands}k tok`;
}

/** `total_tokens` if present, else `input_tokens + output_tokens`; null if neither is known. */
function tokenCount(span: SpanLike): number | null {
if (span.total_tokens !== undefined && span.total_tokens !== null) {
return span.total_tokens;
}
if (
(span.input_tokens !== undefined && span.input_tokens !== null) ||
(span.output_tokens !== undefined && span.output_tokens !== null)
) {
return (span.input_tokens ?? 0) + (span.output_tokens ?? 0);
}
return null;
}

/**
* Compact `model · N tok · $cost` detail for an LLM span, or null when the
* span has no `model_name`. Tokens and cost are each omitted individually when
* unknown, so a span with only a model name still renders that much.
*/
function llmDetail(span: SpanLike): string | null {
if (span.model_name === undefined || span.model_name === null || span.model_name === "") {
return null;
}
const parts = [span.model_name];
const tokens = tokenCount(span);
if (tokens !== null) {
parts.push(formatTokens(tokens));
}
if (span.cost !== undefined && span.cost !== null) {
parts.push(`$${span.cost.toFixed(4)}`);
}
return parts.join(" · ");
}

/** Optional behavior for {@link renderTree}. */
export interface RenderTreeOptions {
/** When true, error span lines are colored red. Off for non-TTY / `NO_COLOR`. */
color?: boolean;
/**
* Terminal width: durations are right-aligned to this column and error
* messages are truncated to it. Defaults to 80.
*/
width?: number;
/**
* ISO timestamp used as "now" for the elapsed-so-far duration of a span with
* no `span_end_time` (still running). Passed in — rather than read via
* `Date.now()` inside the renderer — so this stays a pure, testable
* transform and agrees with whatever "now" the caller used for its own live
* elapsed display (e.g. the `traces get` header).
*/
now?: string;
}

/**
Expand All @@ -46,6 +125,8 @@ export function renderTree(spans: SpanLike[], options: RenderTreeOptions = {}):
return "";
}
const color = options.color === true;
const width = options.width ?? DEFAULT_WIDTH;
const now = options.now;

const indexOf = new Map<string, number>();
for (const [i, span] of spans.entries()) {
Expand Down Expand Up @@ -85,8 +166,39 @@ export function renderTree(spans: SpanLike[], options: RenderTreeOptions = {}):
visited.add(span.span_id);
const connector = isRoot ? "" : isLast ? "└─ " : "├─ ";
const text = `${prefix}${connector}${span.name} ${marker(span.status)}`;
lines.push(color && isError(span.status) ? `${ANSI_RED}${text}${ANSI_RESET}` : text);
const childPrefix = isRoot ? "" : prefix + (isLast ? " " : "│ ");

// Duration, right-aligned to `width`: end time if the span has finished,
// otherwise elapsed-so-far against `now` (only when the caller supplied
// one — a still-running span with no `now` simply shows no duration
// rather than silently computing one from a fresh `Date.now()`).
const end = span.span_end_time ?? now ?? null;
const durationMs =
span.span_start_time !== undefined ? elapsedMs(span.span_start_time, end) : null;
const durationText = durationMs !== null ? formatDuration(durationMs) : null;
const lineCore =
durationText === null
? text
: `${text}${" ".repeat(Math.max(1, width - text.length - durationText.length))}${durationText}`;
lines.push(color && isError(span.status) ? `${ANSI_RED}${lineCore}${ANSI_RESET}` : lineCore);

// Error detail, directly beneath the span line, indented to align under
// the name (the same indent children continue at) and truncated to width.
if (isError(span.status) && span.status_message !== undefined && span.status_message !== null) {
const trimmed = span.status_message.trim();
if (trimmed.length > 0) {
const msgText = `${childPrefix}${truncate(trimmed, Math.max(10, width - childPrefix.length))}`;
Comment thread
dark-sorceror marked this conversation as resolved.
lines.push(color ? `${ANSI_RED}${msgText}${ANSI_RESET}` : msgText);
}
}

// Compact LLM detail (model · tokens · cost), dim, on its own line.
const detail = llmDetail(span);
if (detail !== null) {
const detailText = `${childPrefix}${detail}`;
lines.push(color ? `${ANSI_DIM}${detailText}${ANSI_RESET}` : detailText);
}

const children = [...(childrenOf.get(span.span_id) ?? [])].sort(byStart);
for (const [i, child] of children.entries()) {
walk(child, childPrefix, i === children.length - 1, false);
Expand All @@ -108,13 +220,23 @@ export function renderTree(spans: SpanLike[], options: RenderTreeOptions = {}):
return lines.join("\n");
}

/** Hint appended to truncated text; budgeted into {@link truncate}'s `max`. */
const TRUNCATION_SUFFIX = "… (truncated)";

/**
* Returns `text` unchanged when its length is at most `max`, otherwise the first
* `max` characters followed by a truncation hint. HUMAN-render only.
* Returns `text` unchanged when its length is at most `max`, otherwise a
* truncated form whose TOTAL length (kept text plus the truncation hint) is at
* most `max`, so callers can budget it against a terminal width. When `max` is
* too small for the hint plus at least one source character, the hint is
* dropped and the text is plainly cut at `max`, preserving the length
* invariant. HUMAN-render only.
*/
export function truncate(text: string, max = 200): string {
if (text.length <= max) {
return text;
}
return `${text.slice(0, max)}… (truncated)`;
if (max <= TRUNCATION_SUFFIX.length) {
return text.slice(0, max);
}
return `${text.slice(0, max - TRUNCATION_SUFFIX.length)}${TRUNCATION_SUFFIX}`;
}
21 changes: 21 additions & 0 deletions src/util/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,27 @@ export function parseBackendTime(raw: string): Date | null {
return Number.isNaN(date.getTime()) ? null : date;
}

/**
* Milliseconds between two backend timestamps, or null when not derivable
* (`end` is null, either side fails to parse, or the result would be
* negative). Both sides are parsed via {@link parseBackendTime} so zone-less
* backend times are read as UTC rather than host-local. Shared by the trace
* header (`traces get`) and the per-span tree renderer so their "live" /
* elapsed durations stay consistent with each other.
*/
export function elapsedMs(start: string, end: string | null): number | null {
if (end === null) {
return null;
}
const startDate = parseBackendTime(start);
const endDate = parseBackendTime(end);
if (startDate === null || endDate === null) {
return null;
}
const ms = endDate.getTime() - startDate.getTime();
return Number.isFinite(ms) && ms >= 0 ? ms : null;
}

/**
* Renders a backend timestamp in a readable, unambiguous form: local time with
* an explicit timezone label (e.g. `2026-06-04 16:43:13 PDT`), since the backend
Expand Down
62 changes: 62 additions & 0 deletions tests/commands/traces-get.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,68 @@ describe("runGet (human)", () => {
expect(out.data).not.toContain("Output:");
});

it("shows each span's own duration on its tree line", async () => {
const trace = detail({
spans: [
span({
span_id: "root",
name: "root-span",
span_start_time: "2024-01-01T00:00:00Z",
span_end_time: "2024-01-01T00:00:01.5Z",
}),
],
});
const { writers: w, out } = writers();
await runGet({ client: fakeClient({ trace }), json: false, writers: w, traceId: "t-1" });

const spanLine = out.data.split("\n").find((l) => l.includes("root-span")) as string;
expect(spanLine).toBeDefined();
expect(spanLine).toContain("1.5s");
});

it("shows the error status_message beneath a failing span", async () => {
const trace = detail({
spans: [
span({
span_id: "root",
name: "root-span",
status: "ERROR",
status_message: "downstream timeout after 30s",
}),
],
});
const { writers: w, out } = writers();
await runGet({ client: fakeClient({ trace }), json: false, writers: w, traceId: "t-1" });

const lines = out.data.split("\n");
const spanLineIndex = lines.findIndex((l) => l.includes("root-span"));
expect(spanLineIndex).toBeGreaterThanOrEqual(0);
expect(lines[spanLineIndex + 1]).toContain("downstream timeout after 30s");
});

it("shows a compact model/token/cost detail beneath an LLM span", async () => {
const trace = detail({
spans: [
span({
span_id: "root",
name: "root-span",
model_name: "claude-sonnet-4-5",
total_tokens: 1200,
cost: 0.004,
}),
],
});
const { writers: w, out } = writers();
await runGet({ client: fakeClient({ trace }), json: false, writers: w, traceId: "t-1" });

const lines = out.data.split("\n");
const spanLineIndex = lines.findIndex((l) => l.includes("root-span"));
expect(spanLineIndex).toBeGreaterThanOrEqual(0);
expect(lines[spanLineIndex + 1]).toContain("claude-sonnet-4-5");
expect(lines[spanLineIndex + 1]).toContain("1.2k tok");
expect(lines[spanLineIndex + 1]).toContain("$0.0040");
});

it("renders the trace_url as an OSC 8 hyperlink on a TTY", async () => {
const trace = detail({});
const out = new StringSink(true);
Expand Down
Loading
Loading