A Radio for Your Logs: a Backend That Talks Back
Create your own real-time voice assistant that detects incidents by monitoring log files and speaks out loud about issues.
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.