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.