diff --git a/docs/releases.md b/docs/releases.md index 17c24308..491f90b5 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -7,6 +7,7 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/release **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)) +* Ensure that calls to standard logger `log.Println` also output JSON (no ticket) ## ntfy server v2.0.0 Released February 16, 2023 diff --git a/log/event.go b/log/event.go index 0dd4be07..4ab04399 100644 --- a/log/event.go +++ b/log/event.go @@ -11,10 +11,11 @@ import ( ) const ( - tagField = "tag" - errorField = "error" - timeTakenField = "time_taken_ms" - exitCodeField = "exit_code" + fieldTag = "tag" + fieldError = "error" + fieldTimeTaken = "time_taken_ms" + fieldExitCode = "exit_code" + tagStdLog = "stdlog" 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 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 os.Exit(1) } @@ -72,7 +73,7 @@ func (e *Event) Trace(message string, v ...any) { // Tag adds a "tag" field to the log event func (e *Event) Tag(tag string) *Event { - return e.Field(tagField, tag) + return e.Field(fieldTag, tag) } // Time sets the time field @@ -85,7 +86,7 @@ func (e *Event) Time(t time.Time) *Event { func (e *Event) Timing(f func()) *Event { start := time.Now() 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 @@ -95,7 +96,7 @@ func (e *Event) Err(err error) *Event { } else if c, ok := err.(Contexter); ok { 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 @@ -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 // to determine if they match. This is super complicated, but required for efficiency. 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() if !e.shouldLog(l) { - return + return "" } e.Message = fmt.Sprintf(message, v...) e.Level = l @@ -147,10 +155,9 @@ func (e *Event) maybeLog(l Level, message string, v ...any) { e.applyContexters() } if CurrentFormat() == JSONFormat { - log.Println(e.JSON()) - } else { - log.Println(e.String()) + return e.JSON() } + return e.String() } // Loggable returns true if the given log level is lower or equal to the current log level diff --git a/log/log.go b/log/log.go index c4934f05..7c0f9cdc 100644 --- a/log/log.go +++ b/log/log.go @@ -4,6 +4,7 @@ import ( "io" "log" "os" + "strings" "sync" "time" ) @@ -12,7 +13,7 @@ import ( var ( DefaultLevel = InfoLevel DefaultFormat = TextFormat - DefaultOutput = os.Stderr + DefaultOutput = &peekLogWriter{os.Stderr} ) var ( @@ -20,9 +21,18 @@ var ( format = DefaultFormat overrides = make(map[string]*levelOverride) output io.Writer = DefaultOutput + filename = "" 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 func Fatal(message string, v ...any) { newEvent().Fatal(message, v...) @@ -132,28 +142,27 @@ func SetFormat(newFormat Format) { func SetOutput(w io.Writer) { mu.Lock() defer mu.Unlock() - log.SetOutput(w) - output = w + output = &peekLogWriter{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 func File() string { mu.RLock() defer mu.RUnlock() - if f, ok := output.(*os.File); ok { - return f.Name() - } - return "" + return filename } // IsFile returns true if the output is a non-default file func IsFile() bool { mu.RLock() defer mu.RUnlock() - if _, ok := output.(*os.File); ok && output != DefaultOutput { - return true - } - return false + return filename != "" } // DisableDates disables the date/time prefix @@ -175,3 +184,20 @@ func IsTrace() bool { func IsDebug() bool { 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")) +} diff --git a/log/log_test.go b/log/log_test.go index b0164944..0d1ec4af 100644 --- a/log/log_test.go +++ b/log/log_test.go @@ -4,7 +4,10 @@ import ( "bytes" "encoding/json" "github.com/stretchr/testify/require" + "io" + "log" "os" + "path/filepath" "testing" "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} ` 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 { diff --git a/server/server.go b/server/server.go index 4588614b..4853dabd 100644 --- a/server/server.go +++ b/server/server.go @@ -333,9 +333,9 @@ func (s *Server) handleError(w http.ResponseWriter, r *http.Request, v *visitor, return } 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 { - 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("Access-Control-Allow-Origin", s.config.AccessControlAllowOrigin) // CORS, allow cross-origin requests