Skip to content

Repository files navigation

buildline

CI crates.io docs.rs license

One timeline for your whole build, no matter how many build systems it's made of.

Your CI build takes 22 minutes. cargo build --timings says cargo is fine. Ninja's log says linking is fine. Every tool profiles its own silo, and yet the build is slow. Where did the time actually go?

buildline merges the profiling output your build tools already produce into a single, unified timeline: cargo, ninja, and more, side by side on one time axis, opened in Perfetto. It's not another profiler. It's the layer that makes the profilers you already have talk to each other. Think OpenTelemetry, but for builds.

It sees the time no profiler sees

Because buildline wraps each build step and stamps the real wall-clock, it captures the dead time between and around your tools: toolchain downloads, dependency resolution, environment setup, the minutes that never show up in any single tool's trace because they happen outside it. That's usually where the surprise is.

The screenshot below is a real run: a tiny ninja build finishes in under a second, then a real cargo build starts about 2.7 seconds later. Neither tool's own profiler records that gap. Ninja doesn't know cargo exists, and cargo's timer starts from zero when it launches. On the merged timeline it's just... visibly empty space, exactly where the surprise usually is in a real CI log.

buildline timeline in Perfetto, showing a real ninja build followed by a real cargo build with the gap between them visible

Installation

With Rust installed:

cargo install buildline

Without Rust: pre-built binaries for Linux, macOS (Intel and Apple Silicon) and Windows are attached to each release.

Try it with no build on hand

buildline demo
# open demo.trace.json in https://ui.perfetto.dev

Writes an illustrative trace (synthetic data, clearly labelled as such) so you can see the merge mechanism, two tracks, the wall-clock gap between them, before pointing buildline at a real build.

Usage

BUILDLINE_SESSION=./build.trace buildline -- ninja
BUILDLINE_SESSION=./build.trace buildline -- cargo build
# open build.trace in https://ui.perfetto.dev, flamegraph, zoom, drill-down included

Each invocation wraps one real tool invocation. Run it once per tool, from wherever you'd normally run that tool. No orchestrator wrapping, no process interception: buildline runs the command transparently (inherited stdout/stderr, exact exit code passed through), then reads that tool's own profiling artifact, timestamps it onto the shared session clock, and appends it to the trace file. There's no finalize step: build.trace is a valid, openable trace after every single invocation.

Supported tools today: ninja (.ninja_log, written automatically by every ninja build), cargo (--timings, injected automatically if you don't already pass it, parsed from the report's embedded data, since stable cargo has no machine-readable --timings=json output), and webpack (--profile --json=<path>, both injected automatically since webpack's --json prints to stdout by default and nothing lands on disk otherwise).

Any other tool that already writes Chrome Trace Event Format itself merges in with zero bespoke parsing, no dedicated adapter required: point BUILDLINE_CHROME_TRACE at that file.

BUILDLINE_SESSION=./build.trace BUILDLINE_CHROME_TRACE=./trace/trace.json \
  buildline -- tsc --generateTrace ./trace

TypeScript's tsc --generateTrace <dir> and Bazel's --profile=<path> both write this format natively today, verified against real captures from both, which turned out to be each other's mirror image: a real tsc capture (TypeScript 7.0.2, the Go-ported tsgo compiler) is almost entirely streamed "Begin"/"End" (ph: "B"/"E") pairs (187 of 203 spans, nested up to depth 5 on a single file's own tid); a real Bazel capture (9.2.0, building a small cc_library/cc_binary project) is 100% "Complete" (ph: "X") events, zero "B"/"E" at all, and never writes a process_name event either, so every span from it falls back to the generic track name. Both fixtures are checked into tests/fixtures/generic/, not fabricated. Every span is Status::Success regardless of source, since the format has no success/failure concept of its own. See the doc comment in src/adapters/generic.rs for the detail. A hand-written adapter (like webpack's) will always extract more than this generic path can, this is the broad-but-shallower option, not a replacement for one.

A note on that cargo parsing, honestly: it reads UNIT_DATA, a JavaScript array embedded in cargo's --timings HTML report. That's cargo's own dashboard data, not a documented or versioned format, verified empirically against cargo 1.96.1, not against any spec, because there isn't one. It could change in a future cargo release without notice. If the cargo golden test starts failing after a rustup update, that's the likely cause. Please open an issue with your cargo --version and, ideally, the new HTML report.

A note on webpack, also honestly: unlike cargo or ninja, webpack's --profile output is duration-only, there's no per-module start offset anywhere in the stats JSON. The adapter stacks modules sequentially by webpack's own module-graph visit order as an approximation, which means the merged timeline will show webpack's work as more serial than it actually was (webpack builds modules in parallel). Flagged rather than guessed at silently; see the doc comment in src/adapters/webpack.rs for the detail.

What it is not

  • Not a profiler that competes with cargo --timings or ninja's log. It consumes them.
  • Not a visualizer. It emits standard Chrome Trace Event Format, Perfetto does the UI.
  • Not for distributed, multi-machine builds yet. Single machine first, because you can't align clocks you don't control.

How it works

Every adapter is a pure function: native tool output in, a list of normalized Spans out, relative to that tool's own start, no wall-clock involved. The wrapper is a thin layer on top: it stamps the real wall-clock instant each tool launched, and a Session (persisted alongside the trace file) offsets each batch of spans onto one shared axis.

pub struct Span {
    pub name: String,       // "serde v1.0", "obj/parser.o": no tool prefix, track already carries it
    pub category: Category, // Compile | Link | Configure | Resolve | Download | Test | Other(String)
    pub status: Status,     // Success | Failed | Skipped | Incomplete
    pub track: String,      // "cargo", "ninja": groups rows in Perfetto
    pub lane: u32,          // sub-row for parallel work within a track
    pub start_us: i64,
    pub dur_us: i64,
    pub args: BTreeMap<String, String>,
}

Category is a closed vocabulary, not a free-form string, on purpose: a golden test only diffs an adapter against itself, so nothing would stop one adapter emitting "compile" and another "Compile", each passing its own test while the "unified" timeline is silently incoherent. The enum is the contract that makes it actually one timeline. Other(String) is the escape hatch for genuinely tool-specific states, e.g. cargo's run-custom-build (running an already-compiled build script) isn't a compile step, so it stays Other rather than being folded into Compile.

See CATEGORIES.md for what each term means, tool-agnostic: the bar a new adapter's steps are held to.

Contributing

Support for a new build system is one file plus one fixture pair, CI diffs the two, no human judgment call required to review it. See CONTRIBUTING.md for the exact shape, plus the second, lighter path if your tool already speaks Chrome Trace Event Format itself.

Bring the build system you use that this doesn't support yet. Your real build trace is coverage nobody else can produce.

Status

Early. Ninja, cargo, webpack, and the generic Chrome Trace Event adapter are golden-tested (the generic one against a hand-authored fixture plus real tsc --generateTrace and Bazel --profile captures); the serializer that turns spans into Chrome Trace events is golden-tested too. Single-machine only. The cargo adapter reads an undocumented internal format (see above); the webpack adapter approximates timing order rather than measuring it (also see above); the generic adapter never reports failure, since the format has no such concept (also see above). All three work today, none is guaranteed to keep working exactly as is.

License

Licensed under either of

at your option.

About

One timeline for your whole build, no matter how many build systems it's made of.

Topics

Resources

Contributing

Stars

Watchers

Forks

Releases

Packages

Contributors

Languages