Skip to content
Open
33 changes: 32 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ traceroot traces list
| `login` | Authenticate and save credentials (validates before writing). |
| `status` | Show the identity your credentials resolve to — workspace, project, key hint, host, source. |
| `traces list` | List traces for your project, newest first. `--limit <n>`, `--since <dur>`, `--from`/`--to` |
| `traces get <id>` | Show one trace: span tree, derived duration, I/O preview, and a link to open it. |
| `traces get <id>` | Show one trace: span tree, derived duration, I/O preview, and a link to open it. Bound large traces with `--max-spans <n>`, `--depth <n>`, `--errors-only`, `--output jsonl`. |
| `traces export <id>` | Write a trace bundle (`trace.json`, `spans.json`, `git_context.json`, `manifest.json`) to a directory. `--output <dir>`, `--force` |
| `detectors list` | List your project's detectors, newest first. The `DETECTOR ID` column is what you pass to `findings list --detector`. `--limit <n>`, `--since <dur>`, `--from`/`--to` |
| `findings list` | List detector findings for your project, newest first. `--limit <n>`, `--since <dur>`, `--from`/`--to`, `--detector <id>`, `--trace <id>` |
Expand All @@ -76,6 +76,37 @@ traceroot findings list --detector <detector-id> --since 7d --json | jq '.data[]
traceroot findings get --trace 99224be337d725fd5e8f2e7b45dc22ef
```

### Bounding large traces

An agent trace can carry thousands of spans, which is a lot to dump into a
terminal — or into another model's context. `traces get` has four opt-in,
backward-compatible flags to subset the output (they apply to both the human
tree and the JSON/JSONL bodies):

| Flag | Effect |
| :-- | :-- |
| `--max-spans <n>` | Show at most `n` spans. JSON adds `spans_truncated: { shown, total }`; the human tree appends a `… N more spans` line. |
| `--depth <n>` | Cap tree depth (roots are depth 1); deeper spans are dropped. The human tree marks each elided subtree with `… N deeper spans hidden`. |
| `--errors-only` | Keep only error spans and their full ancestor chains, dropping unrelated branches. |
| `--output jsonl` | Stream a header line (the trace minus its spans, plus `finding`) then one span per line. Implies machine output, so it works with or without `--json`, and stays `head`-safe. |

The flags compose — `--errors-only` is applied first, then `--depth`, then
`--max-spans` — and the truncation total always reflects the post-filter set.
Truncation selects spans in tree traversal order (the order the human tree
prints), so both views keep the same spans; JSON emits the kept spans in the
backend's original array order.

```sh
# Just the failures and how they were reached, as JSON.
traceroot traces get <trace-id> --errors-only --json | jq '.spans[].name'

# Stream spans and grep for slow model calls without buffering the whole trace.
traceroot traces get <trace-id> --output jsonl | grep '"model_name":"gpt-4o"'

# Peek at the top of a huge trace: the header line, nothing more.
traceroot traces get <trace-id> --output jsonl | head -1
```

## Skills & agents

Make your coding agent TraceRoot-aware without touching your application source. The
Expand Down
161 changes: 154 additions & 7 deletions src/commands/traces/get.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,16 @@
import type { Command } from "commander";
import type { ApiClient, FindingDetail, TraceDetail } from "../../api/client.js";
import { type Writers, colorEnabled, defaultWriters, writeJson } from "../../output.js";
import { CliError, type Writers, colorEnabled, defaultWriters, writeJson } from "../../output.js";
import { createStyler } from "../../render/style.js";
import { renderTree } from "../../render/tree.js";
import {
filterErrorsWithAncestors,
renderTree,
spansWithinDepth,
treeOrder,
} from "../../render/tree.js";
import { formatDuration, formatTimestamp, parseBackendTime } from "../../util/index.js";
import { contextFromCommand, requireApiClient } from "../shared.js";
import { onceOption } from "./list.js";

/** Max width for the single-line RCA preview shown inline in `traces get`. */
const RCA_PREVIEW_MAX = 80;
Expand All @@ -31,6 +37,60 @@ export interface RunGetDeps {
json: boolean;
writers: Writers;
traceId: string;
/** Cap emitted spans (tree + JSON). Undefined = no cap. */
maxSpans?: number;
/** Cap tree depth, roots = 1 (tree + JSON). Undefined = no cap. */
depth?: number;
/** Keep only error spans plus their ancestor chains. */
errorsOnly?: boolean;
/**
* Machine output format. `"jsonl"` streams a header line (trace minus spans,
* plus `finding`) then one span per line; it implies machine output whether or
* not the global `--json` flag is set. Undefined = follow `json`.
*/
output?: "jsonl";
}

/**
* Applies the `traces get` span bounds in a fixed order — `--errors-only` first,
* then `--depth`, then `--max-spans` — so the human tree and the JSON/JSONL
* emitters always work from the same set. Returns the spans to emit plus the
* counts the truncation marker and empty-result indicators need.
*/
function boundSpans(
spans: Span[],
opts: { maxSpans?: number; depth?: number; errorsOnly?: boolean },
): {
/** Errors-only working set (or all spans); what the human tree renders. */
working: Span[];
/** Final capped list emitted by the JSON/JSONL paths. */
shown: Span[];
/** Post-depth total the `--max-spans` marker reports against. */
depthTotal: number;
/** True when `--errors-only` matched nothing. */
errorsOnlyEmpty: boolean;
} {
const working = opts.errorsOnly === true ? filterErrorsWithAncestors(spans) : spans;
const depthFiltered = opts.depth !== undefined ? spansWithinDepth(working, opts.depth) : working;
// `--max-spans` SELECTS the first n spans in tree traversal order — the same
// order the human tree prints, via the shared `treeOrder` — so both modes keep
// the SAME spans. The kept spans are then EMITTED in their original backend
// array order, keeping the JSON/JSONL body array-stable.
let shown = depthFiltered;
if (opts.maxSpans !== undefined && opts.maxSpans < depthFiltered.length) {
const keep = new Set(
treeOrder(depthFiltered)
.slice(0, opts.maxSpans)
.map((s) => s.span_id),
);
shown = depthFiltered.filter((s) => keep.has(s.span_id));
}
return {
working,
shown,
depthTotal: depthFiltered.length,
errorsOnlyEmpty: opts.errorsOnly === true && working.length === 0,
};
}

type Span = TraceDetail["spans"][number];
Expand Down Expand Up @@ -70,7 +130,7 @@ function elapsedMs(start: string, end: string | null): number | 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;
const { client, json, writers, traceId, maxSpans, depth, errorsOnly, output } = deps;
const trace = await client.getTrace(traceId);

// Best-effort: surface the detector finding for this trace (findings are
Expand All @@ -83,9 +143,38 @@ export async function runGet(deps: RunGetDeps): Promise<void> {
finding = null;
}

// Apply the bounding flags ONCE; every emit path reads from this result so the
// human tree and the JSON/JSONL bodies can never diverge.
const { working, shown, depthTotal, errorsOnlyEmpty } = boundSpans(trace.spans, {
maxSpans,
depth,
errorsOnly,
});
// Only `--max-spans` advertises a truncation marker (with the true post-filter
// total); `--depth` simply omits deeper spans. Present only when it fires, so
// default output is byte-identical to before.
const truncated = shown.length < depthTotal;
const marker = truncated ? { spans_truncated: { shown: shown.length, total: depthTotal } } : {};
// `--errors-only` matching nothing is surfaced explicitly rather than as a
// silent empty span list.
const emptyMarker = errorsOnlyEmpty ? { errors_only_no_matches: true } : {};

if (output === "jsonl") {
// Header first (all trace fields except spans, plus finding + any markers),
// then one span per line, written incrementally so a downstream `head`/`grep`
// can stop us early (the global EPIPE handler makes that a clean exit).
const { spans: _spans, ...header } = trace;
writeJson({ ...header, finding, ...marker, ...emptyMarker }, writers);
for (const span of shown) {
writeJson(span, writers);
}
return;
}

if (json) {
// FULL untruncated trace, plus the finding (or null) so scripts get it in one call.
writeJson({ ...trace, finding }, writers);
// The full trace with its spans bounded by the flags, plus the finding (or
// null) so scripts get everything in one call.
writeJson({ ...trace, spans: shown, finding, ...marker, ...emptyMarker }, writers);
return;
}

Expand Down Expand Up @@ -135,7 +224,13 @@ export async function runGet(deps: RunGetDeps): Promise<void> {
}
lines.push("");
lines.push(label("Spans:"));
lines.push(renderTree(trace.spans, { color: colorEnabled(writers.out) }));
if (errorsOnlyEmpty) {
lines.push(" (no error spans in this trace)");
} else {
lines.push(
renderTree(working, { color: colorEnabled(writers.out), maxDepth: depth, maxSpans }),
);
}
if (live) {
// Indicate the tree is incomplete — more spans are still arriving.
lines.push(" *** (live — more spans incoming)");
Expand All @@ -149,14 +244,66 @@ export async function runGet(deps: RunGetDeps): Promise<void> {
writers.out.write(`${lines.join("\n")}\n`);
}

/**
* Coercion for a positive-integer option that also rejects a repeated flag.
* Modeled on {@link onceOption} + `parseLimit`, but returns a validated `number`.
*/
function positiveIntOnce(flag: string): (val: string, prev: number | undefined) => number {
return (val: string, prev: number | undefined): number => {
if (prev !== undefined) {
throw new CliError(`${flag} may only be given once`);
}
if (!/^\d+$/.test(val)) {
throw new CliError(`${flag} must be a positive integer`);
}
const value = Number.parseInt(val, 10);
if (!Number.isInteger(value) || value < 1) {
throw new CliError(`${flag} must be a positive integer`);
}
return value;
};
}

export function registerTracesGet(traces: Command): void {
traces
.command("get")
.argument("<traceId>", "trace identifier")
.description("Get a single trace")
.option(
"--max-spans <n>",
"cap the number of spans shown (tree and JSON); adds a truncation marker",
positiveIntOnce("--max-spans"),
)
.option(
"--depth <n>",
"cap tree depth (roots = 1); deeper spans are hidden in both tree and JSON",
positiveIntOnce("--depth"),
)
.option("--errors-only", "show only error spans and their ancestor chains")
.option(
"--output <format>",
"machine output format: 'jsonl' streams a header line then one span per line (implies JSON)",
onceOption("--output"),
)
.action(async (traceId: string, _opts, command: Command) => {
const opts = command.opts();
// `--output` accepts only `jsonl`; reject anything else as a usage error
// before any network work.
const output = opts.output as string | undefined;
if (output !== undefined && output !== "jsonl") {
throw new CliError(`--output must be 'jsonl' (got '${output}')`);
}
const ctx = contextFromCommand(command);
const client = requireApiClient(ctx);
await runGet({ client, json: ctx.json, writers: defaultWriters, traceId });
await runGet({
client,
json: ctx.json,
writers: defaultWriters,
traceId,
maxSpans: opts.maxSpans as number | undefined,
depth: opts.depth as number | undefined,
errorsOnly: opts.errorsOnly as boolean | undefined,
output: output === "jsonl" ? "jsonl" : undefined,
});
});
}
Loading
Loading