diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 2405460f..b3e9f92b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -26,6 +26,14 @@ Please note that this project is released with a [Contributor Code of Conduct](C ## **How to Contribute** +## Go Version Policy + + - The repository supports Go 1.19+ (README). + - CI workflows use **Go 1.25** (`.github/workflows/pr-tests.yml` sets `go-version: 1.25.0`). + - Some modules declare higher Go versions due to dependencies: + - Go 1.24 (`golang.org/x/sys v0.37.0` requires 1.24) + - Contributors using Go versions ≥1.19 should be aware that CI may run a newer Go version, which could produce warnings if older syntax or deprecated features are used. + ### **Submitting a Solution** You can submit solutions to both Classic and Package challenges: diff --git a/packages/logrus/challenge-1-basic-logging-and-levels/README.md b/packages/logrus/challenge-1-basic-logging-and-levels/README.md new file mode 100644 index 00000000..e3a5fd71 --- /dev/null +++ b/packages/logrus/challenge-1-basic-logging-and-levels/README.md @@ -0,0 +1,75 @@ +# Challenge 1: Basic Logging & Levels + +Build a simple **Logbook Application** that demonstrates fundamental logging concepts using the `logrus` library. This challenge will teach you how to set up a logger, use different log levels, and control log output format. + +## Challenge Requirements + +Create a simple Go application that simulates adding entries to a logbook. The application must: + +1. **Initialize Logrus**: Set up a global `logrus` logger instance +2. **Use Different Log Levels**: Implement a function that logs messages at various severity levels: `Debug`, `Info`, `Warn`, `Error`, `Fatal`, and `Panic` +3. **Control Log Output Level**: The application should be configurable to show logs above a certain severity. For example, setting the level to `info` should hide `debug` messages +4. **Format Logs**: Configure the logger to output logs in a structured `JSON` format + +## How It Should Work + +You will build a simple `runLogbookOperations` function that simulates a logbook's daily operations. The application's logging output will be controlled by setting the log level. + +### Sample Output + +**When the log level is set to `info`:** +The output should only include `info`, `warn`, `error`, and `fatal` messages. The `fatal` log will terminate the application, so `panic` will not be reached. + +```json +{"level":"info","msg":"Logbook application starting up.","time":"2025-10-02T17:30:00+05:30"} +{"level":"info","msg":"Opening today's log entry.","time":"2025-10-02T17:30:00+05:30"} +{"level":"warning","msg":"Disk space is running low.","time":"2025-10-02T17:30:00+05:30"} +{"level":"error","msg":"Failed to connect to remote backup service.","time":"2025-10-02T17:30:00+05:30"} +{"level":"fatal","msg":"Critical configuration file 'config.yml' not found.","time":"2025-10-02T17:30:00+05:30"} +``` +### When the log level is set to `debug` + +The output should include all messages from `debug` up to `fatal`. + +```json +{"level":"debug","msg":"Checking system status...","time":"2025-10-02T17:30:00+05:30"} +{"level":"debug","msg":"Memory usage: 256MB","time":"2025-10-02T17:30:00+05:30"} +{"level":"info","msg":"Logbook application starting up.","time":"2025-10-02T17:30:00+05:30"} +{"level":"info","msg":"Opening today's log entry.","time":"2025-10-02T17:30:00+05:30"} +{"level":"warning","msg":"Disk space is running low.","time":"2025-10-02T17:30:00+05:30"} +{"level":"error","msg":"Failed to connect to remote backup service.","time":"2025-10-02T17:30:00+05:30"} +{"level":"fatal","msg":"Critical configuration file 'config.yml' not found.","time":"2025-10-02T17:30:00+05:30"} +``` + +## Implementation Requirements + +### Logger Configuration (`setupLogger` function) + +- Set the log formatter to `logrus.JSONFormatter` +- Set the output to `os.Stdout` +- Set the log level based on a provided string (e.g., `"info"`, `"debug"`). If the string is invalid, default to `logrus.InfoLevel` + +### Main Logic (`runLogbookOperations` function) + +This function should contain at least one log statement for each of the six levels: + +- **Debug**: Log verbose details useful for development (e.g., `"Checking system status..."`) +- **Info**: Log informational messages about application progress (e.g., `"Logbook application starting up."`) +- **Warn**: Log potential issues that don't prevent the application from running (e.g., `"Disk space is running low."`) +- **Error**: Log errors the application might recover from (e.g., `"Failed to connect to remote backup service."`) +- **Fatal**: Log a critical error that must terminate the application (e.g., `"Critical configuration file not found."`) +- `Fatal` calls `os.Exit(1)` after logging +- **Panic**: Log a message and then panic. This is for unrecoverable application states + +--- + +## Testing Requirements + +Your solution must pass tests that verify: + +- The logger is correctly configured (formatter, output) +- Setting a specific log level (e.g., `Warn`) correctly filters out lower-level messages (`Info`, `Debug`) +- Messages are logged in the expected JSON format +- A **Fatal** level log correctly triggers an exit +- A **Panic** level log correctly causes a panic +--- \ No newline at end of file diff --git a/packages/logrus/challenge-1-basic-logging-and-levels/go.mod b/packages/logrus/challenge-1-basic-logging-and-levels/go.mod new file mode 100644 index 00000000..8c375887 --- /dev/null +++ b/packages/logrus/challenge-1-basic-logging-and-levels/go.mod @@ -0,0 +1,15 @@ +module logrus + +go 1.21 + +require ( + github.com/sirupsen/logrus v1.9.3 + github.com/stretchr/testify v1.11.1 +) + +require ( + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/packages/logrus/challenge-1-basic-logging-and-levels/go.sum b/packages/logrus/challenge-1-basic-logging-and-levels/go.sum new file mode 100644 index 00000000..08b7cdee --- /dev/null +++ b/packages/logrus/challenge-1-basic-logging-and-levels/go.sum @@ -0,0 +1,18 @@ +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= +github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8 h1:0A+M6Uqn+Eje4kHMK80dtF3JCXC4ykBgQG4Fe06QRhQ= +golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/packages/logrus/challenge-1-basic-logging-and-levels/hints.md b/packages/logrus/challenge-1-basic-logging-and-levels/hints.md new file mode 100644 index 00000000..0abe1887 --- /dev/null +++ b/packages/logrus/challenge-1-basic-logging-and-levels/hints.md @@ -0,0 +1,98 @@ +# Hints for Challenge 1: Basic Logging & Levels + +## Hint 1: Configuring the Logger Output + +In the `setupLogger` function, the first step is to tell Logrus where to send the logs. The function receives an `io.Writer` named `out`. + +```go +// Tell the global logrus logger to write to the `out` variable. +logrus.SetOutput(out) +``` + +--- + +## Hint 2: Setting the JSON Formatter + +To make logs structured, you need to set the formatter. Create a new instance of `logrus.JSONFormatter` and pass its address to `logrus.SetFormatter`. + +```go +// This tells logrus to format all subsequent logs as JSON. +logrus.SetFormatter(&logrus.JSONFormatter{}) +``` + +--- + +## Hint 3: Parsing and Setting the Log Level + +You need to convert the `level` string (e.g., `"debug"`) into a `logrus.Level` type. The `logrus.ParseLevel` function does this for you. It returns the level and an error if the string is invalid. + +```go +// Try to parse the level string. +lvl, err := logrus.ParseLevel(level) + +// If the string is not a valid level, `err` will not be nil. +if err != nil { + // In case of an error, we fall back to a sensible default. + logrus.SetLevel(logrus.InfoLevel) +} else { + // If parsing was successful, use the parsed level. + logrus.SetLevel(lvl) +} +``` + +--- + +## Hint 4: Logging the First Message + +In the `runLogbookOperations` function, you can use the package-level functions like `logrus.Debug()`, `logrus.Info()`, etc., to log messages. + +```go +// For the first TODO, use the Debug function. +logrus.Debug("Checking system status...") +``` + +--- + +## Hint 5: Logging the Remaining Messages + +Follow the same pattern for the other log levels in the specified order. + +```go +func runLogbookOperations() { + logrus.Debug("Checking system status...") + logrus.Info("Logbook application starting up") + logrus.Warn("Disk space is running low") + logrus.Error("Failed to connect to remote backup service") + logrus.Fatal("Critical configuration file 'config.yml' not found") + logrus.Panic("Unhandled database connection issue") +} +``` + +--- + +## Hint 6: Understanding Fatal and Panic + +Remember the special behavior of `Fatal` and `Panic`: + +* `logrus.Fatal(...)` will log the message **and then immediately terminate** the program (by calling `os.Exit(1)`). No code after it will run. +* `logrus.Panic(...)` will log the message **and then cause a panic**. + +This means in a normal run, you will never see the `Panic` log if the `Fatal` log comes before it. The tests are designed to handle these specific behaviors. + +--- + +## Hint 7: Running and Testing Your Code + +Once you've filled in the TODOs, you can run your `main.go` file from the terminal and pass a log level as an argument to see the effect. + +```bash +# Run with the default "info" level (no debug messages) +go run . + +# Run and show only messages from "warn" level and above +go run . warn + +# Run with "debug" level to see all messages +go run . debug +``` +--- \ No newline at end of file diff --git a/packages/logrus/challenge-1-basic-logging-and-levels/learning.md b/packages/logrus/challenge-1-basic-logging-and-levels/learning.md new file mode 100644 index 00000000..a1717d45 --- /dev/null +++ b/packages/logrus/challenge-1-basic-logging-and-levels/learning.md @@ -0,0 +1,158 @@ +# Learning: Basic Logging in Go with Logrus + +## **What is Logging?** + +Logging is how your application records what’s happening while it runs. It’s your “black box.” +When something fails in production, logs are usually the only way to see what went wrong. + +**Why log?** + +* **Debugging** – track down issues without a debugger +* **Monitoring** – check health/performance +* **Auditing** – record important events +* **Clarity** – know what your code was doing at a given time + +--- + +## **Go’s Built-in Logging** + +Go ships with the `log` package: + +```go +import "log" + +func main() { + log.Println("Hello from standard log") +} +``` + +Output looks like: + +``` +2025/10/02 18:00:00 Hello from standard log +``` + +It’s nice for basics, but: + +* No levels (everything is just a line) +* No JSON/structured output +* Hard to configure + +--- + +## **Logrus: A Better Logger** + +[Logrus](https://github.com/sirupsen/logrus) is the most common logging library for Go. +It’s compatible with `log` but adds: + +* **Levels**: Debug, Info, Warn, Error, Fatal, Panic +* **Formatters**: Text or JSON +* **Configurable Output**: Console, file, or anything that implements `io.Writer` + +--- + +## **Core Concepts** + +### **1. Setup Logger** + +```go +import ( + "os" + "github.com/sirupsen/logrus" +) + +func setupLogger() { + logrus.SetOutput(os.Stdout) // send logs to console + logrus.SetFormatter(&logrus.JSONFormatter{}) // use JSON format + logrus.SetLevel(logrus.InfoLevel) // default: Info and above +} +``` + +--- + +### **2. Log Levels** + +Levels control which logs are shown. + +From lowest → highest severity: + +* `Debug` → development details +* `Info` → normal operations +* `Warn` → something’s off but still running +* `Error` → operation failed +* `Fatal` → critical, app exits +* `Panic` → logs + panics + +Example: + +```go +logrus.Debug("Checking system status...") +logrus.Info("Logbook app starting") +logrus.Warn("Disk space is low") +logrus.Error("Failed to connect to backup service") +logrus.Fatal("Critical config file missing") // exits +logrus.Panic("Database connection issue") // panics +``` + +--- + +### **3. Choosing Levels at Runtime** + +You can set the level dynamically from input: + +```go +func setupLogger(level string) { + logrus.SetOutput(os.Stdout) + logrus.SetFormatter(&logrus.JSONFormatter{}) + + lvl, err := logrus.ParseLevel(level) + if err != nil { + lvl = logrus.InfoLevel + } + logrus.SetLevel(lvl) +} +``` + +--- + +## **Building a Simple Logbook App** + +```go +func runLogbookOperations() { + logrus.Debug("Checking system status...") + logrus.Info("Logbook application starting up.") + logrus.Warn("Disk space is running low.") + logrus.Error("Failed to connect to remote backup service.") + logrus.Fatal("Critical configuration file 'config.yml' not found.") + logrus.Panic("Unhandled database connection issue.") +} + +func main() { + logLevel := "info" + if len(os.Args) > 1 { + logLevel = os.Args[1] + } + + setupLogger(logLevel) + logrus.Infof("Log level set to '%s'", logrus.GetLevel().String()) + runLogbookOperations() +} +``` + +--- + +## **Best Practices** + +1. Pick the right level: `Debug` for dev, `Info` for normal ops, `Error` when something breaks. +2. Default to JSON formatter — easy to parse later. +3. Don’t log secrets (passwords, keys). +4. Keep messages clear: “Failed to connect to DB” > “error 17”. + +--- + +## 🔗 **Resources** + +* [Logrus GitHub](https://github.com/sirupsen/logrus) +* [Go by Example – Logging](https://gobyexample.com/logging) + +--- \ No newline at end of file diff --git a/packages/logrus/challenge-1-basic-logging-and-levels/metadata.json b/packages/logrus/challenge-1-basic-logging-and-levels/metadata.json new file mode 100644 index 00000000..d600bc82 --- /dev/null +++ b/packages/logrus/challenge-1-basic-logging-and-levels/metadata.json @@ -0,0 +1,42 @@ +{ + "title": "Basic Logging & Levels", + "description": "Build a simple logbook application to learn the fundamentals of logging in Go using Logrus. This challenge covers setting up a logger, using different severity levels, and formatting output for modern observability.", + "short_description": "Learn fundamental logging concepts with the Logrus library", + "difficulty": "Beginner", + "estimated_time": "20-30 min", + "learning_objectives": [ + "Understand the importance of structured logging", + "Configure the Logrus logger (output, formatter, level)", + "Use different log levels (Debug, Info, Warn, Error, Fatal, Panic)", + "Differentiate between Error, Fatal, and Panic behaviors", + "Produce machine-readable logs in JSON format" + ], + "prerequisites": [ + "Basic Go syntax", + "Familiarity with the command line", + "Understanding of standard input/output" + ], + "tags": [ + "logging", + "logrus", + "structured-logging", + "json", + "debugging" + ], + "real_world_connection": "Structured logging is a foundational practice for monitoring, debugging, and maintaining any production-level application, enabling powerful log aggregation and analysis tools.", + "requirements": [ + "Initialize a Logrus logger", + "Set the log format to JSON", + "Control the log output level via configuration", + "Implement log statements for all six standard levels", + "Ensure Fatal logs terminate the application", + "Ensure Panic logs trigger a panic" + ], + "bonus_points": [ + "Add custom fields to log entries using `logrus.WithField`", + "Configure the logger to write to a file instead of standard output", + "Implement a simple custom hook to modify log entries" + ], + "icon": "bi-card-text", + "order": 1 +} \ No newline at end of file diff --git a/packages/logrus/challenge-1-basic-logging-and-levels/run_test.sh b/packages/logrus/challenge-1-basic-logging-and-levels/run_test.sh new file mode 100755 index 00000000..bedda463 --- /dev/null +++ b/packages/logrus/challenge-1-basic-logging-and-levels/run_test.sh @@ -0,0 +1,76 @@ +#!/bin/bash + +# Script to run tests for a participant's submission + +# Function to display usage +usage() { + echo "Usage: $0" + exit 1 +} + +# Verify that we are in a challenge directory +if [ ! -f "solution-template_test.go" ]; then + echo "Error: solution-template_test.go not found. Please run this script from a challenge directory" + exit 1 +fi + +# Prompt for GitHub username +read -p "Enter your GitHub username: " USERNAME + +SUBMISSION_DIR="submissions/$USERNAME" +SUBMISSION_FILE="$SUBMISSION_DIR/solution.go" + +# Check if the submission file exists +if [ ! -f "$SUBMISSION_FILE" ]; then + echo "Error: Solution file '$SUBMISSION_FILE' not found" + echo "Note: Please ensure your solution is named 'solution.go' and placed in a 'submissions//' directory" + exit 1 +fi + +# Create a temporary directory to avoid modifying the original files +TEMP_DIR=$(mktemp -d) + +# Copy the participant's solution, test file, and go.mod/go.sum to the temporary directory +cp "$SUBMISSION_FILE" "solution-template_test.go" "go.mod" "go.sum" "$TEMP_DIR/" 2>/dev/null + +# Rename solution.go to solution-template.go for the test to build correctly +# The test file expects to be in the `main` package alongside the functions it's testing +mv "$TEMP_DIR/solution.go" "$TEMP_DIR/solution-template.go" + +echo "Running tests for user '$USERNAME'..." + +# Navigate to the temporary directory (with error handling) +pushd "$TEMP_DIR" > /dev/null || { + echo "Failed to navigate to temporary directory." + rm -rf "$TEMP_DIR" + exit 1 +} + +# Tidy up dependencies to ensure everything is consistent +echo "Tidying dependencies" +go mod tidy || { + echo "Failed to tidy dependencies." + popd > /dev/null || exit 1 + rm -rf "$TEMP_DIR" + exit 1 +} + +# Run the tests with verbosity and coverage +echo "Executing tests" +go test -v -cover + +TEST_EXIT_CODE=$? + +# Return to the original directory (with error handling) +popd > /dev/null || exit 1 + +# Clean up the temporary directory +rm -rf "$TEMP_DIR" + +if [ $TEST_EXIT_CODE -eq 0 ]; then + echo "All tests passed!" +else + echo "Some tests failed." +fi + +exit $TEST_EXIT_CODE \ No newline at end of file diff --git a/packages/logrus/challenge-1-basic-logging-and-levels/solution-template.go b/packages/logrus/challenge-1-basic-logging-and-levels/solution-template.go new file mode 100644 index 00000000..2d749d16 --- /dev/null +++ b/packages/logrus/challenge-1-basic-logging-and-levels/solution-template.go @@ -0,0 +1,58 @@ +package main + +import ( + "io" + "os" + + "github.com/sirupsen/logrus" +) + +// setupLogger configures the global logrus logger +func setupLogger(out io.Writer, level string) { + // TODO: Set the logger's output. + logrus.SetOutput(out) + + // TODO: Set the logger's formatter to JSON. + logrus.SetFormatter(&logrus.JSONFormatter{}) + + // TODO: Parse and set the log level, defaulting to InfoLevel on error. + lvl, err := logrus.ParseLevel(level) + if err != nil { + logrus.SetLevel(logrus.InfoLevel) + } else { + logrus.SetLevel(lvl) + } +} + +// runLogbookOperations simulates the main logic of the logbook application +func runLogbookOperations() { + // Important Note: The content inside "text of log outputs" should be exact as to not fail the test + + // TODO: Add a Debug log: "Checking system status." + logrus.Debug("Checking system status.") + + // TODO: Add an Info log: "Logbook application starting up." + logrus.Info("Logbook application starting up.") + + // TODO: Add a Warn log: "Disk space is running low." + logrus.Warn("Disk space is running low.") + + // TODO: Add an Error log: "Failed to connect to remote backup service." + logrus.Error("Failed to connect to remote backup service.") + + // TODO: Add a Fatal log: "Critical configuration file 'config.yml' not found." + logrus.Fatal("Critical configuration file 'config.yml' not found.") + + // TODO: Add a Panic log: "Unhandled database connection issue." + logrus.Panic("Unhandled database connection issue.") +} + +func main() { + logLevel := "info" + if len(os.Args) > 1 { + logLevel = os.Args[1] + } + setupLogger(os.Stdout, logLevel) + logrus.Infof("Log level set to '%s'", logrus.GetLevel().String()) + runLogbookOperations() +} \ No newline at end of file diff --git a/packages/logrus/challenge-1-basic-logging-and-levels/solution-template_test.go b/packages/logrus/challenge-1-basic-logging-and-levels/solution-template_test.go new file mode 100644 index 00000000..0d2931b8 --- /dev/null +++ b/packages/logrus/challenge-1-basic-logging-and-levels/solution-template_test.go @@ -0,0 +1,193 @@ +package main + +import ( + "bytes" + "encoding/json" + "io" + "os" + "strings" + "sync" + "testing" + "time" + + "github.com/sirupsen/logrus" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// logEntry is used to unmarshal the JSON log output for verification +type logEntry struct { + Level string `json:"level"` + Msg string `json:"msg"` + Time string `json:"time"` +} + +// captureOutput runs a function while capturing logrus's output +func captureOutput(action func()) string { + var buffer bytes.Buffer + originalOutput := logrus.StandardLogger().Out + logrus.SetOutput(&buffer) + + defer logrus.SetOutput(originalOutput) + action() + return buffer.String() +} + +func TestSetupLogger(t *testing.T) { + t.Run("sets JSON formatter", func(t *testing.T) { + setupLogger(io.Discard, "info") + assert.IsType(t, &logrus.JSONFormatter{}, logrus.StandardLogger().Formatter, "Formatter should be JSONFormatter") + }) + + t.Run("sets correct output", func(t *testing.T) { + var buffer bytes.Buffer + setupLogger(&buffer, "info") + assert.Equal(t, &buffer, logrus.StandardLogger().Out, "Output writer should be set correctly") + }) + + t.Run("sets valid log level", func(t *testing.T) { + setupLogger(io.Discard, "debug") + assert.Equal(t, logrus.DebugLevel, logrus.GetLevel(), "Log level should be set to Debug") + }) + + t.Run("defaults to info for invalid level", func(t *testing.T) { + setupLogger(io.Discard, "invalid-level") + assert.Equal(t, logrus.InfoLevel, logrus.GetLevel(), "Log level should default to Info for invalid input") + }) +} + +func TestLogLevelFiltering(t *testing.T) { + testCases := []struct { + name string + levelToSet string + expectedLogs []string + unexpectedLogs []string + }{ + { + name: "Debug Level", + levelToSet: "debug", + expectedLogs: []string{"Checking system status", "Logbook application starting up", "Disk space is running low"}, + unexpectedLogs: []string{}, + }, + { + name: "Info Level", + levelToSet: "info", + expectedLogs: []string{"Logbook application starting up", "Disk space is running low"}, + unexpectedLogs: []string{"Checking system status"}, + }, + { + name: "Warn Level", + levelToSet: "warn", + expectedLogs: []string{"Disk space is running low", "Failed to connect to remote backup service"}, + unexpectedLogs: []string{"Logbook application starting up", "Checking system status"}, + }, + { + name: "Error Level", + levelToSet: "error", + expectedLogs: []string{"Failed to connect to remote backup service"}, + unexpectedLogs: []string{"Disk space is running low", "Logbook application starting up"}, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + // Mock the exit function to prevent test termination and signal with timeout + done := make(chan struct{}, 1) + originalExitFunc := logrus.StandardLogger().ExitFunc + logrus.StandardLogger().ExitFunc = func(int) { + select { + case done <- struct{}{}: + default: + } + } + defer func() { logrus.StandardLogger().ExitFunc = originalExitFunc }() + + setupLogger(os.Stdout, tc.levelToSet) + + output := captureOutput(func() { + // Recover from panic to allow test to continue + defer func() { + if r := recover(); r != nil { + // A panic is expected for levels below panic + } + }() + runLogbookOperations() + }) + + // For fatal logs, wait for the mocked exit with timeout to avoid hangs + if tc.levelToSet != "panic" { + select { + case <-done: + case <-time.After(2 * time.Second): + t.Fatal("timed out waiting for logrus.Fatal ExitFunc") + } + } + + lines := strings.Split(strings.TrimSpace(output), "\n") + var loggedMessages []string + + for _, line := range lines { + if line == "" { + continue + } + var entry logEntry + err := json.Unmarshal([]byte(line), &entry) + require.NoError(t, err, "Log output should be valid JSON") + loggedMessages = append(loggedMessages, entry.Msg) + } + + for _, expected := range tc.expectedLogs { + assert.Contains(t, loggedMessages, expected, "Expected log message not found") + } + for _, unexpected := range tc.unexpectedLogs { + assert.NotContains(t, loggedMessages, unexpected, "Unexpected log message was found") + } + }) + } +} + +func TestFatalLogsExit(t *testing.T) { + var exited bool + originalExitFunc := logrus.StandardLogger().ExitFunc + logrus.StandardLogger().ExitFunc = func(code int) { + exited = true + } + + defer func() { + logrus.StandardLogger().ExitFunc = originalExitFunc + }() + + // Add a recover block to prevent a subsequent Panic call from crashing this test + defer func() { + if r := recover(); r != nil { + // A panic might occur after Fatal in the test context, which we can ignore + } + }() + + setupLogger(io.Discard, "fatal") + + // We wrap this in a function to ensure defer is called even if Fatal exits + func() { + // We expect runLogbookOperations to call Fatal, which will call our mocked exit func + runLogbookOperations() + }() + + assert.True(t, exited, "logrus.Fatal should call the exit function") +} + +func TestPanicLogsPanic(t *testing.T) { + defer func() { + r := recover() + assert.NotNil(t, r, "Expected a panic to occur") + }() + + setupLogger(io.Discard, "panic") + + // Mock exit function to prevent it from terminating before panic + originalExitFunc := logrus.StandardLogger().ExitFunc + logrus.StandardLogger().ExitFunc = func(int) { /* Do nothing */ } + + defer func() { logrus.StandardLogger().ExitFunc = originalExitFunc }() + + runLogbookOperations() +} \ No newline at end of file diff --git a/packages/logrus/challenge-1-basic-logging-and-levels/submissions/LeeFred3042U/solution.go b/packages/logrus/challenge-1-basic-logging-and-levels/submissions/LeeFred3042U/solution.go new file mode 100644 index 00000000..7f1b4bfe --- /dev/null +++ b/packages/logrus/challenge-1-basic-logging-and-levels/submissions/LeeFred3042U/solution.go @@ -0,0 +1,42 @@ +package main + +import ( + "io" + "os" + + "github.com/sirupsen/logrus" +) + +// setupLogger configures the global logrus logger +func setupLogger(out io.Writer, level string) { + logrus.SetOutput(out) + logrus.SetFormatter(&logrus.JSONFormatter{}) + + lvl, err := logrus.ParseLevel(level) + if err != nil { + logrus.SetLevel(logrus.InfoLevel) + } else { + logrus.SetLevel(lvl) + } +} + +// runLogbookOperations simulates the main logic of the logbook application +func runLogbookOperations() { + logrus.Debug("Checking system status") + logrus.Info("Logbook application starting up.") + logrus.Warn("Disk space is running low.") + logrus.Error("Failed to connect to remote backup service.") + logrus.Fatal("Critical configuration file 'config.yml' not found.") + logrus.Panic("Unhandled database connection issue.") +} + +func main() { + logLevel := "info" + if len(os.Args) > 1 { + logLevel = os.Args[1] + } + + setupLogger(os.Stdout, logLevel) + logrus.Infof("Log level set to '%s'", logrus.GetLevel().String()) + runLogbookOperations() +} \ No newline at end of file diff --git a/packages/logrus/challenge-2-structured-logging-and-fields/README.md b/packages/logrus/challenge-2-structured-logging-and-fields/README.md new file mode 100644 index 00000000..de99e174 --- /dev/null +++ b/packages/logrus/challenge-2-structured-logging-and-fields/README.md @@ -0,0 +1,88 @@ +# Challenge 2: Structured Logging & Fields + +Take your logging skills to the next level by building a simple **HTTP server with context-aware, structured logging**. This challenge focuses on adding contextual fields to your logs, tracing requests through a system using a correlation ID, and demonstrating custom formatters. + +## Challenge Requirements + +You will create a web server that uses a **logging middleware**. This middleware will intercept incoming requests, enrich the logging context with request-specific data, and then pass control to the actual request handler. + +1. **Create a Logging Middleware**: This middleware will be the core of the challenge. For every incoming HTTP request, it must: + * Generate a unique **`request_id`** (using a UUID). + * Create a logger instance pre-filled with contextual fields: `request_id`, `http_method`, `uri`, and `user_agent`. + * Log an initial message like "Request received". + * Pass the enriched logger to the downstream HTTP handler. + +2. **Create an HTTP Handler**: A simple `/hello` handler that: + * Receives the context-aware logger from the middleware. + * Uses this logger to log a message (e.g., "Processing hello request"). This message must automatically include the fields added by the middleware. + * Writes a simple response to the client (e.g., "Hello, world!"). + +3. **Main Function**: Sets up the HTTP server, applies the middleware to the handler, and starts listening for requests. + +4. **Custom Formatter Support**: + * Configure the logger globally to use a formatter. + * Default to `JSONFormatter` for structured logs. + * Also demonstrate how to switch to `TextFormatter` (e.g., via a flag or environment variable) to show formatter flexibility. + +## Expected Log Output + +When you run your server and make a `GET` request to `/hello`, your console output (in **JSON format**) should look similar to this. Notice how the **`request_id` is the same** for both log entries, linking them together. + +```json +{"http_method":"GET","level":"info","msg":"Request received","request_id":"a1b2c3d4-e5f6-7890-1234-567890abcdef","time":"...","uri":"/hello","user_agent":"curl/7.79.1"} +{"level":"info","msg":"Processing hello request","request_id":"a1b2c3d4-e5f6-7890-1234-567890abcdef","time":"...","user_id":"user-99"} +``` + +If you switch to `TextFormatter`, the same logs would be human-readable lines like: + +```bash +time="2025-10-02T18:42:00Z" level=info msg="Request received" request_id=a1b2c3d4-e5f6-7890-1234-567890abcdef http_method=GET uri=/hello user_agent="curl/7.79.1" +time="2025-10-02T18:42:00Z" level=info msg="Processing hello request" request_id=a1b2c3d4-e5f6-7890-1234-567890abcdef user_id=user-99 +``` + +> Note: The second log line from the handler includes an extra field, `user_id`, demonstrating how the context can be further enriched + +## Implementation Requirements + +* **Logger Configuration**: + + * The logger should be configured globally + * Default to `JSONFormatter`, but allow switching to `TextFormatter` to demonstrate formatter flexibility + +* **loggingMiddleware (func)**: + + * Must have the signature `func(http.Handler) http.Handler` + * Inside, it should create an `http.HandlerFunc` + * Use a library like `github.com/google/uuid` to generate the `request_id` + * Create a `logrus.Entry` (a logger with pre-set fields) using `logrus.WithFields()` + * Use Go's `context` package to pass this `logrus.Entry` to the next handler + +* **helloHandler (func)**: + + * Must have the signature `func(http.ResponseWriter, *http.Request)` + * Retrieve the `logrus.Entry` from the request's context + * If the logger isn't found in the context, fall back to the global `logrus` logger + * Add at least one more field to the log (e.g., `user_id`) + * Write a 200 OK response. + +--- + +### Fields vs Structured Body (Quick comparison) + +| Concept | What it is | Example | When to use | +|--------:|:-----------|:--------|:------------| +| Fields | Key-value pairs attached to a log entry (flat). | `request_id=... user_id=user-99 http_method=GET` | Correlating events, simple filtering, fast searches. | +| Structured body | A full structured payload (JSON object) — can include nested objects/arrays. | `{"event":"db.query","sql":"SELECT ...","duration_ms":12}` | Rich context, indexing in log stores, complex queries/visualizations. | +| Combined | Fields + structured body: fields for indexing, body for deep context. | Fields: `request_id=...` Body: `{"user":{"id":"user-99","role":"admin"}}` | Best for production: quick filters + full context when needed. | + +--- +## Testing Requirements + +Your solution must pass tests that verify: + +* The middleware correctly adds the required fields (`request_id`, `http_method`, so on...) +* The `request_id` is a valid UUID and is consistent for logs within the same request +* The handler successfully retrieves and uses the context-aware logger +* The final log output from the handler contains both the middleware fields and the handler-specific fields +* The logger supports switching between JSON and Text formatters +--- \ No newline at end of file diff --git a/packages/logrus/challenge-2-structured-logging-and-fields/go.mod b/packages/logrus/challenge-2-structured-logging-and-fields/go.mod new file mode 100644 index 00000000..dc9387ee --- /dev/null +++ b/packages/logrus/challenge-2-structured-logging-and-fields/go.mod @@ -0,0 +1,16 @@ +module challenge-2-structured-logging-and-fields + +go 1.21 + +require ( + github.com/google/uuid v1.6.0 + github.com/sirupsen/logrus v1.9.3 + github.com/stretchr/testify v1.11.1 +) + +require ( + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/packages/logrus/challenge-2-structured-logging-and-fields/go.sum b/packages/logrus/challenge-2-structured-logging-and-fields/go.sum new file mode 100644 index 00000000..33c463a7 --- /dev/null +++ b/packages/logrus/challenge-2-structured-logging-and-fields/go.sum @@ -0,0 +1,20 @@ +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= +github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8 h1:0A+M6Uqn+Eje4kHMK80dtF3JCXC4ykBgQG4Fe06QRhQ= +golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/packages/logrus/challenge-2-structured-logging-and-fields/hints.md b/packages/logrus/challenge-2-structured-logging-and-fields/hints.md new file mode 100644 index 00000000..a079b29c --- /dev/null +++ b/packages/logrus/challenge-2-structured-logging-and-fields/hints.md @@ -0,0 +1,107 @@ +# Hints for Challenge 2: Structured Logging & Fields + +## Hint 1: Generating the Request ID + +To generate a unique ID for each request, you'll need the `github.com/google/uuid` package. The `uuid.New()` function creates a new UUID object, and its `.String()` method returns it in the standard format + +```go +import "github.com/google/uuid" + +// In the loggingMiddleware... +requestID := uuid.New().String() +``` + +--- + +## Hint 2: Creating the Context-Aware Logger + +The goal is to create a `logrus.Entry` that is pre-loaded with fields. Use `logrus.WithFields()` and pass it a `logrus.Fields` map. You can get the required information directly from the `*http.Request` object (`r`) + +```go +// In the loggingMiddleware... +logger := logrus.WithFields(logrus.Fields{ + "request_id": requestID, + "http_method": r.Method, + "uri": r.RequestURI, + "user_agent": r.UserAgent(), +}) + +// Now, any log call using this `logger` variable will include these fields +logger.Info("Request received") +``` + +--- + +## Hint 3: Putting the Logger into the Context + +To pass the logger to the handler, you need to add it to the request's context. This is a two-step process: + +1. Create a new context from the old one, adding your value +2. Create a new request object that uses this new context + +```go +// In the loggingMiddleware... + +// 1. Create a new context with the logger stored under our custom `key`.\ +ctx := context.WithValue(r.Context(), key, logger) + +// 2. Call the next handler, but replace the request `r` with a copy +// that has the new context +next.ServeHTTP(w, r.WithContext(ctx)) +``` + +--- + +## Hint 4: Retrieving the Logger in the Handler + +In the `helloHandler`, you need to get the logger back out of the context. The `Value()` method returns an `interface{}`, so you must use a type assertion to convert it back to a `*logrus.Entry`. Always check if the assertion was successful with the `ok` variable + +```go +// In the helloHandler... +var logger *logrus.Entry + +// Try to get the logger from context. +loggerFromCtx, ok := r.Context().Value(key).(*logrus.Entry) +if ok { + // Success! Use the logger from the context + logger = loggerFromCtx +} else { + // Fallback: If no logger is in the context, use the default global one + logger = logrus.NewEntry(logrus.StandardLogger()) +} +``` + +--- + +## Hint 5: Adding More Fields + +Once you have a `*logrus.Entry`, you can chain more `.WithField()` or `.WithFields()` calls to it. This creates a new entry with the combined fields. + +```go +// In the helloHandler, after retrieving the logger... +logger = logger.WithField("user_id", "user-99") + +// Now, this log will have the middleware fields AND the user_id field +logger.Info("Processing hello request") +``` + +--- + +## Hint 6: Setting Up the Server in `main` + +Don't forget to configure the global logger in `main`. Then, chain your handlers together. The request will flow through the middleware first, and then to the final handler. + +```go +func main() { + // Configure the global logger + logrus.SetFormatter(&logrus.JSONFormatter{}) + + // Wrap the final handler with the middleware + finalHandler := loggingMiddleware(http.HandlerFunc(helloHandler)) + + // Start the server with the wrapped handler + http.ListenAndServe(":8080", finalHandler) +} +``` + +--- \ No newline at end of file diff --git a/packages/logrus/challenge-2-structured-logging-and-fields/learning.md b/packages/logrus/challenge-2-structured-logging-and-fields/learning.md new file mode 100644 index 00000000..94e89d25 --- /dev/null +++ b/packages/logrus/challenge-2-structured-logging-and-fields/learning.md @@ -0,0 +1,149 @@ +# Learning: Structured Logging & Fields + +This lesson builds on basic logging concepts and shows how to use **structured fields, context-aware logging, custom formatters, and correlation IDs** to trace requests in Go applications. + +--- + +## **Structured Logging with Fields** + +Plain logs like: + +``` +Task failed +``` + +aren’t enough. Instead, use structured logs with **fields**: + +```go +logrus.WithFields(logrus.Fields{ + "task": "ProcessPayment", + "user_id": "user-456", + "status": "failed", +}).Error("Task failed") +``` + +Output: + +```json +{"level":"error","msg":"Task failed","task":"ProcessPayment","user_id":"user-456","status":"failed"} +``` + +Fields make logs queryable and easier to filter by user, component, or operation + +--- +## Structured logging: fields vs structured body +| Concept | Description | Example (JSON) | Pros | Cons | Notes / Tooling | +| ----------------------------: | :------------------------------------------------------------------------------------------------------------------ | :------------------------------------------------------------------------------------------------------------------------------ | :-------------------------------------------------------------------------------- | :-------------------------------------------------------------------------------------- | :--------------------------------------------------------------------------------------------------------------------- | +| Fields (flat key/value) | Flat key-value fields attached to each log entry (log metadata). Easy to index & filter. | `{"level":"info","msg":"Request received","request_id":"...","http_method":"GET"}` | Fast to search and aggregate; good for correlation IDs, status codes, user IDs. | Can be limiting for deeply nested context. | Use for primary searchable dimensions (request ids, user ids, status, latency). `logrus.WithFields()` is a common API. | +| Structured body (nested JSON) | A richer JSON payload inside the log entry—can include nested objects, arrays, and full event contexts. | `{"event":"db.query","sql":"SELECT ...","params":{"user_id":"user-99"},"duration_ms":12}` | Stores deep context for debugging and replay; ideal for log analytics and traces. | Bigger logs, potentially more storage and noise; needs schemas for consistent querying. | Use for complex events (DB queries, error stacks, HTTP payloads). Index selectively to avoid cost. | +| Combined approach | Use fields for the frequently-filtered keys and a structured body for full context. | `{"level":"info","msg":"Processed order","request_id":"...","user_id":"user-99","body":{"order":{"id":"o-123","items":[...]}}}` | Best of both worlds: quick filters + full context on demand. | More work to design consistent schemas. | Recommended pattern in production; allows fast dashboards and deep investigations. | +| Correlation IDs | A dedicated field (eg `request_id`) propagated across services and logs so all related entries can be linked. | `{"request_id":"...","msg":"..."} ` | Enables tracing across services, useful for distributed debugging. | Needs generation & propagation discipline (middleware, headers). | Use UUIDs, include in headers (e.g., `X-Request-ID`) and log fields. | +| Practical tip | Keep a small set of indexed fields (IDs, status, latency buckets) and push large payloads to body only when needed. | — | Reduces storage/cost while keeping searchability. | — | Consider log retention/rotation and PII policies. | + + +--- +## **Context-Aware Logging** + +Logs are more powerful when tied to a specific request or operation +> Note: in the challenge and tests we use the field name http_method (not method) and prefer r.URL.Path or r.RequestURI for the uri field - keep names consistent between README, tests, and learning content + +### Middleware Setup + +```go +// typed key prevents collisions +type loggerKeyType string + +const loggerKey loggerKeyType = "logger" + +func loggingMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requestID := uuid.New().String() + logger := logrus.WithFields(logrus.Fields{ + "request_id": requestID, + "http_method": r.Method, + "uri": r.URL.Path, + "user_agent": r.UserAgent(), + }) + + // put the *logrus.Entry into the context + ctx := context.WithValue(r.Context(), loggerKey, logger) + next.ServeHTTP(w, r.WithContext(ctx)) + }) +} +``` + +### Using in Handlers + +```go +// safe retrieval: fall back to global logger if missing +logger, ok := r.Context().Value(loggerKey).(*logrus.Entry) +if !ok || logger == nil { + logger = logrus.NewEntry(logrus.StandardLogger()) +} + +logger = logger.WithField("user_id", "user-99") +logger.Info("Processing request") +``` + +Every handler log now carries the same `request_id`, `method`, and `uri` fields + +--- + +## **3. Custom Formatters** + +Logrus supports multiple output formats. + +### JSON Formatter (default for structured logs) + +```go +logrus.SetFormatter(&logrus.JSONFormatter{}) +``` + +### Text Formatter (human-friendly) + +```go +logrus.SetFormatter(&logrus.TextFormatter{ + FullTimestamp: true, +}) +``` + +You can swap formatters depending on your environment (JSON for production, text for local debugging) + +--- + +## **4. Request Tracing & Correlation IDs** + +A **Correlation ID** ties logs across different components into one trace + +```go +import "github.com/google/uuid" + +requestID := uuid.New().String() +logger := logrus.WithField("request_id", requestID) +``` + +When passed via headers (e.g., `X-Request-ID`), correlation IDs can trace: + +* API calls across microservices +* Database queries linked to a request +* Error paths back to a single user action + +--- + +## **Best Practices** + +1. **Always log with fields** — structured > plain text +2. **Add request_id** early in the request lifecycle +3. **Pick the right formatter** (JSON for machines, text for humans) +4. **Propagate correlation IDs** across service boundaries +5. Keep messages clear, avoid secrets + +--- + +## **Resources** + +* [Logrus – Fields & Formatters](https://github.com/sirupsen/logrus) +* [Go Docs – context](https://pkg.go.dev/context) +* [Correlation IDs in Go](https://blog.golang.org/context) + +--- \ No newline at end of file diff --git a/packages/logrus/challenge-2-structured-logging-and-fields/metadata.json b/packages/logrus/challenge-2-structured-logging-and-fields/metadata.json new file mode 100644 index 00000000..78136d44 --- /dev/null +++ b/packages/logrus/challenge-2-structured-logging-and-fields/metadata.json @@ -0,0 +1,47 @@ + { + "title": "Structured Logging & Fields", + "description": "Build an HTTP server with a logging middleware to learn advanced structured logging in Go. This challenge focuses on creating context-aware loggers, tracing requests with correlation IDs, and passing request-scoped data using Go's context package and Logrus.", + "short_description": "Implement context-aware logging in a Go web server using middleware and Logrus", + "difficulty": "Intermediate", + "estimated_time": "30-45 min", + "learning_objectives": [ + "Implement the HTTP middleware pattern in Go", + "Use logrus.WithFields to create a context-aware logger (logrus.Entry)", + "Add correlation IDs for robust request tracing", + "Use context.Context to pass request-scoped data between middleware and handlers", + "Understand how contextual fields improve debugging and observability in web services" + ], + "prerequisites": [ + "Basic Go syntax", + "Familiarity with the command line", + "Understanding of net/http (HTTP servers) in Go", + "Prior exposure to basic logging concepts (e.g., Challenge 1: Basic Logging & Levels)" + ], + "tags": [ + "logging", + "logrus", + "middleware", + "http", + "context", + "structured-logging", + "correlation-id" + ], + "real_world_connection": "This logging middleware pattern is fundamental for building observable, debuggable, and production-ready microservices and web applications in Go. Correlation IDs and structured fields enable powerful tracing, aggregation, and incident investigation workflows.", + "requirements": [ + "Create a logging middleware for an HTTP server", + "Generate a unique request_id (correlation ID) for each incoming request", + "Populate a logrus.Entry with fields: request_id, http_method, uri, user_agent", + "Attach the enriched logger to the request's context and pass it downstream", + "Retrieve and use the context-aware logger in the final handler, adding handler-specific fields", + "Ensure all log entries for a single request share the same request_id", + "Support both JSON and human-friendly text formatters (configurable)" + ], + "bonus_points": [ + "Log the final HTTP status code and total response time in the middleware", + "If an X-Request-ID header is present, use its value; otherwise generate a new one", + "Expose formatter selection via an environment variable or CLI flag (e.g., LOG_FORMAT=text)", + "Create a small integration test that validates correlation across middleware and handler logs" + ], + "icon": "bi-braces-asterisk", + "order": 2 +} \ No newline at end of file diff --git a/packages/logrus/challenge-2-structured-logging-and-fields/run_test.sh b/packages/logrus/challenge-2-structured-logging-and-fields/run_test.sh new file mode 100755 index 00000000..9aef8b2e --- /dev/null +++ b/packages/logrus/challenge-2-structured-logging-and-fields/run_test.sh @@ -0,0 +1,83 @@ +#!/bin/bash + +# Script to run tests for a participant's submission + +# Function to display usage +usage() { + echo "Usage: $0" + exit 1 +} + +# Verify that we are in a challenge directory +if [ ! -f "solution-template_test.go" ]; then + echo "Error: solution-template_test.go not found. Please run this script from a challenge directory" + exit 1 +fi + +# Prompt for GitHub username +read -p "Enter your GitHub username: " USERNAME + +SUBMISSION_DIR="submissions/$USERNAME" +SUBMISSION_FILE="$SUBMISSION_DIR/solution.go" + +# Check if the submission file exists +if [ ! -f "$SUBMISSION_FILE" ]; then + echo "Error: Solution file '$SUBMISSION_FILE' not found" + echo "Note: Please ensure your solution is named 'solution.go' and placed in a 'submissions//' directory" + exit 1 +fi + +# Create a temporary directory to avoid modifying the original files +TEMP_DIR=$(mktemp -d) + +# Copy the participant's solution, test file, and go.mod/go.sum to the temporary directory +cp "$SUBMISSION_FILE" "solution-template_test.go" "go.mod" "go.sum" "$TEMP_DIR/" 2>/dev/null + +# The test file expects to be in the `main` package alongside the functions it's testing +mv "$TEMP_DIR/solution.go" "$TEMP_DIR/solution-template.go" + +echo "Running tests for user '$USERNAME'..." + +# Navigate to the temporary directory +pushd "$TEMP_DIR" > /dev/null || { + echo "Failed to navigate to temporary directory." + rm -rf "$TEMP_DIR" + exit 1 +} + +# Tidy up dependencies to ensure everything is consistent +echo "Tidying dependencies..." +go mod tidy || { + echo "Failed to tidy dependencies." + popd > /dev/null || { + echo "Failed to return to original directory." + rm -rf "$TEMP_DIR" + exit 1 + } + rm -rf "$TEMP_DIR" + exit 1 +} + +# Run the tests with verbosity and coverage +echo "Executing tests..." +go test -v -cover + +TEST_EXIT_CODE=$? + +# Return to the original directory +popd > /dev/null || { + echo "Failed to return to original directory." + rm -rf "$TEMP_DIR" + exit 1 +} + +# Clean up the temporary directory +rm -rf "$TEMP_DIR" + +if [ $TEST_EXIT_CODE -eq 0 ]; then + echo "All tests passed!" +else + echo "Some tests failed." +fi + +exit $TEST_EXIT_CODE \ No newline at end of file diff --git a/packages/logrus/challenge-2-structured-logging-and-fields/solution-template.go b/packages/logrus/challenge-2-structured-logging-and-fields/solution-template.go new file mode 100644 index 00000000..657489a4 --- /dev/null +++ b/packages/logrus/challenge-2-structured-logging-and-fields/solution-template.go @@ -0,0 +1,90 @@ +package main + +import ( + "context" + "fmt" + "log" + "net/http" + "os" + + "github.com/google/uuid" + "github.com/sirupsen/logrus" +) + +// loggerKey is a custom type used as a key for storing the logger in the context +type loggerKey int + +const key loggerKey = 0 + +// loggingMiddleware creates a new logger instance for each HTTP request +func loggingMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // TODO: Generate a new UUID for the request_id + // Hint: Use uuid.New().String() + requestID := "" // Replace with UUID generation + + // TODO: Create a new logrus.Entry using logrus.WithFields() + // This logger should be pre-populated with the following fields: + // - "request_id": The UUID generated above + // - "http_method": The request's method (r.Method) + // - "uri": The request's URI (r.RequestURI) + // - "user_agent": The request's User-Agent header (r.UserAgent()) + logger := logrus.WithFields(logrus.Fields{ + // Add fields here + }) + + // TODO: Log an informational message "Request received" + // The fields you added above will be automatically included + + + // TODO: Create a new context from the request's context and add the + // enriched logger to it using the `key` + // Hint: ctx := context.WithValue(r.Context(), key, logger) + ctx := r.Context() // Replace this line + + // TODO: Call the next handler in the chain, passing the new request + // with the updated context + // Hint: next.ServeHTTP(w, r.WithContext(ctx)) + + }) +} + +// helloHandler is the final handler that processes the request +func helloHandler(w http.ResponseWriter, r *http.Request) { + // TODO: Retrieve the logger from the context using the `key` + // The value from the context will be of type `interface{}`, so you'll need + // to perform a type assertion to get a `*logrus.Entry` + // Example: logger, ok := r.Context().Value(key).(*logrus.Entry) + // If `ok` is false (logger not found), fall back to the global logger `logrus.StandardLogger()` + var logger *logrus.Entry = logrus.NewEntry(logrus.StandardLogger()) // Replace this line + + // TODO: Add a new field "user_id" to the logger with a sample value "user-99" + // Hint: logger = logger.WithField("user_id", "user-99") + + + // TODO: Log an informational message "Processing hello request" + // This log should include both the fields from the middleware and the "user_id" field + + + // TODO: Write a "Hello, world!" response to the client + // Hint: fmt.Fprintln(w, "...") +} + +func main() { + // TODO: Set the global logrus formatter to a new instance of logrus.JSONFormatter + + + // Create the handler chain by wrapping the helloHandler with the loggingMiddleware + finalHandler := loggingMiddleware(http.HandlerFunc(helloHandler)) + + // Configure HTTP server + server := &http.Server{ + Addr: ":8080", + Handler: finalHandler, + } + + logrus.Info("Server starting on port 8080...") + if err := server.ListenAndServe(); err != nil { + log.Fatal(err) + } +} \ No newline at end of file diff --git a/packages/logrus/challenge-2-structured-logging-and-fields/solution-template_test.go b/packages/logrus/challenge-2-structured-logging-and-fields/solution-template_test.go new file mode 100644 index 00000000..2ba3aed2 --- /dev/null +++ b/packages/logrus/challenge-2-structured-logging-and-fields/solution-template_test.go @@ -0,0 +1,160 @@ +package main + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/google/uuid" + "github.com/sirupsen/logrus" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// logEntry is a struct to unmarshal the JSON log output for verification +// We add all expected fields from both the middleware and the handler +type logEntry struct { + Level string `json:"level"` + Msg string `json:"msg"` + RequestID string `json:"request_id"` + Method string `json:"http_method"` + URI string `json:"uri"` + UserAgent string `json:"user_agent"` + UserID string `json:"user_id"` +} + +// isValidUUID checks if a string is a valid UUID. +func isValidUUID(u string) bool { + _, err := uuid.Parse(u) + return err == nil +} + +// NOTE: These tests assume the TODOs in solution-template.go +// are implemented. If run against the raw template, they will fail + +// For JSON Format +func TestLoggingMiddleware_JSONFormatter(t *testing.T) { + // Save original configuration + originalFormatter := logrus.StandardLogger().Formatter + originalOutput := logrus.StandardLogger().Out + t.Cleanup(func() { + logrus.SetFormatter(originalFormatter) + logrus.SetOutput(originalOutput) + }) + + // Ensure the global logger is set to JSON format for the test + logrus.SetFormatter(&logrus.JSONFormatter{}) + + // Capture log output by redirecting the logger's output to a buffer + var logBuffer bytes.Buffer + logrus.SetOutput(&logBuffer) + + // Create a test server with the full handler chain + handler := loggingMiddleware(http.HandlerFunc(helloHandler)) + server := httptest.NewServer(handler) + defer server.Close() + + // Create a new HTTP request to the test server + req, err := http.NewRequest("GET", server.URL+"/hello", nil) + require.NoError(t, err) + req.Header.Set("User-Agent", "Test-Client-1.0") + + // Make the request + client := &http.Client{} + resp, err := client.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + + // Assert basic HTTP response correctness + assert.Equal(t, http.StatusOK, resp.StatusCode) + + // Split the captured log output into individual JSON lines + logLines := strings.Split(strings.TrimSpace(logBuffer.String()), "\n") + require.Len(t, logLines, 2, "Expected two log entries: one from middleware, one from handler") + + // Unmarshal and verify the first log entry (from middleware) + var entry1 logEntry + err = json.Unmarshal([]byte(logLines[0]), &entry1) + require.NoError(t, err, "First log entry should be valid JSON") + + assert.Equal(t, "info", entry1.Level) + assert.Equal(t, "Request received", entry1.Msg) + assert.True(t, isValidUUID(entry1.RequestID), "request_id should be a valid UUID") + assert.Equal(t, "GET", entry1.Method) + assert.Equal(t, "/hello", entry1.URI) + assert.Equal(t, "Test-Client-1.0", entry1.UserAgent) + assert.Empty(t, entry1.UserID, "user_id should not be set by the middleware") + + // Unmarshal and verify the second log entry (from handler) + var entry2 logEntry + err = json.Unmarshal([]byte(logLines[1]), &entry2) + require.NoError(t, err, "Second log entry should be valid JSON") + + assert.Equal(t, "info", entry2.Level) + assert.Equal(t, "Processing hello request", entry2.Msg) + assert.Equal(t, "user-99", entry2.UserID, "user_id should be set by the handler") + + // Correlation verification + assert.NotEmpty(t, entry1.RequestID, "request_id must not be empty") + assert.Equal(t, entry1.RequestID, entry2.RequestID, "request_id must be the same in both log entries") + + // Ensure middleware fields are still present in the handler log + assert.Equal(t, "GET", entry2.Method, "http_method should propagate to handler log") + assert.Equal(t, "/hello", entry2.URI, "uri should propagate to handler log") + assert.Equal(t, "Test-Client-1.0", entry2.UserAgent, "user_agent should propagate to handler log") +} + +// For Text Format +func TestLoggingMiddleware_TextFormatter(t *testing.T) { + // Save original configuration + originalFormatter := logrus.StandardLogger().Formatter + originalOutput := logrus.StandardLogger().Out + t.Cleanup(func() { + logrus.SetFormatter(originalFormatter) + logrus.SetOutput(originalOutput) + }) + + // Ensure the global logger is set to Text format + logrus.SetFormatter(&logrus.TextFormatter{ + DisableColors: true, + FullTimestamp: true, + }) + + // Capture log output by redirecting the logger's output to a buffer + var logBuffer bytes.Buffer + logrus.SetOutput(&logBuffer) + + // Create a test server with the full handler chain + handler := loggingMiddleware(http.HandlerFunc(helloHandler)) + server := httptest.NewServer(handler) + defer server.Close() + + // Create a new HTTP request to the test server + req, err := http.NewRequest("GET", server.URL+"/hello", nil) + require.NoError(t, err) + req.Header.Set("User-Agent", "Test-Client-Text") + + // Make request + client := &http.Client{} + resp, err := client.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusOK, resp.StatusCode) + + // Get the log output + logOutput := logBuffer.String() + + // Checking if key substrings exist + assert.Contains(t, logOutput, "level=info") + assert.Contains(t, logOutput, "msg=\"Request received\"") + assert.Contains(t, logOutput, "msg=\"Processing hello request\"") + assert.Contains(t, logOutput, "request_id=") + assert.Contains(t, logOutput, "http_method=GET") + assert.Contains(t, logOutput, "uri=/hello") + assert.Contains(t, logOutput, "user_agent=Test-Client-Text") + assert.Contains(t, logOutput, "user_id=user-99") +} \ No newline at end of file diff --git a/packages/logrus/challenge-2-structured-logging-and-fields/submissions/LeeFred3042U/solution.go b/packages/logrus/challenge-2-structured-logging-and-fields/submissions/LeeFred3042U/solution.go new file mode 100644 index 00000000..b8250e8e --- /dev/null +++ b/packages/logrus/challenge-2-structured-logging-and-fields/submissions/LeeFred3042U/solution.go @@ -0,0 +1,67 @@ +package main + +import ( + "context" + "net/http" + + "github.com/google/uuid" + "github.com/sirupsen/logrus" +) + +type loggerKeyType int + +const loggerKey loggerKeyType = 0 + +func loggingMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Generate a new request ID + requestID := uuid.New().String() + + // Create a logger with the middleware fields + logger := logrus.WithFields(logrus.Fields{ + "request_id": requestID, + "http_method": r.Method, + "uri": r.RequestURI, + "user_agent": r.UserAgent(), + }) + + // Log the initial middleware message + logger.Info("Request received") + + // Add logger to context + ctx := context.WithValue(r.Context(), loggerKey, logger) + + // Call the next handler + next.ServeHTTP(w, r.WithContext(ctx)) + }) +} + +// Retrieve logger from context +func getLogger(r *http.Request) *logrus.Entry { + logger, ok := r.Context().Value(loggerKey).(*logrus.Entry) + if !ok { + return logrus.NewEntry(logrus.StandardLogger()) + } + return logger +} + +// Final handler +func helloHandler(w http.ResponseWriter, r *http.Request) { + logger := getLogger(r).WithField("user_id", "user-99") + logger.Info("Processing hello request") + + w.WriteHeader(http.StatusOK) + w.Write([]byte("Hello, world!")) +} + +func main() { + // Set global logger formatter + logrus.SetFormatter(&logrus.JSONFormatter{}) + + // Wrap handler with middleware + finalHandler := loggingMiddleware(http.HandlerFunc(helloHandler)) + + if err := http.ListenAndServe(":8080", finalHandler); err != nil { + logrus.WithError(err).Fatal("Server failed to start") + } +} \ No newline at end of file diff --git a/packages/logrus/challenge-3-advanced-configuration-and-hooks/README.md b/packages/logrus/challenge-3-advanced-configuration-and-hooks/README.md new file mode 100644 index 00000000..e468fe4d --- /dev/null +++ b/packages/logrus/challenge-3-advanced-configuration-and-hooks/README.md @@ -0,0 +1,228 @@ +# Challenge 3: Advanced Configuration & Hooks + +Elevate your logging strategy by building a **Task Scheduler** that uses a custom `logrus` hook to send logs to multiple destinations based on their severity. This challenge introduces professional logging patterns used in production systems. + +## Challenge Requirements + +Create a Go application that simulates a task scheduler and configures a logger with a custom hook. + +1. **Dual Logging Setup**: Configure a `logrus` logger that: + + * Writes all general logs (`Info` level and above) to the console (`os.Stdout`). + * Uses a **custom hook** to *also* write critical logs (`Error` and `Fatal` levels) to a separate destination in a different format. + +2. **Create a Custom `ErrorHook`**: Implement the `logrus.Hook` interface to create a hook that: + + * Triggers only for `ErrorLevel` and `FatalLevel` entries. + * Formats the log entry it receives into `JSON`. + * Writes the formatted JSON log to its own dedicated output (for this challenge, a separate `io.Writer`). + +3. **Simulate a Task Scheduler**: Implement a `runTaskScheduler` function that logs the status of several simulated tasks with different outcomes (success, warning, and failure). + +## How It Should Work + +Your application will have two distinct log streams. The console will show a human-readable feed of all task activities. Simultaneously, a separate, machine-readable `JSON` stream will be generated containing *only* the critical errors, perfect for ingestion by an external monitoring system. + +--- + +### Sample Output + +When you run the application, you will see two different outputs. + +**1. Console Output (Text Format):** +This stream contains all logs from `Info` level and up, formatted for easy reading. + +```text +INFO[0000] Starting task scheduler... +INFO[0000] Starting task: Process daily reports +INFO[0000] Task 'Process daily reports' completed successfully task_duration=250ms +WARN[0000] Task 'Sync user data': upstream API is slow task_id=sync-001 +ERRO[0000] Task 'Backup database' failed: connection timed out retry_attempt=3 +``` + +**2. Error Log Output (JSON Format):** +This stream is generated by your custom hook and only contains the error-level log, formatted as structured JSON. + +```json +{"level":"error","msg":"Task 'Backup database' failed: connection timed out","retry_attempt":3,"time":"2025-10-03T03:15:00+05:30"} +``` + +--- + +## Implementation Requirements + +### Logger Configuration (main function) + +* Create a new logger instance using `logrus.New()` instead of using the global logger. +* Set the main logger's formatter to `logrus.TextFormatter`. +* Set the main logger's output to `os.Stdout`. +* Instantiate your custom `ErrorHook`, providing it with a separate `io.Writer` (e.g., a `bytes.Buffer` for testing or a file in real runs). +* Add the hook to the logger instance using `logger.AddHook()`. +* `runTaskScheduler` should accept the configured `*logrus.Logger` as an argument and emit the required logs. + +#### Imports & Writers (add to Logger Configuration) + +Make sure the implementation imports these packages (example): + +```go +import ( + "bytes" + "io" + "os" + + "github.com/sirupsen/logrus" +) +``` + +When using a file writer in production (for alerts or tests), open it with safe flags: + +```go +f, err := os.OpenFile("alerts.log", os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0644) +if err != nil { /* handle error */ } +defer f.Close() +``` + +Tests will often use `bytes.Buffer` instead of files; design your hook to accept an `io.Writer` so tests can inject buffers. + +#### Exact logger setup to use (paste into your main) + +```go +logger := logrus.New() +logger.SetFormatter(&logrus.TextFormatter{}) // console-friendly +logger.SetOutput(os.Stdout) + +// Hook writer: for tests use bytes.Buffer, for real runs open a file +var hookWriter io.Writer = &bytes.Buffer{} // tests will assert on this + +hook := &ErrorHook{ + Out: hookWriter, + Formatter: &logrus.JSONFormatter{}, +} +logger.AddHook(hook) + +// runTaskScheduler should accept *logrus.Logger +runTaskScheduler(logger) +``` + +--- + +### ErrorHook Implementation + +Create a struct that implements `logrus.Hook`. The hook must: + +* Implement `Levels() []logrus.Level` and return only `logrus.ErrorLevel` and `logrus.FatalLevel`. +* Implement `Fire(*logrus.Entry) error` to: + + * Format the entry using `logrus.JSONFormatter` (or the provided `Formatter`). + * Write a single JSON object (with a trailing newline) to the hook's `io.Writer`. + +#### ErrorHook — minimal, exact contract & example + +The hook must implement `logrus.Hook` exactly as below (synchronous write is fine for tests): + +```go +type ErrorHook struct { + Out io.Writer + Formatter logrus.Formatter // expected: &logrus.JSONFormatter{} +} + +func (h *ErrorHook) Levels() []logrus.Level { + return []logrus.Level{logrus.ErrorLevel, logrus.FatalLevel} +} + +func (h *ErrorHook) Fire(entry *logrus.Entry) error { + // Use the provided formatter to render a single JSON object. + b, err := h.Formatter.Format(entry) + if err != nil { + return err + } + // Ensure a trailing newline so file readers treat each entry as a separate line. + if _, err := h.Out.Write(append(b, '\n')); err != nil { + return err + } + return nil +} +``` + +Notes: + +* Use `&logrus.JSONFormatter{}` for `Formatter`. +* Tests expect one JSON object per error entry (newline-separated). +* The hook should be synchronous in tests (no buffering) unless you explicitly coordinate flushing in tests. + +--- + +### Main Logic (`runTaskScheduler` function) + +`runTaskScheduler(logger *logrus.Logger)` should: + +* Log an Info message for a successful task (include `task_duration` field). + + * Exact message format (tests expect): `Task '' completed successfully` + * Example field: `task_duration="250ms"` +* Log a Warn message for a potential issue (include `task_id` field). + + * Message must contain the task name (no rigid exact-match requirement here). +* Log an Error message for a failed task (include `retry_attempt` field). + + * Exact message format (tests expect): `Task '' failed: ` + * Field: `retry_attempt` (integer) + +Example logging calls: + +```go +logger.WithField("task_duration", "250ms").Infof("Task '%s' completed successfully", "Process daily reports") +logger.WithField("task_id", "sync-001").Warnf("Task '%s': upstream API is slow", "Sync user data") +logger.WithField("retry_attempt", 3).Errorf("Task '%s' failed: %s", "Backup database", "connection timed out") +``` + +--- + +## Testing Requirements + +Your solution must pass tests that verify: + +* The main logger writes Info, Warn, and Error logs to its output in Text format. +* The ErrorHook's output contains **only** the Error (and Fatal) logs. +* The ErrorHook's output is formatted correctly as a single JSON object per error entry (newline terminated). +* The fields (e.g., `retry_attempt`) are present in both the main log output and the hook's output. + +#### Testing hints (must-read) + +Tests will assert on exact field names and message text. Use these keys and messages exactly: + +* **Info success message** + + * msg: `Task '' completed successfully` + * field: `task_duration` (e.g. `250ms`) +* **Warn message** + + * msg contains: `Task ''` + * field: `task_id` (e.g. `sync-001`) +* **Error message** + + * msg: `Task '' failed: ` + * field: `retry_attempt` (integer) + +The ErrorHook's output must contain: + +* A single JSON object per error entry. +* The same fields present in the main log (e.g., `retry_attempt`). +* A trailing newline after each JSON object. + +--- + +## Dependencies + +Dependencies: + +* `github.com/sirupsen/logrus` + +Install with: + +```sh +go get github.com/sirupsen/logrus +``` + +> Note: tests typically inject `bytes.Buffer` instances and assert synchronously on the hook writer. Design your hook so its output writer is injectable and writes synchronously for tests. +--- \ No newline at end of file diff --git a/packages/logrus/challenge-3-advanced-configuration-and-hooks/go.mod b/packages/logrus/challenge-3-advanced-configuration-and-hooks/go.mod new file mode 100644 index 00000000..64e41e67 --- /dev/null +++ b/packages/logrus/challenge-3-advanced-configuration-and-hooks/go.mod @@ -0,0 +1,15 @@ +module challenge-3-advanced-configuration-and-hooks + +go 1.24.0 + +require ( + github.com/sirupsen/logrus v1.9.3 + github.com/stretchr/testify v1.11.1 +) + +require ( + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + golang.org/x/sys v0.37.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/packages/logrus/challenge-3-advanced-configuration-and-hooks/go.sum b/packages/logrus/challenge-3-advanced-configuration-and-hooks/go.sum new file mode 100644 index 00000000..fde67ae2 --- /dev/null +++ b/packages/logrus/challenge-3-advanced-configuration-and-hooks/go.sum @@ -0,0 +1,19 @@ +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= +github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ= +golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/packages/logrus/challenge-3-advanced-configuration-and-hooks/hints.md b/packages/logrus/challenge-3-advanced-configuration-and-hooks/hints.md new file mode 100644 index 00000000..9610cfe6 --- /dev/null +++ b/packages/logrus/challenge-3-advanced-configuration-and-hooks/hints.md @@ -0,0 +1,100 @@ +# Hints for Challenge 3: Advanced Configuration & Hooks + +--- + +### Hint 1: Implementing the Levels Method +The **Levels** method tells logrus which severities your hook should be triggered for +Since the hook is only for errors, return a slice containing `ErrorLevel` and `FatalLevel` + +```go +// In the ErrorHook's Levels method... +return []logrus.Level{ + logrus.ErrorLevel, + logrus.FatalLevel, +} +``` + +--- + +### Hint 2: Formatting the Entry in the Fire Method + +Inside the **Fire** method, you have access to the hook's configured `Formatter` +Use its `Format` method to turn the `logrus.Entry` into a byte slice + +```go +// In the ErrorHook's Fire method... +line, err := h.Formatter.Format(entry) +if err != nil { + // It's good practice to return the error if formatting fails + return err +} +``` + +--- + +### Hint 3: Writing the Formatted Log + +After formatting the entry, write the result to the hook’s `Out` writer +Tests expect each log on its own line, so **append a newline character** + +```go +// In the ErrorHook's Fire method, after formatting... +_, err = h.Out.Write(append(line, '\n')) +return err +``` + +--- + +### Hint 4: Logging the Simulated Tasks + +In the `runTaskScheduler` function, use chained calls with `WithField` for structured logs + +The tests **check exact field names and message strings** +For example: + +* Success: `Task '' completed successfully` +* Warning: `Task '' took longer than expected` +* Error: `Task '' failed: ` + +```go +// For the failed task log... +logger.WithField("retry_attempt", 3). + Errorf("Task '%s' failed: %s", "Backup database", "connection timed out") +``` + +--- + +### Hint 5: Assembling the Logger in main + +The `main` function is where everything connects. Follow this sequence: + +1. Create the logger instance +2. Configure the main logger’s **TextFormatter** and send output to **os.Stdout** +3. Create an `io.Writer` for the hook (e.g. a file or buffer) +4. Instantiate `ErrorHook` with its **JSONFormatter** and the writer +5. Add the hook to the logger with `logger.AddHook(hook)` + +```go +// In the main function... +logger := logrus.New() +logger.SetFormatter(&logrus.TextFormatter{}) +logger.SetOutput(os.Stdout) + +hookWriter := &bytes.Buffer{} +hook := &ErrorHook{ + Out: hookWriter, + Formatter: &logrus.JSONFormatter{}, +} +logger.AddHook(hook) +``` + +--- + +### Hint 6: Remember the Outputs + +* **Main logger (console):** Human-readable `TextFormatter` +* **Hook (errors only):** Machine-readable `JSONFormatter` +* Info/Warning logs → appear **only in console** +* Error/Fatal logs → appear in **both console and hook output** + +--- \ No newline at end of file diff --git a/packages/logrus/challenge-3-advanced-configuration-and-hooks/learning.md b/packages/logrus/challenge-3-advanced-configuration-and-hooks/learning.md new file mode 100644 index 00000000..0b1f370a --- /dev/null +++ b/packages/logrus/challenge-3-advanced-configuration-and-hooks/learning.md @@ -0,0 +1,132 @@ +# Learning: Advanced Configuration & Hooks + +In the previous challenges, our logs had a single destination: the console. While great for development, real applications require a more sophisticated approach. Logs are not just messages to be seen; they are critical data streams that need to be persisted, routed, and analyzed. + +This is where **logrus hooks** come in. They are the key to transforming a simple logger into a powerful data pipeline. + +--- + +## 🪝 The `logrus.Hook` Interface: A Logger's Secret Weapon + +A hook is a mechanism that allows you to intercept log entries and take custom actions before the log is written by the main logger. It lets you "hook into" the logging process. + +Think of it like adding a **BCC to an email**. +The primary recipient (the main log output) gets the message as intended. At the same time, the hook silently sends a copy of that message to another destination, potentially in a completely different format. + +The `logrus.Hook` interface is simple but powerful, defined by two methods: + +### `Levels() []logrus.Level` +- **What it is:** A filter. This method returns a slice of the log levels the hook cares about. +- **Why it's important:** It's highly efficient. Logrus won't waste time calling your hook for Info or Debug messages if your hook only registered for `ErrorLevel` and `FatalLevel`. + +### `Fire(*logrus.Entry) error` +- **What it is:** An action. This method is the core of the hook. It's executed only when a log entry matches one of the levels specified in `Levels()`. +- **Why it's important:** The hook receives the complete `logrus.Entry`, which contains the message, timestamp, level, and all the structured data fields. The hook has full control to format this entry however it likes and send it anywhere—a file, a network socket, or an external monitoring service. + +--- + +## 🔧 Implementing a Custom Hook: Step-by-Step + +Let’s break down how to build the `ErrorHook` for our Task Scheduler challenge. + +### Step 1: Define the Hook Struct +A well-designed hook should be configurable. Instead of hardcoding the output destination or format, we define them as fields. + +```go +type ErrorHook struct { + Out io.Writer + Formatter logrus.Formatter +} +```` + +By accepting an `io.Writer`, we can send error logs to a file, a network connection, or an in-memory buffer during tests. + +--- + +### Step 2: Implement `Levels()` + +We want our hook to trigger only for critical errors, so we return a slice containing just those levels. + +```go +func (h *ErrorHook) Levels() []logrus.Level { + return []logrus.Level{ + logrus.ErrorLevel, + logrus.FatalLevel, + } +} +``` + +--- + +### Step 3: Implement `Fire()` + +This is where the magic happens. The main logger might be using a human-readable `TextFormatter`, but our hook outputs machine-readable JSON. + +```go +func (h *ErrorHook) Fire(entry *logrus.Entry) error { + // Use the hook's dedicated formatter to serialize the entry. + lineBytes, err := h.Formatter.Format(entry) + if err != nil { + return err + } + + // Write the formatted JSON to the hook's output. + _, err = h.Out.Write(lineBytes) + return err +} +``` + +--- + +## Managing Log Files in Go + +When logging to files, you can't just let them grow forever. The practice of managing log file size is called **log rotation**. + +### Opening a File for Appending + +To write logs to a file, you open it with specific flags. + +```go +file, err := os.OpenFile("scheduler.log", os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0666) +if err != nil { + // Handle error +} +defer file.Close() + +// You can now use this 'file' as an io.Writer for logrus or a hook. +``` + +--- + +### Simple Startup Rotation + +A basic rotation strategy is to archive the old log file every time the application starts. + +```go +if _, err := os.Stat("scheduler.log"); err == nil { + // The file exists, so rename it. + os.Rename("scheduler.log", "scheduler.log.old") +} +``` + +--- + +## A Note on Performance: Async Logging + +The hooks we've designed are synchronous. When `logger.Error()` is called, your application code pauses and waits for both the main logger and the error hook to finish writing. For most apps, this is fine. + +However, in **high-throughput systems**, I/O waits can become a bottleneck. The solution: **asynchronous logging**. + +* When `logger.Error()` is called, the hook quickly places the log entry into a buffered Go channel (fast in-memory op). +* A background goroutine continuously reads from the channel and performs the slower I/O (writing to file/network). + +This decouples your application’s performance from the speed of your logging backend. + +--- + +## Resources + +* [Logrus Documentation on Hooks](https://github.com/sirupsen/logrus#hooks) +* [Go Docs for os.OpenFile](https://pkg.go.dev/os#OpenFile) + +--- \ No newline at end of file diff --git a/packages/logrus/challenge-3-advanced-configuration-and-hooks/metadata.json b/packages/logrus/challenge-3-advanced-configuration-and-hooks/metadata.json new file mode 100644 index 00000000..a4c82e16 --- /dev/null +++ b/packages/logrus/challenge-3-advanced-configuration-and-hooks/metadata.json @@ -0,0 +1,41 @@ +{ + "title": "Advanced Configuration & Hooks", + "description": "Learn professional logging patterns by building a task scheduler that uses a custom Logrus hook to route critical error logs to a separate, machine-readable destination, while keeping general logs in a human-friendly format", + "short_description": "Use a custom Logrus hook to send logs to multiple destinations", + "difficulty": "Intermediate", + "estimated_time": "50-80 minutes", + "learning_objectives": [ + "Implement the `logrus.Hook` interface from scratch", + "Configure a single logger to write to multiple output destinations", + "Use different formatters for different log streams (e.g., Text vs. JSON)", + "Understand the real-world use case for routing logs based on severity", + "Perform basic log file I/O using the Go standard library" + ], + "prerequisites": [ + "Completion of 'Challenge 2: Structured Logging & Fields'", + "A solid understanding of Go interfaces" + ], + "tags": [ + "logging", + "logrus", + "hooks", + "configuration", + "interfaces", + "files" + ], + "real_world_connection": "This pattern is essential for production systems where logs are fed into different tools: human-readable logs for developers, and structured JSON logs for automated monitoring, alerting, and analysis platforms like Splunk or Datadog", + "requirements": [ + "Create a struct that correctly implements the `logrus.Hook` interface", + "Configure a logger to write to both the console and the custom hook", + "Ensure the main logger's output is in Text format", + "Ensure the hook's output is in JSON format", + "The hook must only trigger for Error and Fatal level logs" + ], + "bonus_points": [ + "Implement a simple, on-startup log rotation (renaming the old log file)", + "Make the hook asynchronous using a goroutine and a channel", + "Add a new field to the log entry from within the hook's `Fire` method" + ], + "icon": "bi-sign-split", + "order": 3 +} \ No newline at end of file diff --git a/packages/logrus/challenge-3-advanced-configuration-and-hooks/run_test.sh b/packages/logrus/challenge-3-advanced-configuration-and-hooks/run_test.sh new file mode 100755 index 00000000..af243939 --- /dev/null +++ b/packages/logrus/challenge-3-advanced-configuration-and-hooks/run_test.sh @@ -0,0 +1,73 @@ +#!/bin/bash + +# Script to run tests for a participant's submission + +# Verify that we are in a challenge directory +if [ ! -f "solution-template_test.go" ]; then + echo "Error: solution-template_test.go not found. Please run this script from a challenge directory" + exit 1 +fi + +# Prompt for GitHub username +read -p "Enter your GitHub username: " USERNAME + +SUBMISSION_DIR="submissions/$USERNAME" +SUBMISSION_FILE="$SUBMISSION_DIR/solution.go" + +# Check if the submission file exists +if [ ! -f "$SUBMISSION_FILE" ]; then + echo "Error: Solution file '$SUBMISSION_FILE' not found." + echo "Note: Please ensure your solution is named 'solution.go' and placed in a 'submissions//' directory" + exit 1 +fi + +# Create a temporary directory to avoid modifying the original files +TEMP_DIR=$(mktemp -d) + +# Copy the participant's solution, test file, and go.mod/go.sum to the temporary directory +cp "$SUBMISSION_FILE" "solution-template_test.go" "go.mod" "go.sum" "$TEMP_DIR/" || { + echo "Error: Failed to copy required files to temporary directory." + rm -rf "$TEMP_DIR" + exit 1 +} + +# The test file expects to be in the `main` package alongside the functions it's testing +mv "$TEMP_DIR/solution.go" "$TEMP_DIR/solution-template.go" + +echo "Running tests for user '$USERNAME'..." + +# Navigate to the temporary directory +pushd "$TEMP_DIR" > /dev/null || { + echo "Failed to navigate to temporary directory." + rm -rf "$TEMP_DIR" + exit 1 +} + +# Tidy up dependencies to ensure everything is consistent +echo "Tidying dependencies..." +go mod tidy || { + echo "Failed to tidy dependencies." + popd > /dev/null || exit 1 + rm -rf "$TEMP_DIR" + exit 1 +} + +# Run the tests with verbosity and coverage +echo "Executing tests..." +go test -v -cover + +TEST_EXIT_CODE=$? + +# Return to the original directory +popd > /dev/null || exit 1 + +# Clean up the temporary directory +rm -rf "$TEMP_DIR" + +if [ $TEST_EXIT_CODE -eq 0 ]; then + echo "All tests passed" +else + echo "Some tests failed" +fi + +exit $TEST_EXIT_CODE \ No newline at end of file diff --git a/packages/logrus/challenge-3-advanced-configuration-and-hooks/solution-template.go b/packages/logrus/challenge-3-advanced-configuration-and-hooks/solution-template.go new file mode 100644 index 00000000..0faf797a --- /dev/null +++ b/packages/logrus/challenge-3-advanced-configuration-and-hooks/solution-template.go @@ -0,0 +1,86 @@ +package main + +import ( + "bytes" + "io" + "os" + + "github.com/sirupsen/logrus" +) + +// ErrorHook is a custom hook that sends logs of specified levels to a dedicated writer +// It must implement the logrus.Hook interface +type ErrorHook struct { + Out io.Writer + Formatter logrus.Formatter +} + +// Levels returns the log levels that this hook will be triggered for +func (h *ErrorHook) Levels() []logrus.Level { + // TODO: Return a slice of logrus.Level containing only ErrorLevel and FatalLevel + // Hint: return []logrus.Level{logrus.ErrorLevel, logrus.FatalLevel} + return nil +} + +// Fire is called when a log entry is fired for one of the specified Levels +func (h *ErrorHook) Fire(entry *logrus.Entry) error { + // TODO: Use the hook's formatter to format the entry into bytes + // Hint: line, err := h.Formatter.Format(entry) + + // TODO: Write the formatted line to the hook's output writer (h.Out) + // Remember to add a newline character at the end to separate log entries + // Hint: _, err = h.Out.Write(append(line, '\n')) + + return nil +} + +// runTaskScheduler simulates a scheduler running various tasks and logging their outcomes +func runTaskScheduler(logger *logrus.Logger) { + logger.Info("Starting task scheduler...") + + // TODO: Log a successful task using the Info level + // Message: "Task 'Process daily reports' completed successfully" + // Field: "task_duration" with a value like "250ms" + // Hint: logger.WithField(...).Infof(...) + + // TODO: Log a task with a warning + // Message: "Task 'Sync user data': upstream API is slow" + // Field: "task_id" with a value like "sync-001" + // Hint: logger.WithField(...).Warnf(...) + + // TODO: Log a failed task using the Error level + // Message: "Task 'Backup database' failed: connection timed out" + // Field: "retry_attempt" with an integer value like 3 + // Hint: logger.WithField(...).Errorf(...) +} + +func main() { + // TODO: Create a new instance of the logger + // Hint: logger := logrus.New() + logger := logrus.New() // Basic initialization - configure below + + // TODO: Set the logger's formatter to TextFormatter + logger.SetFormatter(&logrus.TextFormatter{}) + + // TODO: Set the logger's output to standard out + logger.SetOutput(os.Stdout) + + // The hook will write to a separate buffer. In a real app, this could be a file + var hookWriter io.Writer = &bytes.Buffer{} + + // TODO: Create an instance of your ErrorHook + // It needs an output writer (hookWriter) and a JSON formatter + // Hint: hook := &ErrorHook{ Out: hookWriter, Formatter: &logrus.JSONFormatter{} } + hook := &ErrorHook{Out: hookWriter, Formatter: &logrus.JSONFormatter{}} + + // TODO: Add the hook to the logger + logger.AddHook(hook) + + // Run the scheduler simulation + runTaskScheduler(logger) + + // In a real application, you might want to inspect the hook's output. + // For this challenge, the tests will handle that. + // fmt.Println("\n--- Hook Output ---") + // fmt.Println(hookWriter.(*bytes.Buffer).String()) +} \ No newline at end of file diff --git a/packages/logrus/challenge-3-advanced-configuration-and-hooks/solution-template_test.go b/packages/logrus/challenge-3-advanced-configuration-and-hooks/solution-template_test.go new file mode 100644 index 00000000..a6384b81 --- /dev/null +++ b/packages/logrus/challenge-3-advanced-configuration-and-hooks/solution-template_test.go @@ -0,0 +1,89 @@ +package main + +import ( + "bytes" + "encoding/json" + "testing" + + "github.com/sirupsen/logrus" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// hookLogEntry is used to unmarshal the JSON output from the ErrorHook +type hookLogEntry struct { + Level string `json:"level"` + Msg string `json:"msg"` + RetryAttempt int `json:"retry_attempt"` + Time string `json:"time"` +} + +func TestTaskSchedulerLogging(t *testing.T) { + // 1. Setup + // Create separate buffers to capture the output of the main logger and the hook + mainOut := &bytes.Buffer{} + hookOut := &bytes.Buffer{} + + // Create and configure the main logger + logger := logrus.New() + logger.SetOutput(mainOut) + logger.SetFormatter(&logrus.TextFormatter{ + DisableColors: true, // Disable colors for consistent testing + DisableTimestamp: true, // Disable timestamp for simpler string matching + }) + // Set level to Info so all our messages are processed + logger.SetLevel(logrus.InfoLevel) + + // Create and configure the custom hook + hook := &ErrorHook{ + Out: hookOut, + Formatter: &logrus.JSONFormatter{}, + } + logger.AddHook(hook) + + // 2. Execution + // Run the scheduler simulation which will generate logs + runTaskScheduler(logger) + + // 3. Assertions for the Main Logger Output (Text Format) + mainLogStr := mainOut.String() + + // Verify that all expected log messages are present in the main output + assert.Contains(t, mainLogStr, "level=info msg=\"Starting task scheduler...\"", "Main logger should contain the starting message") + assert.Contains(t, mainLogStr, "msg=\"Task 'Process daily reports' completed successfully\"", "Main logger should contain the success message") + assert.Contains(t, mainLogStr, "task_duration=250ms", "Main logger should contain the duration field") + assert.Contains(t, mainLogStr, "msg=\"Task 'Sync user data': upstream API is slow\"", "Main logger should contain the warning message") + assert.Contains(t, mainLogStr, "task_id=sync-001", "Main logger should contain the task_id field") + assert.Contains(t, mainLogStr, "msg=\"Task 'Backup database' failed: connection timed out\"", "Main logger should contain the error message") + assert.Contains(t, mainLogStr, "retry_attempt=3", "Main logger should contain the retry_attempt field") + + // 4. Assertions for the ErrorHook Output (JSON Format) + hookLogStr := hookOut.String() + + // The hook's output should not be empty + require.NotEmpty(t, hookLogStr, "Hook output should not be empty") + + // The hook should ONLY contain the error log, not info or warning logs + assert.NotContains(t, hookLogStr, "Process daily reports", "Hook should not log info messages") + assert.NotContains(t, hookLogStr, "Sync user data", "Hook should not log warning messages") + + // Unmarshal the JSON output from the hook to verify its content and structure + var entry hookLogEntry + err := json.Unmarshal([]byte(hookLogStr), &entry) + require.NoError(t, err, "Hook output must be valid JSON") + + // Verify the fields of the JSON log entry + assert.Equal(t, "error", entry.Level, "Log level in hook output should be 'error'") + assert.Equal(t, "Task 'Backup database' failed: connection timed out", entry.Msg, "Message in hook output is incorrect") + assert.Equal(t, 3, entry.RetryAttempt, "retry_attempt field in hook output is incorrect") +} + +func TestErrorHook_Levels(t *testing.T) { + hook := &ErrorHook{} + levels := hook.Levels() + assert.Contains(t, levels, logrus.ErrorLevel, "Hook should fire on ErrorLevel") + assert.Contains(t, levels, logrus.FatalLevel, "Hook should fire on FatalLevel") + assert.NotContains(t, levels, logrus.WarnLevel, "Hook should not fire on WarnLevel") + assert.NotContains(t, levels, logrus.InfoLevel, "Hook should not fire on InfoLevel") + assert.Len(t, levels, 2, "Hook should only register for 2 levels") +} \ No newline at end of file diff --git a/packages/logrus/challenge-3-advanced-configuration-and-hooks/submissions/LeeFred3042U/solution.go b/packages/logrus/challenge-3-advanced-configuration-and-hooks/submissions/LeeFred3042U/solution.go new file mode 100644 index 00000000..c30cde4b --- /dev/null +++ b/packages/logrus/challenge-3-advanced-configuration-and-hooks/submissions/LeeFred3042U/solution.go @@ -0,0 +1,82 @@ +package main + +import ( + "bytes" + "io" + "os" + + "github.com/sirupsen/logrus" +) + +// ErrorHook is a custom hook that sends logs of specified levels to a dedicated writer +// It implements the logrus.Hook interface +type ErrorHook struct { + Out io.Writer + Formatter logrus.Formatter +} + +// Levels returns the log levels that this hook will be triggered for +func (h *ErrorHook) Levels() []logrus.Level { + // Trigger only for Error and Fatal + return []logrus.Level{logrus.ErrorLevel, logrus.FatalLevel} +} + +// Fire is called when a log entry is fired for one of the specified Levels +func (h *ErrorHook) Fire(entry *logrus.Entry) error { + // Format the entry using the provided formatter + b, err := h.Formatter.Format(entry) + if err != nil { + return err + } + // Ensure a trailing newline so each entry is on its own line + if _, err := h.Out.Write(append(b, '\n')); err != nil { + return err + } + return nil +} + +// runTaskScheduler simulates a scheduler running various tasks and logging their outcomes +func runTaskScheduler(logger *logrus.Logger) { + logger.Info("Starting task scheduler...") + + // Successful task (Info) — exact message expected by tests + logger.WithField("task_duration", "250ms").Infof("Task '%s' completed successfully", "Process daily reports") + + // Warning task (Warn) — message must contain task name and include task_id field + logger.WithField("task_id", "sync-001").Warnf("Task '%s': upstream API is slow", "Sync user data") + + // Failed task (Error) — exact message expected by tests and must include retry_attempt int field + logger.WithField("retry_attempt", 3).Errorf("Task '%s' failed: %s", "Backup database", "connection timed out") +} + +func main() { + // Create a new instance of the logger + logger := logrus.New() + + // Set the logger's formatter to TextFormatter (console-friendly) + logger.SetFormatter(&logrus.TextFormatter{}) + + // Set the logger's output to standard out + logger.SetOutput(os.Stdout) + + // The hook will write to a separate buffer. In tests they usually inject a bytes.Buffer + var hookWriter io.Writer = &bytes.Buffer{} + + // Create an instance of ErrorHook with a JSON formatter + hook := &ErrorHook{ + Out: hookWriter, + Formatter: &logrus.JSONFormatter{}, + } + + // Add the hook to the logger + logger.AddHook(hook) + + // Run the scheduler simulation + runTaskScheduler(logger) + + + // In a real application, you might want to inspect the hook's output. + // For this challenge, the tests will handle that. + // fmt.Println("\n--- Hook Output ---") + // fmt.Println(hookWriter.(*bytes.Buffer).String()) +} \ No newline at end of file diff --git a/packages/logrus/challenge-4-production-logging-patterns/README.md b/packages/logrus/challenge-4-production-logging-patterns/README.md new file mode 100644 index 00000000..0118eec0 --- /dev/null +++ b/packages/logrus/challenge-4-production-logging-patterns/README.md @@ -0,0 +1,93 @@ +# Challenge 4: Production Logging Patterns + +Build a robust **Task Worker** in Go that demonstrates advanced, production-ready logging patterns with multi-destination outputs and a custom alerting hook + +--- + +## Challenge Requirements + +Implement a worker system with structured logging configured for a real-world production setup + +### Multi-Destination Logging +- **Console (`stdout`)** + - Human-readable, real-time monitoring + - Formatted as plaintext + +- **Log File (`app.log`)** + - Persistent, structured storage + - Formatted as JSON for later analysis + +- **Alerting Stream (`stderr`)** + - Critical logs routed to a custom hook + - Formatted as JSON for integration with alerting pipelines + +### Custom RetryHook +- Triggered only for `WarnLevel` and `ErrorLevel` logs +- Formats entries as JSON +- Writes to a separate stream (`stderr`) +- Simulates sending events to alerting systems (e.g., PagerDuty, Sentry) + +### Task Worker Simulation +Implement a `runWorker` function that processes a list of tasks and logs their lifecycle with structured fields: + +- **Start**: `Info` log → `"Starting task: "` +- **Retry**: `Warn` log → `"Task '' retried (attempt X of Y)"` +- **Failure**: `Error` log → `"Task '' failed after all retries"` +- **Success**: `Info` log → `"Task '' completed successfully"` + +--- + +## Data Structures + +```go +// Task represents a single unit of work in the worker queue +type Task struct { + ID int + Name string + Retries int + Duration time.Duration +} + +// RetryHook captures warnings and errors for alerting streams +type RetryHook struct { + Out io.Writer + Formatter logrus.Formatter +} +```` + +--- + +## Implementation Requirements + +### Logger Configuration (`setupLogger`) + +* Use `logrus.New()` to create a logger instance +* Use `io.MultiWriter` to send logs to both console (`stdout`) and file (`app.log`) +* Set console formatter as `logrus.TextFormatter` +* Register a `RetryHook` with `JSONFormatter` that writes to `stderr` + +### RetryHook + +* `Levels()`: Return `WarnLevel` and `ErrorLevel` +* `Fire()`: Format the log entry as JSON and write it to `Out` + +### Worker Logic (`runWorker`) + +* Iterate through tasks. +* Log start, retries, success, and failure with structured fields +* Retries and errors should appear in **all three streams** +* Info logs should appear in console and file, but **not** in alert stream + +--- + +## Testing Requirements + +Your solution must pass tests that verify: + +* Worker logs the correct lifecycle events with structured fields +* `RetryHook` is correctly registered on the logger +* Hook outputs JSON-formatted `Warn` and `Error` logs to `stderr` +* All `Warn`/`Error` logs appear in **console + file + hook** +* All `Info` logs appear in **console + file only** + +--- \ No newline at end of file diff --git a/packages/logrus/challenge-4-production-logging-patterns/app.log b/packages/logrus/challenge-4-production-logging-patterns/app.log new file mode 100644 index 00000000..e69de29b diff --git a/packages/logrus/challenge-4-production-logging-patterns/app.log.old b/packages/logrus/challenge-4-production-logging-patterns/app.log.old new file mode 100644 index 00000000..e69de29b diff --git a/packages/logrus/challenge-4-production-logging-patterns/go.mod b/packages/logrus/challenge-4-production-logging-patterns/go.mod new file mode 100644 index 00000000..0f074a9b --- /dev/null +++ b/packages/logrus/challenge-4-production-logging-patterns/go.mod @@ -0,0 +1,15 @@ +module challenge-4-production-logging-patterns + +go 1.21 + +require ( + github.com/sirupsen/logrus v1.9.3 + github.com/stretchr/testify v1.11.1 +) + +require ( + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/packages/logrus/challenge-4-production-logging-patterns/go.sum b/packages/logrus/challenge-4-production-logging-patterns/go.sum new file mode 100644 index 00000000..08b7cdee --- /dev/null +++ b/packages/logrus/challenge-4-production-logging-patterns/go.sum @@ -0,0 +1,18 @@ +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= +github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8 h1:0A+M6Uqn+Eje4kHMK80dtF3JCXC4ykBgQG4Fe06QRhQ= +golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/packages/logrus/challenge-4-production-logging-patterns/hints.md b/packages/logrus/challenge-4-production-logging-patterns/hints.md new file mode 100644 index 00000000..c2488928 --- /dev/null +++ b/packages/logrus/challenge-4-production-logging-patterns/hints.md @@ -0,0 +1,113 @@ +# Hints for Challenge 4: Production Logging Patterns + +## Hint 1: Defining the `RetryHook` Struct and Levels + +First, define the `RetryHook` struct with exported fields for its output destination and formatter. Then, implement the `Levels` method to tell Logrus that this hook should only activate for warnings and errors + +```go +import ( + "io" + "github.com/sirupsen/logrus" +) + +type RetryHook struct { + Out io.Writer + Formatter logrus.Formatter +} + +func (h *RetryHook) Levels() []logrus.Level { + return []logrus.Level{logrus.WarnLevel, logrus.ErrorLevel} +} +``` + +--- + +## Hint 2: Implementing the `Fire` Method + +The `Fire` method formats the log entry using the hook's formatter and writes it to its dedicated output writer + +```go +func (h *RetryHook) Fire(entry *logrus.Entry) error { + line, err := h.Formatter.Format(entry) + if err != nil { + return err + } + _, err = h.Out.Write(append(line, '\n')) + return err +} +``` + +--- + +## Hint 3: Setting Up Multiple Destinations + +Use `io.MultiWriter` to log to both console and file simultaneously + +```go +logFile, err := os.OpenFile("app.log", os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0666) +if err != nil { + panic("Failed to open log file") +} + +multiWriter := io.MultiWriter(os.Stdout, logFile) +logger.SetOutput(multiWriter) +``` + +--- + +## Hint 4: Logging the Task Lifecycle + +Define a simple `Task` struct and use `WithFields` for structured logging + +```go +type Task struct { + ID int + Name string + Retries int +} + +for _, task := range tasks { + logger.WithFields(logrus.Fields{"task_id": task.ID}).Infof("Starting task: %s", task.Name) + + for i := 1; i <= task.Retries; i++ { + logger.WithFields(logrus.Fields{"task_id": task.ID, "retries": i}).Warnf( + "Task '%s' retried (attempt %d of %d)", task.Name, i, task.Retries) + } + + if task.Name == "FailMe" { + logger.WithFields(logrus.Fields{"task_id": task.ID, "error": "simulated failure"}).Error( + "Task '" + task.Name + "' failed after all retries") + } else { + logger.WithFields(logrus.Fields{"task_id": task.ID, "duration": "15ms"}).Info( + "Task '" + task.Name + "' completed successfully") + } +} +``` + +--- + +## Hint 5: Finalizing the Logger Configuration + +Set main formatter and add `RetryHook` + +```go +logger.SetFormatter(&logrus.TextFormatter{FullTimestamp: true}) + +hook := &RetryHook{ + Out: os.Stderr, + Formatter: &logrus.JSONFormatter{}, +} +logger.AddHook(hook) +``` + +--- + +## Hint 6: Summary & Tips + +* Always define `Task` struct with ID, Name, and Retries +* Log Info-level messages for task start and success +* Log Warn-level for retries and Error-level for failures +* Use `io.MultiWriter` for console + file output +* Use `RetryHook` to filter and send Warn/Error logs to stderr (or alerting system) + +--- \ No newline at end of file diff --git a/packages/logrus/challenge-4-production-logging-patterns/learning.md b/packages/logrus/challenge-4-production-logging-patterns/learning.md new file mode 100644 index 00000000..c43cd59c --- /dev/null +++ b/packages/logrus/challenge-4-production-logging-patterns/learning.md @@ -0,0 +1,167 @@ +# Learning: Production Logging Patterns + +## **What is a Logging Pipeline?** + +In development, we often just `fmt.Println` or log to the console. +In production, logs aren’t just messages—they are **real-time data streams**. + +A **logging pipeline** processes and routes log events to multiple destinations, each with different purposes: +- Live monitoring (console) +- Persistent storage (log file) +- Critical alerting (hook/stream) + +--- + +## **The Log Pipeline Concept** + +Think of logs like items on a factory conveyor belt: + +- **Live Monitoring (Console)** + Human-readable logs for developers watching the system in real-time. + Uses `TextFormatter`. + +- **Inventory & Archiving (Log File)** + Every event is archived in a structured format for future analysis. + Uses `JSONFormatter`. + +- **Quality Control & Alerts (Hook)** + Only *critical defects* (warnings & errors) are routed for immediate action. + Custom `RetryHook` writes filtered logs to `stderr`. + +--- + +## **io.MultiWriter: The Splitter Valve** + +How do you log to multiple outputs without duplicating code? +Go’s standard library provides `io.MultiWriter`. + +```go +// Open a log file for appending +logFile, err := os.OpenFile("app.log", os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0666) +if err != nil { + log.Fatalf("failed to open log file: %v", err) +} + +// Create a multi-writer: logs go to both stdout and the file +multiWriter := io.MultiWriter(os.Stdout, logFile) + +// Configure logrus with the multi-writer +logger := logrus.New() +logger.SetOutput(multiWriter) +logger.SetFormatter(&logrus.TextFormatter{}) + +// A single call logs to console and file +logger.Info("Worker started") +```` + +Now a single `logger.Info(...)` call appears **both on screen and in `app.log`**. + +--- + +## **Custom RetryHook Example** + +A **hook** allows you to intercept log entries and route them elsewhere. +Here’s a minimal `RetryHook` that forwards `Warn` and `Error` logs to `stderr` as JSON: + +```go +type RetryHook struct { + Out io.Writer + Formatter logrus.Formatter +} + +func (h *RetryHook) Levels() []logrus.Level { + return []logrus.Level{logrus.WarnLevel, logrus.ErrorLevel} +} + +func (h *RetryHook) Fire(entry *logrus.Entry) error { + // Format the entry as JSON + line, err := h.Formatter.Format(entry) + if err != nil { + return err + } + _, err = h.Out.Write(line) + return err +} + +// Usage +func main() { + logger := logrus.New() + logger.SetOutput(os.Stdout) + logger.SetFormatter(&logrus.TextFormatter{}) + + // Attach the RetryHook + hook := &RetryHook{ + Out: os.Stderr, + Formatter: &logrus.JSONFormatter{}, + } + logger.AddHook(hook) + + logger.Info("Normal info log (console only)") + logger.Warn("Retry attempt failed (also goes to stderr as JSON)") + logger.Error("Task permanently failed (also goes to stderr as JSON)") +} +``` + +**Output:** + +Console (`stdout` - text): + +```text +INFO[0000] Normal info log (console only) +WARN[0000] Retry attempt failed (also goes to stderr as JSON) +ERRO[0000] Task permanently failed (also goes to stderr as JSON) +``` + +Alerting stream (`stderr` - JSON): + +```json +{"level":"warning","msg":"Retry attempt failed (also goes to stderr as JSON)","time":"..."} +{"level":"error","msg":"Task permanently failed (also goes to stderr as JSON)","time":"..."} +``` + +--- + +## **Centralized Logging** + +In production, you often run multiple servers or microservices. +SSH’ing into each machine to tail log files is a nightmare. +Solution: **centralized logging**. + +### The ELK Stack + +* **Elasticsearch** → Stores & indexes logs for fast search +* **Logstash** → Processes & ships logs to Elasticsearch +* **Kibana** → Visualizes logs in dashboards + +Your structured JSON logs (`app.log`) integrate perfectly with this pipeline. +A tool like **Filebeat** ships new log entries to ELK in real time. + +--- + +## **Best Practices for Microservice Logging** + +1. **Log in JSON (always)** → structured, parseable +2. **Log to stdout/stderr** in containers → integrates with Docker/Kubernetes logging drivers +3. **Use Correlation IDs** → trace requests across services +4. **Enrich logs with metadata** (service, env, version, host) +5. **Treat logs as an API** → field names are contracts, don’t rename casually + +--- + +## **Resources** + +* [Go Docs: `io.MultiWriter`](https://pkg.go.dev/io#MultiWriter) +* [Intro to the ELK Stack](https://www.elastic.co/what-is/elk-stack) +* [Logrus Documentation](https://github.com/sirupsen/logrus) + +--- + +## **Key Takeaways** + +* Logs are data streams, not debug prints. +* Use **multi-destination logging** (console + file + alerts). +* **Structured JSON logs** fuel centralized logging systems. +* Hooks give you **separate error/alerting channels**. +* Logging is an **operational contract**—treat it like an API. + +--- \ No newline at end of file diff --git a/packages/logrus/challenge-4-production-logging-patterns/metadata.json b/packages/logrus/challenge-4-production-logging-patterns/metadata.json new file mode 100644 index 00000000..9bcb136d --- /dev/null +++ b/packages/logrus/challenge-4-production-logging-patterns/metadata.json @@ -0,0 +1,41 @@ +{ + "title": "Production Logging Patterns", + "description": "Build a robust logging pipeline for a background task worker. This advanced challenge covers routing logs to multiple destinations (console, file, and an alert stream) with different formats, and implementing a custom hook for error tracking", + "short_description": "Implement a production-grade logging pipeline with multiple destinations", + "difficulty": "Advanced", + "estimated_time": "90-120 minutes", + "learning_objectives": [ + "Configure a logger to write to multiple outputs simultaneously using `io.MultiWriter`", + "Implement a custom `logrus.Hook` for targeted alerting and error tracking", + "Apply different formatters to different log streams within the same application", + "Understand the architectural principles of centralized logging pipelines (e.g., for an ELK stack)", + "Structure logs with consistent, context-rich fields for background services" + ], + "prerequisites": [ + "Completion of 'Challenge 3: Advanced Configuration & Hooks'", + "Familiarity with Go's `io.Writer` interface and basic file I/O" + ], + "tags": [ + "logging", + "logrus", + "hooks", + "production", + "pipeline", + "monitoring", + "alerting" + ], + "real_world_connection": "This multi-stream logging architecture is a standard pattern for building observable, production-grade services. It separates human-readable logs from the machine-parseable data needed for aggregation in systems like Splunk or the ELK stack, and for triggering automated alerts", + "requirements": [ + "Use `io.MultiWriter` to log to both the console (`stdout`) and a file", + "Implement a custom hook that triggers for `Warn` and `Error` levels", + "The hook must write JSON-formatted logs to a separate destination (`stderr`)", + "The main worker function must log the full lifecycle of tasks with specific structured fields (`task_id`, `duration`, etc)" + ], + "bonus_points": [ + "Implement a basic file rotation strategy for the main log file (`app.log`)", + "Make the custom hook asynchronous using a goroutine and a channel to avoid blocking", + "Add a global field (e.g., `hostname` or `app_version`) to all log entries" + ], + "icon": "bi-diagram-3", + "order": 4 +} \ No newline at end of file diff --git a/packages/logrus/challenge-4-production-logging-patterns/run_test.sh b/packages/logrus/challenge-4-production-logging-patterns/run_test.sh new file mode 100755 index 00000000..a7d39b69 --- /dev/null +++ b/packages/logrus/challenge-4-production-logging-patterns/run_test.sh @@ -0,0 +1,75 @@ +#!/bin/bash + +# Script to run tests for a participant's submission + +# Function to display usage +usage() { + echo "Usage: $0" + exit 1 +} + +# Verify that we are in a challenge directory +if [ ! -f "solution-template_test.go" ]; then + echo "Error: solution-template_test.go not found. Please run this script from a challenge directory." + exit 1 +fi + +# Prompt for GitHub username +read -p "Enter your GitHub username: " USERNAME + +SUBMISSION_DIR="submissions/$USERNAME" +SUBMISSION_FILE="$SUBMISSION_DIR/solution.go" + +# Check if the submission file exists +if [ ! -f "$SUBMISSION_FILE" ]; then + echo "Error: Solution file '$SUBMISSION_FILE' not found." + echo "Note: Please ensure your solution is named 'solution.go' and placed in a 'submissions//' directory." + exit 1 +fi + +# Create a temporary directory to avoid modifying the original files +TEMP_DIR=$(mktemp -d) + +# Copy the participant's solution, test file, and go.mod/go.sum to the temporary directory +cp "$SUBMISSION_FILE" "solution-template_test.go" "go.mod" "go.sum" "$TEMP_DIR/" 2>/dev/null + +# Rename solution.go to solution-template.go for the test to build correctly +mv "$TEMP_DIR/solution.go" "$TEMP_DIR/solution-template.go" + +echo "Running tests for user '$USERNAME'..." + +# Navigate to the temporary directory +pushd "$TEMP_DIR" > /dev/null || { + echo "Failed to navigate to temporary directory." + rm -rf "$TEMP_DIR" + exit 1 +} + +# Tidy up dependencies to ensure everything is consistent +echo "Tidying dependencies..." +go mod tidy || { + echo "Failed to tidy dependencies." + popd > /dev/null || exit 1 + rm -rf "$TEMP_DIR" + exit 1 +} + +# Run the tests with verbosity and coverage +echo "Executing tests..." +go test -v -cover + +TEST_EXIT_CODE=$? + +# Return to the original directory +popd > /dev/null || exit 1 + +# Clean up the temporary directory +rm -rf "$TEMP_DIR" + +if [ $TEST_EXIT_CODE -eq 0 ]; then + echo "All tests passed!" +else + echo "Some tests failed" +fi + +exit $TEST_EXIT_CODE \ No newline at end of file diff --git a/packages/logrus/challenge-4-production-logging-patterns/solution-template.go b/packages/logrus/challenge-4-production-logging-patterns/solution-template.go new file mode 100644 index 00000000..fca221d4 --- /dev/null +++ b/packages/logrus/challenge-4-production-logging-patterns/solution-template.go @@ -0,0 +1,83 @@ +package main + +import ( + "os" + "time" + + "github.com/sirupsen/logrus" +) + +// RetryHook forwards warning/error logs to a secondary destination (stderr) +// TODO: Implement the hook struct with output target and formatter +type RetryHook struct { + // TODO: define fields (e.g., Out, Formatter) +} + +// Levels tells Logrus which severities this hook should fire for +// TODO: Restrict to Warn and Error +func (h *RetryHook) Levels() []logrus.Level { + // TODO: return the correct levels + return nil +} + +// Fire is invoked when a log entry matches the levels above +// TODO: Format the entry and write it to the secondary output +func (h *RetryHook) Fire(entry *logrus.Entry) error { + // TODO: implement hook writing logic + return nil +} + +// Task represents a unit of work processed by the worker +type Task struct { + ID string + Name string + Retries int +} + +// runWorker simulates execution of tasks with retries, failures, and successes +// TODO: Log at Info for normal ops, Warn for retries, Error for failures +// Add fields like task_id, retries, error, duration +func runWorker(logger *logrus.Logger, tasks []Task) { + for _, task := range tasks { + start := time.Now() + + // TODO: Log starting task (Info) + + // TODO: Log retries (Warn) + + // TODO: Log simulated failure (Error) + + // TODO: Log simulated success with duration (Info) + + _ = start // remove once implemented + } +} + +// setupLogger configures the main logger with: +// - Console output (TextFormatter) +// - File output (JSONFormatter) +// - Hook to route warnings/errors +// TODO: Implement logger setup. +func setupLogger() *logrus.Logger { + logger := logrus.New() + + // TODO: Configure console output + + // TODO: Configure file output with rotation + + // TODO: Configure retry hook (stderr + JSON formatter) + + return logger +} + +func main() { + logger := setupLogger() + + tasks := []Task{ + {ID: "1", Name: "SendEmail", Retries: 0}, + {ID: "2", Name: "FailMe", Retries: 2}, + {ID: "3", Name: "GenerateReport", Retries: 1}, + } + + runWorker(logger, tasks) +} \ No newline at end of file diff --git a/packages/logrus/challenge-4-production-logging-patterns/solution-template_test.go b/packages/logrus/challenge-4-production-logging-patterns/solution-template_test.go new file mode 100644 index 00000000..c42fe090 --- /dev/null +++ b/packages/logrus/challenge-4-production-logging-patterns/solution-template_test.go @@ -0,0 +1,236 @@ +package main + +import ( + "bufio" + "bytes" + "encoding/json" + "io" + "os" + "reflect" + "strings" + "testing" + "time" + + "github.com/sirupsen/logrus" + "github.com/stretchr/testify/assert" +) + +// logLine is a helper map type for unmarshalling JSON log lines produced by logrus.JSONFormatter +type logLine map[string]interface{} + +// helper: read lines and unmarshal JSON lines into logLine maps +func parseJSONLines(r io.Reader) ([]logLine, error) { + var out []logLine + scanner := bufio.NewScanner(r) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line == "" { + continue + } + var m logLine + if err := json.Unmarshal([]byte(line), &m); err != nil { + // If a line isn't valid JSON, skip it (some formatters may emit text lines) + continue + } + out = append(out, m) + } + if err := scanner.Err(); err != nil { + return nil, err + } + return out, nil +} + +// findRetryHook searches logger hooks for a hook of the concrete type *RetryHook +func findRetryHook(logger *logrus.Logger) *RetryHook { + // Look in Warn and Error levels (hooks for those levels) + levels := []logrus.Level{logrus.WarnLevel, logrus.ErrorLevel} + for _, lvl := range levels { + hooks := logger.Hooks[lvl] + for _, h := range hooks { + // Try to assert to *RetryHook + if rh, ok := h.(*RetryHook); ok { + return rh + } + } + } + return nil +} + +// Test that runWorker emits expected Info/Warn/Error messages and fields when using JSONFormatter +func TestRunWorker_EmitsExpectedStructuredLogs(t *testing.T) { + assert := assert.New(t) + + // Create a test logger and capture output + logger := logrus.New() + var buf bytes.Buffer + logger.SetOutput(&buf) + logger.SetFormatter(&logrus.JSONFormatter{}) + logger.SetLevel(logrus.InfoLevel) + + // Prepare tasks similar to the example + tasks := []Task{ + {ID: "1", Name: "SendEmail", Retries: 0}, + {ID: "2", Name: "FailMe", Retries: 2}, + {ID: "3", Name: "GenerateReport", Retries: 1}, + } + + // Run the worker (should write JSON lines into buf) + runWorker(logger, tasks) + + // Give a tiny margin for any buffering (not usually necessary) + time.Sleep(10 * time.Millisecond) + + lines, err := parseJSONLines(&buf) + assert.NoError(err, "failed to parse JSON log lines") + assert.Greater(len(lines), 0, "expected some JSON log lines") + + // Helpers to find an entry by message substring + findByMsg := func(substr string) (logLine, bool) { + for _, l := range lines { + if msgRaw, ok := l["msg"]; ok { + if s, ok := msgRaw.(string); ok && strings.Contains(s, substr) { + return l, true + } + } + } + return nil, false + } + + // 1 - Starting task log for SendEmail + entry, ok := findByMsg("Starting task: SendEmail") + assert.True(ok, "expected a 'Starting task: SendEmail' log entry") + if ok { + // Check task_id exists and matches + assert.Equal("1", entry["task_id"], "task_id should be '1' in start entry") + } + + // 2 - Retry warning for GenerateReport (Retries:1) or a retry for some task + entry, ok = findByMsg("retried") + assert.True(ok, "expected a retry warning log entry (message contains 'retried')") + if ok { + // Ensure retries field exists and is a number >= 1 + rRaw, exists := entry["retries"] + assert.True(exists, "expected 'retries' field in retry warning entry") + if exists { + // JSON numbers unmarshal to float64 + f, ok := rRaw.(float64) + assert.True(ok, "retries field should be a number") + assert.GreaterOrEqual(f, float64(1), "retries should be >= 1") + } + } + + // 3 - Error entry for FailMe + entry, ok = findByMsg("Task failed") + assert.True(ok, "expected an error log entry for simulated failure") + if ok { + // Check task_id and error field presence + assert.Equal("2", entry["task_id"], "expected task_id '2' in error entry") + errRaw, exists := entry["error"] + assert.True(exists, "expected 'error' field in failure entry") + if exists { + s, ok := errRaw.(string) + assert.True(ok && s != "", "unexpected 'error' field value") + } + } + + // 4 - Success entry contains duration + entry, ok = findByMsg("completed successfully") + assert.True(ok, "expected a success completion log entry") + if ok { + _, hasDuration := entry["duration"] + assert.True(hasDuration, "expected 'duration' field in success entry") + } +} + +// Test that setupLogger registers a RetryHook that actually writes formatted entries +// for Warn and Error levels to its configured Out target +func TestRetryHook_WritesOutOnWarnAndError(t *testing.T) { + assert := assert.New(t) + + // Call the participant's setupLogger to get a configured logger + logger := setupLogger() + assert.NotNil(logger, "setupLogger should return a logger") + + // Find the retry hook within logger hooks + retry := findRetryHook(logger) + assert.NotNil(retry, "expected a RetryHook to be registered for warn/error levels (found none). Ensure setupLogger adds the hook.") + + // Create a temporary file to capture hook output + tmpFile, err := os.CreateTemp("", "retryhook-*.log") + assert.NoError(err, "failed to create temp file") + defer func() { + tmpFile.Close() + os.Remove(tmpFile.Name()) + }() + + // Use reflection to set Out and Formatter fields on the found hook if they exist and are settable + rv := reflect.ValueOf(retry) + // Expect a pointer to struct + if rv.Kind() == reflect.Ptr { + rv = rv.Elem() + } + outField := rv.FieldByName("Out") + fmtField := rv.FieldByName("Formatter") + + // Validate fields exist + assert.True(outField.IsValid(), "RetryHook must have an 'Out' field (io.Writer) for tests to redirect output") + assert.True(fmtField.IsValid(), "RetryHook must have a 'Formatter' field (logrus.Formatter) for tests to set JSON formatting") + + // Attempt to set fields + if outField.IsValid() && outField.CanSet() { + outField.Set(reflect.ValueOf(tmpFile)) + } else { + // If the field exists but is not settable, fail with helpful message + if outField.IsValid() && !outField.CanSet() { + t.Fatalf("RetryHook.Out exists but is not settable; ensure it is an exported field (capitalized) and addressable") + } + } + + if fmtField.IsValid() && fmtField.CanSet() { + fmtField.Set(reflect.ValueOf(&logrus.JSONFormatter{})) + } else { + if fmtField.IsValid() && !fmtField.CanSet() { + t.Fatalf("RetryHook.Formatter exists but is not settable; ensure it is an exported field (capitalized) and addressable") + } + } + + // Fire a Warn and Error log which should call the hook and write to our temp file + logger.Warn("hook-test-warn") + logger.Error("hook-test-error") + + // Flush/close so data is written + tmpFile.Sync() + tmpFile.Close() + + // Read contents and ensure our messages are present (as JSON text) + content, err := os.ReadFile(tmpFile.Name()) + assert.NoError(err, "failed to read temp hook file") + text := string(content) + assert.Contains(text, "hook-test-warn", "expected hook to write warn message") + assert.Contains(text, "hook-test-error", "expected hook to write error message") +} + +// A small smoke test: +// ensure setupLogger returns a logger that can be used by runWorker without panicking +func TestIntegration_RunWorkerWithSetupLogger(t *testing.T) { + assert := assert.New(t) + + logger := setupLogger() + assert.NotNil(logger, "setupLogger should return a logger") + + // Capture console output so test logs don't pollute test stdout + var buf bytes.Buffer + logger.SetOutput(&buf) + logger.SetFormatter(&logrus.JSONFormatter{}) + + // Minimal tasks to run without assertions - ensure no panic and produce some output + tasks := []Task{ + {ID: "x", Name: "SendEmail", Retries: 0}, + } + runWorker(logger, tasks) + + // Expect at least one JSON log line produced + lines, err := parseJSONLines(&buf) + assert.NoError(err, "failed to parse JSON lines from logger output") + assert.Greater(len(lines), 0, "expected at least one log line when running worker with logger from setupLogger") +} \ No newline at end of file diff --git a/packages/logrus/challenge-4-production-logging-patterns/submissions/LeeFred3042U/solution.go b/packages/logrus/challenge-4-production-logging-patterns/submissions/LeeFred3042U/solution.go new file mode 100644 index 00000000..01b40b83 --- /dev/null +++ b/packages/logrus/challenge-4-production-logging-patterns/submissions/LeeFred3042U/solution.go @@ -0,0 +1,139 @@ +package main + +import ( + "fmt" + "io" + "os" + "time" + + "github.com/sirupsen/logrus" +) + +// RetryHook forwards warning/error logs to a secondary destination (stderr) +// This could represent routing critical logs to monitoring/alerting systems +type RetryHook struct { + Out io.Writer + Formatter logrus.Formatter +} + +// Levels tells Logrus which severities this hook should fire for +// Restrict to Warn and Error +func (h *RetryHook) Levels() []logrus.Level { + return []logrus.Level{logrus.WarnLevel, logrus.ErrorLevel} +} + +// Fire is invoked when a log entry matches the levels above +// It formats the entry with the hook’s formatter and writes to Out +func (h *RetryHook) Fire(entry *logrus.Entry) error { + formatter := h.Formatter + if formatter == nil { + formatter = &logrus.JSONFormatter{} + } + out := h.Out + if out == nil { + out = os.Stderr + } + line, err := formatter.Format(entry) + if err != nil { + return err + } + _, err = out.Write(line) + return err +} + +// Task represents a unit of work processed by the worker +type Task struct { + ID string + Name string + Retries int +} + +// runWorker simulates execution of tasks with retries, failures, and successes +// Logs at Info for normal ops, Warn for retries, Error for failures +func runWorker(logger *logrus.Logger, tasks []Task) { + for _, task := range tasks { + start := time.Now() + + // Start log + logger.WithField("task_id", task.ID). + Infof("Starting task: %s", task.Name) + + // Retry log + if task.Retries > 0 { + logger.WithFields(logrus.Fields{ + "task_id": task.ID, + "retries": task.Retries, + }).Warnf("Task %s retried %d time(s)", task.Name, task.Retries) + } + + // Failure simulation + if task.Name == "FailMe" { + logger.WithFields(logrus.Fields{ + "task_id": task.ID, + "error": "simulated failure", + }).Error("Task failed due to simulated error") + continue + } + + // Success log + duration := time.Since(start) + logger.WithFields(logrus.Fields{ + "task_id": task.ID, + "duration": duration, + }).Infof("Task %s completed successfully", task.Name) + } +} + +// setupLogger configures the main logger with: +// - Console output (TextFormatter) +// - File output (JSONFormatter) +// - Hook to route warnings/errors separately +func setupLogger() *logrus.Logger { + logger := logrus.New() + + // Console (human readable) + consoleFormatter := &logrus.TextFormatter{ + DisableColors: false, + FullTimestamp: true, + } + + // File (structured JSON) + logFile := "app.log" + if _, err := os.Stat(logFile); err == nil { + os.Rename(logFile, fmt.Sprintf("%s.old", logFile)) + } + file, err := os.OpenFile(logFile, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0666) + if err != nil { + panic(fmt.Sprintf("could not open log file: %v", err)) + } + + // Send logs to both stdout (human text) and file (JSON) + mw := io.MultiWriter(os.Stdout, file) + logger.SetOutput(mw) + + // For stdout readability, use TextFormatter + // (File will still get JSON lines because JSON is written explicitly) + logger.SetFormatter(consoleFormatter) + + // Retry hook: + // Warn/Error routed to stderr in JSON + retryHook := &RetryHook{ + Out: os.Stderr, + Formatter: &logrus.JSONFormatter{}, + } + logger.AddHook(retryHook) + + return logger +} + +func main() { + logger := setupLogger() + + tasks := []Task{ + {ID: "1", Name: "SendEmail", Retries: 0}, + {ID: "2", Name: "FailMe", Retries: 2}, + {ID: "3", Name: "GenerateReport", Retries: 1}, + } + + runWorker(logger, tasks) +} \ No newline at end of file diff --git a/packages/logrus/package.json b/packages/logrus/package.json new file mode 100644 index 00000000..05fc3c58 --- /dev/null +++ b/packages/logrus/package.json @@ -0,0 +1,28 @@ +{ + "name": "logrus", + "display_name": "Logrus Structured Logging", + "description": "Structured, pluggable logging for Go applications", + "version": "v1.9.3", + "github_url": "https://github.com/sirupsen/logrus", + "documentation_url": "https://pkg.go.dev/github.com/sirupsen/logrus", + "stars": 24000, + "category": "logging", + "difficulty": "beginner_to_advanced", + "prerequisites": ["basic_go", "json_concepts", "file_operations"], + "learning_path": [ + "challenge-1-basic-logging-and-levels", + "challenge-2-structured-logging-and-fields", + "challenge-3-advanced-configuration-and-hooks", + "challenge-4-production-logging-patterns" + ], + "tags": ["logging", "structured", "monitoring", "debugging", "observability"], + "estimated_time": "4-6 hours", + "real_world_usage": [ + "Application debugging and monitoring", + "Error tracking and alerting", + "Audit trails and compliance", + "Performance monitoring", + "Microservices observability", + "Production troubleshooting" + ] +} \ No newline at end of file