The usual way to watch production looks like this:

tail -f sample.log | grep -E 'ERROR|WARN'

Lines scroll by, your eyes glaze over, and the one question that matters — "what is actually happening with my system right now?" — goes unanswered. We'll build a small Rust tool that turns that noisy log tail into short spoken summaries: "failed logins are climbing, the cache is unstable, traffic is falling back to the database." You can leave it running nearby and just listen.

Under the hood it's a pipeline of three independent workers joined by async channels: Watcher reads the file tail and filters lines, Summarizer collects them and asks a model for a summary, AudioPlayer plays the speech back. Everything runs on tokio, and the tasks run concurrently via select_all.

Watcher: reading the file tail

Watcher follows exactly one file, so all it needs is a path. The most convenient way to hold a path is the owning PathBuf type:

use std::path::PathBuf;

pub struct Watcher {
    log_path: PathBuf,
    last_position: usize,
}

last_position is how many bytes we've already read. Storing an offset rather than "the line we stopped on" lets us resume from exactly the unread spot every time, without re-reading the whole file.

There are several ways to "follow a tail." You could subscribe to filesystem events, but that ties you to a system-specific API and adds complexity. For a log that's written to constantly, it's simpler to periodically read the new chunk. We open the file and immediately seek to the unread boundary:

let mut file = File::open(&self.log_path).await?;
let offset = self.last_position as u64;
let position = SeekFrom::Start(offset);
file.seek(position).await?;

SeekFrom::Start sets the offset from the very start of the file — so everything we've already handed down the pipeline is simply skipped. The offset is stored as usize, but seek wants u64, hence the explicit cast.

Reading raw bytes from File is awkward: a log is lines. We wrap the file in a BufReader, which accumulates data until it hits a newline:

let mut reader = BufReader::new(file);
let mut line = String::new();
loop {
    let bytes_read =
        reader.read_line(&mut line).await?;
    if bytes_read == 0 {
        break;
    }
    self.process_line(&line);
    line.clear();
}

read_line returns the number of bytes read; 0 means there's no new data yet, so we leave the loop until the next pass. We reuse the line buffer — clear() resets its length but keeps the already-allocated capacity, so we don't reallocate on every line.

To avoid burning the CPU on empty passes, we wait a small interval between reads:

const INTERVAL: Duration =
    Duration::from_millis(500);

500 ms is reasonable responsiveness for a log; for a calm service you can easily raise the interval to a couple of seconds.

Filtering: only errors and warnings

Most log lines aren't interesting. We want ERROR and WARN records and nothing else. The most flexible way to select them is a regular expression from the regex crate:

let pattern = r"(?i)\b(ERROR|WARN)\b";
let regex = Regex::new(pattern)?;

Two things matter here. The (?i) flag turns on case-insensitive matching, so error, ERROR, and Error are all caught the same way. And \b is a word boundary: it guarantees WARN matches as a whole word and not as part of beware or warningly. Without the boundaries the filter would fire false positives.

The pattern is hard-coded, so we only need to confirm once that it compiles. Still, Regex::new returns a Result, and we propagate it with ? rather than unwrap() — if tomorrow the pattern comes from a user, the check is already in place. Then we test each line for a match:

if self.regex.is_match(&line) {
    let _ = self.tx.send(line.clone());
}

is_match only answers "yes/no," allocating nothing for captures — that's all we need. We clone the line on send because line is the reused read buffer. Only the lines that actually move on get cloned, and those are the minority.

Wiring the workers with channels

To let Watcher and Summarizer work independently, we connect them with an async channel. We take mpsc from tokio:

let (log_tx, log_rx) = mpsc::unbounded_channel();
let watcher = Watcher::new(args.log_file, log_tx);
let summarizer = Summarizer::new(log_rx, /* … */);

unbounded_channel returns a sender/receiver pair. An unbounded channel is fine here: bursts of errors shouldn't block file reads. Watcher holds log_tx and sends matched lines, Summarizer pulls them from log_rx. So reading the log and talking to the model never wait on each other.

Summarizer: collecting context and ticking on a timer

Asking the model to summarize every single line is pointless and expensive. Summarizer accumulates messages in a MessageBuffer that splits them into two groups:

pub struct MessageBuffer {
    new_messages: Vec<String>,
    processed_messages: VecDeque<String>,
}

new_messages is what we haven't reported yet. processed_messages is what we've already voiced; it serves as context, reminding the model of the bigger incident picture. VecDeque is handy because it lets us cheaply pop old messages off one end, keeping the window fresh.

Now we need to receive lines from the channel and, every few seconds, hand the accumulated batch to the model. That's classic work for select!:

select! {
    msg = self.rx.recv() => {
        self.handle_message(msg);
    }
    _ = self.interval.tick() => {
        self.handle_tick().await?;
    }
}

select! waits on both branches and reacts to whichever fires first: a message arrives — put it in the buffer; the interval elapses — build a summary. The interval is set via Interval:

const INTERVAL: Duration =
    Duration::from_secs(5);

An important detail of tick(): it doesn't "reset" the count if we didn't manage to await it in time. So handling lines and the timer firing don't interfere with each other. On a tick we first ask the buffer whether there's anything to send at all:

if self.buffer.has_new() {
    let summary =
        self.generate_summary().await?;
}

has_new() is just !new_messages.is_empty(). An empty tick won't poke the model for nothing — no wasted requests, no wasted spend.

Asking the model for a summary

We collect the context for the model into a single vector of the shared type ChatCompletionRequestMessage: the system prompt, prior problems, and the fresh lines. We keep the system prompt in a separate file and bake it into the binary at compile time:

const SYSTEM_PROMPT: &str =
    include_str!("prompt/system.md");

include_str! reads the file during the build, so the prompt travels with the binary — no companion file needed beside it. In the prompt itself we ask for short summaries in plain language: they'll be heard, not read, including by non-technical people.

We build the request with a builder and immediately cap the response length:

let request =
    CreateChatCompletionRequestArgs::default()
        .model(MODEL)
        .max_completion_tokens(TOKEN_LIMIT)
        .messages(messages)
        .build()?;

MODEL is "gpt-4.1-mini": fast and cheap enough for short summaries. TOKEN_LIMIT is 150 — a hard ceiling on the response length so the voice-over doesn't turn into a lecture. A token isn't a word but a piece of a word, so you can't predict the exact word count up front, but 150 keeps the summary short. We send it and pull out the first response choice:

let response =
    self.client.chat().create(request).await?;
let choice = response
    .choices
    .into_iter()
    .next()
    .context("no completion in response")?;

choices could have held several variants, but we didn't ask for them, so we take the first via into_iter().next(). next() yields an Option, and we turn it into a Result with context() from anyhow — an empty response becomes a clear error with a message instead of a silent panic.

From text to voice

The summary is ready — all that's left is to speak it. The same OpenAI client opens the audio part of the API, and the speech request is assembled with its own builder:

let request =
    CreateSpeechRequestArgs::default()
        .model(SpeechModel::Tts1)
        .input(text)
        .build()?;
let response =
    self.client.audio().speech(request).await?;

SpeechModel::Tts1 is the speech-synthesis model; input(text) takes a reference to our summary. The response comes back as ready audio: the bytes field is a Vec<u8> of MP3. We send it on to the third worker, the player, over a separate channel.

AudioPlayer receives chunks and plays them. The standard library can't do audio, so we take rodio with the symphonia-all feature — it pulls in decoders for various formats, including MP3. The decoder wants to read from something file-like, and all we hold is a plain byte vector:

let cursor = Cursor::new(audio_data);
let source = Decoder::try_from(cursor)?;
sink.append(source);

Cursor from std::io wraps a byte array, giving it a read-and- seek interface — exactly what the decoder needs. try_from returns a Result because corrupt audio is possible, and we handle it without crashing the worker:

if let Err(err) = self.play_audio(audio_data) {
    warn!("failed to play audio: {err}");
}

A single bad chunk shouldn't kill the whole voice-over, so we log the error and keep listening on the channel.

Orchestration: three tasks at once

The three workers must run simultaneously and forever. Each run() returns a Result, but the future types still differ, so we coerce them to a common shape with boxed():

let mut tasks = Vec::new();
tasks.push(watcher.run().boxed());
tasks.push(summarizer.run().boxed());
tasks.push(audio_player.run().boxed());

let (result, _index, _rest) =
    select_all(tasks).await;
result?;

boxed() from FutureExt puts each task into a Box and erases its concrete future type, so the heterogeneous workers fit into one Vec. select_all waits until the first task finishes, then hands back its result along with the rest. Since the workers normally run forever, any completion is an error, and we raise it with ?, stopping the program.

Where to grow

We end up with a working skeleton, and you can extend it along any axis. The unbounded channels are worth swapping for bounded ones with backpressure if the log occasionally "explodes" into thousands of lines per second. The filter regex easily moves to a CLI argument so you can catch not just ERROR/WARN but the domain-specific patterns of a particular service. Summaries can be deduplicated so the radio doesn't repeat itself on every tick, and instead of local playback you could send them to the team chat or page the on-call engineer.

Why build this

In a couple hundred lines, this brings together things that are dull to learn in isolation: tail-reading a file with offsets, filtering with regexes, async channels as glue between tasks, working with an external API through builders, and decoding audio. One clear goal ties them together — turning log noise into a calm voice that tells you, on its own, when it's worth looking up from your work. It's a small but honest example of how a live, real-time pipeline is assembled from simple tokio pieces.