Repository: rs/zerolog
Pull request #603 (2023-10-19 05:21:02)

Fix context retrieval in hook example
`.Ctx(context.Context)` sets, not gets the context


Patch:
diff --git a/README.md b/README.md
index 972b729..afa6156 100644
--- a/README.md
+++ b/README.md
@@ -547,7 +547,7 @@ and facilitates the unification of logging and tracing in some systems:
 type TracingHook struct{}
 
 func (h TracingHook) Run(e *zerolog.Event, level zerolog.Level, msg string) {
-    ctx := e.Ctx()
+    ctx := e.GetCtx()
     spanId := getSpanIdFromContext(ctx) // as per your tracing framework
     e.Str("span-id", spanId)
 }
diff --git a/cmd/prettylog/prettylog.go b/cmd/prettylog/prettylog.go
index 9b8b43c..10d7799 100644
--- a/cmd/prettylog/prettylog.go
+++ b/cmd/prettylog/prettylog.go
@@ -3,9 +3,11 @@ package main
 import (
 	"bufio"
 	"errors"
+	"flag"
 	"fmt"
 	"io"
 	"os"
+	"time"
 
 	"github.com/rs/zerolog"
 )
@@ -15,31 +17,57 @@ func isInputFromPipe() bool {
 	return fileInfo.Mode()&os.ModeCharDevice == 0
 }
 
+func processInput(reader io.Reader, writer io.Writer) error {
+	scanner := bufio.NewScanner(reader)
+	for scanner.Scan() {
+		bytesToWrite := scanner.Bytes()
+		_, err := writer.Write(bytesToWrite)
+		if err != nil {
+			if errors.Is(err, io.EOF) {
+				break
+			}
+
+			fmt.Printf("%s\n", bytesToWrite)
+		}
+	}
+
+	return scanner.Err()
+}
+
 func main() {
+	timeFormats := map[string]string{
+		"default": time.Kitchen,
+		"full":    time.RFC1123,
+	}
+
+	timeFormatFlag := flag.String(
+		"time-format",
+		"default",
+		"Time format, either 'default' or 'full'",
+	)
+
+	flag.Parse()
+
+	timeFormat, ok := timeFormats[*timeFormatFlag]
+	if !ok {
+		panic("Invalid time-format provided")
+	}
+
 	writer := zerolog.NewConsoleWriter()
+	writer.TimeFormat = timeFormat
 
 	if isInputFromPipe() {
-		_, _ = io.Copy(writer, os.Stdin)
-	} else if len(os.Args) > 1 {
-		for _, filename := range os.Args[1:] {
+		_ = processInput(os.Stdin, writer)
+	} else if flag.NArg() >= 1 {
+		for _, filename := range flag.Args() {
 			// Scan each line from filename and write it into writer
-			r, err := os.Open(filename)
+			reader, err := os.Open(filename)
 			if err != nil {
 				fmt.Printf("%s open: %v", filename, err)
 				os.Exit(1)
 			}
-			scanner := bufio.NewScanner(r)
-			for scanner.Scan() {
-				_, err = writer.Write(scanner.Bytes())
-				if err != nil {
-					if errors.Is(err, io.EOF) {
-						break
-					}
-					fmt.Printf("%s write: %v", filename, err)
-					os.Exit(1)
-				}
-			}
-			if err := scanner.Err(); err != nil {
+
+			if err := processInput(reader, writer); err != nil {
 				fmt.Printf("%s scan: %v", filename, err)
 				os.Exit(1)
 			}
diff --git a/console.go b/console.go
index 921e4b3..cc6d623 100644
--- a/console.go
+++ b/console.go
@@ -155,6 +155,15 @@ func (w ConsoleWriter) Write(p []byte) (n int, err error) {
 	return len(p), err
 }
 
+// Call the underlying writer's Close method if it is an io.Closer. Otherwise
+// does nothing.
+func (w ConsoleWriter) Close() error {
+	if closer, ok := w.Out.(io.Closer); ok {
+		return closer.Close()
+	}
+	return nil
+}
+
 // writeFields appends formatted key-value pairs to buf.
 func (w ConsoleWriter) writeFields(evt map[string]interface{}, buf *bytes.Buffer) {
 	var fields = make([]string, 0, len(evt))
@@ -387,27 +396,16 @@ func consoleDefaultFormatLevel(noColor bool) Formatter {
 	return func(i interface{}) string {
 		var l string
 		if ll, ok := i.(string); ok {
-			switch ll {
-			case LevelTraceValue:
-				l = colorize("TRC", LevelColors["TRC"], noColor)
-			case LevelDebugValue:
-				l = colorize("DBG", LevelColors["DBG"], noColor)
-			case LevelInfoValue:
-				l = colorize("INF", LevelColors["INF"], noColor)
-			case LevelWarnValue:
-				l = colorize("WRN", LevelColors["WRN"], noColor)
-			case LevelErrorValue:
-				l = colorize("ERR", LevelColors["ERR"], noColor)
-			case LevelFatalValue:
-				l = colorize("FTL", LevelColors["FTL"], noColor)
-			case LevelPanicValue:
-				l = colorize("PNC", LevelColors["PNC"], noColor)
-			default:
+			level, _ := ParseLevel(ll)
+			fl, ok := FormattedLevels[level]
+			if ok {
+				l = colorize(fl, LevelColors[level], noColor)
+			} else {
 				l = strings.ToUpper(ll)[0:3]
 			}
 		} else {
 			if i == nil {
-				l = colorize("???", colorBold, noColor)
+				l = "???"
 			} else {
 				l = strings.ToUpper(fmt.Sprintf("%s", i))[0:3]
 			}
diff --git a/context.go b/context.go
index ff48b1f..df352eb 100644
--- a/context.go
+++ b/context.go
@@ -3,7 +3,7 @@ package zerolog
 import (
 	"context"
 	"fmt"
-	"io/ioutil"
+	"io"
 	"math"
 	"net"
 	"time"
@@ -57,7 +57,7 @@ func (c Context) Array(key string, arr LogArrayMarshaler) Context {
 
 // Object marshals an object that implement the LogObjectMarshaler interface.
 func (c Context) Object(key string, obj LogObjectMarshaler) Context {
-	e := newEvent(LevelWriterAdapter{ioutil.Discard}, 0)
+	e := newEvent(LevelWriterAdapter{io.Discard}, 0)
 	e.Object(key, obj)
 	c.l.context = enc.AppendObjectData(c.l.context, e.buf)
 	putEvent(e)
@@ -66,7 +66,7 @@ func (c Context) Object(key string, obj LogObjectMarshaler) Context {
 
 // EmbedObject marshals and Embeds an object that implement the LogObjectMarshaler interface.
 func (c Context) EmbedObject(obj LogObjectMarshaler) Context {
-	e := newEvent(LevelWriterAdapter{ioutil.Discard}, 0)
+	e := newEvent(LevelWriterAdapter{io.Discard}, 0)
 	e.EmbedObject(obj)
 	c.l.context = enc.AppendObjectData(c.l.context, e.buf)
 	putEvent(e)
@@ -163,6 +163,22 @@ func (c Context) Errs(key string, errs []error) Context {
 
 // Err adds the field "error" with serialized err to the logger context.
 func (c Context) Err(err error) Context {
+	if c.l.stack && ErrorStackMarshaler != nil {
+		switch m := ErrorStackMarshaler(err).(type) {
+		case nil:
+		case LogObjectMarshaler:
+			c = c.Object(ErrorStackFieldName, m)
+		case error:
+			if m != nil && !isNilValue(m) {
+				c = c.Str(ErrorStackFieldName, m.Error())
+			}
+		case string:
+			c = c.Str(ErrorStackFieldName, m)
+		default:
+			c = c.Interface(ErrorStackFieldName, m)
+		}
+	}
+
 	return c.AnErr(ErrorFieldName, err)
 }
 
@@ -375,6 +391,9 @@ func (c Context) Durs(key string, d []time.Duration) Context {
 
 // Interface adds the field key with obj marshaled using reflection.
 func (c Context) Interface(key string, i interface{}) Context {
+	if obj, ok := i.(LogObjectMarshaler); ok {
+		return c.Object(key, obj)
+	}
 	c.l.context = enc.AppendInterface(enc.AppendKey(c.l.context, key), i)
 	return c
 }
diff --git a/globals.go b/globals.go
index 7e5ca25..b38a7fc 100644
--- a/globals.go
+++ b/globals.go
@@ -111,15 +111,31 @@ var (
 
 	// LevelColors are used by ConsoleWriter's consoleDefaultFormatLevel to color
 	// log levels.
-	LevelColors = map[string]int{
-		"TRC": colorBlue,
-		"DBG": 0,
-		"INF": colorGreen,
-		"WRN": colorYellow,
-		"ERR": colorRed,
-		"FTL": colorRed,
-		"PNC": colorRed,
+	LevelColors = map[Level]int{
+		TraceLevel: colorBlue,
+		DebugLevel: 0,
+		InfoLevel:  colorGreen,
+		WarnLevel:  colorYellow,
+		ErrorLevel: colorRed,
+		FatalLevel: colorRed,
+		PanicLevel: colorRed,
 	}
+
+	// FormattedLevels are used by ConsoleWriter's consoleDefaultFormatLevel
+	// for a short level name.
+	FormattedLevels = map[Level]string{
+		TraceLevel: "TRC",
+		DebugLevel: "DBG",
+		InfoLevel:  "INF",
+		WarnLevel:  "WRN",
+		ErrorLevel: "ERR",
+		FatalLevel: "FTL",
+		PanicLevel: "PNC",
+	}
+
+	// TriggerLevelWriterBufferReuseLimit is a limit in bytes that a buffer is dropped
+	// from the TriggerLevelWriter buffer pool if the buffer grows above the limit.
+	TriggerLevelWriterBufferReuseLimit = 64 * 1024
 )
 
 var (
diff --git a/hlog/hlog.go b/hlog/hlog.go
index 3773400..06ca4ad 100644
--- a/hlog/hlog.go
+++ b/hlog/hlog.go
@@ -3,7 +3,9 @@ package hlog
 
 import (
 	"context"
+	"net"
 	"net/http"
+	"strings"
 	"time"
 
 	"github.com/rs/xid"
@@ -89,6 +91,35 @@ func RemoteAddrHandler(fieldKey string) func(next http.Handler) http.Handler {
 	}
 }
 
+func getHost(hostPort string) string {
+	if hostPort == "" {
+		return ""
+	}
+
+	host, _, err := net.SplitHostPort(hostPort)
+	if err != nil {
+		return hostPort
+	}
+	return host
+}
+
+// RemoteIPHandler is similar to RemoteAddrHandler, but logs only
+// an IP, not a port.
+func RemoteIPHandler(fieldKey string) func(next http.Handler) http.Handler {
+	return func(next http.Handler) http.Handler {
+		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+			ip := getHost(r.RemoteAddr)
+			if ip != "" {
+				log := zerolog.Ctx(r.Context())
+				log.UpdateContext(func(c zerolog.Context) zerolog.Context {
+					return c.Str(fieldKey, ip)
+				})
+			}
+			next.ServeHTTP(w, r)
+		})
+	}
+}
+
 // UserAgentHandler adds the request's user-agent as a field to the context's logger
 // using fieldKey as field key.
 func UserAgentHandler(fieldKey string) func(next http.Handler) http.Handler {
@@ -135,6 +166,21 @@ func ProtoHandler(fieldKey string) func(next http.Handler) http.Handler {
 	}
 }
 
+// HTTPVersionHandler is similar to ProtoHandler, but it does not store the "HTTP/"
+// prefix in the protocol name.
+func HTTPVersionHandler(fieldKey string) func(next http.Handler) http.Handler {
+	return func(next http.Handler) http.Handler {
+		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+			proto := strings.TrimPrefix(r.Proto, "HTTP/")
+			log := zerolog.Ctx(r.Context())
+			log.UpdateContext(func(c zerolog.Context) zerolog.Context {
+				return c.Str(fieldKey, proto)
+			})
+			next.ServeHTTP(w, r)
+		})
+	}
+}
+
 type idKey struct{}
 
 // IDFromRequest returns the unique id associated to the request if any.
@@ -205,14 +251,76 @@ func CustomHeaderHandler(fieldKey, header string) func(next http.Handler) http.H
 	}
 }
 
+// EtagHandler adds Etag header from response's header as a field to
+// the context's logger using fieldKey as field key.
+func EtagHandler(fieldKey string) func(next http.Handler) http.Handler {
+	return func(next http.Handler) http.Handler {
+		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+			defer func() {
+				etag := w.Header().Get("Etag")
+				if etag != "" {
+					etag = strings.ReplaceAll(etag, `"`, "")
+					log := zerolog.Ctx(r.Context())
+					log.UpdateContext(func(c zerolog.Context) zerolog.Context {
+						return c.Str(fieldKey, etag)
+					})
+				}
+			}()
+			next.ServeHTTP(w, r)
+		})
+	}
+}
+
+func ResponseHeaderHandler(fieldKey, headerName string) func(next http.Handler) http.Handler {
+	return func(next http.Handler) http.Handler {
+		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+			defer func() {
+				value := w.Header().Get(headerName)
+				if value != "" {
+					log := zerolog.Ctx(r.Context())
+					log.UpdateContext(func(c zerolog.Context) zerolog.Context {
+						return c.Str(fieldKey, value)
+					})
+				}
+			}()
+			next.ServeHTTP(w, r)
+		})
+	}
+}
+
 // AccessHandler returns a handler that call f after each request.
 func AccessHandler(f func(r *http.Request, status, size int, duration time.Duration)) func(next http.Handler) http.Handler {
 	return func(next http.Handler) http.Handler {
 		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
 			start := time.Now()
 			lw := mutil.WrapWriter(w)
+			defer func() {
+				f(r, lw.Status(), lw.BytesWritten(), time.Since(start))
+			}()
 			next.ServeHTTP(lw, r)
-			f(r, lw.Status(), lw.BytesWritten(), time.Since(start))
+		})
+	}
+}
+
+// HostHandler adds the request's host as a field to the context's logger
+// using fieldKey as field key. If trimPort is set to true, then port is
+// removed from the host.
+func HostHandler(fieldKey string, trimPort ...bool) func(next http.Handler) http.Handler {
+	return func(next http.Handler) http.Handler {
+		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+			var host string
+			if len(trimPort) > 0 && trimPort[0] {
+				host = getHost(r.Host)
+			} else {
+				host = r.Host
+			}
+			if host != "" {
+				log := zerolog.Ctx(r.Context())
+				log.UpdateContext(func(c zerolog.Context) zerolog.Context {
+					return c.Str(fieldKey, host)
+				})
+			}
+			next.ServeHTTP(w, r)
 		})
 	}
 }
diff --git a/log.go b/log.go
index 834c7e6..9fec7cc 100644
--- a/log.go
+++ b/log.go
@@ -118,7 +118,6 @@ import (
 	"errors"
 	"fmt"
 	"io"
-	"io/ioutil"
 	"os"
 	"strconv"
 	"strings"
@@ -246,7 +245,7 @@ type Logger struct {
 // you may consider using sync wrapper.
 func New(w io.Writer) Logger {
 	if w == nil {
-		w = ioutil.Discard
+		w = io.Discard
 	}
 	lw, ok := w.(LevelWriter)
 	if !ok {
@@ -326,10 +325,13 @@ func (l Logger) Sample(s Sampler) Logger {
 }
 
 // Hook returns a logger with the h Hook.
-func (l Logger) Hook(h Hook) Logger {
-	newHooks := make([]Hook, len(l.hooks), len(l.hooks)+1)
+func (l Logger) Hook(hooks ...Hook) Logger {
+	if len(hooks) == 0 {
+		return l
+	}
+	newHooks := make([]Hook, len(l.hooks), len(l.hooks)+len(hooks))
 	copy(newHooks, l.hooks)
-	l.hooks = append(newHooks, h)
+	l.hooks = append(newHooks, hooks...)
 	return l
 }
 
@@ -385,7 +387,14 @@ func (l *Logger) Err(err error) *Event {
 //
 // You must call Msg on the returned event in order to send the event.
 func (l *Logger) Fatal() *Event {
-	return l.newEvent(FatalLevel, func(msg string) { os.Exit(1) })
+	return l.newEvent(FatalLevel, func(msg string) {
+		if closer, ok := l.w.(io.Closer); ok {
+			// Close the writer to flush any buffered message. Otherwise the message
+			// will be lost as os.Exit() terminates the program immediately.
+			closer.Close()
+		}
+		os.Exit(1)
+	})
 }
 
 // Panic starts a new message with panic level. The panic() function
@@ -450,6 +459,14 @@ func (l *Logger) Printf(format string, v ...interface{}) {
 	}
 }
 
+// Println sends a log event using debug level and no extra field.
+// Arguments are handled in the manner of fmt.Println.
+func (l *Logger) Println(v ...interface{}) {
+	if e := l.Debug(); e.Enabled() {
+		e.CallerSkipFrame(1).Msg(fmt.Sprintln(v...))
+	}
+}
+
 // Write implements the io.Writer interface. This is useful to set as a writer
 // for the standard library log.
 func (l Logger) Write(p []byte) (n int, err error) {
@@ -488,6 +505,9 @@ func (l *Logger) newEvent(level Level, done func(string)) *Event {
 
 // should returns true if the log event should be logged.
 func (l *Logger) should(lvl Level) bool {
+	if l.w == nil {
+		return false
+	}
 	if lvl < l.level || lvl < GlobalLevel() {
 		return false
 	}
diff --git a/syslog.go b/syslog.go
index c408283..a2b7285 100644
--- a/syslog.go
+++ b/syslog.go
@@ -78,3 +78,12 @@ func (sw syslogWriter) WriteLevel(level Level, p []byte) (n int, err error) {
 	n = len(p)
 	return
 }
+
+// Call the underlying writer's Close method if it is an io.Closer. Otherwise
+// does nothing.
+func (sw syslogWriter) Close() error {
+	if c, ok := sw.w.(io.Closer); ok {
+		return c.Close()
+	}
+	return nil
+}
diff --git a/writer.go b/writer.go
index 9b9ef88..41b394d 100644
--- a/writer.go
+++ b/writer.go
@@ -27,6 +27,15 @@ func (lw LevelWriterAdapter) WriteLevel(l Level, p []byte) (n int, err error) {
 	return lw.Write(p)
 }
 
+// Call the underlying writer's Close method if it is an io.Closer. Otherwise
+// does nothing.
+func (lw LevelWriterAdapter) Close() error {
+	if closer, ok := lw.Writer.(io.Closer); ok {
+		return closer.Close()
+	}
+	return nil
+}
+
 type syncWriter struct {
 	mu sync.Mutex
 	lw LevelWriter
@@ -57,6 +66,15 @@ func (s *syncWriter) WriteLevel(l Level, p []byte) (n int, err error) {
 	return s.lw.WriteLevel(l, p)
 }
 
+func (s *syncWriter) Close() error {
+	s.mu.Lock()
+	defer s.mu.Unlock()
+	if closer, ok := s.lw.(io.Closer); ok {
+		return closer.Close()
+	}
+	return nil
+}
+
 type multiLevelWriter struct {
 	writers []LevelWriter
 }
@@ -89,6 +107,20 @@ func (t multiLevelWriter) WriteLevel(l Level, p []byte) (n int, err error) {
 	return n, err
 }
 
+// Calls close on all the underlying writers that are io.Closers. If any of the
+// Close methods return an error, the remainder of the closers are not closed
+// and the error is returned.
+func (t multiLevelWriter) Close() error {
+	for _, w := range t.writers {
+		if closer, ok := w.(io.Closer); ok {
+			if err := closer.Close(); err != nil {
+				return err
+			}
+		}
+	}
+	return nil
+}
+
 // MultiLevelWriter creates a writer that duplicates its writes to all the
 // provided writers, similar to the Unix tee(1) command. If some writers
 // implement LevelWriter, their WriteLevel method will be used instead of Write.
@@ -180,3 +212,135 @@ func (w *FilteredLevelWriter) WriteLevel(level Level, p []byte) (int, error) {
 	}
 	return len(p), nil
 }
+
+var triggerWriterPool = &sync.Pool{
+	New: func() interface{} {
+		return bytes.NewBuffer(make([]byte, 0, 1024))
+	},
+}
+
+// TriggerLevelWriter buffers log lines at the ConditionalLevel or below
+// until a trigger level (or higher) line is emitted. Log lines with level
+// higher than ConditionalLevel are always written out to the destination
+// writer. If trigger never happens, buffered log lines are never written out.
+//
+// It can be used to configure "log level per request".
+type TriggerLevelWriter struct {
+	// Destination writer. If LevelWriter is provided (usually), its WriteLevel is used
+	// instead of Write.
+	io.Writer
+
+	// ConditionalLevel is the level (and below) at which lines are buffered until
+	// a trigger level (or higher) line is emitted. Usually this is set to DebugLevel.
+	ConditionalLevel Level
+
+	// TriggerLevel is the lowest level that triggers the sending of the conditional
+	// level lines. Usually this is set to ErrorLevel.
+	TriggerLevel Level
+
+	buf       *bytes.Buffer
+	triggered bool
+	mu        sync.Mutex
+}
+
+func (w *TriggerLevelWriter) WriteLevel(l Level, p []byte) (n int, err error) {
+	w.mu.Lock()
+	defer w.mu.Unlock()
+
+	// At first trigger level or above log line, we flush the buffer and change the
+	// trigger state to triggered.
+	if !w.triggered && l >= w.TriggerLevel {
+		err := w.trigger()
+		if err != nil {
+			return 0, err
+		}
+	}
+
+	// Unless triggered, we buffer everything at and below ConditionalLevel.
+	if !w.triggered && l <= w.ConditionalLevel {
+		if w.buf == nil {
+			w.buf = triggerWriterPool.Get().(*bytes.Buffer)
+		}
+
+		// We prefix each log line with a byte with the level.
+		// Hopefully we will never have a level value which equals a newline
+		// (which could interfere with reconstruction of log lines in the trigger method).
+		w.buf.WriteByte(byte(l))
+		w.buf.Write(p)
+		return len(p), nil
+	}
+
+	// Anything above ConditionalLevel is always passed through.
+	// Once triggered, everything is passed through.
+	if lw, ok := w.Writer.(LevelWriter); ok {
+		return lw.WriteLevel(l, p)
+	}
+	return w.Write(p)
+}
+
+// trigger expects lock to be held.
+func (w *TriggerLevelWriter) trigger() error {
+	if w.triggered {
+		return nil
+	}
+	w.triggered = true
+
+	if w.buf == nil {
+		return nil
+	}
+
+	p := w.buf.Bytes()
+	for len(p) > 0 {
+		// We do not use bufio.Scanner here because we already have full buffer
+		// in the memory and we do not want extra copying from the buffer to
+		// scanner's token slice, nor we want to hit scanner's token size limit,
+		// and we also want to preserve newlines.
+		i := bytes.IndexByte(p, '\n')
+		line := p[0 : i+1]
+		p = p[i+1:]
+		// We prefixed each log line with a byte with the level.
+		level := Level(line[0])
+		line = line[1:]
+		var err error
+		if lw, ok := w.Writer.(LevelWriter); ok {
+			_, err = lw.WriteLevel(level, line)
+		} else {
+			_, err = w.Write(line)
+		}
+		if err != nil {
+			return err
+		}
+	}
+
+	return nil
+}
+
+// Trigger forces flushing the buffer and change the trigger state to
+// triggered, if the writer has not already been triggered before.
+func (w *TriggerLevelWriter) Trigger() error {
+	w.mu.Lock()
+	defer w.mu.Unlock()
+
+	return w.trigger()
+}
+
+// Close closes the writer and returns the buffer to the pool.
+func (w *TriggerLevelWriter) Close() error {
+	w.mu.Lock()
+	defer w.mu.Unlock()
+
+	if w.buf == nil {
+		return nil
+	}
+
+	// We return the buffer only if it has not grown above the limit.
+	// This prevents accumulation of large buffers in the pool just
+	// because occasionally a large buffer might be needed.
+	if w.buf.Cap() <= TriggerLevelWriterBufferReuseLimit {
+		w.buf.Reset()
+		triggerWriterPool.Put(w.buf)
+	}
+	w.buf = nil
+
+	return nil
+}


Test patch:
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index 93c0756..a9725b3 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -9,12 +9,12 @@ jobs:
     runs-on: ${{ matrix.os }}
     steps:
     - name: Install Go
-      uses: actions/setup-go@v4
+      uses: actions/setup-go@v5
       with:
         go-version: ${{ matrix.go-version }}
     - name: Checkout code
       uses: actions/checkout@v4
-    - uses: actions/cache@v3
+    - uses: actions/cache@v4
       with:
         path: ~/go/pkg/mod
         key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }}
diff --git a/benchmark_test.go b/benchmark_test.go
index b2bb79a..39a35da 100644
--- a/benchmark_test.go
+++ b/benchmark_test.go
@@ -3,7 +3,7 @@ package zerolog
 import (
 	"context"
 	"errors"
-	"io/ioutil"
+	"io"
 	"net"
 	"testing"
 	"time"
@@ -15,7 +15,7 @@ var (
 )
 
 func BenchmarkLogEmpty(b *testing.B) {
-	logger := New(ioutil.Discard)
+	logger := New(io.Discard)
 	b.ResetTimer()
 	b.RunParallel(func(pb *testing.PB) {
 		for pb.Next() {
@@ -25,7 +25,7 @@ func BenchmarkLogEmpty(b *testing.B) {
 }
 
 func BenchmarkDisabled(b *testing.B) {
-	logger := New(ioutil.Discard).Level(Disabled)
+	logger := New(io.Discard).Level(Disabled)
 	b.ResetTimer()
 	b.RunParallel(func(pb *testing.PB) {
 		for pb.Next() {
@@ -35,7 +35,7 @@ func BenchmarkDisabled(b *testing.B) {
 }
 
 func BenchmarkInfo(b *testing.B) {
-	logger := New(ioutil.Discard)
+	logger := New(io.Discard)
 	b.ResetTimer()
 	b.RunParallel(func(pb *testing.PB) {
 		for pb.Next() {
@@ -45,7 +45,7 @@ func BenchmarkInfo(b *testing.B) {
 }
 
 func BenchmarkContextFields(b *testing.B) {
-	logger := New(ioutil.Discard).With().
+	logger := New(io.Discard).With().
 		Str("string", "four!").
 		Time("time", time.Time{}).
 		Int("int", 123).
@@ -60,7 +60,7 @@ func BenchmarkContextFields(b *testing.B) {
 }
 
 func BenchmarkContextAppend(b *testing.B) {
-	logger := New(ioutil.Discard).With().
+	logger := New(io.Discard).With().
 		Str("foo", "bar").
 		Logger()
 	b.ResetTimer()
@@ -72,7 +72,7 @@ func BenchmarkContextAppend(b *testing.B) {
 }
 
 func BenchmarkLogFields(b *testing.B) {
-	logger := New(ioutil.Discard)
+	logger := New(io.Discard)
 	b.ResetTimer()
 	b.RunParallel(func(pb *testing.PB) {
 		for pb.Next() {
@@ -102,7 +102,7 @@ func BenchmarkLogArrayObject(b *testing.B) {
 	obj1 := obj{"a", "b", 2}
 	obj2 := obj{"c", "d", 3}
 	obj3 := obj{"e", "f", 4}
-	logger := New(ioutil.Discard)
+	logger := New(io.Discard)
 	b.ResetTimer()
 	b.ReportAllocs()
 	for i := 0; i < b.N; i++ {
@@ -224,7 +224,7 @@ func BenchmarkLogFieldType(b *testing.B) {
 			return e.Object("k", objects[0])
 		},
 	}
-	logger := New(ioutil.Discard)
+	logger := New(io.Discard)
 	b.ResetTimer()
 	for name := range types {
 		f := types[name]
@@ -358,7 +358,7 @@ func BenchmarkContextFieldType(b *testing.B) {
 			return c.Timestamp()
 		},
 	}
-	logger := New(ioutil.Discard)
+	logger := New(io.Discard)
 	b.ResetTimer()
 	for name := range types {
 		f := types[name]
diff --git a/binary_test.go b/binary_test.go
index b4882d7..6559735 100644
--- a/binary_test.go
+++ b/binary_test.go
@@ -7,7 +7,6 @@ import (
 	"errors"
 	"fmt"
 
-	//	"io/ioutil"
 	stdlog "log"
 	"time"
 )
diff --git a/console_test.go b/console_test.go
index c3dbdb3..acf1ccb 100644
--- a/console_test.go
+++ b/console_test.go
@@ -3,7 +3,7 @@ package zerolog_test
 import (
 	"bytes"
 	"fmt"
-	"io/ioutil"
+	"io"
 	"os"
 	"strings"
 	"testing"
@@ -474,7 +474,7 @@ func BenchmarkConsoleWriter(b *testing.B) {
 
 	var msg = []byte(`{"level": "info", "foo": "bar", "message": "HELLO", "time": "1990-01-01"}`)
 
-	w := zerolog.ConsoleWriter{Out: ioutil.Discard, NoColor: false}
+	w := zerolog.ConsoleWriter{Out: io.Discard, NoColor: false}
 
 	for i := 0; i < b.N; i++ {
 		w.Write(msg)
diff --git a/ctx_test.go b/ctx_test.go
index 5bc41e5..6315cf7 100644
--- a/ctx_test.go
+++ b/ctx_test.go
@@ -1,14 +1,17 @@
 package zerolog
 
 import (
+	"bytes"
 	"context"
-	"io/ioutil"
+	"io"
 	"reflect"
 	"testing"
+
+	"github.com/rs/zerolog/internal/cbor"
 )
 
 func TestCtx(t *testing.T) {
-	log := New(ioutil.Discard)
+	log := New(io.Discard)
 	ctx := log.WithContext(context.Background())
 	log2 := Ctx(ctx)
 	if !reflect.DeepEqual(log, *log2) {
@@ -37,13 +40,13 @@ func TestCtx(t *testing.T) {
 }
 
 func TestCtxDisabled(t *testing.T) {
-	dl := New(ioutil.Discard).Level(Disabled)
+	dl := New(io.Discard).Level(Disabled)
 	ctx := dl.WithContext(context.Background())
 	if ctx != context.Background() {
 		t.Error("WithContext stored a disabled logger")
 	}
 
-	l := New(ioutil.Discard).With().Str("foo", "bar").Logger()
+	l := New(io.Discard).With().Str("foo", "bar").Logger()
 	ctx = l.WithContext(ctx)
 	if !reflect.DeepEqual(Ctx(ctx), &l) {
 		t.Error("WithContext did not store logger")
@@ -68,3 +71,31 @@ func TestCtxDisabled(t *testing.T) {
 		t.Error("WithContext did not override logger with a disabled logger")
 	}
 }
+
+type logObjectMarshalerImpl struct {
+	name string
+	age  int
+}
+
+func (t logObjectMarshalerImpl) MarshalZerologObject(e *Event) {
+	e.Str("name", "custom_value").Int("age", t.age)
+}
+
+func Test_InterfaceLogObjectMarshaler(t *testing.T) {
+	var buf bytes.Buffer
+	log := New(&buf)
+	ctx := log.WithContext(context.Background())
+
+	log2 := Ctx(ctx)
+
+	withLog := log2.With().Interface("obj", &logObjectMarshalerImpl{
+		name: "foo",
+		age:  29,
+	}).Logger()
+
+	withLog.Info().Msg("test")
+
+	if got, want := cbor.DecodeIfBinaryToString(buf.Bytes()), `{"level":"info","obj":{"name":"custom_value","age":29},"message":"test"}`+"\n"; got != want {
+		t.Errorf("got %q, want %q", got, want)
+	}
+}
diff --git a/diode/diode_test.go b/diode/diode_test.go
index d0d0aff..ab6b55e 100644
--- a/diode/diode_test.go
+++ b/diode/diode_test.go
@@ -3,9 +3,10 @@ package diode_test
 import (
 	"bytes"
 	"fmt"
-	"io/ioutil"
+	"io"
 	"log"
 	"os"
+	"os/exec"
 	"testing"
 	"time"
 
@@ -38,8 +39,45 @@ func TestClose(t *testing.T) {
 	w.Close()
 }
 
+func TestFatal(t *testing.T) {
+	if os.Getenv("TEST_FATAL") == "1" {
+		w := diode.NewWriter(os.Stderr, 1000, 0, func(missed int) {
+			fmt.Printf("Dropped %d messages\n", missed)
+		})
+		defer w.Close()
+		log := zerolog.New(w)
+		log.Fatal().Msg("test")
+		return
+	}
+
+	cmd := exec.Command(os.Args[0], "-test.run=TestFatal")
+	cmd.Env = append(os.Environ(), "TEST_FATAL=1")
+	stderr, err := cmd.StderrPipe()
+	if err != nil {
+		t.Fatal(err)
+	}
+	err = cmd.Start()
+	if err != nil {
+		t.Fatal(err)
+	}
+	slurp, err := io.ReadAll(stderr)
+	if err != nil {
+		t.Fatal(err)
+	}
+	err = cmd.Wait()
+	if err == nil {
+		t.Error("Expected log.Fatal to exit with non-zero status")
+	}
+
+	want := "{\"level\":\"fatal\",\"message\":\"test\"}\n"
+	got := cbor.DecodeIfBinaryToString(slurp)
+	if got != want {
+		t.Errorf("Diode Fatal Test failed. got:%s, want:%s!", got, want)
+	}
+}
+
 func Benchmark(b *testing.B) {
-	log.SetOutput(ioutil.Discard)
+	log.SetOutput(io.Discard)
 	defer log.SetOutput(os.Stderr)
 	benchs := map[string]time.Duration{
 		"Waiter": 0,
@@ -47,7 +85,7 @@ func Benchmark(b *testing.B) {
 	}
 	for name, interval := range benchs {
 		b.Run(name, func(b *testing.B) {
-			w := diode.NewWriter(ioutil.Discard, 100000, interval, nil)
+			w := diode.NewWriter(io.Discard, 100000, interval, nil)
 			log := zerolog.New(w)
 			defer w.Close()
 
diff --git a/hlog/hlog_test.go b/hlog/hlog_test.go
index c584e98..445d6b6 100644
--- a/hlog/hlog_test.go
+++ b/hlog/hlog_test.go
@@ -7,7 +7,7 @@ import (
 	"bytes"
 	"context"
 	"fmt"
-	"io/ioutil"
+	"io"
 	"net/http"
 	"net/http/httptest"
 	"net/url"
@@ -122,6 +122,38 @@ func TestRemoteAddrHandlerIPv6(t *testing.T) {
 	}
 }
 
+func TestRemoteIPHandler(t *testing.T) {
+	out := &bytes.Buffer{}
+	r := &http.Request{
+		RemoteAddr: "1.2.3.4:1234",
+	}
+	h := RemoteIPHandler("ip")(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		l := FromRequest(r)
+		l.Log().Msg("")
+	}))
+	h = NewHandler(zerolog.New(out))(h)
+	h.ServeHTTP(nil, r)
+	if want, got := `{"ip":"1.2.3.4"}`+"\n", decodeIfBinary(out); want != got {
+		t.Errorf("Invalid log output, got: %s, want: %s", got, want)
+	}
+}
+
+func TestRemoteIPHandlerIPv6(t *testing.T) {
+	out := &bytes.Buffer{}
+	r := &http.Request{
+		RemoteAddr: "[2001:db8:a0b:12f0::1]:1234",
+	}
+	h := RemoteIPHandler("ip")(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		l := FromRequest(r)
+		l.Log().Msg("")
+	}))
+	h = NewHandler(zerolog.New(out))(h)
+	h.ServeHTTP(nil, r)
+	if want, got := `{"ip":"2001:db8:a0b:12f0::1"}`+"\n", decodeIfBinary(out); want != got {
+		t.Errorf("Invalid log output, got: %s, want: %s", got, want)
+	}
+}
+
 func TestUserAgentHandler(t *testing.T) {
 	out := &bytes.Buffer{}
 	r := &http.Request{
@@ -201,6 +233,46 @@ func TestCustomHeaderHandler(t *testing.T) {
 	}
 }
 
+func TestEtagHandler(t *testing.T) {
+	out := &bytes.Buffer{}
+	w := httptest.NewRecorder()
+	r := &http.Request{}
+	h := EtagHandler("etag")(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		w.Header().Set("Etag", `"abcdef"`)
+		w.WriteHeader(http.StatusOK)
+	}))
+	h2 := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		h.ServeHTTP(w, r)
+		l := FromRequest(r)
+		l.Log().Msg("")
+	})
+	h3 := NewHandler(zerolog.New(out))(h2)
+	h3.ServeHTTP(w, r)
+	if want, got := `{"etag":"abcdef"}`+"\n", decodeIfBinary(out); want != got {
+		t.Errorf("Invalid log output, got: %s, want: %s", got, want)
+	}
+}
+
+func TestResponseHeaderHandler(t *testing.T) {
+	out := &bytes.Buffer{}
+	w := httptest.NewRecorder()
+	r := &http.Request{}
+	h := ResponseHeaderHandler("encoding", "Content-Encoding")(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		w.Header().Set("Content-Encoding", `gzip`)
+		w.WriteHeader(http.StatusOK)
+	}))
+	h2 := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		h.ServeHTTP(w, r)
+		l := FromRequest(r)
+		l.Log().Msg("")
+	})
+	h3 := NewHandler(zerolog.New(out))(h2)
+	h3.ServeHTTP(w, r)
+	if want, got := `{"encoding":"gzip"}`+"\n", decodeIfBinary(out); want != got {
+		t.Errorf("Invalid log output, got: %s, want: %s", got, want)
+	}
+}
+
 func TestProtoHandler(t *testing.T) {
 	out := &bytes.Buffer{}
 	r := &http.Request{
@@ -217,6 +289,22 @@ func TestProtoHandler(t *testing.T) {
 	}
 }
 
+func TestHTTPVersionHandler(t *testing.T) {
+	out := &bytes.Buffer{}
+	r := &http.Request{
+		Proto: "HTTP/1.1",
+	}
+	h := HTTPVersionHandler("proto")(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		l := FromRequest(r)
+		l.Log().Msg("")
+	}))
+	h = NewHandler(zerolog.New(out))(h)
+	h.ServeHTTP(nil, r)
+	if want, got := `{"proto":"1.1"}`+"\n", decodeIfBinary(out); want != got {
+		t.Errorf("Invalid log output, got: %s, want: %s", got, want)
+	}
+}
+
 func TestCombinedHandlers(t *testing.T) {
 	out := &bytes.Buffer{}
 	r := &http.Request{
@@ -245,10 +333,10 @@ func BenchmarkHandlers(b *testing.B) {
 	}))
 	h2 := MethodHandler("method")(RequestHandler("request")(h1))
 	handlers := map[string]http.Handler{
-		"Single":           NewHandler(zerolog.New(ioutil.Discard))(h1),
-		"Combined":         NewHandler(zerolog.New(ioutil.Discard))(h2),
-		"SingleDisabled":   NewHandler(zerolog.New(ioutil.Discard).Level(zerolog.Disabled))(h1),
-		"CombinedDisabled": NewHandler(zerolog.New(ioutil.Discard).Level(zerolog.Disabled))(h2),
+		"Single":           NewHandler(zerolog.New(io.Discard))(h1),
+		"Combined":         NewHandler(zerolog.New(io.Discard))(h2),
+		"SingleDisabled":   NewHandler(zerolog.New(io.Discard).Level(zerolog.Disabled))(h1),
+		"CombinedDisabled": NewHandler(zerolog.New(io.Discard).Level(zerolog.Disabled))(h2),
 	}
 	for name := range handlers {
 		h := handlers[name]
@@ -292,3 +380,56 @@ func TestCtxWithID(t *testing.T) {
 		t.Errorf("CtxWithID() = %v, want %v", got, want)
 	}
 }
+
+func TestHostHandler(t *testing.T) {
+	out := &bytes.Buffer{}
+	r := &http.Request{Host: "example.com:8080"}
+	h := HostHandler("host")(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		l := FromRequest(r)
+		l.Log().Msg("")
+	}))
+	h = NewHandler(zerolog.New(out))(h)
+	h.ServeHTTP(nil, r)
+	if want, got := `{"host":"example.com:8080"}`+"\n", decodeIfBinary(out); want != got {
+		t.Errorf("Invalid log output, got: %s, want: %s", got, want)
+	}
+}
+
+func TestHostHandlerWithoutPort(t *testing.T) {
+	out := &bytes.Buffer{}
+	r := &http.Request{Host: "example.com:8080"}
+	h := HostHandler("host", true)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		l := FromRequest(r)
+		l.Log().Msg("")
+	}))
+	h = NewHandler(zerolog.New(out))(h)
+	h.ServeHTTP(nil, r)
+	if want, got := `{"host":"example.com"}`+"\n", decodeIfBinary(out); want != got {
+		t.Errorf("Invalid log output, got: %s, want: %s", got, want)
+	}
+}
+
+func TestGetHost(t *testing.T) {
+	tests := []struct {
+		input    string
+		expected string
+	}{
+		{"", ""},
+		{"example.com:8080", "example.com"},
+		{"example.com", "example.com"},
+		{"invalid", "invalid"},
+		{"192.168.0.1:8080", "192.168.0.1"},
+		{"[2001:0db8:85a3:0000:0000:8a2e:0370:7334]:8080", "2001:0db8:85a3:0000:0000:8a2e:0370:7334"},
+		{"こんにちは.com:8080", "こんにちは.com"},
+	}
+
+	for _, tt := range tests {
+		tt := tt
+		t.Run(tt.input, func(t *testing.T) {
+			result := getHost(tt.input)
+			if tt.expected != result {
+				t.Errorf("Invalid log output, got: %s, want: %s", result, tt.expected)
+			}
+		})
+	}
+}
diff --git a/hook_test.go b/hook_test.go
index e9f5b9e..100b71e 100644
--- a/hook_test.go
+++ b/hook_test.go
@@ -3,7 +3,7 @@ package zerolog
 import (
 	"bytes"
 	"context"
-	"io/ioutil"
+	"io"
 	"testing"
 )
 
@@ -172,7 +172,7 @@ func TestHook(t *testing.T) {
 }
 
 func BenchmarkHooks(b *testing.B) {
-	logger := New(ioutil.Discard)
+	logger := New(io.Discard)
 	b.ResetTimer()
 	b.Run("Nop/Single", func(b *testing.B) {
 		log := logger.Hook(nopHook)
diff --git a/log_example_test.go b/log_example_test.go
index 70caa66..c48a1e3 100644
--- a/log_example_test.go
+++ b/log_example_test.go
@@ -72,7 +72,7 @@ func ExampleLogger_Hook() {
 	var levelNameHook LevelNameHook
 	var messageHook MessageHook = "The message"
 
-	log := zerolog.New(os.Stdout).Hook(levelNameHook).Hook(messageHook)
+	log := zerolog.New(os.Stdout).Hook(levelNameHook, messageHook)
 
 	log.Info().Msg("hello world")
 
@@ -95,6 +95,14 @@ func ExampleLogger_Printf() {
 	// Output: {"level":"debug","message":"hello world"}
 }
 
+func ExampleLogger_Println() {
+	log := zerolog.New(os.Stdout)
+
+	log.Println("hello world")
+
+	// Output: {"level":"debug","message":"hello world\n"}
+}
+
 func ExampleLogger_Trace() {
 	log := zerolog.New(os.Stdout)
 
diff --git a/pkgerrors/stacktrace_test.go b/pkgerrors/stacktrace_test.go
index a09b986..787a096 100644
--- a/pkgerrors/stacktrace_test.go
+++ b/pkgerrors/stacktrace_test.go
@@ -60,6 +60,22 @@ func TestLogStackFromContext(t *testing.T) {
 	}
 }
 
+func TestLogStackFromContextWith(t *testing.T) {
+	zerolog.ErrorStackMarshaler = MarshalStack
+
+	err := fmt.Errorf("from error: %w", errors.New("error message"))
+	out := &bytes.Buffer{}
+	log := zerolog.New(out).With().Stack().Err(err).Logger() // calling Stack() on log context instead of event
+
+	log.Error().Msg("")
+
+	got := out.String()
+	want := `\{"level":"error","stack":\[\{"func":"TestLogStackFromContextWith","line":"66","source":"stacktrace_test.go"\},.*\],"error":"from error: error message"\}\n`
+	if ok, _ := regexp.MatchString(want, got); !ok {
+		t.Errorf("invalid log output:\ngot:  %v\nwant: %v", got, want)
+	}
+}
+
 func BenchmarkLogStack(b *testing.B) {
 	zerolog.ErrorStackMarshaler = MarshalStack
 	out := &bytes.Buffer{}
diff --git a/syslog_test.go b/syslog_test.go
index c168ba6..e889b01 100644
--- a/syslog_test.go
+++ b/syslog_test.go
@@ -100,7 +100,7 @@ func TestSyslogWriter_WithCEE(t *testing.T) {
 	sw := testCEEwriter{&buf}
 	log := New(SyslogCEEWriter(sw))
 	log.Info().Str("key", "value").Msg("message string")
-	got := string(buf.Bytes())
+	got := buf.String()
 	want := "@cee:{"
 	if !strings.HasPrefix(got, want) {
 		t.Errorf("Bad CEE message start: want %v, got %v", want, got)
diff --git a/writer_test.go b/writer_test.go
index 60595ba..f2a61df 100644
--- a/writer_test.go
+++ b/writer_test.go
@@ -195,3 +195,58 @@ func TestFilteredLevelWriter(t *testing.T) {
 		t.Errorf("Expected %q, got %q.", want, p)
 	}
 }
+
+type testWrite struct {
+	Level
+	Line []byte
+}
+
+func TestTriggerLevelWriter(t *testing.T) {
+	tests := []struct {
+		write []testWrite
+		want  []byte
+		all   []byte
+	}{{
+		[]testWrite{
+			{DebugLevel, []byte("no\n")},
+			{InfoLevel, []byte("yes\n")},
+		},
+		[]byte("yes\n"),
+		[]byte("yes\nno\n"),
+	}, {
+		[]testWrite{
+			{DebugLevel, []byte("yes1\n")},
+			{InfoLevel, []byte("yes2\n")},
+			{ErrorLevel, []byte("yes3\n")},
+			{DebugLevel, []byte("yes4\n")},
+		},
+		[]byte("yes2\nyes1\nyes3\nyes4\n"),
+		[]byte("yes2\nyes1\nyes3\nyes4\n"),
+	}}
+
+	for k, tt := range tests {
+		t.Run(fmt.Sprintf("case=%d", k), func(t *testing.T) {
+			buf := bytes.Buffer{}
+			writer := TriggerLevelWriter{Writer: LevelWriterAdapter{&buf}, ConditionalLevel: DebugLevel, TriggerLevel: ErrorLevel}
+			t.Cleanup(func() { writer.Close() })
+			for _, w := range tt.write {
+				_, err := writer.WriteLevel(w.Level, w.Line)
+				if err != nil {
+					t.Error(err)
+				}
+			}
+			p := buf.Bytes()
+			if want := tt.want; !bytes.Equal([]byte(want), p) {
+				t.Errorf("Expected %q, got %q.", want, p)
+			}
+			err := writer.Trigger()
+			if err != nil {
+				t.Error(err)
+			}
+			p = buf.Bytes()
+			if want := tt.all; !bytes.Equal([]byte(want), p) {
+				t.Errorf("Expected %q, got %q.", want, p)
+			}
+		})
+	}
+}