feat: Add JSON logging

Signed-off-by: Julien Riou <julien@riou.xyz>
This commit is contained in:
Julien Riou 2026-06-30 12:44:42 +02:00
commit e72feaf0f6
Signed by: jriou
GPG key ID: 9A099EDA51316854
8 changed files with 77 additions and 13 deletions

View file

@ -131,5 +131,13 @@ The following placeholders are available to format log messages using `log-forma
* `%q`: query * `%q`: query
* `%a`: application name * `%a`: application name
Set `log-format` to `json` to log each session as a JSON object instead:
```
pgterminate -log-format json
```
Keys are `pid`, `user`, `database`, `client`, `state`, `query`, `state_duration` and `application_name`. With `log-destination: file`, a `timestamp` key is added to each object.
# License # License
`pgterminate` is released under [The Unlicense](LICENSE) license. Code is under public domain. `pgterminate` is released under [The Unlicense](LICENSE) license. Code is under public domain.

View file

@ -1,20 +1,21 @@
package base package base
import ( import (
"encoding/json"
"fmt" "fmt"
"strings" "strings"
) )
// Session represents a PostgreSQL backend // Session represents a PostgreSQL backend
type Session struct { type Session struct {
Pid int64 Pid int64 `json:"pid"`
User string User string `json:"user"`
Db string Db string `json:"database"`
Client string Client string `json:"client"`
State string State string `json:"state"`
Query string Query string `json:"query"`
StateDuration float64 StateDuration float64 `json:"state_duration"`
ApplicationName string ApplicationName string `json:"application_name"`
} }
// NewSession instanciates a Session // NewSession instanciates a Session
@ -53,6 +54,12 @@ func (s *Session) Format(format string) string {
return output return output
} }
// JSON returns a Session marshaled as a JSON string
func (s *Session) JSON() string {
b, _ := json.Marshal(s)
return string(b)
}
// IsIdle returns true when a session is doing nothing // IsIdle returns true when a session is doing nothing
func (s *Session) IsIdle() bool { func (s *Session) IsIdle() bool {
if s.State == "idle" || s.State == "idle in transaction" || s.State == "idle in transaction (aborted)" { if s.State == "idle" || s.State == "idle in transaction" || s.State == "idle in transaction (aborted)" {

View file

@ -1,9 +1,32 @@
package base package base
import ( import (
"encoding/json"
"testing" "testing"
) )
func TestSessionJSON(t *testing.T) {
session := &Session{
Pid: 1,
User: "test",
Db: "db",
Client: "127.0.0.1:5432",
State: "active",
Query: "select 1",
StateDuration: 1.5,
ApplicationName: "psql",
}
var got Session
if err := json.Unmarshal([]byte(session.JSON()), &got); err != nil {
t.Fatalf("invalid JSON: %v", err)
}
if got != *session {
t.Errorf("got %+v; want %+v", got, *session)
}
}
func TestSessionEqual(t *testing.T) { func TestSessionEqual(t *testing.T) {
tests := []struct { tests := []struct {
name string name string

View file

@ -51,7 +51,7 @@ func main() {
flag.Float64Var(&config.ActiveTimeout, "active-timeout", 0, "Time for active connections to be terminated in seconds") flag.Float64Var(&config.ActiveTimeout, "active-timeout", 0, "Time for active connections to be terminated in seconds")
flag.StringVar(&config.LogDestination, "log-destination", "console", "Log destination between 'console', 'syslog' or 'file'") flag.StringVar(&config.LogDestination, "log-destination", "console", "Log destination between 'console', 'syslog' or 'file'")
flag.StringVar(&config.LogFile, "log-file", "", "Write logs to a file") flag.StringVar(&config.LogFile, "log-file", "", "Write logs to a file")
flag.StringVar(&config.LogFormat, "log-format", "pid=%p user=%u db=%d client=%r state=%s state_duration=%m query=%q", "Represent messages using this format") flag.StringVar(&config.LogFormat, "log-format", "pid=%p user=%u db=%d client=%r state=%s state_duration=%m query=%q", "Represent messages using this format, or 'json' to log sessions as JSON")
flag.StringVar(&config.PidFile, "pid-file", "", "Write process id into a file") flag.StringVar(&config.PidFile, "pid-file", "", "Write process id into a file")
flag.StringVar(&config.SyslogIdent, "syslog-ident", "pgterminate", "Define syslog tag") flag.StringVar(&config.SyslogIdent, "syslog-ident", "pgterminate", "Define syslog tag")
flag.StringVar(&config.SyslogFacility, "syslog-facility", "", "Define syslog facility from LOCAL0 to LOCAL7") flag.StringVar(&config.SyslogFacility, "syslog-facility", "", "Define syslog facility from LOCAL0 to LOCAL7")

View file

@ -9,6 +9,7 @@
#active-timeout: 10 #active-timeout: 10
#log-file: /var/log/pgterminate/pgterminate.log #log-file: /var/log/pgterminate/pgterminate.log
#log-format: 'pid=%p user=%u db=%d client=%r state=%s state_duration=%m query=%q' #log-format: 'pid=%p user=%u db=%d client=%r state=%s state_duration=%m query=%q'
#log-format: json
#pid-file: /var/run/pgterminate/pgterminate.pid #pid-file: /var/run/pgterminate/pgterminate.pid
#log-destination: console|file|syslog #log-destination: console|file|syslog
#syslog-ident: pgterminate #syslog-ident: pgterminate

View file

@ -23,9 +23,13 @@ func NewConsole(format string, sessions chan *base.Session) Notifier {
func (c *Console) Run() { func (c *Console) Run() {
log.Info("Starting console notifier") log.Info("Starting console notifier")
for session := range c.sessions { for session := range c.sessions {
if c.format == "json" {
log.Info(session.JSON())
} else {
log.Info(session.Format(c.format)) log.Info(session.Format(c.format))
} }
} }
}
// Reload for handling SIGHUP signals // Reload for handling SIGHUP signals
func (c *Console) Reload() { func (c *Console) Reload() {

View file

@ -1,6 +1,7 @@
package notifier package notifier
import ( import (
"encoding/json"
"os" "os"
"sync" "sync"
"time" "time"
@ -19,7 +20,7 @@ type File struct {
} }
// NewFile creates a file notifier // NewFile creates a file notifier
func NewFile(format string, name string, sessions chan *base.Session) Notifier { func NewFile(name string, format string, sessions chan *base.Session) Notifier {
return &File{ return &File{
name: name, name: name,
format: format, format: format,
@ -35,11 +36,27 @@ func (f *File) Run() {
for session := range f.sessions { for session := range f.sessions {
timestamp := time.Now().Format(time.RFC3339) timestamp := time.Now().Format(time.RFC3339)
_, err := f.handle.WriteString(timestamp + " " + session.Format(f.format) + "\n") var line string
if f.format == "json" {
line = jsonLine(timestamp, session)
} else {
line = timestamp + " " + session.Format(f.format)
}
_, err := f.handle.WriteString(line + "\n")
base.Panic(err) base.Panic(err)
} }
} }
// jsonLine marshals a session as a self-contained JSON object including the
// timestamp, so each file line stays valid JSON instead of being prefixed
func jsonLine(timestamp string, session *base.Session) string {
b, _ := json.Marshal(struct {
Timestamp string `json:"timestamp"`
*base.Session
}{timestamp, session})
return string(b)
}
// open opens a log file // open opens a log file
func (f *File) open() { func (f *File) open() {
var err error var err error

View file

@ -53,9 +53,13 @@ func (s *Syslog) Run() {
base.Panic(err) base.Panic(err)
} }
for session := range s.sessions { for session := range s.sessions {
if s.format == "json" {
s.writer.Info(session.JSON())
} else {
s.writer.Info(session.Format(s.format)) s.writer.Info(session.Format(s.format))
} }
} }
}
// Reload disconnect from syslog daemon and re-connect // Reload disconnect from syslog daemon and re-connect
// Executed when receiving SIGHUP signal // Executed when receiving SIGHUP signal