What we are building

The goal is not to create a full observability pipeline. Instead, we will build a focused utility that can help answer questions like:

  • How many errors occurred in the last deployment?
  • Which log levels dominate a given file?
  • Are there suspicious lines that do not match the expected format?

A simple analyzer is a good beginner project because it touches several core Rust concepts without requiring advanced architecture.

Example input

We will assume a log format like this:

2026-04-20T10:15:30Z INFO server started
2026-04-20T10:15:31Z WARN cache miss for user:42
2026-04-20T10:15:32Z ERROR database connection failed
2026-04-20T10:15:33Z INFO request completed

The analyzer will:

  1. Read the file line by line
  2. Extract the log level from each line
  3. Count occurrences of each level
  4. Report malformed lines separately

Why this is useful

Log analysis is one of the most common developer tasks. Even a lightweight tool can save time during debugging, incident response, or release validation.

Rust is especially well suited here because it gives you:

  • Predictable performance for large files
  • Strong correctness guarantees when handling text and errors
  • Easy distribution as a single binary
  • Good standard library support for file and string processing

If you later want to extend the tool, you can add features such as filtering by date, searching for keywords, or writing JSON output.


Project setup

Create a new Rust project:

cargo new log_analyzer
cd log_analyzer

You only need the standard library for this tutorial. That keeps the example focused and makes it easy to understand the moving parts.

Your project structure will look like this:

log_analyzer/
├── Cargo.toml
└── src/
    └── main.rs

Designing the data model

Before writing code, decide what information the program should track. For this tutorial, we will count:

  • INFO
  • WARN
  • ERROR
  • OTHER for lines that do not match the expected pattern

A small struct is enough:

#[derive(Default, Debug)]
struct LogSummary {
    info: usize,
    warn: usize,
    error: usize,
    other: usize,
}

Using Default lets us create a zero-initialized summary easily. Debug is useful while developing and testing.


Parsing a log line

We will treat the second whitespace-separated token as the log level. For example:

  • 2026-04-20T10:15:30Z INFO server startedINFO
  • 2026-04-20T10:15:32Z ERROR database connection failedERROR

This is intentionally simple, but the pattern is common in many application logs.

Add this helper function:

fn parse_level(line: &str) -> Option<&str> {
    let mut parts = line.split_whitespace();
    parts.next()?; // timestamp
    parts.next()   // level
}

Why Option?

A line may be empty or malformed. Returning Option<&str> lets the caller decide how to handle that case without panicking.


Counting log levels

Now we can write a function that processes a single line and updates the summary:

fn update_summary(summary: &mut LogSummary, line: &str) {
    match parse_level(line) {
        Some("INFO") => summary.info += 1,
        Some("WARN") => summary.warn += 1,
        Some("ERROR") => summary.error += 1,
        Some(_) => summary.other += 1,
        None => summary.other += 1,
    }
}

This function is small, but it demonstrates an important Rust pattern: pass mutable state explicitly and keep the logic local.


Reading the file efficiently

For small files, reading the entire contents into memory is fine. For log files, though, line-by-line processing is usually better because logs can be large.

Use BufReader to stream lines efficiently:

use std::fs::File;
use std::io::{self, BufRead, BufReader};

fn analyze_file(path: &str) -> io::Result<LogSummary> {
    let file = File::open(path)?;
    let reader = BufReader::new(file);

    let mut summary = LogSummary::default();

    for line_result in reader.lines() {
        let line = line_result?;
        update_summary(&mut summary, &line);
    }

    Ok(summary)
}

What this code does

  • File::open(path)? opens the file and propagates any I/O error
  • BufReader::new(file) adds buffering for efficient line reads
  • reader.lines() yields Result<String, io::Error> for each line
  • ? propagates errors cleanly to the caller

This is idiomatic Rust: simple control flow, explicit error handling, and no hidden exceptions.


Building the command-line interface

A useful tool should accept a file path from the command line. We can read arguments with std::env::args().

Here is a complete main.rs:

use std::env;
use std::fs::File;
use std::io::{self, BufRead, BufReader};

#[derive(Default, Debug)]
struct LogSummary {
    info: usize,
    warn: usize,
    error: usize,
    other: usize,
}

fn parse_level(line: &str) -> Option<&str> {
    let mut parts = line.split_whitespace();
    parts.next()?;
    parts.next()
}

fn update_summary(summary: &mut LogSummary, line: &str) {
    match parse_level(line) {
        Some("INFO") => summary.info += 1,
        Some("WARN") => summary.warn += 1,
        Some("ERROR") => summary.error += 1,
        Some(_) => summary.other += 1,
        None => summary.other += 1,
    }
}

fn analyze_file(path: &str) -> io::Result<LogSummary> {
    let file = File::open(path)?;
    let reader = BufReader::new(file);

    let mut summary = LogSummary::default();

    for line_result in reader.lines() {
        let line = line_result?;
        update_summary(&mut summary, &line);
    }

    Ok(summary)
}

fn print_summary(summary: &LogSummary) {
    println!("Log analysis summary");
    println!("--------------------");
    println!("INFO : {}", summary.info);
    println!("WARN : {}", summary.warn);
    println!("ERROR: {}", summary.error);
    println!("OTHER: {}", summary.other);
}

fn main() {
    let path = match env::args().nth(1) {
        Some(path) => path,
        None => {
            eprintln!("Usage: log_analyzer <path-to-log-file>");
            std::process::exit(1);
        }
    };

    match analyze_file(&path) {
        Ok(summary) => print_summary(&summary),
        Err(err) => {
            eprintln!("Failed to analyze file '{}': {}", path, err);
            std::process::exit(1);
        }
    }
}

Running the tool

Create a sample file named app.log:

2026-04-20T10:15:30Z INFO server started
2026-04-20T10:15:31Z WARN cache miss for user:42
2026-04-20T10:15:32Z ERROR database connection failed
2026-04-20T10:15:33Z INFO request completed
bad line without expected format

Run the program:

cargo run -- app.log

Expected output:

Log analysis summary
--------------------
INFO : 2
WARN : 1
ERROR: 1
OTHER: 1

This confirms that the analyzer correctly handles both valid and malformed lines.


Handling malformed data more carefully

In real logs, not every line is trustworthy. Some may be truncated, partially written, or formatted differently by another service. A robust analyzer should not fail just because one line is bad.

Our current implementation counts malformed lines as OTHER. That is a reasonable default for a beginner tool, but you may want to distinguish between:

  • Unknown level: the line has a level token, but it is not one of the expected values
  • Malformed line: the line does not even contain two tokens

You can model that distinction with a richer enum:

enum LineKind<'a> {
    Info,
    Warn,
    Error,
    Other(&'a str),
    Malformed,
}

That approach becomes useful if you later want to report warnings or export structured results.


Best practices for small Rust utilities

Even simple tools benefit from a few habits that make them easier to maintain.

PracticeWhy it helps
Stream large files with BufReaderAvoids loading the entire file into memory
Return Result from I/O functionsKeeps error handling explicit and composable
Separate parsing from reportingMakes code easier to test and extend
Use small helper functionsImproves readability and reuse
Prefer eprintln! for errorsKeeps diagnostics separate from normal output

These patterns scale well as the tool grows.

Keep parsing logic isolated

If you later add support for timestamps, modules, or request IDs, avoid embedding all parsing rules inside main. Instead, keep a dedicated parser function or module. That makes it easier to test edge cases and change the log format without rewriting the whole program.

Be careful with assumptions

This tutorial assumes a fixed token order. In production systems, logs often vary by service, environment, or library. If you need to support multiple formats, consider:

  • Regex-based parsing
  • Structured logs such as JSON
  • Configurable format strings

For a first version, though, a simple whitespace parser is often enough.


Extending the analyzer

Once the basic version works, there are several practical improvements you can add.

1. Count total lines

Add a total field to the summary so users can see the size of the input.

2. Track the top error messages

Instead of only counting levels, extract the message portion of ERROR lines and count repeated failures.

3. Support multiple files

Accept several paths and aggregate the results across all of them.

4. Add filtering

Allow users to analyze only lines containing a keyword such as payment or auth.

5. Output machine-readable data

Print JSON or CSV if the analyzer will be used in scripts or CI pipelines.

These features are natural next steps because the current structure already separates reading, parsing, and reporting.


Testing the parser

A small parser is easy to test, and tests are especially valuable when working with text formats.

Add a test module to main.rs or move the logic into a library module later:

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn parses_level_from_valid_line() {
        let line = "2026-04-20T10:15:30Z INFO server started";
        assert_eq!(parse_level(line), Some("INFO"));
    }

    #[test]
    fn returns_none_for_short_line() {
        let line = "invalid";
        assert_eq!(parse_level(line), None);
    }
}

These tests verify the assumptions behind your parser and help prevent regressions when you change the format later.


Summary

You have built a practical Rust command-line tool that reads a log file, parses each line, and summarizes log levels. Along the way, you used several foundational Rust techniques:

  • File I/O with File and BufReader
  • Safe parsing with Option
  • Error propagation with Result and ?
  • Mutable state passed through helper functions
  • Basic testing for parser behavior

This pattern is a strong starting point for many developer tools. Once you are comfortable with it, you can evolve the analyzer into a more capable log inspection utility without changing the core structure.

Learn more with useful resources