Guide contents

Lesson 1 of 12

Access Log Analyzer

We build loganal, a tool that reads an access log and prints a report like this:

Text
$ 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 failed

Each log line has seven space-separated fields: time, client IP, method, path, status, duration, and response size.

Text
2026-09-27T10:15:02Z 10.0.0.5 GET /api/orders 200 35ms 512

The 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.

Terminal
cargo new loganal
Text
loganal/
└── 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 report

The 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.

Rustcargo test
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\"");
    }
}
Output
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.00s

The 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.

Rust
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(())
}
Output
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.

Rustcargo test
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));
    }
}
Output
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.00s

Step 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.

Rustcargo test
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);
    }
}
Output
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.00s

Step 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.

Rust
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);
    }
}
Output
10:16  2 of 3 requests failed

Minute 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.

Rust
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
}
Output
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 failed

The 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 flate2 crate: BufReader::new(GzDecoder::new(file)) is still a BufRead, so nothing else changes.
  • Accept several files and process them in parallel with rayon, merging the counts at the end.
  • Add --json output by deriving serde::Serialize on Report.
  • Parse the real nginx "combined" format with the regex crate, keeping FromStr as the entry point.

Summary

  • FromStr with a custom error enum makes line.parse::<Entry>() read naturally and gives precise error messages.
  • Taking impl BufRead lets the same function read a file, standard input, or a string in tests.
  • HashMap counts things; sorting by count with a tie-break on the key makes the output deterministic. BTreeMap keeps keys ordered when order is part of the output.
  • Percentiles come from one sort and an index calculation.
  • Display on the report separates computing from printing, and bad lines go to standard error so the report stays machine-readable.

Next: grep-Style Search Tool.