https://github.com/yawn/tracing-wide
Log and catalogue wide events with tracing
https://github.com/yawn/tracing-wide
logging tracing
Last synced: 7 days ago
JSON representation
Log and catalogue wide events with tracing
- Host: GitHub
- URL: https://github.com/yawn/tracing-wide
- Owner: yawn
- License: apache-2.0
- Created: 2026-06-09T14:51:15.000Z (21 days ago)
- Default Branch: main
- Last Pushed: 2026-06-14T14:45:00.000Z (16 days ago)
- Last Synced: 2026-06-14T16:45:30.638Z (16 days ago)
- Topics: logging, tracing
- Language: Rust
- Homepage:
- Size: 73.2 KB
- Stars: 0
- Watchers: 0
- Forks: 0
- Open Issues: 3
-
Metadata Files:
- Readme: README.md
- Changelog: CHANGELOG.md
- License: LICENSE-APACHE
Awesome Lists containing this project
README
# `tracing-wide`
[](https://github.com/yawn/tracing-wide/actions/workflows/ci.yml)
> [!CAUTION]
> This is *NOT* an official [tokio](https://tokio.rs) / [tokio-tracing](https://github.com/tokio-rs/tracing) product or associated crate.
Wide structured events for tokio [`tracing`](https://docs.rs/tracing): one struct
per event, defined once, carrying every observability-relevant field for that
site. The message text stays static; all variance lives in typed fields. The
core is `no_std` and runs in WASM — everything else is opt-in behind features.
```rust,ignore
use serde::Serialize;
use tracing_wide::{event, message};
/// A request finished handling. // doc comment, recorded in the catalogue
#[message(
msg = "request completed", // static text, the catalogue's unique key
level = info, // severity (default: info)
tags = ["analytics", "api"], // routing intent for potential subscribers
owner = "platform", // arbitrary metadata, recorded in the catalogue
)]
#[derive(Default, Serialize)] // Serialize: opt-in, per type
struct RequestCompleted {
/// Route template, e.g. `/users/:id`. // field docs, recorded in the catalogue
route: &'static str, // required: set at the event! site
#[field(unit = "ms")] // arbitrary field metadata, recorded in the catalogue
duration: u64,
region: Option, // Option: may fill from the ambient span
}
/// A payment was captured.
#[message(msg = "payment captured", level = warn, tags = ["analytics", "persist"])]
#[derive(Serialize)]
struct PaymentCaptured {
amount_cents: u64,
currency: &'static str,
#[deprecated = "use amount_cents"] // recorded in the catalogue; warns at construction
amount: Option,
}
// Emit: required fields are checked here; an unset Option stays None
// (and may fill from the surrounding span — see Instrument (ambient) autocapture).
event!(RequestCompleted { route: "/users/:id", duration: 12, ..Default::default() });
```
See [`examples/`](tracing-wide/examples) for more examples.
## Emit — `event!`
`event!(RequestCompleted { .. })` builds the struct, fills any unset `Option`
fields from the ambient span, fans it out to registered subscribers, then records
it to `tracing` at the type's level. `#[message]` is the only supported way to make
a type emittable (the trait is pseudo-sealed); a field named `message` is rejected
(tracing reserves it for the event text) and generics aren't allowed (a message is
a concrete `'static` type).
For spans, use stock `tracing::instrument` — tracing-wide ships no span macro.
## Catalogue — every message a system can emit *(`catalogue`)*
A catalogue enables stakeholder engagement: a serialized catalogue lets non-technical
stakeholders see every message a system emits, so they can reason about it and
build downstream recipients — analytics, BI, alerts — against a stable contract.
`#[message]` auto-registers one descriptor per type; `catalogue::all()` walks
them. Each descriptor carries everything from the definitions above — `msg`,
`level`, `tags`, `origin`, doc comments, `#[field(unit = ...)]` and other
metadata, and deprecations (field- or struct-level).
With the `serde` feature the descriptors `Serialize`, so a build step can dump a
manifest — the `catalogue-serde` example emits YAML keyed by `msg`. With the
`facet` feature they derive `Facet`, so the same manifest can be produced
through any facet serializer — the `catalogue-facet` example emits the *same* YAML
via `facet-yaml`. `level`, `origin`, and the `meta` maps render identically either
way, so the two are interchangeable.
- `msg` is the unique join key; `catalogue::duplicates()` flags collisions (run
it in a test).
- Link-accurate: the catalogue holds exactly the messages of the crates linked
into the binary. Registration survives dead-code elimination, so it may
over-report what actually fires but never under-reports.
### Origin — where a message is defined
`origin()` is automatic provenance captured by `#[message]`: crate, module, file,
line, column — no input, can't drift. It's object-safe, so a subscriber can
attribute or route a `&dyn Message` by its originating crate without a downcast,
and it has a compact `Display`: `mycrate src/lib.rs:12:1`.
## Instrument (ambient) autocapture *(`instrument`)*
`RequestCompleted::region` is an `Option`, so when left unset at the `event!` site
it fills at emit time from the surrounding span scope — by field name, innermost
span wins, across crate boundaries. Required (bare) fields never do this, and a
field already `Some` is never overwritten.
Stock `tracing::instrument` is the contribution surface — it records function
arguments and `fields(..)` by default; install `instrument::layer()` on a
`tracing_subscriber::registry()` stack to capture them. With no layer or no
current span the lookup simply misses; `event!` never fails on ambient state.
> Name-based by design: an `Option` field fills from *any* same-named span field
> in scope, including spans the message author doesn't own — name fields
> deliberately (`token`, `id`, `user` collide easily). The layer also retains
> every span field for the span's lifetime; keep that in mind for sensitive data.
## Subscribe to wide events *(`subscriber`)*
With the `subscriber` feature, `event!` hands each message to every registered
subscriber as a typed `&dyn Message` *before* the tracing handoff — useful for
storing events in a database or forwarding to another subsystem (especially in
frontends). A sink can stay generic through the object-safe accessors, or
downcast to the concrete type via `m.as_any()`.
```rust,ignore
fn on_message(&self, m: &dyn Message) {
if let Some(body) = m.as_serialize() { // Some iff the type derives Serialize
// body is a &dyn erased_serde::Serialize — serialize it with any format
}
}
```
Serialization is opt-in per type (`#[derive(Serialize)]`, as on both messages
above) and never a bound on `Message`; a type that doesn't derive it yields
`None`. Register sinks into `Subscribers`, then `install()` once — set-once, like
tracing's global default.
> A panicking `on_message` panics the `event!` call site — same posture as
> tracing itself, but a logging sink can crash the app.
### Routing on message types via Tags
`RequestCompleted` is tagged `analytics` + `api`; `PaymentCaptured`, `analytics`
+ `persist`. Tags are *where to send*, not *where from*: a subscriber routes on
them with no downcast, and one message can fan out to several subsystems.
```rust,ignore
fn on_message(&self, m: &dyn Message) {
if m.tags().contains(&"analytics") { /* forward */ }
}
```
Tags are sorted, deduped and lowercased at compile time, and lowercase is
enforced. Crate-prefix namespacing is unnecessary — `origin()` already carries
the crate.
### Routing on messages data via reflect *(`facet`)*
When the decision lives in the *data* (only the `eu` region, only
payments over a threshold), it's per-event and can't be a tag. With the `facet`
feature, `as_facet()` hands back a `facet::Peek` over the live body, so a
subscriber reads a field *by name* and filters on its value — no downcast, no
per-type match.
```rust,ignore
fn on_message(&self, m: &dyn Message) {
let Some(peek) = m.as_facet() else { return }; // Some iff the type derives Facet
let Ok(body) = peek.into_struct() else { return };
// pull one field out by name and filter on its live value
if body.field_by_name("region").ok().and_then(|f| f.as_str()) == Some("eu") {
// matched on data, not on a tag — and you can walk every field from here
}
}
```
Reflection is opt-in per type (`#[derive(Facet)]`) and never a bound on `Message`;
a type that doesn't derive it yields `None` — the same shape as `as_serialize`.
Where serialization forwards the body whole, reflection pulls it apart. See the
`subscriber-facet` example.
> The `facet` feature is unstable: [facet](https://docs.rs/facet) is pre-1.0 and
> every minor is a breaking change, so expect churn. It's re-exported as
> `tracing_wide::facet` so a subscriber names `Peek`/`Facet` through the exact
> version this crate compiled against.