|
4 | 4 | [](https://docs.rs/wide-event) |
5 | 5 | [](LICENSE-MIT) |
6 | 6 |
|
7 | | -Honeycomb-style wide events for Rust. |
| 7 | +[Wide events](https://charity.wtf/2022/08/15/live-your-best-life-with-structured-events/) |
| 8 | +for Rust, built on `tracing`. One structured event per request with all |
| 9 | +fields accumulated over the request lifecycle, emitted as a single JSON |
| 10 | +line (or logfmt) on completion. |
8 | 11 |
|
9 | | -A wide event accumulates key-value pairs throughout a request (or task) |
10 | | -lifecycle and emits them as a **single structured event** when the request |
11 | | -completes. This gives you one row per request in your log aggregator with |
12 | | -every dimension attached — perfect for high-cardinality exploratory analysis. |
| 12 | +## Why |
13 | 13 |
|
14 | | -## Quick start |
| 14 | +A span tree for one request can be 10–30 spans. A wide event is one row |
| 15 | +with all the dimensions attached. Cheaper to store and query than a span |
| 16 | +tree, and retains per-request detail that pre-aggregated metrics lose. |
| 17 | + |
| 18 | +### Metrics from events |
| 19 | + |
| 20 | +An emit hook receives the accumulated fields before serialization. You |
| 21 | +can update Prometheus counters and histograms from the event data |
| 22 | +directly, which keeps metric definitions next to the event they describe |
| 23 | +rather than scattered as `counter.inc()` calls across the codebase. |
| 24 | + |
| 25 | +This means you don't end up with a counter that's incremented on a code |
| 26 | +path that doesn't emit enough context to debug it, or an event that's |
| 27 | +missing the metric update. The event is the source of truth; metrics are |
| 28 | +a view of it. |
| 29 | + |
| 30 | +### Dynamic fields |
| 31 | + |
| 32 | +With tracing spans, fields must be declared at span creation as `Empty` |
| 33 | +slots, and each `Span::record` call dispatches through the full |
| 34 | +subscriber stack. Wide event setters are `HashMap` inserts behind a local |
| 35 | +`Mutex` — any key, any time, no subscriber dispatch until emit. |
| 36 | + |
| 37 | +This matters when telemetry is contributed by several layers that don't |
| 38 | +know about each other's fields. Middleware sets `auth.user_id`, the |
| 39 | +handler sets `project_id`, the proxy layer sets `upstream_latency_ns`, |
| 40 | +error recovery sets `error.type`. Each just calls `event.set_str(...)` on |
| 41 | +the shared `WideEvent` without needing a reference to a root span or |
| 42 | +knowing what other fields exist. |
| 43 | + |
| 44 | +### Complements tracing |
| 45 | + |
| 46 | +Wide events and tracing spans answer different questions. Spans give you |
| 47 | +the call tree — where time was spent within a request. Wide events give |
| 48 | +you the outcome — one flat row you can filter and aggregate across |
| 49 | +requests. |
| 50 | + |
| 51 | +With the `opentelemetry` feature enabled, `trace_id` and `span_id` from |
| 52 | +the current span context are attached to the event automatically. So you |
| 53 | +can query wide events to find interesting requests, then jump to the |
| 54 | +trace for the ones that need deeper investigation. |
| 55 | + |
| 56 | +## Usage |
15 | 57 |
|
16 | 58 | ```rust |
17 | 59 | use wide_event::{WideEventGuard, WideEventLayer}; |
18 | 60 | use tracing_subscriber::prelude::*; |
19 | 61 |
|
20 | | -// Once at startup: |
21 | 62 | tracing_subscriber::registry() |
22 | 63 | .with(WideEventLayer::stdout().with_system("myapp")) |
23 | 64 | .init(); |
24 | 65 |
|
25 | | -// Per request — guard auto-emits on drop: |
26 | 66 | { |
27 | 67 | let req = WideEventGuard::new("http"); |
28 | 68 | req.set_str("method", "GET"); |
29 | 69 | req.set_str("path", "/api/users"); |
30 | 70 | req.set_u64("status", 200); |
31 | | -} // ← emitted here as single JSON line |
| 71 | +} // emitted here |
32 | 72 | ``` |
33 | 73 |
|
34 | | -## How it works |
| 74 | +`WideEventGuard` emits on drop. Use `WideEvent` directly if you want |
| 75 | +explicit `emit()` control. |
| 76 | + |
| 77 | +For async task propagation, enable the `tokio` feature and use |
| 78 | +`context::scope` / `context::current`. |
35 | 79 |
|
36 | | -1. `WideEvent::new` starts a timer and creates an empty field map. |
37 | | -2. Throughout processing, call setters (`set_str`, `set_u64`, `incr`, etc.) |
38 | | - to accumulate fields — these are cheap local `Mutex` operations that never |
39 | | - touch the tracing subscriber. |
40 | | -3. `WideEvent::emit` (or `WideEventGuard` drop) finalizes the record, pushes |
41 | | - it to a thread-local stack, and dispatches a structured `tracing::info!` |
42 | | - event. The `WideEventLayer` pulls the record from the stack, formats the |
43 | | - timestamp, and serializes in a single pass. |
| 80 | +## Internals |
| 81 | + |
| 82 | +1. `WideEvent::new` — starts a timer, allocates an empty field map. |
| 83 | +2. Setters (`set_str`, `set_u64`, `set_bool`, `incr`, …) — `Mutex` |
| 84 | + lock + `HashMap::insert`. No subscriber dispatch. |
| 85 | +3. `emit()` — finalizes the record, calls the emit hook if set, pushes |
| 86 | + to a thread-local stack, fires `tracing::info!`. The `WideEventLayer` |
| 87 | + pops the record and serializes it in a single pass. |
| 88 | + |
| 89 | +Field keys are `&'static str` so setter calls don't allocate for the key. |
| 90 | +Field names are almost always string literals, so this is a natural fit. |
| 91 | + |
| 92 | +The thread-local emit stack avoids cross-thread synchronization between |
| 93 | +`emit()` and the layer. The layer pulls the record from the stack during |
| 94 | +the `tracing::info!` dispatch on the same thread. |
44 | 95 |
|
45 | 96 | ## Features |
46 | 97 |
|
47 | | -| Feature | Description | |
| 98 | +| Feature | What it does | |
48 | 99 | |---------|-------------| |
49 | | -| `opentelemetry` | Attaches `trace_id` and `span_id` from the current OpenTelemetry span context | |
50 | | -| `tokio` | Provides `context::scope` and `context::current` for async task-local wide event propagation | |
| 100 | +| `opentelemetry` | Attaches `trace_id` / `span_id` from current OTel context | |
| 101 | +| `tokio` | Task-local propagation via `context::scope` / `context::current` | |
51 | 102 |
|
52 | 103 | ```toml |
53 | 104 | [dependencies] |
54 | 105 | wide-event = { version = "0.1", features = ["tokio"] } |
55 | 106 | ``` |
56 | 107 |
|
57 | | -## Formatter options |
| 108 | +## Formatters |
58 | 109 |
|
59 | | -- **`JsonFormatter`** (default) — one JSON object per line |
60 | | -- **`LogfmtFormatter`** — `key=value` pairs per line |
| 110 | +`JsonFormatter` (default) or `LogfmtFormatter`: |
61 | 111 |
|
62 | 112 | ```rust |
63 | 113 | use wide_event::{WideEventLayer, LogfmtFormatter}; |
64 | | - |
65 | 114 | let layer = WideEventLayer::new(std::io::stdout(), LogfmtFormatter); |
66 | 115 | ``` |
67 | 116 |
|
68 | | -## Performance |
| 117 | +Custom timestamp formatting via `with_timer()` — accepts any |
| 118 | +`tracing_subscriber::fmt::time::FormatTime` implementation: |
69 | 119 |
|
70 | | -- Field keys are `&'static str` — zero allocation on every setter call. Field |
71 | | - names are almost always string literals, so this is natural and avoids the |
72 | | - `key.to_string()` overhead entirely. |
73 | | -- Field setters (`set_str`, `set_u64`, `incr`, …) are cheap local `Mutex` |
74 | | - operations — they never interact with the tracing subscriber. |
75 | | -- Timestamp formatting reuses a thread-local buffer — no `String` allocation |
76 | | - per emit. |
77 | | -- Serialization happens once at emit time in a single pass over the accumulated |
78 | | - fields. |
79 | | -- A thread-local emit stack avoids cross-thread synchronization on the hot path. |
| 120 | +```rust |
| 121 | +use wide_event::WideEventLayer; |
| 122 | +use tracing_subscriber::fmt::time::Uptime; |
| 123 | + |
| 124 | +let layer = WideEventLayer::stdout().with_timer(Uptime::default()); |
| 125 | +``` |
| 126 | + |
| 127 | +The default timer is `Rfc3339` — microsecond-precision RFC 3339 |
| 128 | +timestamps using `humantime`. |
80 | 129 |
|
81 | 130 | ## Development |
82 | 131 |
|
83 | 132 | ```bash |
84 | | -# Set up pre-push hook (runs fmt, clippy, tests before each push) |
85 | | -git config core.hooksPath .githooks |
86 | | - |
87 | | -# Release a new version (bumps Cargo.toml, commits, tags, pushes) |
88 | | -# CI publishes to crates.io automatically on tag push. |
89 | | -cargo release patch # or: minor, major |
| 133 | +git config core.hooksPath .githooks # pre-push: fmt + clippy + test |
| 134 | +cargo release patch # or: minor, major |
90 | 135 | ``` |
91 | 136 |
|
92 | | -Requires [`cargo-release`](https://crates.io/crates/cargo-release): |
93 | | -`cargo install cargo-release` |
| 137 | +Requires [`cargo-release`](https://crates.io/crates/cargo-release). |
94 | 138 |
|
95 | 139 | ## License |
96 | 140 |
|
97 | | -Licensed under either of |
98 | | - |
99 | | -- [MIT license](LICENSE-MIT) |
100 | | -- [Apache License, Version 2.0](LICENSE-APACHE) |
101 | | - |
102 | | -at your option. |
| 141 | +MIT or Apache-2.0, at your option. |
0 commit comments