Design notes on emit's macros

May, 2025

The human-readable message is a key part of a diagnostic event, and so is a cornerstone of a framework's API. Without it, you're just looking at a bag of data. A useful message includes enough detail from the ambient environment to help contextualize you when you see it. In Rust, you might construct such a message with the format!() macro from std::fmt:

let user = "user-123";
let product = "product-456";

let msg = format!("{user} added {product} to their cart");

The original motivations for std::fmt's current template syntax are largely lost to time, but the result is in the same ballpark as similar features in other languages, including Python and C#. The complete syntax is documented here, which we'll revisit a bit later.

Existing diagnostic frameworks use std::fmt for their human-readable messages, and so inherit its template syntax. In log, it looks like this:

let user = "user-123";
let product = "product-456";

log::info!("{user} added {product} to their cart");

and in tracing it looks like this:

let user = "user-123";
let product = "product-456";

tracing::event!(tracing::Level::INFO, "{user} added {product} to their cart");

Relying on std::fmt makes sense; you get the exact same syntax Rust developers are already familiar with, along with all the work to integrate it into the compiler, optimizations, supporting IDE tooling, and you didn't have to do anything to get it.

emit's macros are a departure from how existing frameworks in this space work. emit invents its own syntax using procedural macros instead of deferring to std::fmt. This document outlines why emit's macro syntax was chosen and how it works, so it might serve as a data point for others who want to explore this space more in their own projects.

Why not build on std::fmt?

The output of format!() is a string, like "user-123 added product-456 to their cart". When using format!() (or format_args!(), which can write to other destinations besides a string) you only see this final output. You don't get to see the template "{user} added {product} to their cart" or its arguments user or product. As a framework building on std::fmt you don't know that there's a variable called user or product that the user has plumbed in to their message.

The human-readable message is a key part of a diagnostic event, but it's not enough on its own. If all you have is the string "user-123 added product-456 to their cart" you can't reliably work backwards to figure out who user or product were. You need properties like user and product as structured data to make your diagnostic events useful.

To get around the opaqueness of std::fmt, frameworks relying on it need you to duplicate any values you format into the message, so they can also capture them as structured data. In log, it looks like this:

let user = "user-123";
let product = "product-456";

log::info!(user, product; "{user} added {product} to their cart");

and in tracing it looks like this:

let user = "user-123";
let product = "product-456";

tracing::event!(tracing::Level::INFO, user, product, "{user} added {product} to their cart");

It's not unreasonable for std::fmt to be largely opaque. Its sole job is to build strings, and minimizing its public API gives it options to optimize the way it does this. This is a major papercut in existing APIs though. Ideally, we should be able to capture values we interpolate as structured data without having to duplicate them elsewhere. The template itself without values interpolated also makes a nice "type" for diagnostic events, or names for spans. This is the main idea behind message templates, which is widely used in the .NET ecosystem. This isn't possible with std::fmt though, and I would say it is pretty much out-of-scope for it, so we have to implement something new if we want it.

std::fmt syntax vs new syntax

emit needed to build an alternative to std::fmt to see inside the template and capture values within it as structured data. A major decision point is whether to use the same end-user syntax as std::fmt, or to invent something new. emit largely opted to invent something new. To understand why, let's first explore a few relevant features of std::fmt's syntax in more detail.

std::fmt's capturing syntax

std::fmt interpolates values into its template literal by formatting them in holes with an identifier for the value to interpolate. These holes are denoted by {} within the template:

format!("hello, {x}");
//              --- hole
//       ---------- template literal

The identifier is optional, but we're not looking at positional parameters here. If you want to interpolate a more complex expression, instead of binding a hole's identifier to some local variable, you can assign it a value that's specified after the template as a named parameter:

format!("hello, {x}", x = 42);
//                        -- value
//                    - identifier
//                    ------ named parameter
//               - identifier
//              --- hole
//       ---------- template literal

std::fmt's configuration syntax

Inside a hole, you can also specify flags that can set formatting options for the value. Each flag has its own bespoke, compact syntax. When formatting, std::fmt will pass a formatter with these particular flags set to an implementation of a formatting trait on the interpolated value. Let's take an example:

format!("hello, {x:>08.3}");
//                 ----- flags
//               - identifier
//              --------- hole
//       ---------------- template literal

We're specifying two flags here:

  1. >08: Left padding with the > sigil, followed by the padding character, 0, and the total width to pad to, 8.
  2. .3: Fractional precision with the . sigil, followed by the number of fractional digits, 3.

The choice of formatting trait is also configurable via sigils. Here's another example:

format!("hello, {x:?}");
//                 - type
//               - identifier
//              ----- hole
//       ------------ template literal

The ? sigil uses x's std::fmt::Debug implementation instead of its std::fmt::Display.

In log, a similar syntax is used for its choice of capturing trait on values:

log::info!(x:?; "hello");

or:

log::info!(x:debug; "hello");

In tracing, it looks like this:

tracing::event!(tracing::Level::INFO, ?x, "hello");

emit's capturing syntax

emit adopts some of std::fmt's capturing syntax. It also uses identifiers to label a hole in its template literals, which can refer to a local variable:

emit::info!("hello, {x}");
//                   - identifier
//                  --- hole
//          ------------ template literal

In the above example, x isn't just interpolated into the message. It's also captured as a structured value on the diagnostic event with the key "x".

A major decision point is how to represent named parameters after the template literal. There are two main options here: the named function argument syntax used by std::fmt with ident = expr, or struct field initialization syntax with ident: expr.

emit uses ident: expr struct field initialization syntax:

emit::info!("hello, {x}", x: 42);
//                           -- value
//                        - identifier
//                        ----- named parameter
//                   - identifier
//                  --- hole
//           ---------- template literal

The choice of ident = expr hypothetical named function argument syntax would also have been perfectly defensible here. In the end I opted for ident: expr because the set of properties becomes a datastructure, like a map of { prop_0: value_0, prop_1: value_1, prop_n: value_n }. I think it would also work naturally with some datastructure-like syntax extensions we might consider in the future.

Since emit wholesale adopts struct field initialization syntax, it means you can put arbitrarily complex expressions in template holes:

emit::info!("hello, {x: 42}");

This is both a benefit and a drawback. It's convenient to have a single syntax that works the same everywhere, but it means an emit template literal is not fully parseable without full Rust syntax support. That could become an issue for fully interpreted templates in the future.

emit's configuration syntax

std::fmt's flags are largely irrelevant for capturing structured data the way emit does. If you're capturing an f64, the flag to pad it to 8 characters is meaningless if you're eventually serializing it into a protobuf message as a native binary floating point. You do still need some syntax to configure capturing though. Rust doesn't have reflection, so you can't tell just by looking at a value what the best trait to capture it with is.

Standard Rust supports meta configuration through attributes, like #[cfg]. Since emit uses struct field initialization syntax, it already technically supports attributes on them. For example, in std::fmt syntax you can format a value using its Debug implementation using the ? sigil:

format!("{x:?}");

In emit using attributes, you can do this:

emit::info!("{#[emit::as_debug] x}");

or this:

emit::info!("{x}", #[emit::as_debug] x);

Rust attributes can accept arguments of their own. emit opts to re-use struct field initialization syntax here:

emit::info!("{x}", #[emit::as_debug(inspect: true)] x);

The drawback of attributes is that they're less compact than sigil-based flags. Unlike sigil-based flags though, attributes can naturally support more complex names and configuration, so are less of a syntactic dead-end.

It's also worth noting that #[emit::as_debug] is not magically understood by emit::info!. It's just a regular procedural macro that operates on the output of emit::info!. emit's attribute macros are based on hooks. These are named function calls emitted by previously evaluated macros that the attribute looks for and replaces. As an example, emit::info! converts this:

emit::info!("hello", x);

into something like this:

match ({
    ("x", x.__private_capture_as_default())
}) {
    p0 => {
        // ..
    }
}

The #[emit::as_debug] attribute hooks into __private_capture_as calls it finds in the annotated expression and replaces them with a new one:

match ({
    ("x", x.__private_capture_as_debug())
}) {
    p0 => {
        // ..
    }
}

This is a surprisingly simple and powerful pattern for composing macros. Using standard Rust attributes means emit's capturing is technically user-extensible. There's nothing that #[emit::as_debug] does that an end-user's own attribute macros couldn't.

NOTE: The idea that emit::info! doesn't know anything about #[emit::as_debug] is not strictly true. Since attribute macros on expressions are currently unstable, to make these attributes work on stable compilers, they're expanded internally just before the tokens for emit::info! are emitted. They're still implemented as regular unprivileged attribute macros though and this hack can be removed at some point in the future.

Runtime Interpolation API

emit uses lazy interpolation, just like format_args!() does. To render a template, you give it a set of emit::Props and an implementation of emit::template::Write, which is fed the text and property holes in sequence. This ends up working a lot like JavaScript's tagged templates, and makes it possible to do things like emit_term's type-based coloring:

emit_term colored output

Exploring the source

emit's macros are implemented in the emit_macros crate. They generate code that calls functions defined in the macro_hooks module in emit.

Possible future directions

We may want to introduce some kind of spread syntax for merging sets of properties:

emit::info!(
    "hello, {x}",
    x: 42,
    ...props,
    y: 13,
);

If Rust does start seriously considering some kind of named function parameter syntax using ident = expr, we may want to start accepting that syntax in attributes and control parameters (meta configuration values that appear before the template literal and aren't captured as properties):

emit::info!(
    mdl = "a::b",
    "hello, {x}",
    #[emit::as_debug(inspect = true)]
    x: 42,
);

The syntactic difference here may also make it clearer what's captured as a property, and what's meta configuration.