Skip to Content
Course content

165: Practice Exercise: Building a Simple CLI Log Viewer

Click on the "Edit" button in the top corner of the screen to edit your slide content.

I've spent a lot of my career digging through massive `.log` files on remote servers. Usually, I just use grep or tail, but there are times when you need a custom tool—maybe something that parses a specific weird timestamp format or filters by a complex set of rules—where a quick Go utility is the way to go.

Let's build a simple CLI log viewer together. I'm not going to give you a finished blueprint; instead, let's just start coding and see where we hit walls.

The "Easy Way" that fails at scale

My first instinct when reading a file is usually os.ReadFile. It's simple: you give it a path, it gives you a byte slice. Let's try that.

package main

import (
	"fmt"
	"os"
	"strings"
)

func main() {
	data, _ := os.ReadFile("server.log")
	lines := strings.Split(string(data), "\n")
	for _, line := range lines {
		if strings.Contains(line, "ERROR") {
			fmt.Println(line)
		}
	}
}

This works perfectly for a 10KB file. But here's the problem: log files in the real world are often gigabytes in size. If I run this against a 4GB production log, Go is going to try to allocate 4GB of RAM immediately, and the OS is likely to kill my process. We're treating the log like a single string, which is a classic rookie mistake in systems programming.

Switching to a stream

We need to process the file line by line without loading the whole thing into memory. This is where bufio.Scanner comes in. It's like a sliding window moving through the file.

package main

import (
	"bufio"
	"fmt"
	"os"
	"strings"
)

func main() {
	file, err := os.Open("server.log")
	if err != nil {
		fmt.Println("Error opening file:", err)
		return
	}
	defer file.Close()

	scanner := bufio.NewScanner(file)
	for scanner.Scan() {
		line := scanner.Text()
		if strings.Contains(line, "ERROR") {
			fmt.Println(line)
		}
	}
}

This is much better. The memory footprint stays flat regardless of the file size. But wait—I just noticed something. If the log file has an incredibly long line (some JSON logs can be massive), bufio.Scanner will actually fail because it has a default buffer limit (usually 64KB). If we hit a line longer than that, scanner.Scan() returns false and stops silently.

I always forget to check the error after the loop. Let's add that so we aren't flying blind.

	for scanner.Scan() {
		// ... processing ...
	}

	if err := scanner.Err(); err != nil {
		fmt.Fprintln(os.Stderr, "reading standard input:", err)
	}

Adding a bit of flexibility

Hardcoding "ERROR" and "server.log" makes this a script, not a tool. I want to be able to search for different terms and target different files. I'll use the flag package. I've found that keeping flags simple is better than over-engineering a complex CLI parser for a small utility.

package main

import (
	"bufio"
	"flag"
	"fmt"
	"os"
	"strings"
)

func main() {
	// Define our flags
	filePath := flag.String("file", "", "Path to the log file")
	searchTerm := flag.String("search", "ERROR", "Term to filter by")
	flag.Parse()

	if *filePath == "" {
		fmt.Println("Please provide a file path using -file")
		os.Exit(1)
	}

	file, err := os.Open(*filePath)
	if err != nil {
		fmt.Printf("Could not open %s: %v\n", *filePath, err)
		os.Exit(1)
	}
	defer file.Close()

	scanner := bufio.NewScanner(file)
	for scanner.Scan() {
		line := scanner.Text()
		if strings.Contains(line, *searchTerm) {
			fmt.Println(line)
		}
	}

	if err := scanner.Err(); err != nil {
		fmt.Printf("Error reading file: %v\n", err)
	}
}

Now we have a tool that streams data, handles memory efficiently, warns us if the buffer overflows, and takes user input. It's not grep, but it's a solid foundation for a custom log processor.




📋 Practical Task

Exercise: Build a Log Viewer with Line Limiting

Your task is to extend the log viewer logic we just explored. Create a CLI tool that allows a user to view logs with a specific limit on how many matching lines are returned.

Requirements:

  • Implement a -file flag (string) for the log path.
  • Implement a -search flag (string) for the keyword.
  • Implement a -limit flag (int) that stops the program once it has found X number of matching lines. If the limit is 0 or negative, it should show all matches.
  • Use bufio.Scanner to ensure the tool can handle files larger than the available system RAM.
  • Ensure you check for scanner.Err() after the loop finishes.

Test Case:
If you have a file with 100 "ERROR" lines and you run go run main.go -file app.log -search ERROR -limit 10, the output should contain exactly 10 lines and then exit.

Rating
0 0

There are no comments for now.

to be the first to leave a comment.