4 A log story: create a library
This chapter motivates logging through a late‑night debugging vignette and then zooms out: logging is the practice of recording meaningful, readable messages with different levels of importance. The goal is to build a small, reusable Go library that any project can import to log messages at three levels (Debug, Info, Error), using an integer-backed enumeration, and to expose a stable, minimal API. Along the way, the chapter reinforces Go package conventions, the exported/unexported boundary, and the value of keeping public APIs simple, documented, and backward‑compatible.
The implementation begins by defining a Logger type and a Level enum via iota, then adding receiver methods (Debugf/Infof/Errorf) with fmt.Printf-like variadic signatures. A New constructor sets required state (notably a logging threshold) and returns a pointer, while documentation comments and an optional package header (doc.go) make the library discoverable via tooling. The first version writes to standard output, then is generalized by introducing io.Writer so callers can choose the destination. To reduce duplication, formatting and emission are centralized in an internal logf helper, and configuration becomes flexible with the functional options pattern (e.g., WithOutput) applied in New. Testing is emphasized early: examples validate stdout behavior, while external tests (pocketlog_test) and a simple io.Writer test double capture and assert output without touching internals.
Finally, the chapter explores enhancements and guidance: exposing a generic Logf for dynamic levels, optionally decorating messages (such as including the level), and considering structured logging (JSON) for machine consumption. It also highlights practical logging discipline—write clear, concise messages; avoid excessive payloads; be mindful of loops and volume; and prefer clearer code, documentation, and tests over treating logs as a debugging crutch. The result is a compact, idiomatic library that is easy to adopt, easy to extend via options, and well positioned for future improvements like concurrency safety and richer formatting, without breaking its published API.
Summary
- A library in Go is a package containing exported types and functions (its API) that clients can use directly.
- A library should only export what is necessary for its users, keeping internal details unexposed to maintain simplicity and encapsulation.
- Use explicit names and replicate familiar patterns or existing function signatures in your library to make it intuitive for users.
- Define domain-specific types rather than relying on primitive ones like int or string for improved readability and maintainability. For example, replacing int with a UserID type can make code clearer.
- Use enumerations with iota when defining types with a small, finite set of possible values, such as states or modes. This approach ensures type safety and readability.
- Implementing a New() method allows you to enforce required parameters during object initialization, ensuring clean and consistent setup for users of your library.
- Use the functional options pattern to initialize optional fields or set default values for struct fields, improving flexibility while avoiding excessive constructors.
- When implementing and testing your library, rely on closed-box testing to validate behavior through its public API rather than internal details, ensuring robust and client-focused design.
- Write smaller files to make code easier to navigate and read, especially in larger projects. Splitting logically distinct pieces of functionality into separate files improves maintainability.
- Be selective about what you log. Focus on logging meaningful information, avoiding unnecessary clutter or sensitive data.
FAQ
What log levels does the pocketlog library support and how are they defined?
It defines three levels: Debug, Info, and Error. They’re declared as a Level enum (underlying type byte) using iota, ordered from least to most critical. Example:type Level byte
const (
LevelDebug Level = iota
LevelInfo
LevelError
)
You can add more (e.g., Warn, Fatal) by inserting new constants in order.How do I create a Logger and why does New return a pointer?
Use the constructor:lgr := pocketlog.New(pocketlog.LevelInfo)
New returns a pointer so the same logger (and its configuration) can be shared easily across your app. The logger’s internal fields (like threshold) are unexported; New is the stable entry point to set them.How do I choose where logs are written?
The logger writes to an io.Writer. By default it uses os.Stdout. You can change it with a functional option:lgr := pocketlog.New(
pocketlog.LevelInfo,
pocketlog.WithOutput(myWriter),
)
myWriter can be a file, bytes.Buffer, a network writer, or anything implementing io.Writer.Should I use Debugf/Infof/Errorf or the generic Logf?
Use Debugf/Infof/Errorf for clarity in most cases. If the level is only known at runtime, use Logf:lgr.Logf(pocketlog.LevelInfo, "message: %s", data)
You can even implement your own switch to pick methods based on your domain logic.How does the threshold filter messages?
The logger compares its threshold to the message level. If threshold > message level, the log is skipped. Examples: - threshold = LevelDebug: all messages log. - threshold = LevelInfo: Debug is skipped; Info and Error log. - threshold = LevelError: only Error logs.How do I test the logger from another package?
Write external tests in package pocketlog_test, import the library, and inject a custom io.Writer to capture output:type testWriter struct{ contents string }
func (tw *testWriter) Write(p []byte) (int, error) {
tw.contents += string(p)
return len(p), nil
}
func TestLogger(t *testing.T) {
tw := &testWriter{}
lgr := pocketlog.New(pocketlog.LevelInfo, pocketlog.WithOutput(tw))
lgr.Debugf("skip")
lgr.Infof("hello")
if tw.contents != "hello\n" { t.Fatal("unexpected output") }
}
What is the functional options pattern and how is it used here?
Options are functions that mutate the logger during construction:type Option func(*Logger)
func WithOutput(w io.Writer) Option {
return func(l *Logger) { l.output = w }
}
func New(threshold Level, opts ...Option) *Logger {
l := &Logger{threshold: threshold, output: os.Stdout}
for _, opt := range opts { opt(l) }
return l
}
This keeps the API stable while allowing new configurable behaviors.How should I document the package and exported symbols?
Document exported items with comments starting with their name, and add a doc.go with a package overview. Use go doc to inspect docs locally:// New returns a logger configured with the given threshold.
func New(level Level, opts ...Option) *Logger { ... }
/*
Package pocketlog provides a small, configurable logger...
*/
package pocketlog
Why does the code use Fprintf with a newline, and what about ignored return values?
It writes via Fprintf to the configured io.Writer and appends "\n" so each log is on its own line:_, _ = fmt.Fprintf(l.output, format+"\n", args...)
The underscore explicitly ignores the returned byte count and error when you don’t need them.What logging best practices does the chapter recommend?
- Write clear, useful messages (avoid “Step 1/2/3”).- Keep messages reasonably short (large blobs cost storage).
- Log at milestones; be careful in tight loops or recursion.
- Don’t rely on logs to “debug unclear code”; improve code, docs, and tests instead.
- Consider structured logs (e.g., JSON) and include level/time if helpful.
- Be aware of concurrency; a shared Writer isn’t inherently thread-safe.
Learn Go with Pocket-Sized Projects ebook for free