Skip to content

Let's write our own logging package

Posted on:August 14, 2024 at 03:21 PM
Share on

In the previous videos, we introduced some open source log packages of golang, including: standard library log package, glog, logrus, zap. We also discussed how to record logs, and the modules and functions that a log package should have. Now let’s write a log package with basic functions to master the core design ideas of the log package.

Table of contents

Open Table of contents

Youtube Video

Watch My Youtube video

Included Features

The name of the log package is catlog, and the complete code of the sample project is stored in catlog.

The specific implementation is divided into the following four steps:

  1. Define: define the log level and log options.
  2. Create: create Logger and log printing methods of each level.
  3. Write: output logs to supported outputs.
  4. Customize: customize log output format.

Define log levels and log options

A basic log package first needs to define the log level and log options

type Level uint8

const (
    DebugLevel Level = iota
    InfoLevel
    WarnLevel
    ErrorLevel
    PanicLevel
    FatalLevel
)

var LevelNameMapping = map[Level]string{
    DebugLevel: "DEBUG",
    InfoLevel:  "INFO",
    WarnLevel:  "WARN",
    ErrorLevel: "ERROR",
    PanicLevel: "PANIC",
    FatalLevel: "FATAL",
}

When outputting logs, the switch level and the output level must be compared to decide whether to output, so the log level Level must be defined as a numerical type that is easy to compare. Almost all log packages use the constant counter iota to define the log level.

In addition, because you need to output a readable log level in the log output (for example, output INFO instead of 1), you need a mapping from Level to Level Name LevelNameMapping, which will be used during formatting.

Next, let’s define the log options. The log needs to be configurable so that developers can set different log behaviors according to different environments. The more common configuration options are:

type options struct {
    output        io.Writer
    level         Level
    stdLevel      Level
    formatter     Formatter
    disableCaller bool
}

In order to flexibly set the log options, you can set the log options through the option pattern.

type Option func(*options)

func initOptions(opts ...Option) (o *options) {
    o = &options{}
    for _, opt := range opts {
        opt(o)
    }

    if o.output == nil {
        o.output = os.Stderr
    }

    if o.formatter == nil {
        o.formatter = &TextFormatter{}
    }

    return
}

For log packages with option mode, you can dynamically modify log options in the following ways:

catlog.SetOptions(catlog.WithLevel(catlog.DebugLevel))

You can create a setting function WithXXXX for each log option as needed.

Next, let’s create the following option setting function:

func WithOutput(output io.Writer) Option {
	return func(o *options) {
		o.output = output
	}
}

func WithLevel(level Level) Option {
	return func(o *options) {
		o.level = level
	}
}

func WithStdLevel(level Level) Option {
	return func(o *options) {
		o.stdLevel = level
	}
}

func WithFormatter(formatter Formatter) Option {
	return func(o *options) {
		o.formatter = formatter
	}
}

func WithDisableCaller(caller bool) Option {
	return func(o *options) {
		o.disableCaller = caller
	}
}

Create Logger and log printing methods at different levels

In order to print logs, we need to create a Logger according to the log configuration, and then complete the output of logs at various levels by calling the logger’s log printing method.

type logger struct {
	opt       *options
	mu        sync.Mutex
	entryPool *sync.Pool
}

func New(opts ...Option) *logger {
	logger := &logger{opt: initOptions(opts...)}
	logger.entryPool = &sync.Pool{New: func() interface{} { return entry(logger) }}
	return logger
}
var std = New()

First, define a Logger, and the New function for creating a Logger. The log package will have a default global Logger. This example creates a global default Logger through var std = New(). Functions such as catlog.Debug, catlog.Info, and catlog.Warnf print logs by calling the methods provided by std Logger.

After defining a Logger, you also need to add the most core log printing method to the Logger, and provide log printing methods for all supported levels.

If the log level is Xyz, you usually need to provide two types of methods, namely the non-formatted method Xyz(args ...interface{}) and the formatted method Xyzf(format string, args ...interface{}).

Let’s implement the Debug,Debugf, Info, Infof, Warn, Warnf, Error, Errorf, Panic, Panicf, Fatal, Fatalf method for instance and function for the std global logger.

Please note that Panic and Panicf must call the panic() function, and Fatal and Fatalf functions must call the os.Exit(1) function.

func (l *logger) Debug(args ...interface{}) {
	l.entry().write(DebugLevel, FmtEmptySeparate, args...)
}

func (l *logger) Info(args ...interface{}) {
	l.entry().write(InfoLevel, FmtEmptySeparate, args...)
}

func (l *logger) Warn(args ...interface{}) {
	l.entry().write(WarnLevel, FmtEmptySeparate, args...)
}

func (l *logger) Error(args ...interface{}) {
	l.entry().write(ErrorLevel, FmtEmptySeparate, args...)
}

func (l *logger) Panic(args ...interface{}) {
	l.entry().write(PanicLevel, FmtEmptySeparate, args...)
	panic(fmt.Sprint(args...))
}

func (l *logger) Fatal(args ...interface{}) {
	l.entry().write(FatalLevel, FmtEmptySeparate, args...)
	os.Exit(1)
}

func (l *logger) Debugf(format string, args ...interface{}) {
	l.entry().write(DebugLevel, format, args...)
}

func (l *logger) Infof(format string, args ...interface{}) {
	l.entry().write(InfoLevel, format, args...)
}

func (l *logger) Warnf(format string, args ...interface{}) {
	l.entry().write(WarnLevel, format, args...)
}

func (l *logger) Errorf(format string, args ...interface{}) {
	l.entry().write(ErrorLevel, format, args...)
}

func (l *logger) Panicf(format string, args ...interface{}) {
	l.entry().write(PanicLevel, format, args...)
	panic(fmt.Sprintf(format, args...))
}

func (l *logger) Fatalf(format string, args ...interface{}) {
	l.entry().write(FatalLevel, format, args...)
	os.Exit(1)
}

// std logger
func Debug(args ...interface{}) {
	std.entry().write(DebugLevel, FmtEmptySeparate, args...)
}

func Info(args ...interface{}) {
	std.entry().write(InfoLevel, FmtEmptySeparate, args...)
}

func Warn(args ...interface{}) {
	std.entry().write(WarnLevel, FmtEmptySeparate, args...)
}

func Error(args ...interface{}) {
	std.entry().write(ErrorLevel, FmtEmptySeparate, args...)
}

func Panic(args ...interface{}) {
	std.entry().write(PanicLevel, FmtEmptySeparate, args...)
	panic(fmt.Sprint(args...))
}

func Fatal(args ...interface{}) {
	std.entry().write(FatalLevel, FmtEmptySeparate, args...)
	os.Exit(1)
}

func Debugf(format string, args ...interface{}) {
	std.entry().write(DebugLevel, format, args...)
}

func Infof(format string, args ...interface{}) {
	std.entry().write(InfoLevel, format, args...)
}

func Warnf(format string, args ...interface{}) {
	std.entry().write(WarnLevel, format, args...)
}

func Errorf(format string, args ...interface{}) {
	std.entry().write(ErrorLevel, format, args...)
}

func Panicf(format string, args ...interface{}) {
	std.entry().write(PanicLevel, format, args...)
	panic(fmt.Sprintf(format, args...))
}

func Fatalf(format string, args ...interface{}) {
	std.entry().write(FatalLevel, format, args...)
	os.Exit(1)
}

Output logs to supported output

After calling the log printing function, you also need to output these logs to the supported output, so you need to implement the write function

type Entry struct {
    logger *logger
    Buffer *bytes.Buffer
    Map    map[string]interface{}
    Level  Level
    Time   time.Time
    File   string
    Line   int
    Func   string
    Format string
    Args   []interface{}
}

func (e *Entry) write(level Level, format string, args ...interface{}) {
    if e.logger.opt.level > level {
        return
    }
    e.Time = time.Now()
    e.Level = level
    e.Format = format
    e.Args = args
    if !e.logger.opt.disableCaller {
        if pc, file, line, ok := runtime.Caller(2); !ok {
            e.File = "???"
            e.Func = "???"
        } else {
            e.File, e.Line, e.Func = file, line, runtime.FuncForPC(pc).Name()
            e.Func = e.Func[strings.LastIndex(e.Func, "/")+1:]
        }
    }
    e.format()
    e.writer()
    e.release()
}

func (e *Entry) format() {
    _ = e.logger.opt.formatter.Format(e)
}

func (e *Entry) writer() {
    e.logger.mu.Lock()
    _, _ = e.logger.opt.output.Write(e.Buffer.Bytes())
    e.logger.mu.Unlock()
}

func (e *Entry) release() {
    e.Args, e.Line, e.File, e.Format, e.Func = nil, 0, "", "", ""
    e.Buffer.Reset()
    e.logger.entryPool.Put(e)
}

First, define an Entry structure type, which is used to save all log information, namely log configuration and log content.

Use the write method of Entry to complete the writing of the log. In the write method, the output level and switch level of the log will be determined first. If the output level is less than the switch level, it will return directly without making any records.

In write method, it will also determine whether the file name and line number need to be recorded. If necessary, runtime.Caller() is called to obtain the file name and line number. When calling runtime.Caller(), pay attention to passing in the correct stack depth.

In the write function, e.format is called to format the log, and e.writer is called to write the log. In the log configuration passed in when creating the Logger, the output location output io.Writer is specified, and the output type is io.Writer.

e.Writer implements the Write method for writing, so you only need to call e.logger.opt.output.Write(e.Buffer.Bytes()) to write the log to the specified location.

Finally, the release() method will be called to clear the cache and object pool. At this point, we have completed the logging and writing.

Custom log output format

The catlog package supports custom output formats and has built-in JSON and Text format Formatters. First, define The Formatter interface:

type Formatter interface {
    Format(entry *Entry) error
}

next, create the JSON format Formatters.

package catlog

import (
	"fmt"
	"strconv"
	"time"

	jsoniter "github.com/json-iterator/go"
)

type JsonFormatter struct {
	IgnoreBasicFields bool
}

func (f *JsonFormatter) Format(e *Entry) error {
	if !f.IgnoreBasicFields {
		e.Map["level"] = LevelNameMapping[e.Level]
		e.Map["time"] = e.Time.Format(time.RFC3339)
		if e.File != "" {
			e.Map["file"] = e.File + ":" + strconv.Itoa(e.Line)
			e.Map["func"] = e.Func
		}
	}

	switch e.Format {
	case FmtEmptySeparate:
		e.Map["message"] = fmt.Sprint(e.Args...)

	default:
		e.Map["message"] = fmt.Sprintf(e.Format, e.Args...)

	}
	return jsoniter.NewEncoder(e.Buffer).Encode(e.Map)
}

and Text format Formatters

package catlog

import (
	"fmt"
	"time"
)

type TextFormatter struct {
	IgnoreBasicFields bool
}

func (f *TextFormatter) Format(e *Entry) error {
	if !f.IgnoreBasicFields {
		e.Buffer.WriteString(fmt.Sprintf("%s %s", e.Time.Format(time.RFC3339), LevelNameMapping[e.Level])) // allocs
		if e.File != "" {
			short := e.File
			for i := len(e.File) - 1; i > 0; i-- {
				if e.File[i] == '/' {
					short = e.File[i+1:]
					break
				}
			}
			e.Buffer.WriteString(fmt.Sprintf(" %s:%d", short, e.Line))
		}
		e.Buffer.WriteString(" ")
	}

	switch e.Format {
	case FmtEmptySeparate:
		e.Buffer.WriteString(fmt.Sprint(e.Args...))
	default:
		e.Buffer.WriteString(fmt.Sprintf(e.Format, e.Args...))
	}
	e.Buffer.WriteString("\n")
	return nil
}

Test log package

package main

import (
	"log"
	"os"

	"github.com/zerotone.ae/catlog"
)

func main() {
	catlog.Info("std log")
	catlog.SetOptions(catlog.WithLevel(catlog.DebugLevel))
	catlog.Debug("change std log to debug level")
	catlog.SetOptions(catlog.WithFormatter(&catlog.JsonFormatter{IgnoreBasicFields: false}))
	catlog.Debug("log in json format")
	catlog.Info("another log in json format")

	// output to file
	fd, err := os.OpenFile("test.log", os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
	if err != nil {
		log.Fatalln("create file test.log failed")
	}
	defer fd.Close()

	l := catlog.New(catlog.WithLevel(catlog.InfoLevel), catlog.WithOutput(fd), catlog.WithFormatter(&catlog.JsonFormatter{IgnoreBasicFields: false}))
	l.Info("custom log with json formatter")
}

run below command in the terminal,

go run example/main.go

and get the result:

2024-08-14T22:10:16+08:00 INFO main.go:11 std log
2024-08-14T22:10:16+08:00 DEBUG main.go:13 change std log to debug level
{"level":"DEBUG","time":"2024-08-14T22:10:16+08:00","file":"/Users/andying/github/maloong-zhang/catlog/example/main.go:15","func":"main.main","message":"log in json format"}
{"level":"INFO","time":"2024-08-14T22:10:16+08:00","file":"/Users/andying/github/maloong-zhang/catlog/example/main.go:16","func":"main.main","message":"another log in json format"}

Congratulations, you have completed a golang log package.

Share on