
Getting Started with Rust: Building a Simple Log File Analyzer
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 completedThe analyzer will:
- Read the file line by line
- Extract the log level from each line
- Count occurrences of each level
- 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_analyzerYou 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.rsDesigning the data model
Before writing code, decide what information the program should track. For this tutorial, we will count:
INFOWARNERROROTHERfor 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 started→INFO2026-04-20T10:15:32Z ERROR database connection failed→ERROR
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 errorBufReader::new(file)adds buffering for efficient line readsreader.lines()yieldsResult<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 formatRun the program:
cargo run -- app.logExpected output:
Log analysis summary
--------------------
INFO : 2
WARN : 1
ERROR: 1
OTHER: 1This 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.
| Practice | Why it helps |
|---|---|
Stream large files with BufReader | Avoids loading the entire file into memory |
Return Result from I/O functions | Keeps error handling explicit and composable |
| Separate parsing from reporting | Makes code easier to test and extend |
| Use small helper functions | Improves readability and reuse |
Prefer eprintln! for errors | Keeps 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
FileandBufReader - Safe parsing with
Option - Error propagation with
Resultand? - 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.
