Skip to content
Open
Show file tree
Hide file tree
Changes from 4 commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
71579fd
Initial plan
Copilot Feb 23, 2026
73ddbe9
feat: add context field extraction to log.WithContext via RegisterCon…
Copilot Feb 23, 2026
e862b94
fix: apply betteralign struct field ordering for defaultLogger
Copilot Feb 23, 2026
2c4216c
docs: improve documentation for context extractors and requestid init
Copilot Feb 23, 2026
4549449
refactor: replace init() with sync.Once in New(), add basicauth extra…
Copilot Feb 23, 2026
ae206fb
feat: add log context extractors for csrf, keyauth, and session middl…
Copilot Feb 23, 2026
a0d4054
Update log/log.go
gaby Feb 23, 2026
041145b
Update log/log.go
gaby Feb 23, 2026
40bd5d0
security: redact sensitive values (api-key, csrf-token) in log contex…
Copilot Feb 23, 2026
add61a5
fix: skip context extractors that return empty key to prevent malform…
Copilot Feb 24, 2026
8234a50
Merge branch 'main' into copilot/fix-request-id-logging
gaby Mar 12, 2026
1b78967
fix: make RegisterContextExtractor concurrent-safe with RWMutex, reda…
Copilot Mar 12, 2026
e5f0b43
docs: fix redactSessionID comment to accurately describe >8 vs <=8 be…
Copilot Mar 12, 2026
57fd7aa
fix: CSRF extractor respects DisableValueRedaction; clarify WithConte…
Copilot Mar 12, 2026
be7f6ff
docs: clarify WithContext accepted context types in log.md
Copilot Mar 13, 2026
13b97d7
Potential fix for pull request finding
gaby Mar 13, 2026
c419d2a
fix: always redact CSRF tokens in log extractor, remove sync.Once con…
Copilot Mar 13, 2026
34d3cb8
test: add missing Test_BasicAuth_LogWithContext for complete coverage
Claude Mar 13, 2026
060dd32
Merge branch 'main' into copilot/fix-request-id-logging
gaby Mar 13, 2026
7695b26
Potential fix for pull request finding
gaby Mar 13, 2026
3f98165
fix: remove blank line after function signature in basicauth_test.go …
Copilot Mar 13, 2026
cdcd49b
feat: make log.WithContext accept any instead of context.Context for …
Claude Mar 14, 2026
3da399c
Merge branch 'main' into copilot/fix-request-id-logging
gaby Mar 17, 2026
b3fc68a
docs: add output examples and format compatibility notes to log.WithC…
Claude Mar 17, 2026
e359e50
Merge branch 'main' into copilot/fix-request-id-logging
gaby Mar 29, 2026
5c3dddd
fix: address PR review comments - API signature, log injection, and docs
Claude Mar 29, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 26 additions & 4 deletions log/default.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,29 @@ import (
var _ AllLogger[*log.Logger] = (*defaultLogger)(nil)

type defaultLogger struct {
ctx context.Context //nolint:containedctx // stored for deferred field extraction
stdlog *log.Logger
level Level
depth int
}

// writeContextFields appends extracted context key-value pairs to buf.
// Each pair is written as "key=value " (trailing space included).
func (l *defaultLogger) writeContextFields(buf *bytebufferpool.ByteBuffer) {
if l.ctx == nil || len(contextExtractors) == 0 {
return
}
for _, extractor := range contextExtractors {
key, value, ok := extractor(l.ctx)
if ok {
buf.WriteString(key)
buf.WriteByte('=')
buf.WriteString(utils.ToString(value))
buf.WriteByte(' ')
}
}
}

// privateLog logs a message at a given level log the default logger.
// when the level is fatal, it will exit the program.
func (l *defaultLogger) privateLog(lv Level, fmtArgs []any) {
Expand All @@ -28,6 +46,7 @@ func (l *defaultLogger) privateLog(lv Level, fmtArgs []any) {
level := lv.toString()
buf := bytebufferpool.Get()
buf.WriteString(level)
l.writeContextFields(buf)
fmt.Fprint(buf, fmtArgs...)

_ = l.stdlog.Output(l.depth, buf.String()) //nolint:errcheck // It is fine to ignore the error
Expand All @@ -51,6 +70,7 @@ func (l *defaultLogger) privateLogf(lv Level, format string, fmtArgs []any) {
level := lv.toString()
buf := bytebufferpool.Get()
buf.WriteString(level)
l.writeContextFields(buf)

if len(fmtArgs) > 0 {
_, _ = fmt.Fprintf(buf, format, fmtArgs...)
Expand Down Expand Up @@ -78,8 +98,7 @@ func (l *defaultLogger) privateLogw(lv Level, format string, keysAndValues []any
level := lv.toString()
buf := bytebufferpool.Get()
buf.WriteString(level)

// Write format privateLog buffer
l.writeContextFields(buf)
if format != "" {
buf.WriteString(format)
}
Expand Down Expand Up @@ -220,12 +239,15 @@ func (l *defaultLogger) Panicw(msg string, keysAndValues ...any) {
l.privateLogw(LevelPanic, msg, keysAndValues)
}

// WithContext returns a logger that shares the underlying output but adjusts the call depth.
func (l *defaultLogger) WithContext(_ context.Context) CommonLogger {
// WithContext returns a logger that shares the underlying output but carries
// the provided context. Any registered ContextExtractor functions will be
// called at log time to prepend key-value fields extracted from the context.
func (l *defaultLogger) WithContext(ctx context.Context) CommonLogger {
return &defaultLogger{
stdlog: l.stdlog,
level: l.level,
depth: l.depth - 1,
ctx: ctx,
}
}

Expand Down
80 changes: 80 additions & 0 deletions log/default_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,86 @@ func Test_CtxLogger(t *testing.T) {
"[Panic] work panic\n", string(w.b))
}

type testContextKey struct{}

func Test_WithContextExtractor(t *testing.T) {
// Save and restore global extractors
saved := contextExtractors
defer func() { contextExtractors = saved }()
contextExtractors = nil

RegisterContextExtractor(func(ctx context.Context) (string, any, bool) {
if v, ok := ctx.Value(testContextKey{}).(string); ok && v != "" {
return "request-id", v, true
}
return "", nil, false
})

t.Run("Info with context field", func(t *testing.T) {
var buf bytes.Buffer
l := &defaultLogger{
stdlog: log.New(&buf, "", 0),
level: LevelTrace,
depth: 4,
}
ctx := context.WithValue(context.Background(), testContextKey{}, "abc-123")
l.WithContext(ctx).Info("hello")

require.Equal(t, "[Info] request-id=abc-123 hello\n", buf.String())
})

t.Run("Infof with context field", func(t *testing.T) {
var buf bytes.Buffer
l := &defaultLogger{
stdlog: log.New(&buf, "", 0),
level: LevelTrace,
depth: 4,
}
ctx := context.WithValue(context.Background(), testContextKey{}, "abc-123")
l.WithContext(ctx).Infof("hello %s", "world")

require.Equal(t, "[Info] request-id=abc-123 hello world\n", buf.String())
})

t.Run("Infow with context field", func(t *testing.T) {
var buf bytes.Buffer
l := &defaultLogger{
stdlog: log.New(&buf, "", 0),
level: LevelTrace,
depth: 4,
}
ctx := context.WithValue(context.Background(), testContextKey{}, "abc-123")
l.WithContext(ctx).Infow("hello", "key", "value")

require.Equal(t, "[Info] request-id=abc-123 hello key=value\n", buf.String())
})

t.Run("no context field when value absent", func(t *testing.T) {
var buf bytes.Buffer
l := &defaultLogger{
stdlog: log.New(&buf, "", 0),
level: LevelTrace,
depth: 4,
}
ctx := context.Background()
l.WithContext(ctx).Info("hello")

require.Equal(t, "[Info] hello\n", buf.String())
})

t.Run("no context field without WithContext", func(t *testing.T) {
var buf bytes.Buffer
l := &defaultLogger{
stdlog: log.New(&buf, "", 0),
level: LevelTrace,
depth: 4,
}
l.Info("hello")

require.Equal(t, "[Info] hello\n", buf.String())
})
}

func Test_LogfKeyAndValues(t *testing.T) {
tests := []struct {
name string
Expand Down
22 changes: 22 additions & 0 deletions log/log.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,28 @@ import (
"os"
)

// ContextExtractor extracts a key-value pair from the given context for
// inclusion in log output when using WithContext.
// It returns the log field name, its value, and whether extraction succeeded.
type ContextExtractor func(ctx context.Context) (string, any, bool)

// contextExtractors holds all registered context field extractors.
//
// This slice is read during logging and written during registration.
// All calls to RegisterContextExtractor must happen during program
// initialization (e.g. in init functions or before starting goroutines),
// before any logging occurs, to avoid data races.
var contextExtractors []ContextExtractor

// RegisterContextExtractor registers a function that extracts a key-value pair
// from context for inclusion in log output when using WithContext.
//
// Note that this function is not concurrent-safe and must be called during
// program initialization (e.g. in an init function), before any logging occurs.
func RegisterContextExtractor(extractor ContextExtractor) {
contextExtractors = append(contextExtractors, extractor)
}

// baseLogger defines the minimal logger functionality required by the package.
// It allows storing any logger implementation regardless of its generic type.
type baseLogger interface {
Expand Down
13 changes: 13 additions & 0 deletions middleware/requestid/requestid.go
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
package requestid

import (
"context"

"github.com/gofiber/fiber/v3"
"github.com/gofiber/fiber/v3/log"
"github.com/gofiber/utils/v2"
)

Expand All @@ -14,6 +17,16 @@ const (
requestIDKey contextKey = iota
)

func init() {
// Register a context extractor so that log.WithContext(c) automatically
// includes the request ID when the requestid middleware is in use.
// An empty request ID (no middleware or middleware skipped) is omitted.
log.RegisterContextExtractor(func(ctx context.Context) (string, any, bool) {
rid := FromContext(ctx)
return "request-id", rid, rid != ""
})
}

// New creates a new middleware handler
func New(config ...Config) fiber.Handler {
// Set default config
Expand Down
29 changes: 29 additions & 0 deletions middleware/requestid/requestid_test.go
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
package requestid

import (
"bytes"
"net/http"
"net/http/httptest"
"os"
"testing"

"github.com/gofiber/fiber/v3"
fiberlog "github.com/gofiber/fiber/v3/log"
"github.com/stretchr/testify/require"
)

Expand Down Expand Up @@ -233,3 +236,29 @@ func Test_RequestID_FromContext_Types(t *testing.T) {
require.NoError(t, err)
require.Equal(t, fiber.StatusOK, resp.StatusCode)
}

func Test_RequestID_LogWithContext(t *testing.T) {
reqID := "test-request-id-456"

app := fiber.New()
app.Use(New(Config{
Generator: func() string {
return reqID
},
}))

var logOutput bytes.Buffer
fiberlog.SetOutput(&logOutput)
defer fiberlog.SetOutput(os.Stderr)

app.Get("/", func(c fiber.Ctx) error {
fiberlog.WithContext(c).Info("hello from handler")
return c.SendStatus(fiber.StatusOK)
})

resp, err := app.Test(httptest.NewRequest(fiber.MethodGet, "/", http.NoBody))
require.NoError(t, err)
require.Equal(t, fiber.StatusOK, resp.StatusCode)
require.Contains(t, logOutput.String(), "request-id="+reqID)
require.Contains(t, logOutput.String(), "hello from handler")
}
Loading