Initialize hotpath Profiling
Set up hotpath profiling in the current Rust project. The setup is fully feature-gated: zero compile-time and runtime overhead unless the hotpath feature is explicitly enabled. All macros are noops when the feature is off, so no cfg_attr wrapping is needed.
Steps
1. Inspect the project
- Find the binary crate(s) and the
mainfunction. If there is nomainyou control (e.g. a library or a test harness), use theHotpathGuardBuilderAPI instead of#[hotpath::main](see step 3). - Detect the async runtime (
tokio,smol, none) and which instrumentable primitives the code uses: channels (tokio::sync::mpsc/oneshot,std::sync::mpsc,crossbeam_channel,flume,async-channel,futures_channel),MutexandRwLock(std/parking_lot/tokio/async-lock), futures streams, sqlx, diesel, reqwest clients (async only; note which reqwest major - 0.12 or 0.13), axum 0.8 routers (find where theRouteris finished and passed toaxum::serve), byte-level I/O values implementingstd::io::Read/Writeortokio::io::AsyncRead/AsyncWrite(files, sockets, compression codecs).
2. Add the dependency and feature passthrough
In the target crate's Cargo.toml:
[dependencies]
hotpath = "0.25"
[features]
hotpath = ["hotpath/hotpath"]
hotpath-alloc = ["hotpath/hotpath-alloc"]
hotpath-prometheus = ["hotpath/hotpath-prometheus"]
Enable extra hotpath cargo features on the dependency based on what the project uses:
tokio- fortokio::syncchannel instrumentation, asyncio!traits (AsyncRead/AsyncWrite), andhotpath::tokio_runtime!()metrics:hotpath = { version = "0.25", features = ["tokio"] }crossbeam- forcrossbeam_channelinstrumentationfutures- forfutures_channelinstrumentationflume- forflumechannel instrumentationasync-channel- forasync-channelinstrumentationparking_lot- forparking_lotRwLock/Mutexinstrumentationasync-lock- forasync-lockRwLock/Mutexinstrumentationsqlx- for SQL query profiling viahotpath::sqlx_tracing_layer()diesel- for SQL query profiling viahotpath::instrument_diesel_sql()reqwest-0-12/reqwest-0-13- for HTTP request profiling viahotpath::http!(client); pick the feature matching the project's reqwest major versionaxum-0-8- for server-side response time profiling per axum 0.8 route viahotpath::axum!(router)
If the crate already has a [features] section, merge the entries.
3. Instrument main
#[hotpath::main] initializes the profiler and prints the report when main exits. With tokio, #[tokio::main] must come FIRST (above):
#[tokio::main]
#[hotpath::main]
async fn main() {
// ...
}
Optional parameters: #[hotpath::main(percentiles = [50, 95, 99.9], format = "json", limit = 20)]. Defaults are fine for a first setup; don't add parameters unless asked.
If attribute placement on main is not possible, build a guard programmatically (report prints when the guard drops):
let _hotpath = hotpath::HotpathGuardBuilder::new("main")
.build();
Unlike #[hotpath::main], the builder does not install the allocation-tracking allocator, so with the guard builder also declare it as a static (required for hotpath-alloc to report anything):
#[global_allocator]
static GLOBAL: hotpath::CountingAllocator = hotpath::CountingAllocator::new();
No feature gating needed: CountingAllocator is a no-op pass-through when hotpath-alloc (or hotpath) is disabled. Never combine this static with #[hotpath::main] - the macro emits its own #[global_allocator] under hotpath-alloc and the build fails with a duplicate; with the macro, pass allocator = ... instead.
If the project already declares a custom global allocator (jemalloc, mimalloc, ...):
- With the guard builder: replace the existing static's type with the wrapper, e.g.
static GLOBAL: hotpath::CountingAllocator<tikv_jemallocator::Jemalloc> = hotpath::CountingAllocator::with(tikv_jemallocator::Jemalloc);. The program keeps running on the custom allocator in all builds; tracking activates only underhotpath-alloc. - With
#[hotpath::main]: add theallocator = tikv_jemallocator::Jemallocparameter and gate the project's own#[global_allocator]static behind#[cfg(not(feature = "hotpath-alloc"))]so the two never coexist.
4. Instrument functions
- Prefer
#[hotpath::measure_all]on inline modules andimplblocks - it instruments every function inside. Exclude noisy or trivial functions with#[hotpath::skip]. - Use
#[hotpath::measure]on individual functions, both sync and async. - Useful parameters:
log = true(log return values, requiresDebug),label = "name"(custom identifier, duplicates panic at runtime). hotpath::measure_block!("label", { ... })for ad-hoc code blocks.
Start with hot paths: request handlers, worker loops, parsing/serialization, IO-heavy functions. Don't instrument one-line getters.
Async functions are measured runtime-agnostically, and under hotpath-alloc their allocations are tracked too (per-poll attribution via an async bridge), so no special handling is needed.
Don't try to instrument everything, use up to ~5 hotpath::measure_all annotations and up to 30 hotpath::measure. Goal of the initial setup is not to measure all functions, but to get the initial working instrumentation in place.
5. Wrap data-flow primitives
Wrap at the creation site; all wrappers accept optional label = "name" and (where noted) log = true:
// Channels (tokio mpsc/oneshot, std mpsc, crossbeam, flume, async-channel, futures_channel)
let (tx, rx) = hotpath::channel!(mpsc::channel::<String>(100), label = "jobs", log = true);
// bounded std sync_channel and futures_channel mpsc need capacity = N (must match):
let (tx, rx) = hotpath::channel!(futures_channel::mpsc::channel::<String>(10), capacity = 10);
// Locks (wait time + held time)
let mutex = hotpath::mutex!(std::sync::Mutex::new(state), label = "state");
let lock = hotpath::rw_lock!(tokio::sync::RwLock::new(config), label = "config");
// Streams and futures
let s = hotpath::stream!(stream::iter(1..=10), label = "events");
let result = hotpath::future!(some_async_operation(), label = "fetch").await;
// Byte-level I/O (std Read/Write; AsyncRead/AsyncWrite require the `tokio` feature) -
// per-operation counts, bytes, transfer rate, durations, and errors
let mut file = hotpath::io!(std::fs::File::open("data.bin")?, label = "data-file");
let stream = hotpath::io!(tokio::net::TcpStream::connect(addr).await?, label = "conn");
Call-site aggregation (channel!, stream!, io!):
- By default all instances created at one call site (with the same message/item type) aggregate into a single report entry: counts, rates, and histograms are summed across instances, and an
Instcolumn reports how many instances the entry aggregates. Profiler state stays bounded by the number of call sites, so this is safe for unbounded instance churn (a channel or stream per handled request, anio!wrapper per accepted connection). - Aggregated channel/stream entries show
-for state (instances open and close independently); single-instance entries keep their exact state. - Disable aggregation with
iter = true(e.g.hotpath::channel!(mpsc::channel::<u32>(8), iter = true)): every instance gets its own row (label,label-2,label-3, ...) with individual counts and rates - useful for one row per spawned worker. State then grows with the number of instances ever created, so avoid it for unbounded churn.
io! notes:
- Wrapping the underlying resource (file, socket) measures actual resource I/O; wrapping a
BufReader/BufWritermeasures application-facing buffered operations. - The wrapper derefs to the wrapped value, so call sites don't change. For consuming methods (e.g. a codec's
finish(self)), unwrap first withhotpath::io_unwrap(x)- identity when profiling is off, so call sites compile identically in both modes. - Wrap the side where the work happens, or the reported rate is meaningless. Deferring writers (e.g.
brotli::CompressorWriter) accept cheap bufferedwritecalls and compress at finalization, outside any measured op; prefer read-side codec adapters (flate2::read::GzEncoder,brotli::CompressorReader,zstd::stream::read::Encoder), which compress inside instrumentedreadcalls and report compressed bytes out.
Wrapped locks/channels are drop-in: the wrappers expose the same API, so call sites don't change. If passing them across function boundaries requires type-signature changes, note that to the user rather than rewriting half the codebase silently.
Wrapper macros return types prefixed with hotpath::wrap, make sure to update type signatures where needed. Explain to user that these types are no-op unless hotpath feature is enabled.
Apply log = true only if Debug is already implemented.
6. Optional extras (only when relevant)
- Tokio runtime metrics: call
hotpath::tokio_runtime!();once at startup (requirestokiofeature). - SQL profiling (sqlx 0.8/0.9): add the layer to the tracing subscriber once -
tracing_subscriber::registry().with(hotpath::sqlx_tracing_layer()).init();(requiressqlxfeature). Don't filter out thesqlx::querytarget. - SQL profiling (diesel): call
hotpath::instrument_diesel_sql();once at startup (requiresdieselfeature). - HTTP profiling (reqwest, async client only): wrap the client once at creation -
let client = hotpath::http!(reqwest::Client::new());(requiresreqwest-0-12orreqwest-0-13feature). Common request-building methods work as usual; requests are reported per normalized endpoint (GET host/pathwith id-like segments collapsed to{id}) with an error count. Optionallabel = "name"prefixes endpoint keys - use it when the app has several clients. Where the client is stored in a struct or named in signatures, usehotpath::wrap::reqwest::Client(it resolves to the rawreqwest::Clientwhen the feature is off); likewisehotpath::wrap::reqwest::Errorfor code that names thesend()/execute()error type. Both error types supportwithout_url(); response methods still return rawreqwest::Error. When both reqwest versions are enabled, use the versionedwrap::reqwest_012path for 0.12 clients and errors. If the app already uses reqwest-middleware, attachhotpath::ReqwestHttpMiddleware::new()to its existing stack instead of the macro. - axum server profiling (axum 0.8 only): wrap the finished router once -
let app = hotpath::axum!(Router::new().route(..).route(..));(requiresaxum-0-8feature). It expands torouter.layer(hotpath::AxumLayer::new()), so it must come after the last.route(..)/.fallback(..)/.nest(..)call - routes added later are not profiled. With the feature off the macro returns the router unchanged, so the line stays unconditional. Requests are reported in aserversection per matched route template (GET /users/{id}, nested routers include the nest prefix; fallback/nest_servicerequests fall back to the raw path with id-like segments collapsed to{id}) with request count, latency percentiles, and separate 4xx/5xx counts. If the app already stacks tower layers on the router, add.layer(hotpath::AxumLayer::new())where it fits instead of the macro: layers added later run outside earlier ones, so placing hotpath first times only the handler, placing it last times the whole middleware stack (auth, compression, ...). Measurement covers the request until the response head is produced, so streaming/SSE bodies are not included; work detached viatokio::spawn/spawn_blockingcounts only if the handler awaits it.- Route scoping: with the layer installed, SQL queries (sqlx/diesel) and outbound reqwest requests issued while a handler runs gain a
Routecolumn next toSource, keyed per route, so the same query under two routes appears as two rows - dividing a row's calls by that route's request count surfaces N+1 patterns. Tell the user this is on by default and can be disabled withHotpathGuardBuilder::route_scope(false)orHOTPATH_ROUTE_SCOPE=0. Caveat: async sqlx sqlite runs statements on its own worker thread, so it gets neither source nor route (PostgreSQL/MySQL sqlx, diesel, and toasty attribute normally). - Cap the number of routes shown with
.server_limit(n)/HOTPATH_SERVER_LIMIT(default unlimited). Per-request allocations are not tracked.
- Route scoping: with the layer installed, SQL queries (sqlx/diesel) and outbound reqwest requests issued while a handler runs gain a
7. Verify
cargo check # feature off: must still compile, zero overhead
cargo check --features hotpath # feature on
cargo run --features hotpath # prints report on exit
Optionally verify alloc mode: cargo run --features 'hotpath,hotpath-alloc'.
Report what was instrumented and mention next steps: the live TUI (cargo install hotpath --features tui, then hotpath console while the app runs - metrics server listens on port 6770 by default), and HOTPATH_OUTPUT_FORMAT=json for machine-readable output. Report sections need no configuration: the default HOTPATH_REPORT=auto shows function and thread sections plus every instrumented section with data (channels, streams, futures, rw_locks, mutexes, sql, ...). Mention HOTPATH_REPORT only if the user wants to restrict output: an exact comma-separated list (e.g. HOTPATH_REPORT=functions-timing,sql), all, or auto with exclusions like HOTPATH_REPORT=auto,-threads / HOTPATH_REPORT=-threads.
Also explain to the user that hotpath is safe to keep as a regular (non-optional) dependency: unless the hotpath feature is enabled, it compiles zero third-party dependencies (only the hotpath crates themselves), and all macros expand to noops, so there is no compile-time bloat and no runtime overhead.
Rules
- Never enable the
hotpathfeature by default (default = []); profiling must stay opt-in. - Keep edits minimal: dependency, main, and a sensible starting set of instrumented functions/primitives. Expand coverage only when the user asks.