Skip to main content
Version: v1.0.0

Meridio - logging

Meridio uses structured logging implemented by go-logr/logr. Structured logging means that printf-like formatted messages are not used, instead key/object pairs are passed to the log functions.

import "github.com/nordix/meridio/pkg/log"

var config Config
err := envconfig.Process("ipam", &config)
if err != nil {
panic(err) // We can't log since we have no logger yet
}
logger := log.New("Meridio-ipam", config.LogLevel)
logger.Info("Configuration read", "config", config)

When executed this will produce (formatted with jq);

{
"severity": "info",
"timestamp": "2022-08-31T09:04:03.482+00:00",
"service_id": "Meridio-ipam",
"message": "Configuration read",
"version": "1.0.0",
"extra_data": {
"config": {
"Port": 7777,
"Datasource": "/run/ipam/data/registry.db",
"TrenchName": "red",
"NSPService": "meridio-nsp-red:7778",
"PrefixIPv4": "172.16.0.0/16",
"ConduitPrefixLengthIPv4": 20,
"NodePrefixLengthIPv4": 24,
"PrefixIPv6": "fd00::172.16.0.0/112",
"ConduitPrefixLengthIPv6": 116,
"NodePrefixLengthIPv6": 120,
"IPFamily": "dualstack",
"LogLevel": "DEBUG"
}
}
}

Structured logs can be scanned with jq.

kubectl logs -n red meridio-load-balancer-6dbbb9556f-f5cc4 -c load-balancer \
| grep '^{' | jq 'select(.extra_data.class == "SimpleNetworkService")'
kubectl logs -n red meridio-load-balancer-6dbbb9556f-f5cc4 -c load-balancer \
| grep '^{' | jq 'select(.extra_data.class == "SimpleNetworkService")|select(.message == "updateVips")'

Logger from context

A logger should be created in main() and be used for logging everywhere. The logger is not passed in every call but a go context should. Functions should use the logger from the context;

// In main();
ctx = logr.NewContext(ctx, logger)
// In a function;
logger = log.FromContextOrGlobal(ctx)

Functions really should always have a context as first parameter but they might not. A global logger is provided;

log.Logger.Info("Configuration read", "config", config)

The global logger is set by the first call to log.New. A global logger named "Meridio" on INFO level is pre-installed before log.New is called.

Log levels

Severity debug, info, error and critical are used (not warning). The Info() call can have different "verbosity", set with the V(n) method;

logger.Info("This is a normal info message")
logger.V(1).Info("This is a debug message")
logger.V(2).Info("This is a trace message")

There is no defined "trace" level in output so both trace and debug messages will have severity "debug". The level filtering is still valid though, trace messages are suppressed unless TRACE level is set.

The Fatal() function logs on critical level.

Costly parameter computations

This is important!

Consider;

logger.V(2).Info("Gathered data", "collected", verySlowFunction())

The verySlowFunction() will always be executed, even if not on trace level. A few of these may have a severe impact on performance but you may really want them for trace. Luckily there is a trick;

 if loggerV := logger.V(2); loggerV.Enabled() {
loggerV.Info("Gathered data", "collected", verySlowFunction())
}

Now the verySlowFunction() is only executed when trace level is set.

Fatal

import "github.com/nordix/meridio/pkg/log"
logger := log.New("Meridio-ipam", config.LogLevel)
log.Fatal(logger, "Can't read crucial data", "error", err)

The logger is a pure logr.Logger logger so there is no Fatal() method. However we want to print a termination message using the same formatting as other log items so the logger is passed as a parameter.

Example output;

{
"severity": "critical",
"timestamp": "2022-08-31T13:42:29.345+02:00",
"service_id": "Meridio-test",
"message": "Can't read crucial data",
"version": "1.1.0",
"extra_data": {
"error": "Not found"
}
}

Design patterns

Patterns must evolve slowly to get really good so these are mere ideas. It is very easy to get carried away and impose some over-structured logging that floods the logs with useless data.

Class logger

A logger used in a type (Class) can be decorated with class and instance records;

type someHandler struct {
ctx context.Context
logger logr.Logger
Adr *net.TCPAddr // (optional; capitalized to make it visible)
}

func newHandler(ctx context.Context, addr string) *someHandler {
logger := log.FromContextOrGlobal(ctx).WithValues("class", "someHandler")
adr, err := net.ResolveTCPAddr("tcp", addr)
if err != nil {
log.Fatal(logger, "ResolveTCPAddr", "error", err)
}
h := &someHandler{
ctx: ctx,
logger: logger.WithValues("instance", adr),
Adr: adr,
}
h.logger.Info("Created", "object", h)
return h
}

func (h *someHandler) connect() error {
logger := h.logger.WithValues("func", "connect")
logger.Info("Called")
return nil
}

The class is the name of the type and instance can be anything that identifies an instance. The instance field must be capitalized if you want it visible.

The example shows a func entry to identify a function. This should not be used as a common pattern but may be handy in some cases.