1 The Problem
We want a log analyser: read a server log file, count how many entries are errors versus normal, find which hour had the most traffic, and list the most frequent error messages. It teaches parsing semi-structured text at scale and aggregating it into useful insight — a daily task in operations.
2 How to Think About It
Think about turning lines into counts, before any code:
3 The Build — explained part by part
Here is the complete analyser. Go has no built-in equivalent of Python’s collections.Counter, so this project builds its counting and “top N” logic explicitly with a plain map and sort.Slice. Each part is explained below.
package main
import (
"bufio"
"fmt"
"os"
"sort"
"strings"
)
// Entry is one parsed log line.
type Entry struct {
Hour string
Level string
Message string
}
// parseLine expects: "2026-06-24 14:30:00 ERROR Database timeout"
func parseLine(line string) (Entry, bool) {
parts := strings.SplitN(line, " ", 4)
if len(parts) < 4 {
return Entry{}, false
}
timePart, level, message := parts[1], parts[2], parts[3]
hour := timePart
if len(hour) >= 2 {
hour = hour[:2]
}
return Entry{Hour: hour, Level: level, Message: strings.TrimSpace(message)}, true
}
// CountPair is one key and how many times it occurred.
type CountPair struct {
Key string
Count int
}
// topN returns the N highest-count entries, most frequent first — Go has no
// built-in Counter.most_common, so this is the explicit equivalent.
func topN(counts map[string]int, n int) []CountPair {
pairs := make([]CountPair, 0, len(counts))
for k, v := range counts {
pairs = append(pairs, CountPair{k, v})
}
sort.Slice(pairs, func(i, j int) bool { return pairs[i].Count > pairs[j].Count })
if len(pairs) > n {
pairs = pairs[:n]
}
return pairs
}
// Report is the finished analysis.
type Report struct {
Total int
Errors int
BusiestHour string
BusiestCount int
TopErrors []CountPair
}
func analyse(lines []string) Report {
levels := map[string]int{}
hours := map[string]int{}
errors := map[string]int{}
total := 0
for _, line := range lines {
entry, ok := parseLine(line)
if !ok {
continue
}
levels[entry.Level]++
hours[entry.Hour]++
total++
if entry.Level == "ERROR" {
errors[entry.Message]++
}
}
report := Report{Total: total, Errors: levels["ERROR"], TopErrors: topN(errors, 3)}
if busiest := topN(hours, 1); len(busiest) > 0 {
report.BusiestHour = busiest[0].Key
report.BusiestCount = busiest[0].Count
}
return report
}
func main() {
file, err := os.Open("server.log")
if err != nil {
fmt.Println("Could not open server.log:", err)
return
}
defer file.Close()
var lines []string
scanner := bufio.NewScanner(file)
for scanner.Scan() {
lines = append(lines, scanner.Text())
}
report := analyse(lines)
fmt.Printf("Total entries: %d\n", report.Total)
fmt.Printf("Errors: %d\n", report.Errors)
fmt.Printf("Busiest hour: (%q, %d)\n", report.BusiestHour, report.BusiestCount)
fmt.Print("Top errors: [")
for i, e := range report.TopErrors {
if i > 0 {
fmt.Print(", ")
}
fmt.Printf("(%q, %d)", e.Key, e.Count)
}
fmt.Println("]")
}
N is doing real work here.map[string]int used as a counter — Go has no
Counter class, but a map from key to int does the same job: counts[key]++ works even the first time (a missing key reads as its zero value, 0, then increments to 1).type CountPair struct { Key string; Count int } plus topN — since a Go map has no defined iteration order and no built-in “most common” method, we copy the counts into a slice of pairs and sort it ourselves with
sort.Slice, comparing by Count in descending order. This is exactly what Python’s Counter.most_common(n) does for you automatically — here it is one small function you write once and reuse for both the busiest hour and the top errors.bufio.Scanner — read the log file line by line without loading the whole thing into memory at once, the right approach for a file that could be gigabytes long in a real system.
strings.Split(line, " ") instead of SplitN(line, " ", 4) — a multi-word error message gets chopped into extra pieces and the field count check breaks.for k, v := range counts visits entries in a random order every run.parts[3] on a malformed line panics.len(parts) < 4 and skip the line first, as parseLine does.4 Test & Prove Each Part
We test parsing a log line and the aggregation, using a few known lines.
package main
import "testing"
var testLines = []string{
"2026-06-24 14:30:00 ERROR Database timeout",
"2026-06-24 14:45:00 INFO Request served",
"2026-06-24 15:00:00 ERROR Disk full",
}
func TestParse(t *testing.T) {
entry, ok := parseLine(testLines[0])
if !ok {
t.Fatalf("parseLine(%q) failed to parse", testLines[0])
}
if entry.Hour != "14" {
t.Errorf("entry.Hour = %q; want %q", entry.Hour, "14")
}
if entry.Level != "ERROR" {
t.Errorf("entry.Level = %q; want %q", entry.Level, "ERROR")
}
}
func TestErrorCount(t *testing.T) {
report := analyse(testLines)
if report.Errors != 2 {
t.Errorf("report.Errors = %d; want 2", report.Errors)
}
}
func TestBusiestHour(t *testing.T) {
report := analyse(testLines)
if report.BusiestHour != "14" {
t.Errorf("report.BusiestHour = %q; want %q", report.BusiestHour, "14")
}
}
Run with go test -v ./.... We feed analyse a few known log lines so every count can be checked by hand. This is how you trust an analyser before running it on millions of real lines.
5 The Interface
What it expects
2026-06-24 14:30:00 ERROR Database timeoutWhat it returns
Total: 6 Errors: 3
Busiest hour: ("14", 3)
Top errors: [("Database timeout", 2), ...]6 Run It & Automate It
Save the code as loganalyse.go and run it with go run loganalyse.go — Go compiles and executes in one step, no separate build needed while you are experimenting.
go run loganalyse.goPoint it at a
server.log file in the same folder to get a full report.A CI tool like Jenkins runs go test automatically whenever the code changes — every line below has a plain explanation.
Total entries: 6
Errors: 3
Busiest hour: ("14", 3)
Top errors: [("Database timeout", 2), ("Disk full", 1)]server.log file in the same directory as the program, with lines in the expected format.len(parts) < 4 returns false, nil before indexing further.// Jenkinsfile — runs the tests automatically every time the code changes.
pipeline {
agent any // run on any available machine
stages {
stage('Get the code') {
steps { checkout scm } // download the latest code
}
stage('Set up Go') {
steps {
sh 'go version' // confirm Go is installed
sh 'test -f go.mod || go mod init log_analyser' // create a module if none exists
}
}
stage('Run the tests') {
steps {
sh 'go vet ./...' // catch obvious mistakes before running
sh 'go test -v ./...' // run every test, show each result
}
}
}
post {
success { echo 'All tests passed.' }
failure { echo 'A test failed — look above.' }
}
}
- Date filtering. Parse the date with
time.Parseand only include one day. (Teaches: thetimepackage.) - Regex parsing. Handle varied log formats with
regexpinstead of a fixed split. (Teaches: regular expressions.) - Live tail. Keep reading a log file as new lines arrive, like
tail -f. (Teaches: following a growing file.)
sort.Slice-based “most common” helper, since Go has no built-in Counter. Turning raw logs into insight is a vital operations skill in any language. Related: Maps, Standard Library.