# penna
[](https://crates.io/crates/penna)
[](https://docs.rs/penna)
[](https://github.com/bas3line/penna/actions/workflows/ci.yml)
Structured JSON logging for [`tracing`](https://docs.rs/tracing), one line per
event, with three crates in the tree instead of fifteen.
```rust
penna::json().install();
tracing::info!(events = 5000, backup_key = "…", "telemetry events persisted");
```
```json
{"timestamp":"2026-09-21T00:56:59.991167Z","level":"INFO","fields":{"message":"telemetry events persisted","events":5000,"backup_key":"…"},"target":"my_service"}
```
## Why
A service that logs JSON lines to stdout and filters them by level needs a
timestamp, a string escaper, and a comparison against a target prefix. The
usual way to get that is `tracing-subscriber`, which brings a regex engine, an
ANSI colour library, a sharded slab and a thread-local crate — none of which a
JSON line ever touches.
| Crates in the tree | 15 | 3 |
| Of those, not already yours | 12 | 1 |
`penna`'s three are itself, `tracing-core` — which defines the traits, and
which you already have if you use `tracing` — and `once_cell`, which
`tracing-core` brings. There is no regex engine, no colour support, and no
serialisation framework: a `tracing` field arrives already typed, so each one
is written straight out as a `"key":value` pair.
The line shape is `tracing-subscriber`'s JSON formatter's, key for key, so a
dashboard or log pipeline already parsing those lines keeps working.
## Use
```toml
[dependencies]
penna = "0.1"
```
```rust
fn main() {
penna::json().install(); // filter from RUST_LOG, default "info"
// …
}
```
Set the filter directly, or write somewhere other than stdout:
```rust
penna::json().filter("warn,my_service=debug").install();
penna::json().with_writer(penna::Stderr).install();
```
Build it without installing, for tests or for wrapping:
```rust
let subscriber = penna::json().filter("info").finish();
tracing::subscriber::with_default(subscriber, || {
tracing::info!("scoped to this closure");
});
```
### Filtering
`RUST_LOG` directives, in the form almost everyone uses:
```text
info everything at info and above
warn,my_service=debug debug for one module, warn elsewhere
my_service::spool=trace one module, everything else off
```
A directive is a target prefix and a level. Matching is by path prefix on
module boundaries — `my_service` matches `my_service::spool` but not
`my_service_other` — and the longest matching prefix wins, so a specific
directive beats a general one whatever order they appear in.
### Spans
Open spans ride along on the events inside them: the innermost as `span`, the
whole stack as `spans`, each with the fields it was created with. Nothing is
printed when a span opens or closes, because a span is context for its events
rather than an event.
```json
{"timestamp":"…","level":"INFO","fields":{"message":"inside"},"target":"my_service","span":{"worker":3,"name":"delivery"},"spans":[{"worker":3,"name":"delivery"}]}
```
## What it does not do
No regex or field matching in the filter, no per-span filtering, no colours,
no non-JSON formats, no log rotation, and no `log` crate bridge. If you want
any of those, `tracing-subscriber` is the right tool and this is not trying to
replace it — only to be enough for a service that writes JSON lines and greps
them later.
## How you know it is right
Every line is parsed back and checked field by field: levels, targets,
messages, each field type, an error's source chain, quotes and newlines and
unicode, nested spans, and that a closed span never leaks into a later event.
String escaping is compared against `serde_json`'s own output, and eight
threads logging at once must produce four hundred whole lines. Date formatting
is checked at the epoch, on leap days, and across the century rule.
## Minimum supported Rust
1.75.
## License
MIT or Apache-2.0, at your option.