penna 0.1.0

Structured JSON logging for tracing, in one line per event, without a regex engine underneath
Documentation
# penna

[![crates.io](https://img.shields.io/crates/v/penna.svg)](https://crates.io/crates/penna)
[![docs.rs](https://docs.rs/penna/badge.svg)](https://docs.rs/penna)
[![ci](https://github.com/bas3line/penna/actions/workflows/ci.yml/badge.svg)](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.

| | `tracing-subscriber` (`json`, `env-filter`) | `penna` |
| --- | ---: | ---: |
| 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.