Files
coredns/plugin/pkg/log/json.go
houyuwushang b93e449b2f Add opt-in JSON logging with structured DNS query fields (#8553)
* plugin/pkg/log: add opt-in JSON logging backend

Signed-off-by: houyuwushang <liuluoqianqiu@outlook.com>

* plugin/log: emit typed query records in JSON mode

Signed-off-by: houyuwushang <liuluoqianqiu@outlook.com>

* coremain: expose process-wide JSON logging

Signed-off-by: houyuwushang <liuluoqianqiu@outlook.com>

---------

Signed-off-by: houyuwushang <liuluoqianqiu@outlook.com>
2026-09-16 17:50:39 -07:00

132 lines
3.4 KiB
Go

package log
import (
"context"
"fmt"
"io"
golog "log"
"log/slog"
"strings"
"sync"
"sync/atomic"
)
var jsonBackend atomic.Pointer[jsonLogger]
type jsonLogger struct {
logger *slog.Logger
output *logOutput
flags int
prefix string
}
// Configure selects text (the default) or JSON logging and its destination.
// Call it at process startup, before starting servers. The format is process-wide
// and should not be changed by individual plugins or during a Corefile reload.
// JSON mode also routes the standard library's default logger through this
// backend, at INFO level, without interpreting message text as structured data.
func Configure(format string, output io.Writer) error {
if format != "text" && format != "json" {
return fmt.Errorf("unknown log format %q: expected text or json", format)
}
if output == nil {
return fmt.Errorf("log output must not be nil")
}
flags, prefix := golog.Flags(), golog.Prefix()
if previous := jsonBackend.Load(); previous != nil {
flags, prefix = previous.flags, previous.prefix
}
if format == "text" {
jsonBackend.Store(nil)
golog.SetOutput(output)
golog.SetFlags(flags)
golog.SetPrefix(prefix)
return nil
}
w := &logOutput{writer: output}
l := slog.New(slog.NewJSONHandler(w, &slog.HandlerOptions{
Level: slog.LevelDebug,
ReplaceAttr: func(_ []string, a slog.Attr) slog.Attr {
if a.Key == slog.LevelKey {
if level, ok := a.Value.Any().(slog.Level); ok && level == levelFatal {
return slog.String(slog.LevelKey, "FATAL")
}
}
return a
},
}))
jsonBackend.Store(&jsonLogger{logger: l, output: w, flags: flags, prefix: prefix})
golog.SetFlags(0)
golog.SetPrefix("")
golog.SetOutput(standardWriter{logger: l})
return nil
}
// IsJSON reports whether structured logging is enabled.
func IsJSON() bool { return jsonBackend.Load() != nil }
// SetOutput changes the destination of both CoreDNS and standard-library logs.
// Use this instead of log.SetOutput when JSON logging is enabled.
func SetOutput(w io.Writer) {
if b := jsonBackend.Load(); b != nil {
b.output.mu.Lock()
b.output.writer = w
b.output.mu.Unlock()
return
}
golog.SetOutput(w)
}
// InfoAttrs logs msg with typed attributes in JSON mode. In text mode it is
// equivalent to Info(msg). Like Info, it does not call named-plugin listeners.
func InfoAttrs(msg string, attrs ...slog.Attr) {
if b := jsonBackend.Load(); b != nil {
b.logger.LogAttrs(context.Background(), slog.LevelInfo, msg, attrs...)
return
}
Info(msg)
}
func (b *jsonLogger) log(level, plugin, msg string) {
lvl := slog.LevelInfo
switch level {
case debug:
lvl = slog.LevelDebug
case warning:
lvl = slog.LevelWarn
case err:
lvl = slog.LevelError
case fatal:
lvl = levelFatal
}
if plugin != "" {
b.logger.LogAttrs(context.Background(), lvl, msg, slog.String("plugin", plugin))
return
}
b.logger.LogAttrs(context.Background(), lvl, msg)
}
const levelFatal = slog.Level(12)
// The adapter writes directly to the shared handler, not back through golog.
// This preserves multiline messages as one JSON record without recursion.
type standardWriter struct{ logger *slog.Logger }
func (w standardWriter) Write(p []byte) (int, error) {
w.logger.Info(strings.TrimSuffix(string(p), "\n"))
return len(p), nil
}
type logOutput struct {
mu sync.Mutex
writer io.Writer
}
func (w *logOutput) Write(p []byte) (int, error) {
w.mu.Lock()
defer w.mu.Unlock()
return w.writer.Write(p)
}