access-log/src/main.nika

// Summarises a web server's access log: how many requests, how many failed,
// and the hits and bytes sent per path, busiest first.
//
//     nikaia run -- tests/five-requests.in/access.log
//     5 requests, 1 failed
//     /index.html 2 10240
//     /api/order 1 512
//     …
//
// Each line of the log is `<ip> <method> <path> <status> <bytes>`, such as
// `203.0.113.7 GET /index.html 200 5120`.
//
// Shows: a `grammar` for lines of several fields, a parallel `par_fold`, a
// parse error caught and printed, and a two-key sort done as two stable sorts.
//
// A package: `nikaia run` in this directory runs it, and `nikaia test` checks
// what it prints against `tests/` (ADR-269 D13).

use std::fs
use std::cli
use std::collections

grammar Log {

    // Up to the next blank or the end of the line. Without `frame_end`, a line
    // missing a field would run on into the next line; the compiler rejects
    // a scan like that inside a frame.
    rule FIELD -> String = s:until(" " | frame_end) -> { s }

    // Exactly three digits: a status of "20" is a parse error here.
    rule STATUS -> i32 = n:dec[i32](digit{3}) -> { n }

    rule SIZE -> i64 = n:dec[i64](digit+) -> { n }

    // `@frame` says a request can be found from any offset by scanning to the
    // next newline, so the file can be cut into pieces and parsed in parallel.
    // The rule is uppercase, so *lexical*: no blanks are skipped for it, and
    // each `" "` between fields is written out. `=>` is a commit point: once the
    // first field and its blank are read, the line must be a request.
    @frame(boundary: "\n")
    rule ENTRY -> Hit =
        _ip:FIELD " " => method:FIELD " " path:FIELD " " status:STATUS " " bytes:SIZE frame_end
        -> { Hit { path, status, bytes } }

    // A fold hands each entry to an accumulator instead of collecting them
    // all, as `ENTRY*` would. `par_fold` also says how two partial reports
    // merge, so each core can fold its own piece of the file.
    entry rule file -> Report =
        par_fold(ENTRY, Report, fn(acc, e) { acc.record(e) }, Report::merge)
}

// `path` is a slice of the mapped file, not a copy.
pub struct Hit {
    path: String,
    status: i32,
    bytes: i64,
}

pub struct Counts {
    hits: i64,
    bytes: i64,
}

impl Counts {
    pub fn(bytes: i64) -> Counts sync {
        return Counts { hits: 1, bytes: bytes }
    }

    fn add(ref mut self, bytes: i64) sync {
        self.hits += 1
        self.bytes += bytes
    }

    fn merge(ref mut self, other: ref Counts) sync {
        self.hits += other.hits
        self.bytes += other.bytes
    }
}

pub struct Report {
    paths: collections::HashMap[String, Counts],
    requests: i64,
    failures: i64,
}

impl Report {
    pub fn() -> Report sync {
        return Report { paths: collections::HashMap(), requests: 0, failures: 0 }
    }

    // `sync`: this function cannot pause, and the compiler checks it.
    fn record(ref mut self, e: Hit) sync {
        self.requests += 1
        if e.status >= 400 { self.failures += 1 }
        self.paths
            .entry(e.path)
            .and_modify fn (counts) { counts.add(e.bytes) }
            .or_insert_with fn { Counts(e.bytes) }
    }

    fn merge(ref mut self, other: Report) sync {
        self.requests += other.requests
        self.failures += other.failures
        for (path, counts) in other.paths.drain() {
            self.paths
                .entry(path)
                .and_modify fn (into) { into.merge(ref counts) }
                .or_insert(counts)
        }
    }
}

fn main() throws {
    let path = cli::args().nth(1) ?? "access.log"

    let data = fs::map(ref path, fs::Root::Anywhere) catch {
        eprintln(f"cannot open {path}: {error}")
        return
    }

    // A malformed line is an error that `catch` prints: its line and column
    // in the whole file, and what was expected there. No summary is printed.
    let report = Log::file(data) catch {
        eprintln(f"{path}: {error}")
        return
    }

    println(f"{report.requests} requests, {report.failures} failed")

    // A map keeps no order, so the report chooses one.
    let mut names = report.paths.keys().collect()
    names.sort()

    // One row per path, as a tuple: the name and the two numbers.
    let mut rows = names.map fn(path) {
        // A lookup gives a `Counts?` that may be absent; `??` handles that
        // case, which cannot happen for the map's own keys.
        let counts = report.paths[path] ?? panic(f"{path} was a key a moment ago")
        (path, counts.hits, counts.bytes)
    }

    // `sort_by_key` is stable: rows with the same number of hits keep the name
    // order from the first sort.
    rows.sort_by_key fn(row) { -row.1 }

    let body = rows
        .map fn(row) { f"{row.0} {row.1} {row.2}" }
        .join("\n")

    println(f"{body}")
}

The file itself · all the examples

This site is open source. Improve this page.