Overview
Golang currently holds really special place in my heart ❤️
But since I'm doing mostly user facing oriented projects I usually need a database.
My weapon of choice is Postgres (like many others).
But I really want to know how queries are doing.
I am a freak and want to optimize things.
Now since I sometimes don't think ahead enough - I get "burned" and I found out that looking at my logs in terminal
usually catch this. So I want to add zerolog to pgx so that I can see traces and how long a query took to execute. This
way I can catch it easily.
9:08PM TRC Query args=[] commandTag="DELETE 0" duration=4.653 pid=123 sql="-- name: CleanupExpiredTokens :exec\nDELETE\nFROM refresh_tokens\nWHERE expires_at < CURRENT_TIMESTAMP\n OR revoked_at IS NOT NULL\n"
Why I like zerolog?
I started with normal golang log API and was having okay experience. But started sniffing around the web to see what
other people were using.
At the same time I was developing my first bigger hobby project and started to hit the "wall" of project structure "
smell".
I didn't like the way the project was structured.
I have a pretty okay nose for figuring out that something isn't at the best level... I have a tougher time figuring out
a solution for that problem on my own.
So I started looking on the web and found out a
very nice public repository depicting a project structure that I
really liked.
I really liked it - good job dumindu and thanks for sharing the idea!
Afterwards I checked hashicorp who I think are really next level company in golang space... dear god - things they have
open source... the amount of knowledge someone can gain from them by just reading and implementing ideas from them...
Amazing! Even
the comment out thoughts
are a very nice touch!
Anyway... lets talk about zerolog and pgx working somehow hand in hand!
This is more of a general idea what you can do with it and a few tips on how to use it.
It's not a general defacto way or anything... just my own implementation that I liked.
Let's start with dependencies I guess.
I am working with versions:
zerolog: 1.33.0
pgx: v5 5.7.1
go get -u github.com/rs/zerolog/log
go get github.com/jackc/pgx/v5
Now I personally like to split the depth of logging into 2 separate concerns. One is for local, development and staging
environments - the other is for production.
Now this is totally up to you.
I like to triple tests stuff before publishing to production, so I don't usually include traces there. Buuuuut you
theoretically could - its just... depends how much logs can your production take. If you have high traffic... you will
fill them big boy log files up.
What we'll do now is create a function that will create a new instance of zerolog with some configs that we want
package logger
import (
"fmt"
"os"
"path/filepath"
"github.com/rs/zerolog"
)
func New(isDebug bool, logPath string) (*zerolog.Logger, error) {
logLevel := zerolog.InfoLevel
if isDebug {
logLevel = zerolog.TraceLevel
}
if err := os.MkdirAll(filepath.Dir(logPath), 0755); err != nil {
return nil, fmt.Errorf("create log directory: %w", err)
}
runLogFile, err := os.OpenFile(
logPath,
os.O_APPEND|os.O_CREATE|os.O_WRONLY,
0664,
)
if err != nil {
return nil, fmt.Errorf("open log file: %w", err)
}
zerolog.SetGlobalLevel(logLevel)
multi := zerolog.MultiLevelWriter(zerolog.ConsoleWriter{Out: os.Stdout}, runLogFile)
logger := zerolog.New(multi).With().Timestamp().Logger()
return &logger, nil
}
now we want 2 level writers - one for the console standard out and one for appending to the log file.
We can set global level - for production we want Information level events - and for debug environments we lower that
down to trace so we see everything!
But this config depends entirely up to you!
The last line
logger := zerolog.New(multi).With().Timestamp().Logger()
Creates a new logger and we return a pointer to that logger.
Alternative solution would be that we create a struct and assign the pointer to a property inside the struct. We could
add the pointer to *os.File as-well so we can do operations on it (closing it for example).
After that we instantiate this new beautiful piece of abstract code:
l, _ := logger.New(conf.Server.Debug, "logs/app.log")
!!deal with your errors boys and girls!!
Ok now we'll create a new Trace Logger!
We need to satisfy this daddy interface
type Logger interface {
Log(ctx context.Context, level LogLevel, msg string, data map[string]any)
}
After that we'll use this new struct and method Log and append him on this pgx5 struct:
// /github.com/jackc/pgx/v5@v5.7.1/tracelog/tracelog.go:135
type TraceLog struct {
Logger Logger
LogLevel LogLevel
Config *TraceLogConfig
ensureConfigOnce sync.Once
}
Let's bring it all together now!
package logger
import (
"context"
"github.com/jackc/pgx/v5/tracelog"
"github.com/rs/zerolog"
)
type zerologPgxLogger struct {
logger zerolog.Logger
}
func (l zerologPgxLogger) Log(ctx context.Context, level tracelog.LogLevel, msg string, data map[string]interface{}) {
logEvent := l.logger.With().Fields(data).Logger()
zerologLevel := l.logger.GetLevel()
if zerologLevel == zerolog.TraceLevel {
level = tracelog.LogLevelTrace
}
switch level {
case tracelog.LogLevelTrace:
logEvent.Trace().Msg(msg)
case tracelog.LogLevelDebug:
logEvent.Debug().Msg(msg)
case tracelog.LogLevelInfo:
logEvent.Info().Msg(msg)
case tracelog.LogLevelWarn:
logEvent.Warn().Msg(msg)
case tracelog.LogLevelError:
logEvent.Error().Msg(msg)
default:
logEvent.Info().Msg(msg)
}
}
func NewTraceLogger(zeroLog zerolog.Logger, isDebug bool) *tracelog.TraceLog {
logLevel := tracelog.LogLevelInfo
if isDebug {
logLevel = tracelog.LogLevelTrace
}
return &tracelog.TraceLog{
Logger: zerologPgxLogger{logger: zeroLog},
LogLevel: logLevel,
Config: &tracelog.TraceLogConfig{
TimeKey: "duration",
},
}
}
so inside Log method we basically decide what we want to do with the message that we get and data that we get - for this example we're just logging message that we get and we route it to correct log level event.
The only thing that is really important is the
TimeKey: "duration"
This is how we'll get duration of the query! I think this is really, really important, and I am sad that out of the box the time value gets overwritten with time when the query took place (that is important as well! But we need both!!!!)
https://github.com/jackc/pgx/pull/2098 <- say thanks ❤️
Next comes creating a multi query which means satistfying another interface daddy, this time:
// QueryTracer traces Query, QueryRow, and Exec.
type QueryTracer interface {
// TraceQueryStart is called at the beginning of Query, QueryRow, and Exec calls. The returned context is used for the
// rest of the call and will be passed to TraceQueryEnd.
TraceQueryStart(ctx context.Context, conn *Conn, data TraceQueryStartData) context.Context
TraceQueryEnd(ctx context.Context, conn *Conn, data TraceQueryEndData)
}
Why MultiQuery?
Because mby in the future you'll want to implement OpenTelemetry Tracer (https://github.com/exaring/otelpgx) - why should I stop you with just standard logging - you can do so much more!!! I am feeling observability slowly becoming "my thing". I will dive deeper! I will go so fu*?ing deep!
Anyway...
package logger
import (
"context"
"github.com/jackc/pgx/v5"
)
type MultiQueryTracer struct {
Tracers []pgx.QueryTracer
}
func (m *MultiQueryTracer) TraceQueryStart(ctx context.Context, conn *pgx.Conn, data pgx.TraceQueryStartData) context.Context {
for _, t := range m.Tracers {
ctx = t.TraceQueryStart(ctx, conn, data)
}
return ctx
}
func (m *MultiQueryTracer) TraceQueryEnd(ctx context.Context, conn *pgx.Conn, data pgx.TraceQueryEndData) {
for _, t := range m.Tracers {
t.TraceQueryEnd(ctx, conn, data)
}
}
Basically we're just ranging over the 2 traces and telling them to do their thing - nothing else... just a smoll wrapper :)
Now we have all the components!
We'll just go and combine them.
func main() {
// prepare everything
l, _ := logger.New(conf.Server.Debug, "logs/app.log")
traceLogger := logger.NewTraceLogger(*l, conf.Server.Debug)
m := logger.MultiQueryTracer{
Tracers: []pgx.QueryTracer{
// open telemetry tracer: https://github.com/exaring/otelpgx
otelpgx.NewTracer(),
// logger
traceLogger,
},
}
l.Info().Msg("Initializing database connection")
dbString := fmt.Sprintf(fmtDBString, conf.DB.Host, conf.DB.Username, conf.DB.Password, conf.DB.DBName, conf.DB.Port)
l.Info().
Str("host", conf.DB.Host).
Int("port", conf.DB.Port).
Str("database", conf.DB.DBName).
Msg("Connecting to database")
dbConfig, err := pgxpool.ParseConfig(dbString)
if err != nil {
l.Fatal().
Err(err).
Msg("Failed to parse database config")
return
}
dbConfig.ConnConfig.Tracer = &m
pool, err := pgxpool.NewWithConfig(
ctx,
dbConfig,
)
if err != nil {
l.Fatal().
Err(err).
Msg("Failed to create database connection pool")
return
}
defer pool.Close()
l.Info().Msg("Database connection established successfully")
}
This is it actually... you should have a fully functional trace logging into your log file and console output!
Let's dive into it even more sometime in the future! 😍