logged-stream
Transparent logging wrapper for any synchronous or asynchronous Rust IO stream — inspect every byte, error, and lifecycle event that passes through.
- Description
- Usage
- Quick start
- Example
- Record kinds
- Architecture
- Provided implementations
- Custom implementations
- Use cases
- License
- Contribution
Description
logged-stream provides a single wrapper type, LoggedStream, that wraps any underlying IO object and logs every read, write, error, shutdown and drop that passes through it — without changing the code on either side. Because it re-implements the same IO trait it wraps, it drops straight into existing synchronous or asynchronous code.
- Drop-in. Same trait in, same trait out — wrapping a stream leaves the surrounding code unchanged.
- Sync and async. Works with
std::ioRead/Writestreams andtokioAsyncRead/AsyncWritestreams alike. - Replaceable parts. Formatting, filtering and log output are each a public trait — use a built-in, or implement the trait yourself if none fits your needs.
- Small and safe. A small dependency set and no
unsafecode.
LoggedStream is deliberately a skeleton: it wires together four replaceable parts — the IO object you wrap, plus a formatter, a filter and a logger. Each event flows through them as event -> Formatter -> Filter -> Logger; see Architecture for how they fit together, and Custom implementations to write your own.
Usage
Add logged-stream to your Cargo.toml:
[]
= "0.8"
or run:
$ cargo add logged-stream@0.8
It requires Rust 1.85.1 or newer (Rust 2024 edition).
Quick start
LoggedStream::new takes the stream to wrap plus the three pluggable parts — a formatter, a filter, and a logger. The snippet below wraps an in-memory Cursor (standing in for a socket or file, so it runs with no external setup), renders bytes as lowercase hexadecimal, keeps every record with DefaultFilter, and prints them through ConsoleLogger:
use LevelFilter;
use ConsoleLogger;
use DefaultFilter;
use LoggedStream;
use LowercaseHexadecimalFormatter;
use Cursor;
use Read;
Running it logs the read, then the drop when stream goes out of scope:
[2026-01-01T12:00:00.000Z DEBUG logged_stream::logger] < de:ad:be:ef
[2026-01-01T12:00:00.000Z DEBUG logged_stream::logger] x Deallocated.
To see any output you also need
env_loggerandlogin yourCargo.toml:ConsoleLoggeronly forwards records to thelogfacade, and any backend works.
See Provided implementations for the full set of formatters, filters and loggers you can swap in.
Example
A more realistic case: wrapping a std::net::TcpStream connected to an echo server. Each write_all logs a > record and each read_exact logs a < record, so both directions of the exchange are visible.
use LevelFilter;
use ConsoleLogger;
use DefaultFilter;
use LoggedStream;
use LowercaseHexadecimalFormatter;
use Read;
use Write;
use TcpStream;
Output to console:
[2026-01-01T12:00:00.000Z DEBUG logged_stream::logger] > 01:02:03:04
[2026-01-01T12:00:00.000Z DEBUG logged_stream::logger] < 01:02:03:04
[2026-01-01T12:00:00.000Z DEBUG logged_stream::logger] > 05:06:07:08
[2026-01-01T12:00:00.000Z DEBUG logged_stream::logger] < 05:06:07:08
[2026-01-01T12:00:00.000Z DEBUG logged_stream::logger] x Deallocated.
The snippets above are condensed. Full runnable versions live in the examples/ directory:
tcp-stream-console-logger.rs— the synchronous example above, together with the echo server.tokio-tcp-stream-console-logger.rs— the same overtokio's asynchronous API.file-logger.rs— writing records to a file instead of the console.shared-file-logger.rs— several concurrent connections logging into one shared file, each tagged with its own prefix, and why append mode is what makes that safe.composite-filters.rs— combining filters withAllFilter/AnyFilter.
Full API documentation is on docs.rs.
Record kinds
Every log line begins with a single character identifying the kind of event:
| Char | Kind | Emitted when |
|---|---|---|
< |
Read | bytes were read from the wrapped stream |
> |
Write | bytes were written to the wrapped stream |
! |
Error | a real IO error occurred (transient WouldBlock / WriteZero are skipped) |
- |
Shutdown | an asynchronous stream was shut down (poll_shutdown) |
x |
Drop | the wrapper was dropped (message Deallocated.) |
+ |
Open | a manual marker you emit yourself with log_open (e.g. connection start); never produced automatically |
Every kind except Open is produced automatically. Open is a marker you record yourself with LoggedStream::log_open — for example stream.log_open(format!("Established connection with {peer}")) right after a connection is established. Like any record, it passes through the filter, so a RecordKindFilter that omits Open will suppress it.
Architecture
LoggedStream<S, Formatter, Filter, L> is generic over four independent, pluggable parts. Each logged event flows through them in order:
event -> Formatter -> Filter -> Logger
- The inner IO object (
S). The stream you are wrapping. Any type implementingRead/Write(or thetokioasync equivalents) works — a socket, a file, an in-memory buffer, or your own type.LoggedStreamimplements the same IO traitSdoes, so it slots in whereverSwas used. - Formatter (
BufferFormatter). Turns the read and written byte buffers into the display strings you see in the log. - Filter (
RecordFilter). Decides which records are logged. It runs on every record kind, includingShutdownandDrop. - Logger (
Logger). The sink that consumes accepted records.
All three of BufferFormatter, RecordFilter and Logger are public, Send + 'static and object-safe, with blanket implementations for Box<...> (and Arc<T> where T: Sync for BufferFormatter). You are free to supply your own implementation of any part and select one at runtime as a trait object — see Custom implementations.
Provided implementations
Formatters (BufferFormatter)
Control how byte buffers are rendered. Each formatter stores a separator (default :) and exposes parallel constructors: new, new_static, new_owned and new_default.
| Formatter | Renders each byte as |
|---|---|
LowercaseHexadecimalFormatter |
lowercase hexadecimal — 0a:ff |
UppercaseHexadecimalFormatter |
uppercase hexadecimal — 0A:FF |
DecimalFormatter |
decimal — 10:255 |
OctalFormatter |
octal — 012:377 |
BinaryFormatter |
binary — 00001010:11111111 |
Filters (RecordFilter)
Decide which records reach the logger.
| Filter | Behavior |
|---|---|
DefaultFilter |
Accepts every record. |
RecordKindFilter |
Accepts only the record kinds in an allow-list given at construction. |
AllFilter |
AND — a record passes only if every child filter accepts it (an empty list accepts everything). |
AnyFilter |
OR — a record passes if any child filter accepts it (an empty list rejects everything). |
Loggers (Logger)
Consume each accepted record.
| Logger | Destination |
|---|---|
ConsoleLogger |
Emits records through the log facade. |
FileLogger |
Writes records to a file, one line per record. |
MemoryStorageLogger |
Retains recent records in a bounded in-memory buffer. |
ChannelLogger |
Sends records over an mpsc channel for handling elsewhere. |
ConsoleLogger and FileLogger additionally accept an optional prefix (with_prefix / set_prefix, none by default), written verbatim before the record kind character of every line. It tells apart several LoggedStreams — for example one per connection — that share a single console or file:
[2026-07-20T12:34:56Z] [conn 5] > 01:02:03:04
[2026-07-20T12:34:56Z] [conn 7] < 05:06:07:08
To let several FileLoggers write to one file, construct them with FileLogger::open, which opens the file in append mode. Each record is rendered up front and written with a single write_all call, so concurrent loggers never interleave parts of a line. Passing independently opened non-append files (for example from File::create) instead gives each logger its own starting offset, and they will silently overwrite each other's records.
If none of the provided implementations matches your requirements, you can implement the corresponding trait yourself and pass your type to LoggedStream exactly like a built-in.
Custom implementations
LoggedStream is deliberately a skeleton: three of its four parts are defined by public traits, and everything this crate ships is an ordinary implementation of one of them.
| Part | Trait | You must implement | Provided for you |
|---|---|---|---|
| Formatter | BufferFormatter |
get_separator, format_byte |
format_buffer — joins formatted bytes with your separator |
| Filter | RecordFilter |
check |
fmt_debug — override it so composite filters print more than UnknownFilter |
| Logger | Logger |
log |
— |
All three are Send + 'static and object-safe, so you can box them (Box<dyn BufferFormatter>) and choose an implementation at runtime. The fourth part needs no trait from this crate at all — anything implementing std::io::Read/Write or the tokio async equivalents can be wrapped, including your own types.
For example, a formatter that renders printable bytes as ASCII — handy for text protocols, which the built-in numeric formatters do not cover:
use BufferFormatter;
;
Every record a Logger receives is a Record carrying a kind, a message and a time, so a custom logger can route by record kind, re-timestamp, or forward to any sink you like.
Use cases
LoggedStream hands you a formatted, filterable, timestamped record of every read, write, error, shutdown, and drop that crosses a stream — without changing the code on either side of it. That makes it a building block for a range of tools and diagnostics. A few examples of what you can build:
- Network traffic monitoring — wrap a
TcpStream(synchronous or asynchronous) to record every byte a client and server exchange, for debugging protocols, tracking data flow, or auditing what actually crossed the wire. - I/O debugging — see the exact bytes moving through a file, socket, or any custom stream, alongside the errors, shutdowns, and drops around them, to pin down data corruption or where a connection unexpectedly closed.
- Protocol and application-traffic capture — log the raw wire traffic of a protocol you are implementing or reverse-engineering, such as a database driver, an RPC channel, or a custom binary format.
LoggedStreamcaptures the bytes; decode or parse them downstream if you need higher-level detail like SQL statements. - Timing and throughput diagnostics — every record is timestamped, so you can layer your own cadence, gap, and throughput analysis on top: when reads and writes happened and how much data moved. (The timestamps are yours to correlate — the library does not measure per-call latency itself.)
- Transparent proxies and audit trails — build a man-in-the-middle proxy or an audit log that relays traffic unchanged while recording it. The author's
logged_tcp_proxydoes exactly this: it wraps eachTcpStream, splits it into read/write halves, and prints every connection's payload. Route the records to a file, memory, or a channel through the pluggable loggers to keep a durable trail.
License
Licensed under either of
- Apache License, Version 2.0, (LICENSE-APACHE or https://www.apache.org/licenses/LICENSE-2.0)
- MIT license (LICENSE-MIT or https://opensource.org/licenses/MIT)
at your option.
Contribution
Unless you explicitly state otherwise, any contribution intentionally submitted for inclusion in the work by you, as defined in the Apache-2.0 license, shall be dual licensed as above, without any additional terms or conditions.