Ensure that calls to standard logger `log.Println` also output JSON
parent
38e7801b41
commit
9ff3bb0c87
|
@ -7,6 +7,7 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/release
|
||||||
**Bug fixes + maintenance:**
|
**Bug fixes + maintenance:**
|
||||||
|
|
||||||
* Avoid panic in manager when `attachment-cache-dir` is not set ([#617](https://github.com/binwiederhier/ntfy/issues/617), thanks to [@ksurl](https://github.com/ksurl))
|
* Avoid panic in manager when `attachment-cache-dir` is not set ([#617](https://github.com/binwiederhier/ntfy/issues/617), thanks to [@ksurl](https://github.com/ksurl))
|
||||||
|
* Ensure that calls to standard logger `log.Println` also output JSON (no ticket)
|
||||||
|
|
||||||
## ntfy server v2.0.0
|
## ntfy server v2.0.0
|
||||||
Released February 16, 2023
|
Released February 16, 2023
|
||||||
|
|
31
log/event.go
31
log/event.go
|
@ -11,10 +11,11 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
tagField = "tag"
|
fieldTag = "tag"
|
||||||
errorField = "error"
|
fieldError = "error"
|
||||||
timeTakenField = "time_taken_ms"
|
fieldTimeTaken = "time_taken_ms"
|
||||||
exitCodeField = "exit_code"
|
fieldExitCode = "exit_code"
|
||||||
|
tagStdLog = "stdlog"
|
||||||
timestampFormat = "2006-01-02T15:04:05.999Z07:00"
|
timestampFormat = "2006-01-02T15:04:05.999Z07:00"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -40,7 +41,7 @@ func newEvent() *Event {
|
||||||
|
|
||||||
// Fatal logs the event as FATAL, and exits the program with exit code 1
|
// Fatal logs the event as FATAL, and exits the program with exit code 1
|
||||||
func (e *Event) Fatal(message string, v ...any) {
|
func (e *Event) Fatal(message string, v ...any) {
|
||||||
e.Field(exitCodeField, 1).maybeLog(FatalLevel, message, v...)
|
e.Field(fieldExitCode, 1).maybeLog(FatalLevel, message, v...)
|
||||||
fmt.Fprintf(os.Stderr, message+"\n", v...) // Always output error to stderr
|
fmt.Fprintf(os.Stderr, message+"\n", v...) // Always output error to stderr
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
@ -72,7 +73,7 @@ func (e *Event) Trace(message string, v ...any) {
|
||||||
|
|
||||||
// Tag adds a "tag" field to the log event
|
// Tag adds a "tag" field to the log event
|
||||||
func (e *Event) Tag(tag string) *Event {
|
func (e *Event) Tag(tag string) *Event {
|
||||||
return e.Field(tagField, tag)
|
return e.Field(fieldTag, tag)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Time sets the time field
|
// Time sets the time field
|
||||||
|
@ -85,7 +86,7 @@ func (e *Event) Time(t time.Time) *Event {
|
||||||
func (e *Event) Timing(f func()) *Event {
|
func (e *Event) Timing(f func()) *Event {
|
||||||
start := time.Now()
|
start := time.Now()
|
||||||
f()
|
f()
|
||||||
return e.Field(timeTakenField, time.Since(start).Milliseconds())
|
return e.Field(fieldTimeTaken, time.Since(start).Milliseconds())
|
||||||
}
|
}
|
||||||
|
|
||||||
// Err adds an "error" field to the log event
|
// Err adds an "error" field to the log event
|
||||||
|
@ -95,7 +96,7 @@ func (e *Event) Err(err error) *Event {
|
||||||
} else if c, ok := err.(Contexter); ok {
|
} else if c, ok := err.(Contexter); ok {
|
||||||
return e.With(c)
|
return e.With(c)
|
||||||
}
|
}
|
||||||
return e.Field(errorField, err.Error())
|
return e.Field(fieldError, err.Error())
|
||||||
}
|
}
|
||||||
|
|
||||||
// Field adds a custom field and value to the log event
|
// Field adds a custom field and value to the log event
|
||||||
|
@ -136,9 +137,16 @@ func (e *Event) With(contexts ...Contexter) *Event {
|
||||||
// is actually logged. If overrides are defined, then Contexters have to be applied in any case
|
// is actually logged. If overrides are defined, then Contexters have to be applied in any case
|
||||||
// to determine if they match. This is super complicated, but required for efficiency.
|
// to determine if they match. This is super complicated, but required for efficiency.
|
||||||
func (e *Event) maybeLog(l Level, message string, v ...any) {
|
func (e *Event) maybeLog(l Level, message string, v ...any) {
|
||||||
|
m := e.Render(l, message, v...)
|
||||||
|
if m != "" {
|
||||||
|
log.Println(m)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Event) Render(l Level, message string, v ...any) string {
|
||||||
appliedContexters := e.maybeApplyContexters()
|
appliedContexters := e.maybeApplyContexters()
|
||||||
if !e.shouldLog(l) {
|
if !e.shouldLog(l) {
|
||||||
return
|
return ""
|
||||||
}
|
}
|
||||||
e.Message = fmt.Sprintf(message, v...)
|
e.Message = fmt.Sprintf(message, v...)
|
||||||
e.Level = l
|
e.Level = l
|
||||||
|
@ -147,10 +155,9 @@ func (e *Event) maybeLog(l Level, message string, v ...any) {
|
||||||
e.applyContexters()
|
e.applyContexters()
|
||||||
}
|
}
|
||||||
if CurrentFormat() == JSONFormat {
|
if CurrentFormat() == JSONFormat {
|
||||||
log.Println(e.JSON())
|
return e.JSON()
|
||||||
} else {
|
|
||||||
log.Println(e.String())
|
|
||||||
}
|
}
|
||||||
|
return e.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Loggable returns true if the given log level is lower or equal to the current log level
|
// Loggable returns true if the given log level is lower or equal to the current log level
|
||||||
|
|
48
log/log.go
48
log/log.go
|
@ -4,6 +4,7 @@ import (
|
||||||
"io"
|
"io"
|
||||||
"log"
|
"log"
|
||||||
"os"
|
"os"
|
||||||
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
@ -12,7 +13,7 @@ import (
|
||||||
var (
|
var (
|
||||||
DefaultLevel = InfoLevel
|
DefaultLevel = InfoLevel
|
||||||
DefaultFormat = TextFormat
|
DefaultFormat = TextFormat
|
||||||
DefaultOutput = os.Stderr
|
DefaultOutput = &peekLogWriter{os.Stderr}
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
|
@ -20,9 +21,18 @@ var (
|
||||||
format = DefaultFormat
|
format = DefaultFormat
|
||||||
overrides = make(map[string]*levelOverride)
|
overrides = make(map[string]*levelOverride)
|
||||||
output io.Writer = DefaultOutput
|
output io.Writer = DefaultOutput
|
||||||
|
filename = ""
|
||||||
mu = &sync.RWMutex{}
|
mu = &sync.RWMutex{}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// init sets the default log output (including log.SetOutput)
|
||||||
|
//
|
||||||
|
// This has to be explicitly called, because DefaultOutput is a peekLogWriter,
|
||||||
|
// which wraps os.Stderr.
|
||||||
|
func init() {
|
||||||
|
SetOutput(DefaultOutput)
|
||||||
|
}
|
||||||
|
|
||||||
// Fatal prints the given message, and exits the program
|
// Fatal prints the given message, and exits the program
|
||||||
func Fatal(message string, v ...any) {
|
func Fatal(message string, v ...any) {
|
||||||
newEvent().Fatal(message, v...)
|
newEvent().Fatal(message, v...)
|
||||||
|
@ -132,28 +142,27 @@ func SetFormat(newFormat Format) {
|
||||||
func SetOutput(w io.Writer) {
|
func SetOutput(w io.Writer) {
|
||||||
mu.Lock()
|
mu.Lock()
|
||||||
defer mu.Unlock()
|
defer mu.Unlock()
|
||||||
log.SetOutput(w)
|
output = &peekLogWriter{w}
|
||||||
output = w
|
if f, ok := w.(*os.File); ok {
|
||||||
|
filename = f.Name()
|
||||||
|
} else {
|
||||||
|
filename = ""
|
||||||
|
}
|
||||||
|
log.SetOutput(output)
|
||||||
}
|
}
|
||||||
|
|
||||||
// File returns the log file, if any, or an empty string otherwise
|
// File returns the log file, if any, or an empty string otherwise
|
||||||
func File() string {
|
func File() string {
|
||||||
mu.RLock()
|
mu.RLock()
|
||||||
defer mu.RUnlock()
|
defer mu.RUnlock()
|
||||||
if f, ok := output.(*os.File); ok {
|
return filename
|
||||||
return f.Name()
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// IsFile returns true if the output is a non-default file
|
// IsFile returns true if the output is a non-default file
|
||||||
func IsFile() bool {
|
func IsFile() bool {
|
||||||
mu.RLock()
|
mu.RLock()
|
||||||
defer mu.RUnlock()
|
defer mu.RUnlock()
|
||||||
if _, ok := output.(*os.File); ok && output != DefaultOutput {
|
return filename != ""
|
||||||
return true
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// DisableDates disables the date/time prefix
|
// DisableDates disables the date/time prefix
|
||||||
|
@ -175,3 +184,20 @@ func IsTrace() bool {
|
||||||
func IsDebug() bool {
|
func IsDebug() bool {
|
||||||
return Loggable(DebugLevel)
|
return Loggable(DebugLevel)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// peekLogWriter is an io.Writer which will peek at the rendered log event,
|
||||||
|
// and ensure that the rendered output is valid JSON. This is a hack!
|
||||||
|
type peekLogWriter struct {
|
||||||
|
w io.Writer
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *peekLogWriter) Write(p []byte) (n int, err error) {
|
||||||
|
if len(p) == 0 || p[0] == '{' || CurrentFormat() == TextFormat {
|
||||||
|
return w.w.Write(p)
|
||||||
|
}
|
||||||
|
m := newEvent().Tag(tagStdLog).Render(InfoLevel, strings.TrimSpace(string(p)))
|
||||||
|
if m == "" {
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
return w.w.Write([]byte(m + "\n"))
|
||||||
|
}
|
||||||
|
|
|
@ -4,7 +4,10 @@ import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
"os"
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
@ -170,6 +173,51 @@ func TestLog_LevelOverrideAny(t *testing.T) {
|
||||||
{"time":"1970-01-01T00:00:14Z","level":"INFO","message":"this is also logged","time_taken_ms":0}
|
{"time":"1970-01-01T00:00:14Z","level":"INFO","message":"this is also logged","time_taken_ms":0}
|
||||||
`
|
`
|
||||||
require.Equal(t, expected, out.String())
|
require.Equal(t, expected, out.String())
|
||||||
|
require.False(t, IsFile())
|
||||||
|
require.Equal(t, "", File())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLog_UsingStdLogger_JSON(t *testing.T) {
|
||||||
|
t.Cleanup(resetState)
|
||||||
|
|
||||||
|
var out bytes.Buffer
|
||||||
|
SetOutput(&out)
|
||||||
|
SetFormat(JSONFormat)
|
||||||
|
|
||||||
|
log.Println("Some other library is using the standard Go logger")
|
||||||
|
require.Contains(t, out.String(), `,"level":"INFO","message":"Some other library is using the standard Go logger","tag":"stdlog"}`+"\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLog_UsingStdLogger_Text(t *testing.T) {
|
||||||
|
t.Cleanup(resetState)
|
||||||
|
|
||||||
|
var out bytes.Buffer
|
||||||
|
SetOutput(&out)
|
||||||
|
|
||||||
|
log.Println("Some other library is using the standard Go logger")
|
||||||
|
require.Contains(t, out.String(), `Some other library is using the standard Go logger`+"\n")
|
||||||
|
require.NotContains(t, out.String(), `{`)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLog_File(t *testing.T) {
|
||||||
|
t.Cleanup(resetState)
|
||||||
|
|
||||||
|
logfile := filepath.Join(t.TempDir(), "ntfy.log")
|
||||||
|
f, err := os.OpenFile(logfile, os.O_CREATE|os.O_WRONLY, 0600)
|
||||||
|
require.Nil(t, err)
|
||||||
|
SetOutput(f)
|
||||||
|
SetFormat(JSONFormat)
|
||||||
|
require.True(t, IsFile())
|
||||||
|
require.Equal(t, logfile, File())
|
||||||
|
|
||||||
|
Time(time.Unix(11, 0).UTC()).Field("this_one", "11").Info("this is logged")
|
||||||
|
require.Nil(t, f.Close())
|
||||||
|
|
||||||
|
f, err = os.Open(logfile)
|
||||||
|
require.Nil(t, err)
|
||||||
|
contents, err := io.ReadAll(f)
|
||||||
|
require.Nil(t, err)
|
||||||
|
require.Equal(t, `{"time":"1970-01-01T00:00:11Z","level":"INFO","message":"this is logged","this_one":"11"}`+"\n", string(contents))
|
||||||
}
|
}
|
||||||
|
|
||||||
type fakeError struct {
|
type fakeError struct {
|
||||||
|
|
|
@ -333,9 +333,9 @@ func (s *Server) handleError(w http.ResponseWriter, r *http.Request, v *visitor,
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if isNormalError {
|
if isNormalError {
|
||||||
logvr(v, r).Err(httpErr).Debug("Connection closed with HTTP %d (ntfy error %d)", httpErr.HTTPCode, httpErr.Code)
|
logvr(v, r).Err(err).Debug("Connection closed with HTTP %d (ntfy error %d)", httpErr.HTTPCode, httpErr.Code)
|
||||||
} else {
|
} else {
|
||||||
logvr(v, r).Err(httpErr).Info("Connection closed with HTTP %d (ntfy error %d)", httpErr.HTTPCode, httpErr.Code)
|
logvr(v, r).Err(err).Info("Connection closed with HTTP %d (ntfy error %d)", httpErr.HTTPCode, httpErr.Code)
|
||||||
}
|
}
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
w.Header().Set("Access-Control-Allow-Origin", s.config.AccessControlAllowOrigin) // CORS, allow cross-origin requests
|
w.Header().Set("Access-Control-Allow-Origin", s.config.AccessControlAllowOrigin) // CORS, allow cross-origin requests
|
||||||
|
|
Loading…
Reference in New Issue