Logs and tracing in Rust: Fundamentals

Author

Caroline Morton

Date

May 21, 2026

In this blog post I am going to be introducing the concept of logging and how to use tracing in your Rust projects. In this first of three posts, we are going to be covering: what is the purpose of logging, when we should log and how to set up a basic tracing subscriber. This series accompanies my Women in Rust talk on this topic but should stand alone as a reference. A lot of the initial concepts I am going to talk about in this first blog is not specific to Rust but speak more to the philosophy behind knowing exactly what is happening in your code at any particular time. There are some great references on this topic and I will try to reference them as I go along. The second post is about how to use spans and structured fields to add more context to your logs and the third post is about how to ship your logs to a log aggregation tool like Grafana Loki and query them once they are there.

What is a Log?

A log is a record of something that happened in your program. Your program did a thing, and you wrote that thing down somewhere. Logs can be written to the terminal, to a file, to an external service, or to all three. In production, they are often shipped to something like Grafana Loki, Datadog, or Elasticsearch where you can search and filter them. In development, printing to the terminal is usually enough.

Logs are emitted by your running program. They are distinct from compile-time errors and warnings from the Rust compiler. They tell you what is happening at runtime - i.e. when the program is running. They are also distinct from panics and custom generated errors. Panics are when your program encounters an unrecoverable error and crashes. Custom generated errors are when you have some kind of error handling in your code and you return an error instead of panicking. Logs are not errors, they are just information about what is happening in your code. They can be emitted at any time, and they can be about anything. They are often about errors and edge cases, but they can also be about normal, expected activity like “a request was handled” or a user logged in successfully.

Filing cabinets

Logs don’t just record the activity that happened. They often record some metrics around that activity, like how long it took, what the input was, what the output was and so on. This is where the concept of “tracing” comes in. Tracing is the practice of recording not only what happened in your code (i.e. what function fired) but also the context of the event. It allows you to understand the state of your program at the time of the event. This can be particularly important in a programming language like Rust where you often have a lot of async code and you want to understand how different events relate to each other.

When should we be logging?

Since logs can be emitted at any time about anything, I think it is important to have some sort of framework or philosophy about when to log. I have found that the best way to think about this is to ask yourself: “what do I want to know about my code when it is running?” If you can answer that question, then you can start to think about what logs you need to emit in order to get that information. Before we talk about that in more detail, let’s talk about log levels.

Not all events are equally important, and log levels exist to let you express that. The standard levels, from most to least severe, are:

  • ERROR – something has gone wrong and the program cannot do what it was asked to do. A database connection failed. A required file is missing. An API returned a 500.
  • WARN – something is not right, but the program can continue.
  • INFO – a normal, noteworthy event. The server started. A request was handled. A batch job completed.
  • DEBUG – detailed information useful during development.
  • TRACE – very fine-grained information, typically only useful when you are trying to track down a specific issue.

In production, you will typically run at INFO or WARN. In development, DEBUG is usually appropriate. TRACE is for when you are actively hunting a specific problem and need to see everything.

The key thing is this: choose the level deliberately. If everything is logged at INFO, you have no way to turn up the detail when something goes wrong. If everything is at DEBUG, your production logs will be enormous and potentially expensive to store and search. If you are generating thousands of log lines per second, you will pay a lot to store and search those logs, so you want to be careful about what you log and at what level.

In terms of what should we be logging, I think the best way to answer this is to think about two things. This is just my personal take on this by the way and there are loads of online resources out there that talk about other approaches in more detail. I first think about boundaries. In my experience, a lot of bugs happen at boundaries. What do I mean about boundaries? I mean boundaries between different parts of your code or boundaries between the outside world and your code. For example, a boundary could be the line between a database query and your application code. It could also be the line between user input via an API and your application code. It could also be the line between different services in a microservices architecture. These are all places where things can go wrong, and they are all places where it is useful to have logs that tell you what is happening.

Secondly I think about context: What information do I need, in the absence of anything else, to reproduce this issue in my local development environment? If I have a log that tells me exactly what the input was, what the output was, how long it took and any other relevant information, then I can take that log and use it to reproduce the issue locally. I like this approach because it forces you to think about the information from a debugging perspective.

If you want to read more about the philosophy behind understanding what is happening in your production systems, Charity Majors’ Observability: A 3-Year Retrospective is a great place to start. It goes well beyond logging into the broader concept of observability, but the core idea is the same: you need to be able to understand the state of your system from its outputs.

How is this different from print statements?

I think I speak for everyone when I say that we have all used print statements at some point when writing code. If I am totally honest, I still do when I am working locally on occasion. Print statements are a quick and dirty way to see what is happening in your code. We have all done this “println!(“here”)” thing at some point. The issue with print statements in my mind is three fold:

Printing everything

Firstly they are not structured. They are just strings that you have to try to understand. You have to do a lot more work to get in context and understand what is going on.

Secondly they are not easily searchable. You are essentially just printing to the console and if you want to find a specific print statement, you either have to scroll through the console or grep through the output to find it. If you have thousands of print statements, this can be a nightmare. They often will not have timestamps or other metadata that can help you track down the print statement you want, unless you have done extra work to add that in.

Thirdly they are just not useful in production. You do not want to have a bunch of print statements in your production code. They can be noisy, they are not easily stored or searched and importantly they might leak sensitive information - particularly if you are trying to debug a database query or a user input. Logs on the other hand are structured, with metadata and context. They are designed to be easily searchable and can be emitted as discussed above at different levels of severity.

Logs are also designed to be used in production. Because they are structured, it is much easier to filter and search through them. You can set up reliable alerts based on log events - this is only possible because the log is structured and comes in a reliable format.

How is it different from using a debugger?

Let’s talk about debuggers. A debugger is a tool that allows you to step through your code line by line and inspect the state of your program at any given time. They are incredibly useful for understanding how your code is executing and tracking down bugs. They are however not useful in general for production debugging. You usually cannot attach a debugger to a production system and even if you could, depending on how you set up your program, you would probably be slowing down performance significantly. Logs on the other hand are designed to be emitted in production and can be used to track down issues without having to attach a debugger. They can also be used to understand the state of your program at the time of an event, which is something that a debugger cannot do unless you are actively stepping through the code at that moment.

Setting up basic logging in Rust

Now we have covered some of the philosophy behind logging, let’s get into the practicalities of how to set up logging in Rust.

Before we get into tracing, it is worth briefly mentioning the log crate. log is the original logging facade in Rust, maintained by the Rust team. It provides the macros you would expect (info!, warn!, error!, debug!, trace!) and is designed to be paired with a separate logger implementation like env_logger or log4rs that actually does the output. If you are writing a library, log is still the conventional choice because it is the most widely compatible facade and does not tie your users to a specific logging backend. For applications though, and especially for async applications, tracing has become the standard. It is developed by the Tokio team, it supports structured fields and spans natively, and it is built from the ground up for async code. tracing is actually more than just a logging library. It is a diagnostics framework that can do a lot more than emit log lines. It can track the flow of execution through your program using spans, attach structured data to events, and integrate with distributed tracing systems like OpenTelemetry. We are only going to scratch the surface of what it can do in this series, but if you want a deep dive, Jon Gjengset’s Decrusting the tracing crate is an excellent watch. In fact, anything Jon Gjengset does is worth watching, but this one is particularly good for understanding the internals of tracing and how it works under the hood.

The two crates are also compatible: you can bridge tracing events to log consumers (and vice versa) using the tracing-log crate, so adopting tracing does not mean abandoning libraries that use log. For a comprehensive comparison of the different logging crates available in Rust, the Shuttle blog has a good overview here. In this series we are going to focus on tracing.

We are going to be using two crates:

  • the tracing crate is the core crate that provides some useful macros for emitting logs, and something called spans which we are going to get into shortly!
  • the tracing-subscriber crate allows us to collect our emitted events and do something deliberate with them.

First we need to add these dependencies to our Cargo.toml:

[dependencies]
tracing = "0.1"
tracing-subscriber = "0.3"

The simplest possible setup looks like this:

fn main() {
    tracing_subscriber::fmt::init();
    tracing::info!("Hello, world!");
}

If you run this code, you will see a log line in your terminal that looks something like this:

2026-05-21T09:57:22.543938Z  INFO first_setup: Hello, world!

Congratulations, you have made your first log! Let’s briefly talk through what happened here:

  1. We called tracing_subscriber::fmt::init(). This sets up a subscriber that will collect our logs and print them to the terminal in a human readable format.
  2. We called tracing::info!("Hello, world!"). This emits a log at the INFO level with the message “Hello, world!”. The tracing crate provides macros for each log level: error!, warn!, info!, debug!, and trace!.
  3. The subscriber we set up in step 1 collects the log event emitted in step 2 and prints it to the terminal with a timestamp, the log level, and the message.

I hope that you can see that there is some immediate value over using println! here. With no effort at all, we have a structured log with a timestamp and a log level.

Configuring what we see

By default, the subscriber we set up will print all logs at INFO level and above. If we want to see all logs at DEBUG level and above, we can set the environment variable RUST_LOG=debug before running our program:

RUST_LOG=debug cargo run

Let’s see this in action by adding in a debug log to our code:

fn main() {
    tracing_subscriber::fmt::init();
    tracing::info!("Hello, world!");
    tracing::debug!("This is a debug log");
}

If we run this code without setting the RUST_LOG environment variable, we will only see the INFO log. However, if we set RUST_LOG=debug and run the code again, we will see both the INFO and DEBUG logs:

2026-05-21T10:02:04.146463Z  INFO first_setup: Hello, world!
2026-05-21T10:02:04.146515Z DEBUG first_setup: This is a debug log

We can also go up the log levels to only see WARN and above by setting RUST_LOG=warn:

RUST_LOG=warn cargo run

In this case, we would see no output in our current code because we only have INFO and DEBUG logs, but if we had an ERROR log, we would see that.

This is a really simple way to control what logs we see without having to change our code. We can set the log level dynamically at runtime, which is really useful for debugging in production or when we want to get more detail without having to redeploy our code.

If we wanted to set the log level in our code instead of using an environment variable, we could do that by configuring our subscriber. We first need to add a feature to our tracing-subscriber dependency in our Cargo.toml:

[dependencies]
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }

Then we can set the log level in our code like this:

use tracing_subscriber::EnvFilter;

fn main() {
    tracing_subscriber::fmt()
        .with_env_filter(EnvFilter::new("debug"))
        .init();

    tracing::info!("Hello, world!");
    tracing::debug!("This is a debug log");
}

You can see that we could actually create the log level from a configurable environment variable here as well. This can be useful on occasion in production when you want to be able to turn up the log level without having to change your code, although you typically would need to restart your application for this to take effect, so this is not ideal for all use cases. The environment variable approach is usually the most common way to control log levels in production.

Wrapping up

That covers the fundamentals. We have talked about what logs are, when to emit them, how they differ from print statements and debuggers, and how to set up the basic tracing subscriber in a Rust project. In the next post, we will get into spans, structured fields and how to add context that flows through your entire request lifecycle. That is where tracing really starts to differentiate itself from traditional logging.

Know someone who'd like this?

Related Posts

head_brain yellow

Why Use Newtypes? Encoding Domain Knowledge in the Type System

How Rust's newtype pattern lets you encode domain knowledge - valid ranges, clinical thresholds, meaningful operations - directly into the type system, so the compiler enforces what you already know to be true about your data.

Read More
crab orange

Women in Rust 2025

Celebrating another wonderful year of women making strides in the Rust programming community.

Read More
table orange

Serde Rust: Data Serialisation for Data Scientists

Practical Rust patterns for building validated data pipelines with Serde. Custom deserialisers, domain-constrained types, streaming CSV processing, and structured error handling for messy real-world data.

Read More