Lesson 1 of 12
Access Log Analyzer
We build loganal, a tool that reads an access log and prints a report like this:
$ loganal access.log
12 requests, 2 unparseable lines
status: 2xx=8 4xx=2 5xx=2
latency: p50=38ms p95=950ms p99=950ms max=950ms
busiest endpoints:
5 GET /api/orders
3 POST /api/orders
2 GET /health
top clients:
6 10.0.0.5
4 10.0.0.9
error spikes (5xx >= 20% of a minute):
10:16 2 of 5 requests failedEach log line has seven space-separated fields: time, client IP, method, path, status, duration, and response size.
2026-09-27T10:15:02Z 10.0.0.5 GET /api/orders 200 35ms 512The project uses structs and enums, FromStr with a custom error type, BufRead, HashMap and BTreeMap, sorting, iterators, and Display. It has no dependencies beyond the standard library.
cargo new loganalloganal/
└── src/
├── main.rs # read the file named on the command line, print the report
├── entry.rs # step 1: one parsed line
├── read.rs # step 2: parse a whole input, keeping bad lines
└── stats.rs # steps 3 to 5: aggregation and the reportThe steps below are self-contained so each compiles and runs on its own. Step 6 puts them together.
Step 1: Parsing one line
An Entry is one request. Parsing goes through FromStr, so callers write line.parse::<Entry>(). Errors say which field was wrong, which matters when a log has millions of lines and one is broken.
use std::fmt;
use std::str::FromStr;
#[derive(Debug, Clone, PartialEq)]
pub struct Entry {
pub time: String, // "2026-09-27T10:15:02Z"
pub client: String,
pub method: String,
pub path: String,
pub status: u16,
pub duration_ms: u32,
pub bytes: u64,
}
impl Entry {
/// "10:15" from "2026-09-27T10:15:02Z": the minute this request belongs to.
pub fn minute(&self) -> &str {
self.time.get(11..16).unwrap_or("??:??")
}
}
#[derive(Debug, PartialEq)]
pub enum ParseError {
WrongFieldCount(usize),
BadNumber { field: &'static str, value: String },
}
impl fmt::Display for ParseError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
ParseError::WrongFieldCount(n) => write!(f, "expected 7 fields, found {n}"),
ParseError::BadNumber { field, value } => write!(f, "invalid {field}: {value:?}"),
}
}
}
fn number<T: FromStr>(field: &'static str, value: &str) -> Result<T, ParseError> {
value.parse().map_err(|_| ParseError::BadNumber { field, value: value.to_string() })
}
impl FromStr for Entry {
type Err = ParseError;
fn from_str(line: &str) -> Result<Entry, ParseError> {
let fields: Vec<&str> = line.split_whitespace().collect();
let [time, client, method, path, status, duration, bytes] = fields[..] else {
return Err(ParseError::WrongFieldCount(fields.len()));
};
Ok(Entry {
time: time.to_string(),
client: client.to_string(),
method: method.to_string(),
path: path.to_string(),
status: number("status", status)?,
duration_ms: number("duration", duration.trim_end_matches("ms"))?,
bytes: number("size", bytes)?,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_a_valid_line() {
let entry: Entry = "2026-09-27T10:15:02Z 10.0.0.5 GET /api/orders 200 35ms 512".parse().unwrap();
assert_eq!(entry.status, 200);
assert_eq!(entry.duration_ms, 35);
assert_eq!(entry.minute(), "10:15");
}
#[test]
fn reports_the_wrong_field_count() {
let error = "2026-09-27T10:15:02Z GET /".parse::<Entry>().unwrap_err();
assert_eq!(error.to_string(), "expected 7 fields, found 3");
}
#[test]
fn reports_the_bad_field() {
let error = "2026-09-27T10:15:02Z 10.0.0.5 GET / OK 35ms 512".parse::<Entry>().unwrap_err();
assert_eq!(error.to_string(), "invalid status: \"OK\"");
}
}running 3 tests
test tests::parses_a_valid_line ... ok
test tests::reports_the_bad_field ... ok
test tests::reports_the_wrong_field_count ... ok
test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00sThe slice pattern let [time, client, ...] = fields[..] else { ... } checks the count and names every field in one step.
Step 2: Reading a whole input
A real log has blank lines, comments, and the occasional truncated line. read_log takes any BufRead (a file, standard input, or a byte slice in tests), keeps the good entries, and collects the bad lines with their line numbers instead of stopping.
use std::io::{self, BufRead};
#[derive(Debug)]
struct Entry {
status: u16,
path: String,
}
fn parse(line: &str) -> Result<Entry, String> {
let fields: Vec<&str> = line.split_whitespace().collect();
if fields.len() != 7 {
return Err(format!("expected 7 fields, found {}", fields.len()));
}
let status = fields[4].parse().map_err(|_| format!("invalid status: {:?}", fields[4]))?;
Ok(Entry { status, path: fields[3].to_string() })
}
struct Parsed {
entries: Vec<Entry>,
rejected: Vec<(usize, String)>, // (line number, reason)
}
fn read_log(input: impl BufRead) -> io::Result<Parsed> {
let mut parsed = Parsed { entries: Vec::new(), rejected: Vec::new() };
for (index, line) in input.lines().enumerate() {
let line = line?; // an I/O error stops the run; a bad line doesn't
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
match parse(line) {
Ok(entry) => parsed.entries.push(entry),
Err(reason) => parsed.rejected.push((index + 1, reason)),
}
}
Ok(parsed)
}
fn main() -> io::Result<()> {
let log = "\
# nginx access log
2026-09-27T10:15:02Z 10.0.0.5 GET /api/orders 200 35ms 512
2026-09-27T10:15:09Z 10.0.0.9 GET /health
2026-09-27T10:15:30Z 10.0.0.5 POST /api/orders ??? 80ms 40
2026-09-27T10:15:41Z 10.0.0.7 GET /health 200 2ms 12
";
let parsed = read_log(log.as_bytes())?;
for entry in &parsed.entries {
println!("ok {} {}", entry.status, entry.path);
}
for (line, reason) in &parsed.rejected {
println!("bad line {line}: {reason}");
}
Ok(())
}ok 200 /api/orders
ok 200 /health
bad line 4: expected 7 fields, found 4
bad line 5: invalid status: "???"&[u8] implements BufRead, so log.as_bytes() stands in for a file here and in tests.
Step 3: Counting with HashMap
The report needs three kinds of counts: requests per status class, per endpoint, and per client. One generic helper covers the "top N" lists. Ties are broken by name so the output is stable from run to run, since HashMap iteration order is random.
use std::collections::HashMap;
use std::hash::Hash;
pub struct Entry {
pub client: String,
pub method: String,
pub path: String,
pub status: u16,
}
/// The `n` most frequent keys, most frequent first, ties in key order.
pub fn top_n<K: Hash + Eq + Ord + Clone>(counts: &HashMap<K, usize>, n: usize) -> Vec<(K, usize)> {
let mut sorted: Vec<(K, usize)> = counts.iter().map(|(k, &c)| (k.clone(), c)).collect();
sorted.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
sorted.truncate(n);
sorted
}
pub fn count_by<K: Hash + Eq>(entries: &[Entry], key: impl Fn(&Entry) -> K) -> HashMap<K, usize> {
let mut counts = HashMap::new();
for entry in entries {
*counts.entry(key(entry)).or_insert(0) += 1;
}
counts
}
#[cfg(test)]
mod tests {
use super::*;
fn entry(client: &str, method: &str, path: &str, status: u16) -> Entry {
Entry { client: client.into(), method: method.into(), path: path.into(), status }
}
fn sample() -> Vec<Entry> {
vec![
entry("10.0.0.5", "GET", "/api/orders", 200),
entry("10.0.0.9", "GET", "/health", 200),
entry("10.0.0.5", "GET", "/api/orders", 500),
entry("10.0.0.7", "POST", "/api/orders", 201),
entry("10.0.0.9", "GET", "/api/orders", 404),
]
}
#[test]
fn counts_status_classes() {
let classes = count_by(&sample(), |e| e.status / 100);
assert_eq!(classes[&2], 3);
assert_eq!(classes[&4], 1);
assert_eq!(classes[&5], 1);
}
#[test]
fn busiest_endpoints_include_the_method() {
let endpoints = count_by(&sample(), |e| format!("{} {}", e.method, e.path));
let top = top_n(&endpoints, 2);
assert_eq!(top, vec![(String::from("GET /api/orders"), 3), (String::from("GET /health"), 1)]);
}
#[test]
fn ties_are_ordered_by_key() {
let clients = count_by(&sample(), |e| e.client.clone());
let top = top_n(&clients, 3);
assert_eq!(top[0], (String::from("10.0.0.5"), 2));
assert_eq!(top[1], (String::from("10.0.0.9"), 2));
}
}running 3 tests
test tests::busiest_endpoints_include_the_method ... ok
test tests::counts_status_classes ... ok
test tests::ties_are_ordered_by_key ... ok
test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00sStep 4: Latency percentiles
Averages hide slow requests; percentiles don't. "p95 = 950 ms" means 95% of requests took 950 ms or less. The nearest-rank method sorts the durations once and picks an index.
pub struct Percentiles {
pub p50: u32,
pub p95: u32,
pub p99: u32,
pub max: u32,
}
pub fn percentiles(durations: &[u32]) -> Option<Percentiles> {
if durations.is_empty() {
return None;
}
let mut sorted = durations.to_vec();
sorted.sort_unstable();
// Nearest rank: the smallest value with at least p% of the values at or below it.
let rank = |p: f64| -> u32 {
let index = ((p / 100.0) * sorted.len() as f64).ceil() as usize;
sorted[index.saturating_sub(1).min(sorted.len() - 1)]
};
Some(Percentiles { p50: rank(50.0), p95: rank(95.0), p99: rank(99.0), max: *sorted.last().unwrap() })
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn empty_input_has_no_percentiles() {
assert!(percentiles(&[]).is_none());
}
#[test]
fn one_to_hundred() {
let values: Vec<u32> = (1..=100).collect();
let p = percentiles(&values).unwrap();
assert_eq!((p.p50, p.p95, p.p99, p.max), (50, 95, 99, 100));
}
#[test]
fn a_single_slow_request_shows_up_in_the_tail() {
let p = percentiles(&[20, 25, 30, 22, 900]).unwrap();
assert_eq!(p.p50, 25);
assert_eq!(p.p95, 900);
}
}running 3 tests
test tests::a_single_slow_request_shows_up_in_the_tail ... ok
test tests::empty_input_has_no_percentiles ... ok
test tests::one_to_hundred ... ok
test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00sStep 5: Error spikes per minute with BTreeMap
A few 500s spread over a day are noise; half the requests in one minute failing is an incident. Grouping by minute uses a BTreeMap, which keeps the minutes in order, so the report reads chronologically without a separate sort.
use std::collections::BTreeMap;
struct Entry {
minute: &'static str,
status: u16,
}
#[derive(Default)]
struct Minute {
total: usize,
server_errors: usize,
}
fn spikes(entries: &[Entry], threshold: f64) -> Vec<(&str, Minute)> {
let mut minutes: BTreeMap<&str, Minute> = BTreeMap::new();
for entry in entries {
let minute = minutes.entry(entry.minute).or_default();
minute.total += 1;
if entry.status >= 500 {
minute.server_errors += 1;
}
}
minutes
.into_iter()
.filter(|(_, m)| m.server_errors as f64 / m.total as f64 >= threshold)
.collect()
}
fn main() {
let entries = [
Entry { minute: "10:15", status: 200 },
Entry { minute: "10:15", status: 200 },
Entry { minute: "10:16", status: 503 },
Entry { minute: "10:16", status: 200 },
Entry { minute: "10:16", status: 500 },
Entry { minute: "10:17", status: 200 },
Entry { minute: "10:17", status: 502 },
Entry { minute: "10:17", status: 200 },
Entry { minute: "10:17", status: 200 },
Entry { minute: "10:17", status: 200 },
Entry { minute: "10:17", status: 200 },
];
for (minute, m) in spikes(&entries, 0.2) {
println!("{minute} {} of {} requests failed", m.server_errors, m.total);
}
}10:16 2 of 3 requests failedMinute 10:17 has one failure out of six (17%), below the 20% threshold.
Step 6: The complete program
The final version combines the pieces into a Report that implements Display. main reads the file named on the command line; with no argument it analyzes a built-in sample, so you can try it right away.
use std::collections::{BTreeMap, HashMap};
use std::fmt;
use std::fs::File;
use std::hash::Hash;
use std::io::{self, BufRead, BufReader};
use std::process::ExitCode;
use std::str::FromStr;
// ---------- entry.rs ----------
#[derive(Debug, Clone)]
struct Entry {
time: String,
client: String,
method: String,
path: String,
status: u16,
duration_ms: u32,
}
impl Entry {
fn minute(&self) -> &str {
self.time.get(11..16).unwrap_or("??:??")
}
}
#[derive(Debug)]
enum ParseError {
WrongFieldCount(usize),
BadNumber { field: &'static str, value: String },
}
impl fmt::Display for ParseError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
ParseError::WrongFieldCount(n) => write!(f, "expected 7 fields, found {n}"),
ParseError::BadNumber { field, value } => write!(f, "invalid {field}: {value:?}"),
}
}
}
fn number<T: FromStr>(field: &'static str, value: &str) -> Result<T, ParseError> {
value.parse().map_err(|_| ParseError::BadNumber { field, value: value.to_string() })
}
impl FromStr for Entry {
type Err = ParseError;
fn from_str(line: &str) -> Result<Entry, ParseError> {
let fields: Vec<&str> = line.split_whitespace().collect();
let [time, client, method, path, status, duration, _bytes] = fields[..] else {
return Err(ParseError::WrongFieldCount(fields.len()));
};
Ok(Entry {
time: time.to_string(),
client: client.to_string(),
method: method.to_string(),
path: path.to_string(),
status: number("status", status)?,
duration_ms: number("duration", duration.trim_end_matches("ms"))?,
})
}
}
// ---------- read.rs ----------
struct Parsed {
entries: Vec<Entry>,
rejected: Vec<(usize, ParseError)>,
}
fn read_log(input: impl BufRead) -> io::Result<Parsed> {
let mut parsed = Parsed { entries: Vec::new(), rejected: Vec::new() };
for (index, line) in input.lines().enumerate() {
let line = line?;
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
match line.parse() {
Ok(entry) => parsed.entries.push(entry),
Err(error) => parsed.rejected.push((index + 1, error)),
}
}
Ok(parsed)
}
// ---------- stats.rs ----------
fn count_by<K: Hash + Eq>(entries: &[Entry], key: impl Fn(&Entry) -> K) -> HashMap<K, usize> {
let mut counts = HashMap::new();
for entry in entries {
*counts.entry(key(entry)).or_insert(0) += 1;
}
counts
}
fn top_n<K: Hash + Eq + Ord + Clone>(counts: &HashMap<K, usize>, n: usize) -> Vec<(K, usize)> {
let mut sorted: Vec<(K, usize)> = counts.iter().map(|(k, &c)| (k.clone(), c)).collect();
sorted.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
sorted.truncate(n);
sorted
}
struct Percentiles {
p50: u32,
p95: u32,
p99: u32,
max: u32,
}
fn percentiles(durations: &[u32]) -> Option<Percentiles> {
if durations.is_empty() {
return None;
}
let mut sorted = durations.to_vec();
sorted.sort_unstable();
let rank = |p: f64| -> u32 {
let index = ((p / 100.0) * sorted.len() as f64).ceil() as usize;
sorted[index.saturating_sub(1).min(sorted.len() - 1)]
};
Some(Percentiles { p50: rank(50.0), p95: rank(95.0), p99: rank(99.0), max: *sorted.last().unwrap() })
}
#[derive(Default)]
struct Minute {
total: usize,
server_errors: usize,
}
struct Report {
requests: usize,
rejected: usize,
classes: BTreeMap<u16, usize>,
latency: Option<Percentiles>,
endpoints: Vec<(String, usize)>,
clients: Vec<(String, usize)>,
spikes: Vec<(String, Minute)>,
}
const SPIKE_THRESHOLD: f64 = 0.2;
impl Report {
fn build(parsed: &Parsed) -> Report {
let entries = &parsed.entries;
let durations: Vec<u32> = entries.iter().map(|e| e.duration_ms).collect();
let mut minutes: BTreeMap<String, Minute> = BTreeMap::new();
for entry in entries {
let minute = minutes.entry(entry.minute().to_string()).or_default();
minute.total += 1;
if entry.status >= 500 {
minute.server_errors += 1;
}
}
Report {
requests: entries.len(),
rejected: parsed.rejected.len(),
classes: count_by(entries, |e| e.status / 100).into_iter().collect(),
latency: percentiles(&durations),
endpoints: top_n(&count_by(entries, |e| format!("{} {}", e.method, e.path)), 3),
clients: top_n(&count_by(entries, |e| e.client.clone()), 2),
spikes: minutes
.into_iter()
.filter(|(_, m)| m.server_errors as f64 / m.total as f64 >= SPIKE_THRESHOLD)
.collect(),
}
}
}
impl fmt::Display for Report {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
writeln!(f, "{} requests, {} unparseable lines", self.requests, self.rejected)?;
let classes: Vec<String> = self.classes.iter().map(|(class, n)| format!("{class}xx={n}")).collect();
writeln!(f, "status: {}", classes.join(" "))?;
if let Some(p) = &self.latency {
writeln!(f, "latency: p50={}ms p95={}ms p99={}ms max={}ms", p.p50, p.p95, p.p99, p.max)?;
}
writeln!(f, "\nbusiest endpoints:")?;
for (endpoint, n) in &self.endpoints {
writeln!(f, " {n:>3} {endpoint}")?;
}
writeln!(f, "\ntop clients:")?;
for (client, n) in &self.clients {
writeln!(f, " {n:>3} {client}")?;
}
writeln!(f, "\nerror spikes (5xx >= {:.0}% of a minute):", SPIKE_THRESHOLD * 100.0)?;
if self.spikes.is_empty() {
writeln!(f, " none")?;
}
for (minute, m) in &self.spikes {
writeln!(f, " {minute} {} of {} requests failed", m.server_errors, m.total)?;
}
Ok(())
}
}
// ---------- main.rs ----------
const SAMPLE: &str = "\
2026-09-27T10:15:02Z 10.0.0.5 GET /api/orders 200 35ms 512
2026-09-27T10:15:09Z 10.0.0.9 GET /health 200 2ms 12
2026-09-27T10:15:20Z 10.0.0.5 GET /api/orders 200 40ms 530
2026-09-27T10:15:31Z 10.0.0.9 POST /api/orders 201 80ms 40
2026-09-27T10:15:45Z 10.0.0.5 GET /api/orders/77 404 5ms 0
2026-09-27T10:16:01Z 10.0.0.5 GET /api/orders 503 950ms 0
2026-09-27T10:16:03Z 10.0.0.9 GET /api/orders 200 60ms 498
2026-09-27T10:16:10Z 10.0.0.5 POST /api/orders 500 700ms 0
2026-09-27T10:16:30Z 10.0.0.7 GET /health 200 3ms 12
2026-09-27T10:16:44 truncated line
2026-09-27T10:16:52Z 10.0.0.9 POST /api/orders 201 90ms 40
2026-09-27T10:17:05Z 10.0.0.5 GET /api/orders 200 38ms 510
2026-09-27T10:17:15Z 10.0.0.3 DELETE /api/orders/9 403 4ms 0
2026-09-27T10:17:20Z 10.0.0.5 GET /api/orders fast 0
";
fn main() -> ExitCode {
let parsed = match std::env::args().nth(1) {
Some(path) => match File::open(&path).and_then(|file| read_log(BufReader::new(file))) {
Ok(parsed) => parsed,
Err(error) => {
eprintln!("loganal: {path}: {error}");
return ExitCode::from(2);
}
},
None => read_log(SAMPLE.as_bytes()).expect("reading from memory can't fail"),
};
for (line, error) in &parsed.rejected {
eprintln!("warning: line {line}: {error}");
}
print!("{}", Report::build(&parsed));
ExitCode::SUCCESS
}12 requests, 2 unparseable lines
status: 2xx=8 4xx=2 5xx=2
latency: p50=38ms p95=950ms p99=950ms max=950ms
busiest endpoints:
5 GET /api/orders
3 POST /api/orders
2 GET /health
top clients:
6 10.0.0.5
4 10.0.0.9
error spikes (5xx >= 20% of a minute):
10:16 2 of 5 requests failedThe two warnings go to standard error, so loganal access.log > report.txt keeps the report clean.
Ideas for extending it
- Read gzip-compressed logs with the
flate2crate:BufReader::new(GzDecoder::new(file))is still aBufRead, so nothing else changes. - Accept several files and process them in parallel with
rayon, merging the counts at the end. - Add
--jsonoutput by derivingserde::SerializeonReport. - Parse the real nginx "combined" format with the
regexcrate, keepingFromStras the entry point.
Summary
FromStrwith a custom error enum makesline.parse::<Entry>()read naturally and gives precise error messages.- Taking
impl BufReadlets the same function read a file, standard input, or a string in tests. HashMapcounts things; sorting by count with a tie-break on the key makes the output deterministic.BTreeMapkeeps keys ordered when order is part of the output.- Percentiles come from one sort and an index calculation.
Displayon the report separates computing from printing, and bad lines go to standard error so the report stays machine-readable.
Next: grep-Style Search Tool.
