diff --git a/README.md b/README.md index f8d018d..e27a7d0 100644 --- a/README.md +++ b/README.md @@ -131,5 +131,13 @@ The following placeholders are available to format log messages using `log-forma * `%q`: query * `%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 `pgterminate` is released under [The Unlicense](LICENSE) license. Code is under public domain. diff --git a/base/session.go b/base/session.go index 45cc98e..51d4529 100644 --- a/base/session.go +++ b/base/session.go @@ -1,20 +1,21 @@ package base import ( + "encoding/json" "fmt" "strings" ) // Session represents a PostgreSQL backend type Session struct { - Pid int64 - User string - Db string - Client string - State string - Query string - StateDuration float64 - ApplicationName string + Pid int64 `json:"pid"` + User string `json:"user"` + Db string `json:"database"` + Client string `json:"client"` + State string `json:"state"` + Query string `json:"query"` + StateDuration float64 `json:"state_duration"` + ApplicationName string `json:"application_name"` } // NewSession instanciates a Session @@ -53,6 +54,12 @@ func (s *Session) Format(format string) string { 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 func (s *Session) IsIdle() bool { if s.State == "idle" || s.State == "idle in transaction" || s.State == "idle in transaction (aborted)" { diff --git a/base/session_test.go b/base/session_test.go index 10b56c5..c6ca924 100644 --- a/base/session_test.go +++ b/base/session_test.go @@ -1,9 +1,32 @@ package base import ( + "encoding/json" "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) { tests := []struct { name string diff --git a/cmd/pgterminate/main.go b/cmd/pgterminate/main.go index 9375b3b..3f1f353 100644 --- a/cmd/pgterminate/main.go +++ b/cmd/pgterminate/main.go @@ -51,7 +51,7 @@ func main() { 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.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.SyslogIdent, "syslog-ident", "pgterminate", "Define syslog tag") flag.StringVar(&config.SyslogFacility, "syslog-facility", "", "Define syslog facility from LOCAL0 to LOCAL7") diff --git a/config.yaml.example b/config.yaml.example index 7acce28..1e5c479 100644 --- a/config.yaml.example +++ b/config.yaml.example @@ -9,6 +9,7 @@ #active-timeout: 10 #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: json #pid-file: /var/run/pgterminate/pgterminate.pid #log-destination: console|file|syslog #syslog-ident: pgterminate diff --git a/notifier/console.go b/notifier/console.go index daa3b77..9d10de6 100644 --- a/notifier/console.go +++ b/notifier/console.go @@ -23,7 +23,11 @@ func NewConsole(format string, sessions chan *base.Session) Notifier { func (c *Console) Run() { log.Info("Starting console notifier") for session := range c.sessions { - log.Info(session.Format(c.format)) + if c.format == "json" { + log.Info(session.JSON()) + } else { + log.Info(session.Format(c.format)) + } } } diff --git a/notifier/file.go b/notifier/file.go index 142bd69..c7ced15 100644 --- a/notifier/file.go +++ b/notifier/file.go @@ -1,6 +1,7 @@ package notifier import ( + "encoding/json" "os" "sync" "time" @@ -19,7 +20,7 @@ type File struct { } // 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{ name: name, format: format, @@ -35,11 +36,27 @@ func (f *File) Run() { for session := range f.sessions { 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) } } +// 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 func (f *File) open() { var err error diff --git a/notifier/syslog.go b/notifier/syslog.go index efb8335..6f9cf8b 100644 --- a/notifier/syslog.go +++ b/notifier/syslog.go @@ -53,7 +53,11 @@ func (s *Syslog) Run() { base.Panic(err) } for session := range s.sessions { - s.writer.Info(session.Format(s.format)) + if s.format == "json" { + s.writer.Info(session.JSON()) + } else { + s.writer.Info(session.Format(s.format)) + } } }