asopi tech
asopi techIndie Developer
Choosing a Rust Logging Library — tracing, fastrace, fast_log and the Rest

[August 2026 edition]

Choosing a Rust Logging Library — tracing, fastrace, fast_log and the Rest

Published: Aug 9, 2026
Reading time: ~20 min

This article surveys the main Rust logging and tracing crates — log, tracing, slog, fast_log, fastrace, spdlog-rs, and defmt — and the situations each one fits. It covers the layer each crate occupies, a comparison of the major options, the constraints imposed by different runtimes, the connection to OpenTelemetry, and the conditions that make a performance measurement meaningful.

The short version

Here is the mapping from use case to crate. The reasoning behind each row follows in the later sections.

Use caseTypical crates
Tokio-based async serverstracing + tracing-subscriber
Observability across servicestracing + an OpenTelemetry bridge
Low-overhead distributed tracingfastrace
Small CLIs, instrumenting librarieslog + env_logger
Fast local file outputfast_log, spdlog-rs
File rotation and retentionflexi_logger
Bare-metal embeddeddefmt + RTT
Linux kernel modulesThe kernel’s own pr_info! family

What you record and where you send it determines the configuration. Logs are built on the assumption that records survive, though high-frequency distributed tracing works from sampling, and microcontrollers often overwrite a ring buffer or drop frames under contention.

Three layers of logging

Rust’s logging crates fall into distinct layers.

application / library instrumentation


instrumentation API (facade)     log / tracing / fastrace


structure, filtering, spans      tracing-subscriber / slog


output and transport             tracing-appender / flexi_logger / OTel exporter

log, tracing, and fastrace sit at the layer that emits events. tracing-subscriber and slog handle structure, level filtering, and span management, while tracing-appender, an OTel exporter, and flexi_logger push bytes to stderr, a file, or OTLP.

tracing and fast_log are not strictly the same kind of thing either. tracing is a framework that includes an instrumentation API, and its real-world speed depends heavily on the subscriber, formatter, output path, buffering, and OTLP exporter you assemble around it.

What “fast” means also changes with the layer. Each layer has its own bottleneck and its own thing to give up.

LayerMain bottleneckWhat you can shrinkWhat you trade away
Instrumentation APIWork done on the caller per log call or spanEarly return on disabled levels, fewer spansGranularity (sampling)
Structure and filteringCPU and memory for formatting and serializationField count, dropping JSON, compile-time filtersDetail in the output
Output and transportI/O wait and link bandwidthAsync writes, batching, ring buffersLatency, or loss when the queue fills

Trimming the instrumentation layer does nothing for the caller if the output layer still does synchronous I/O. Going async at the output layer leaves the instrumentation cost intact if you are still creating spans in a tight loop. Choosing a “fast logger” means deciding which layer’s cost to cut and which layer absorbs the reduced granularity or the dropped records. The crate-by-crate sections below assume this distinction.

Use cases and constraints

Alongside the layering, the runtime constrains which crates are even available. A kernel has the kernel log buffer; a bare-metal microcontroller has RTT or a UART. Whether a heap and threads exist, and whether the logger can be called from interrupt context, narrows the set further.

Three questions decide the outcome: whether you can record safely in that environment, whether recording stalls the main work, and whether the logs survive a failure.

EnvironmentMain constraintsRealistic outputsTypical candidates
CLIStartup time, dependency size, human-readable terminal outputstderr, stdoutlog + env_logger, tracing-subscriber
Desktop and standalone appsFile management, long uptime, incident analysisTerminal, rotated files, OS logflexi_logger, spdlog-rs, tracing
Async serversCausality across tasks, high load, remote collectionJSON, journald, Collectortracing, plus OpenTelemetry when needed
High-frequency local logsCaller latency, write volumeAsync files, ring buffersfast_log, spdlog-rs, tracing-appender
Linux kernelno_std, kernel APIs, interrupt contextKernel log bufferpr_info! and friends
Bare-metal embeddedSmall memory, link bandwidth, interrupts, no heapRTT, UART, ITM, semihostingdefmt, rtt-target, log + custom backend
RTOS and real-time controlExecution-time bounds, priority inversion, no stallingFixed-size ring buffers, a dedicated transfer taskEnvironment-specific backends, defmt, static event records

OTel support is not the top-level criterion in this table.

The major crates

tracing — one API for events and spans

For Rust servers and async work on Tokio, tracing has become the default candidate. The core idea is that structured Event values and Span values representing a region of work come from the same API, not just plain strings. Relationships survive across async function boundaries, and logs and distributed traces derive from the same instrumentation.

Install the instrumentation API together with the subscriber side.

cargo add tracing tracing-subscriber
instrument.rs
#[tracing::instrument(skip(db))]
async fn get_user(db: &Database, user_id: u64) -> Result<User> {
    tracing::info!(user_id, "loading user");
    db.get(user_id).await
}

Its strength is how well it fits the Tokio ecosystem. Events carry structured fields, spans cross await points, and stacking subscriber layers combines several outputs at once. With those layers, one event can reach the terminal, JSON, a file, and OTel, and adding tracing-log pulls existing log records onto the same path.

The cost is configuration complexity and the price of spans. Assembling a subscriber gets harder to read as layers accumulate, and creating spans in bulk adds real overhead. OTLP conversion, attribute construction, timestamps, and context management are not free, and the dependency count grows compared with a logging-only library. Creating a span on every iteration of a hot loop and attaching many attributes turns context management, clock reads, allocation, and formatting into the bottleneck. Splitting granularity works better: tracing for ordinary business logic, fastrace for extremely hot regions, and metrics for aggregates.

OTel is not the only reason to adopt tracing. The context that survives across async tasks, and the structured diagnostic events themselves, carry their own value. It works under no_std with default-features = false, though it needs alloc and the std-dependent features stay limited. A no_std checkbox alone does not establish fitness for embedded or kernel work; the evaluation has to include a subscriber and a transport that suit the target.

tracing-appender — file output and the OTel bridges

tracing itself stops at emitting events; writing them to a file belongs to tracing-appender. Sending to OTel involves yet another set of crates.

For file output alone, one crate is enough.

cargo add tracing-appender

For OTel, add the SDK, an exporter, and the bridges.

cargo add opentelemetry opentelemetry_sdk opentelemetry-otlp tracing-opentelemetry opentelemetry-appender-tracing

The key piece for file output is the non_blocking writer, which moves formatting and writing onto a dedicated thread. That keeps I/O off the application’s execution path.

non-blocking.rs
let file = tracing_appender::rolling::daily("./logs", "server.log");
let (writer, guard) = tracing_appender::non_blocking(file);

tracing_subscriber::fmt()
    .json()
    .with_writer(writer)
    .init();

Two things deserve attention. Holding WorkerGuard until the application exits is what flushes the final records; drop it early and the last logs disappear. Lossy mode discards records under pressure, so unless something watches the dropped count through ErrorCounter, the loss you traded for speed stays invisible.

The OTel connection splits between tracing-opentelemetry and opentelemetry-appender-tracing. The first maps tracing spans onto OpenTelemetry traces; the second converts tracing::Event into an OTel LogRecord. Adding only the first does not mean you are sending OTel Logs. The OpenTelemetry getting-started guide for Rust also presents tracing as the existing logging API with a bridge in front of OTel.

fastrace — a distributed tracer with minimal overhead

Crates that lead with speed differ by the output they target. fastrace aims at very fast distributed tracing as the successor to TiKV’s minitrace, and the minitrace repository itself points users toward fastrace.

Install the core crate together with the exporter for OTLP.

cargo add fastrace fastrace-opentelemetry

The design keeps the span-creation hot path small and pushes OTel conversion and export into the background, which makes sampling-based setups straightforward. fastrace-opentelemetry connects to OTLP, and fastrace-tracing pulls in libraries already instrumented with tracing.

Published benchmarks show it well ahead of tracing-opentelemetry, but the comparisons do not share conditions. When the point at which OTel objects are created, the presence of async, and how much of drop, batch, and export is included all differ, lining up ns/op figures proves nothing.

Its position is a low-overhead distributed tracer rather than a fast logger. Text logs, file rotation, and operational logging are not what it takes over. In high-frequency systems such as a distributed database, the targets are QUIC request paths, Raft message handling, storage I/O spans, parent-child relationships in distributed queries, and sampled latency analysis.

fast_log and spdlog-rs — fast local file logs

These two suit cases where write throughput to a local file matters more than OTel. Both are compatible with the log facade, so existing log-based code can switch over.

fast_log reduces waiting with a Crossbeam channel and batched writes and is a high-performance async logger behind the log facade.

cargo add fast_log

Splitting work per appender cuts the time the calling thread spends waiting on a write.

It fits bulk text logs written asynchronously to local files, in log-based applications including the RBatis family, or wherever write speed outranks OTel. In a setup centred on OTel integration it is not the first choice, since running fast log files and distributed tracing as separate systems duplicates trace ID correlation and filter configuration.

spdlog-rs is a Rust implementation influenced by C++‘s spdlog, offering compile-time filters, async sinks, rotation, multiple sinks, structured logging, and log compatibility. Developers familiar with spdlog will recognise the design immediately.

cargo add spdlog-rs

As the spdlog-rs documentation shows, the design as a fast local logging library is clear. Its integration with Rust’s async servers and the OTel ecosystem does not reach tracing, so it suits cases that want a standalone fast logger and an spdlog-shaped API.

log and flexi_logger — keeping the facade

log is the most basic logging facade in Rust. It lives outside the standard library, maintained by the rust-lang organisation as an external crate.

Install the log facade together with flexi_logger as the backend. The facade alone does not decide an output, so a backend has to be named.

cargo add log flexi_logger
log-facade.rs
log::info!("server started on {}", address);

A call disabled by a filter compiles down to roughly an integer load, a comparison, and a branch, according to the log documentation. It is widely used and stable as a dependency for libraries, and the backend stays an application-level choice. In exchange for that simplicity it does not handle spans or async context directly, and reaching OTel cleanly needs a bridge. Typed fields and hierarchical context are also weaker than in tracing.

For anyone publishing a general-purpose library, depending on the log API and letting users pick the backend remains sound. When you control the whole system, tracing gives you more.

A reusable library that opens files or initialises a global logger on its own takes that choice away from its users. Sticking to the facade is the baseline, and tracing fits libraries where async spans and structured fields carry meaning in the API. Rather than emitting to both, an application can insert a bridge such as tracing-log where it is needed.

log can be built without requiring the standard library, which makes it usable as a shared facade for no_std libraries. Compiling under no_std and being able to emit safely in that environment are separate questions, though: the actual backend still needs an implementation matched to the device, whether that is a UART, a ring buffer, or a host link.

As a backend, flexi_logger is mature for the log facade and thick on file-handling features. It offers synchronous, asynchronous, and buffered output, rotation by size and by time, compression, and cleanup of old files. Runtime level changes, multiple writers, and tracing interop are all included. The WriteMode documentation notes that Direct output is the slowest and that buffering lowers I/O pressure. It is not OTel-native, but for embedded devices or single processes that need local logs managed reliably, its file handling is more complete than tracing-appender.

slog and the simple loggers — existing systems and development

slog implemented structured logging in Rust early on. It offers key-value structured records, composable drains, slog-async, and a wide set of surrounding crates including JSON and syslog.

Pair the core crate with the async drain.

cargo add slog slog-async

It is mature, though new projects built around Tokio and OTel, such as multithreaded server services, usually reach for tracing first. When an existing system runs steadily on slog, performance alone is a thin reason for a full migration.

The simple loggers are lightweight options for development and CLIs. The most convenient, env_logger, needs only two crates alongside the facade.

cargo add log env_logger

env_logger targets CLIs, development, and simple servers; log4rs brings configuration files and appenders close to Java’s log4j. fern dispatches flexibly from code, simple_logger is minimal, and pretty_env_logger prints readably for developers. None of them lead on raw performance or OTel integration, and env_logger in particular is simple at the cost of not being designed for high-frequency production file output.

What CLI logs serve is diagnostics for the user, not distributed tracing. Sending ordinary results to stdout and warnings and diagnostics to stderr matters more, along with letting --verbose or RUST_LOG change the detail level.

cli-logging.rs
fn main() {
    env_logger::init();
    log::debug!(target: "scanner", "scanning input");
    log::info!("completed");
}

env_logger writes to stderr by default and filters per module with expressions like RUST_LOG=tool=debug. It is easy to configure and avoids tying application code tightly to one output implementation.

When a CLI has many stages or parallel tasks and you want to follow a region of work, tracing-subscriber earns its place. If the program only prints a handful of messages without spans, adding tracing buys little. Settling exit codes, stderr usage, error chains, and the interaction of --quiet and --verbose pays off before a structured logging stack does.

Kernel macros — pr_info! and the log buffer

Inside an OS kernel the assumptions differ from ordinary Rust applications. There is no stderr and no normal file I/O, and some contexts forbid allocation or sleeping. Rust code in the Linux kernel uses the logging facilities the kernel provides, such as pr_info!, pr_warn!, pr_err!, and dev_info!, and the output integrates with existing kernel log levels, device information, rate limiting, and the log buffer.

Writing your own OS or kernel in Rust puts serial output during early boot, per-CPU ring buffers, interrupt and NMI safety, lock reentrancy, and flushing on panic ahead of any general-purpose logger comparison. A logger that is fast in normal operation is worthless if it cannot run once the scheduler or the allocator is broken, which is exactly when the failure log matters most.

Speed in a kernel is measured by how short the interrupt-disabled window is, how little lock contention appears, whether it avoids recursive failures, and whether the buffer can be recovered after a fault.

defmt — instrumentation and transport for embedded

On a bare-metal microcontroller, pushing a formatted string out of a UART every time costs CPU, binary size, and link bandwidth. defmt moves the format information to the host side and sends compact binary frames from the device. Its advantage in small, low-bandwidth environments comes from that design reducing the work done on the target, not from raw implementation speed.

cargo add defmt defmt-rtt

The usual transport is RTT. defmt-rtt connects to RTT through a debug probe; in blocking mode it rarely loses logs, but if the host disconnects and the buffer fills, execution can halt. Choosing non-blocking or drop-on-contention protects the control path and accepts missing logs in return.

Points worth checking in an embedded setup:

  • Whether it can be called from an interrupt handler
  • Whether the logger allocates internally or holds a long critical section
  • Whether it halts or discards when the debugger disconnects
  • Whether a single frame fits in the ring buffer
  • How much can still be flushed after a panic or HardFault
  • Whether Debug and Trace can be compiled out of release builds

RTT is a debug-time path, and a shipping product pairs it with something else for service records: a fixed-size ring buffer in RAM or flash, a crash record that survives a reboot, and a dedicated task that ships data to a host. That arrangement raises the question of what happens past capacity. Some implementations, like Memfault’s, overwrite the oldest logs once the RAM buffer fills and report the number of dropped messages on read. In real-time control, an event trace holding a fixed ID, a timestamp, and a few short arguments often fits better than text logs.

Crate comparison

CrateLayerCharacteristicsOTel integrationMain use
logInstrumentation facadeMinimal cost when disabledVia a bridgeInstrumenting libraries
tracingInstrumentation API + frameworkEvents/spans, layered subscriberstracing-opentelemetry and othersServers generally, Tokio
tracing-subscriberStructure, filtering, spansStacked layers, fmt and JSON outputWorks as a layerThe subscriber side of tracing
tracing-appenderOutputAsync writes on a dedicated threadFile output
fastraceInstrumentation API (tracing only)Minimal hot path, built for samplingfastrace-opentelemetryDistributed tracing of hot paths
fast_loglog backendChannel plus batched writesSeparate systemFast local file logs
spdlog-rsStandalone loggerCompile-time filters, async sinksLimitedFast local logging
flexi_loggerlog backendRotation, compression, runtime level changesNot nativeFile-heavy operation
slogInstrumentation + structureKey-value, drainsMaintaining existing slog systems
env_loggerlog backendEnv-var filtering, minimal setupCLIs, development
log4rslog backendlog4j-style config files and appendersConfig-driven file output
fernlog backendFlexible dispatch from codeFine-grained output routing
simple_logger / pretty_env_loggerlog backendMinimal / readable terminal outputDevelopment
defmtInstrumentation + transport (embedded)Formatting on the host, RTT transportBare-metal embedded
Kernel macrosIn-kernel instrumentationpr_info! family, integrated with the log bufferLinux kernel modules

Combinations by requirement

For server applications the first candidate today is the tracing family. fastrace remains an option when minimising instrumentation overhead comes first, fast_log when local file throughput does, and flexi_logger when preserving the existing log ecosystem matters. Move the runtime to a kernel or a microcontroller and the candidate list itself changes.

RequirementSuggested setupWhy
Mainstream, long-lived, OTel-readytracing + tracing-subscriber + OpenTelemetryStrong async context and layering
Unified logs, traces, and metricstracing + tracing-opentelemetry + opentelemetry-appender-tracingCarries trace IDs across services to a backend
Distributed tracing at minimal overheadfastrace + fastrace-opentelemetrySpecialised for high-frequency span recording
Fast local file logsfast_logAsync and batched configurations available
log API compatibility with rotationflexi_loggerThorough file generation management
A fast logger close to C++ spdlogspdlog-rsSync and async loggers with multiple sinks
Small CLIslog + env_loggerLightweight, easy stderr and RUST_LOG handling
Complex CLIs, parallel jobstracing + tracing-subscriberFollows regions of work with structured fields
Published librarieslog or tracingLeaves the output choice to the consumer
Rich structured loggingslog, though new work should prefer tracingMature key-value and drain model
Linux kernel modulesThe kernel’s pr_* and dev_*Follows kernel logging and its constraints
Bare-metal developmentdefmt with RTT or similarLimits transfer volume and on-target formatting
Field failure records in productsA dedicated fixed-size ring bufferMatches reboots, disconnects, and real-time limits

To keep one instrumentation model across the server side, a two-tier arrangement works well. Put tracing in the standard position and switch only the extremely hot regions to fastrace, custom metrics, and sampled events. Separating ordinary diagnostics from hot-path telemetry is safer than replacing everything with a fast logger.

Two paths into OTel

OpenTelemetry becomes the premise when trace IDs have to survive across processes and services and logs, traces, and metrics go to a Collector or an observability backend. For a single CLI, an offline desktop application, a kernel, or a microcontroller in production, OTel support is rarely a requirement. Where it does appear is in E2E testing, where device-side work joins the service-side trace, and in edge computing and similar platforms built around OTel.

Even when you adopt it, avoid sending synchronously from the application’s hot path, and settle batching, queue limits, retries, backpressure while the Collector is down, and the flush deadline at shutdown. How the application behaves when the external backend fails matters more than whether OTel is supported at all.

With OTel as a given, splitting instrumentation into two entry points is reasonable. Ordinary tracing events and spans flow one way from application and database instrumentation, while fastrace spans from hot regions flow the other. The first branches to local fmt or JSON output and to the OTel Logs and Traces bridges; the second passes through sampling and batching. Both exits meet at a Collector, embedded or external.

application / database instrumentation
  ├─ tracing events and spans
  │    ├─ tracing-subscriber::fmt ──→ local diagnostics (fmt / JSON)
  │    ├─ tracing-opentelemetry ───→ OTel Trace
  │    └─ opentelemetry-appender-tracing ─→ OTel Logs
  └─ fastrace spans (very hot regions)
       └─ sampling and batching


      opentelemetry-otlp ──→ Collector (embedded or external)

The crate assignment follows from that. Public APIs and ordinary events go through tracing; local diagnostics land in tracing-subscriber::fmt, and async file output in tracing-appender::non_blocking. tracing-opentelemetry converts OTel traces and opentelemetry-appender-tracing converts OTel logs, with opentelemetry-otlp doing the sending. Only the places that need it switch to fastrace, and metrics take a dedicated path through opentelemetry or Prometheus.

What to measure

Comparing loggers on “lines per second” alone leaves too much out. A useful comparison measures these separately.

TestWhat it measuresHow to measure
Disabled levelCost of a filtered-out callCriterion / Divan
Binary size and static dataIncrement after linkingcargo bloat, cargo size
Initialisation timeCost before the first outputhyperfine
Synchronous outputTime the caller waits on I/OCriterion writing to a tempfile
Async, blockingLatency when the queue is fullLoad generation plus p99
Async, lossyEffective throughput including losserror_counter().dropped_lines()
JSON formattingCPU and memory for serializationCriterion, dhat
Span creationContext and attribute managementCriterion, with and without spans
Interrupt-disabled time, lock contentionTime the main work is stalledDWT cycle counter
UART and RTT throughputCeiling of the transportBytes per second received on the host
Rotation and disk limitsConsumption over long uptimeLong runs plus disk usage records
OTLP batchingCost including the SDK and exporterLoad tests against a mock Collector
Collector outageBackpressure and memory growthStop the Collector and watch RSS
ShutdownFlush time and lost recordsElapsed time to exit and lines written
Panic and abnormal exitRecovery rate for logsLines written after a deliberate panic

Measuring caller-side cost with Criterion

For the instrumentation layer, use Criterion, the de facto benchmark harness in the Rust community. It handles warmup and statistics and flags differences between runs as regressions. divan, which offers a simpler API and allocation measurement, is another option.

cargo add --dev criterion --features html_reports

Measure the disabled-level cost by calling without a subscriber installed. Without black_box, the optimiser removes the call entirely.

bench-disabled.rs
use criterion::{black_box, criterion_group, criterion_main, Criterion};

fn disabled_level(c: &mut Criterion) {
    c.bench_function("info! (filtered out)", |b| {
        b.iter(|| tracing::info!(value = black_box(42), "message"));
    });
}

criterion_group!(benches, disabled_level);
criterion_main!(benches);

Span cost shows up as the difference between the same work with and without a span. For JSON, run fmt().json() and fmt() as separate benchmarks to isolate the formatting difference.

Pinning down loss and latency with counters

Throughput figures alone hide how many lines lossy mode threw away. tracing-appender exposes a counter for dropped lines, so record it alongside the benchmark.

measure-drops.rs
let (writer, _guard) = tracing_appender::non_blocking::NonBlockingBuilder::default()
    .lossy(true)
    .finish(std::io::sink());
let counter = writer.error_counter();

// read the drop count after applying load
println!("dropped: {}", counter.dropped_lines());

dropped_lines() always returns zero outside lossy mode, so running blocking and lossy under the same load puts the latency you pay and the records you lose side by side as numbers. Flush time at shutdown comes from the elapsed time between dropping WorkerGuard and process exit, together with the number of lines actually written.

On embedded targets, where a host clock is unavailable, the DWT cycle counter gives the cycle delta around the instrumented region. Read cortex_m::peripheral::DWT::cycle_count() before and after to check the length of an interrupt-disabled window in cycles. For binary size, cargo bloat --release breaks the increment down by origin.

Comparing an async lossy logger against a synchronous one on throughput alone makes the former look faster purely because it is discarding records. What matters in production is not the average but p99 and p999 latency, the drop count, queue occupancy, memory use, and flush time at shutdown. In the same way, ranking defmt’s transfer volume against JSON’s expressiveness, or a kernel ring buffer against an OTLP exporter, does not produce a meaningful comparison.

Wrapping up

tracing is strong for async servers and structured tracking of work, and OpenTelemetry carries that information out to multiple services. In CLIs the simplicity of log and env_logger decides it; in standalone applications the lifecycle of log files; in kernels the execution context and safety during failures; in embedded work bandwidth, binary size, and whether the transport blocks.

Setting targets first makes the comparison converge. On a server that means deciding the p99 you will accept for a log call in microseconds, how many dropped lines per second lossy mode may discard, and how much RSS growth is tolerable while the Collector is down. On embedded it means a cycle budget for the interrupt-disabled window and a size budget in kilobytes for the logging machinery. Without those numbers, lining up candidates leaves only an impression of fast and slow.

Measure in the same units as the targets. Caller-side cost comes from Criterion, loss from dropped_lines(), binary growth from cargo bloat, and embedded window length from the DWT cycle counter. On the server side, put tracing in the baseline position and line up fastrace, fast_log, and spdlog-rs under the same output conditions and the same loss guarantees. Read each number knowing whether it came from a disabled level, synchronous output, async lossy mode, span creation, or OTLP.

A logging setup has choices at all three layers — instrumentation, structure, and output — and the runtime decides which of them are available. Starting from crate names obscures that mapping, so it is easier to fix what you want to record and where it goes, then fill in candidates layer by layer. With targets and measurement methods settled as well, you can confirm in numbers whether the resulting configuration meets the requirement.

References