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>
This commit is contained in:
houyuwushang
2026-09-17 08:50:39 +08:00
committed by GitHub
parent 84a93a0b89
commit b93e449b2f
20 changed files with 1064 additions and 22 deletions

View File

@@ -21,7 +21,6 @@ func msg() *dns.Msg {
}
func TestNoDebug(t *testing.T) {
// Must come first, because set log.D.Set() which is impossible to undo.
var f bytes.Buffer
golog.SetOutput(&f)
@@ -45,6 +44,7 @@ func ExampleHexdump() {
}
func TestHexdump(t *testing.T) {
t.Cleanup(log.D.Clear)
var f bytes.Buffer
golog.SetOutput(&f)
log.D.Set()
@@ -59,6 +59,7 @@ func TestHexdump(t *testing.T) {
}
func TestHexdumpf(t *testing.T) {
t.Cleanup(log.D.Clear)
var f bytes.Buffer
golog.SetOutput(&f)
log.D.Set()

View File

@@ -88,12 +88,51 @@ The default Common Log Format is:
`{remote}:{port} - {>id} "{type} {class} {name} {proto} {size} {>do} {>bufsize}" {rcode} {>rflags} {rsize} {duration}`
~~~
Each of these logs will be outputted with `log.Infof`, so a typical example looks like this:
In the default text mode, each of these logs is output with `log.Info`, so a typical example looks like this:
~~~ txt
[INFO] [::1]:50759 - 29008 "A IN example.org. udp 41 false 4096" NOERROR qr,rd,ra,ad 68 0.037990251s
~~~
## JSON Output
Start CoreDNS with `-log-format=json` to select JSON output for the entire process.
This is a command-line flag, not a Corefile directive. `-log-format=text` is the default.
The `log` plugin's name and response-class filters work identically in both modes.
Each query produces one JSON record with common fields `time`, `level`, `msg`, and
`plugin` (always `log` for query records), plus these typed fields:
| Field | Type | Meaning |
| --- | --- | --- |
| `client_ip` | string | Client address, without brackets around IPv6 addresses |
| `client_port` | number | Client port |
| `qname` | string | Lowercase, fully qualified query name, in DNS presentation format |
| `qtype`, `qclass` | string | Query type and class, including numeric forms for unknown values |
| `protocol` | string | `udp` or `tcp`, as for `{proto}` |
| `id`, `opcode` | number | Query ID and opcode |
| `request_size` | number | Request size in bytes, as for `{size}` |
| `dnssec_ok` | boolean | Query's DNSSEC OK bit |
| `bufsize` | number | Effective response buffer size, as for `{>bufsize}` |
| `rcode` | string or null | Response RCODE, or null if no DNS response was recorded |
| `response_size` | number | Recorded response size in bytes, as for `{rsize}` |
| `duration_seconds` | number | Elapsed handling time in seconds |
As in text mode, response sizes describe recorded, uncompressed messages, not
necessarily the bytes delivered to the client. Deferred errors (such as SERVFAIL)
use the response CoreDNS will generate after the plugin chain returns. A dropped
request has `rcode: null`; it is not logged as a successful response. Raw `Write`
calls contribute to the size but do not provide a decoded response RCODE.
`FORMAT` still controls `msg`, including custom formats and metadata placeholders.
It does not replace the JSON schema or define new top-level fields. The DNS fields
come directly from the request and response, not from parsing `msg`. For example,
with `log . "{name} {rcode}"`:
```json
{"time":"2026-09-15T08:00:00Z","level":"INFO","msg":"example.org. NOERROR","plugin":"log","client_ip":"127.0.0.1","client_port":40212,"qname":"example.org.","qtype":"A","qclass":"IN","protocol":"udp","id":42,"opcode":0,"request_size":29,"dnssec_ok":false,"bufsize":512,"rcode":"NOERROR","response_size":29,"duration_seconds":0.001}
```
## Additional metadata
The log plugin adds the following metadata to allow for granular differentiation of NOERROR denial vs success messages. These are mapped from `plugin/pkg/response/classify.go` and `plugin/pkg/response/typify.go`.

44
plugin/log/json.go Normal file
View File

@@ -0,0 +1,44 @@
package log
import (
"log/slog"
"strconv"
"time"
"github.com/coredns/coredns/plugin/pkg/dnstest"
clog "github.com/coredns/coredns/plugin/pkg/log"
"github.com/coredns/coredns/request"
"github.com/miekg/dns"
)
func logJSON(msg string, state request.Request, rr *dnstest.Recorder) {
// A missing response is not NOERROR (for example an ACL drop). Deferred
// errors have already been synthesized by ServeDNS, just as in text mode.
rcode := slog.Any("rcode", nil)
if rr.Msg != nil {
rc := dns.RcodeToString[rr.Rcode]
if rc == "" {
rc = strconv.Itoa(rr.Rcode)
}
rcode = slog.String("rcode", rc)
}
port, _ := strconv.Atoi(state.Port())
clog.InfoAttrs(msg,
slog.String("plugin", "log"),
slog.String("client_ip", state.IP()),
slog.Int("client_port", port),
slog.String("qname", state.Name()),
slog.String("qtype", state.Type()),
slog.String("qclass", state.Class()),
slog.String("protocol", state.Proto()),
slog.Int("id", int(state.Req.Id)),
slog.Int("opcode", state.Req.Opcode),
slog.Int("request_size", state.Len()),
slog.Bool("dnssec_ok", state.Do()),
slog.Int("bufsize", state.Size()),
rcode,
slog.Int("response_size", rr.Len),
slog.Float64("duration_seconds", time.Since(rr.Start).Seconds()),
)
}

205
plugin/log/json_test.go Normal file
View File

@@ -0,0 +1,205 @@
package log
import (
"bytes"
"context"
"encoding/json"
"errors"
"io"
golog "log"
"strings"
"testing"
"github.com/coredns/coredns/plugin"
"github.com/coredns/coredns/plugin/metadata"
"github.com/coredns/coredns/plugin/pkg/dnstest"
clog "github.com/coredns/coredns/plugin/pkg/log"
"github.com/coredns/coredns/plugin/pkg/replacer"
"github.com/coredns/coredns/plugin/pkg/response"
"github.com/coredns/coredns/plugin/test"
"github.com/coredns/coredns/request"
"github.com/miekg/dns"
)
func jsonQueryOutput(tb testing.TB, format string, w io.Writer) {
tb.Helper()
output, flags, prefix := golog.Writer(), golog.Flags(), golog.Prefix()
tb.Cleanup(func() {
if err := clog.Configure("text", output); err != nil {
tb.Error(err)
}
golog.SetFlags(flags)
golog.SetPrefix(prefix)
})
if err := clog.Configure(format, w); err != nil {
tb.Fatal(err)
}
}
func TestJSONQueryEscaping(t *testing.T) {
for _, name := range []string{"Example.ORG.", `a\.b.example.`, `a\001b.example.`, `a\"b.example.`, `a\\b.example.`} {
t.Run(name, func(t *testing.T) {
var out bytes.Buffer
jsonQueryOutput(t, "json", &out)
ctx := metadata.ContextWithMetadata(t.Context())
value := "arbitrary \"metadata\"\nwith\t\\escapes"
metadata.SetValueFunc(ctx, "test/value", func() string { return value })
r := new(dns.Msg)
r.SetQuestion(name, dns.TypeA)
r.SetEdns0(1232, true)
r.Id = 42
wire, err := r.Pack()
if err != nil {
t.Fatal(err)
}
if err := r.Unpack(wire); err != nil {
t.Fatal(err)
}
state := request.Request{Req: r}
logger := Logger{
Rules: []Rule{{NameScope: ".", Class: map[response.Class]struct{}{response.All: {}},
Format: `{"name":"{name}","metadata":"{/test/value}"}`}},
Next: test.NextHandler(dns.RcodeRefused, nil),
}
rec := dnstest.NewRecorder(&test.ResponseWriter{TCP: true, RemoteIP: "2001:db8::1"})
rc, err := logger.ServeDNS(ctx, rec, r)
if rc != dns.RcodeRefused || err != nil || rec.Msg != nil {
t.Fatalf("logging changed deferred response: rc=%d, err=%v, msg=%v", rc, err, rec.Msg)
}
if strings.Count(out.String(), "\n") != 1 {
t.Fatalf("not one physical line: %q", out.String())
}
var record map[string]any
if err := json.Unmarshal(out.Bytes(), &record); err != nil {
t.Fatalf("invalid JSON %q: %v", out.String(), err)
}
want := map[string]any{
"plugin": "log", "level": "INFO", "qname": state.Name(), "qtype": "A", "qclass": "IN",
"client_ip": "2001:db8::1", "client_port": float64(40212), "protocol": "tcp",
"id": float64(42), "opcode": float64(0), "request_size": float64(r.Len()),
"dnssec_ok": true, "bufsize": float64(dns.MaxMsgSize), "rcode": "REFUSED",
"msg": `{"name":"` + state.Name() + `","metadata":"` + value + `"}`,
}
for field, value := range want {
if record[field] != value {
t.Errorf("%s = %v, want %v", field, record[field], value)
}
}
if d, ok := record["duration_seconds"].(float64); !ok || d < 0 {
t.Errorf("invalid duration: %v", record["duration_seconds"])
}
response := new(dns.Msg)
response.SetRcode(r, dns.RcodeRefused)
if record["response_size"] != float64(response.Len()) {
t.Errorf("unexpected deferred response size: %v", record)
}
})
}
}
func TestJSONQueryResponses(t *testing.T) {
writeErr := errors.New("test write failure")
for _, tc := range []struct {
name string
rcode int
write bool
nodata bool
fail bool
class response.Class
logged bool
}{
{"success", dns.RcodeSuccess, true, false, false, response.Success, true},
{"nxdomain", dns.RcodeNameError, true, false, false, response.Denial, true},
{"nodata", dns.RcodeSuccess, true, true, false, response.Denial, true},
{"servfail", dns.RcodeServerFailure, false, false, false, response.Error, true},
{"refused", dns.RcodeRefused, false, false, false, response.Error, true},
{"drop", dns.RcodeSuccess, false, false, false, response.All, true},
{"write-error", dns.RcodeSuccess, true, false, true, response.All, true},
{"unknown-rcode", 4095, true, false, false, response.All, true},
{"denial-filtered", dns.RcodeNameError, true, false, false, response.Success, false},
{"error-filtered", dns.RcodeServerFailure, false, false, false, response.Denial, false},
} {
t.Run(tc.name, func(t *testing.T) {
var out bytes.Buffer
jsonQueryOutput(t, "json", &out)
r := new(dns.Msg)
r.SetQuestion("example.org.", dns.TypeA)
logger := Logger{
Rules: []Rule{{NameScope: "example.org.", Class: map[response.Class]struct{}{tc.class: {}}, Format: DefaultLogFormat}},
Next: plugin.HandlerFunc(func(_ context.Context, w dns.ResponseWriter, r *dns.Msg) (int, error) {
if !tc.write {
return tc.rcode, nil
}
m := new(dns.Msg)
m.SetRcode(r, tc.rcode)
if tc.nodata {
m.Ns = []dns.RR{test.SOA("example.org. 60 IN SOA ns.example.org. hostmaster.example.org. 1 2 3 4 5")}
}
return tc.rcode, w.WriteMsg(m)
}),
}
w := &jsonFailWriter{err: nil}
if tc.fail {
w.err = writeErr
}
rc, err := logger.ServeDNS(t.Context(), w, r)
if rc != tc.rcode || !errors.Is(err, w.err) {
t.Fatalf("return changed: rc=%d err=%v", rc, err)
}
if !tc.logged {
if out.Len() != 0 {
t.Fatalf("class filter ignored: %s", &out)
}
return
}
var record map[string]any
if err := json.Unmarshal(out.Bytes(), &record); err != nil {
t.Fatal(err)
}
if tc.name == "drop" {
if rc, exists := record["rcode"]; !exists || rc != nil || record["response_size"] != float64(0) {
t.Errorf("missing response misrepresented: %v", record)
}
} else if tc.name == "unknown-rcode" {
if record["rcode"] != "4095" {
t.Errorf("unknown rcode lost: %v", record)
}
} else if record["rcode"] != dns.RcodeToString[tc.rcode] {
t.Errorf("wrong rcode: %v", record)
}
out.Reset()
r.SetQuestion("outside.example.net.", dns.TypeA)
logger.ServeDNS(t.Context(), w, r)
if out.Len() != 0 {
t.Fatalf("name filter ignored: %s", &out)
}
})
}
}
type jsonFailWriter struct {
test.ResponseWriter
err error
}
func (w *jsonFailWriter) WriteMsg(_ *dns.Msg) error { return w.err }
func BenchmarkQueryLogFormat(b *testing.B) {
for _, format := range []string{"text", "json"} {
b.Run(format, func(b *testing.B) {
jsonQueryOutput(b, format, io.Discard)
logger := Logger{
Rules: []Rule{{NameScope: ".", Class: map[response.Class]struct{}{response.All: {}}, Format: DefaultLogFormat}},
Next: test.NextHandler(dns.RcodeRefused, nil), repl: replacer.New(),
}
r := new(dns.Msg)
r.SetQuestion("example.org.", dns.TypeA)
w := &test.ResponseWriter{}
b.ReportAllocs()
for b.Loop() {
logger.ServeDNS(b.Context(), w, r)
}
})
}
}

View File

@@ -62,7 +62,11 @@ func (l Logger) ServeDNS(ctx context.Context, w dns.ResponseWriter, r *dns.Msg)
}
if ok || ok1 {
logstr := l.repl.Replace(ctx, state, rrw, rule.Format)
clog.Info(logstr)
if clog.IsJSON() {
logJSON(logstr, state, rrw)
} else {
clog.Info(logstr)
}
}
return rc, err

131
plugin/pkg/log/json.go Normal file
View File

@@ -0,0 +1,131 @@
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)
}

286
plugin/pkg/log/json_test.go Normal file
View File

@@ -0,0 +1,286 @@
package log
import (
"bytes"
"encoding/json"
"fmt"
"io"
golog "log"
"log/slog"
"os"
"os/exec"
"strings"
"sync"
"testing"
"time"
)
func configureTestLog(tb testing.TB, format string, w io.Writer) {
tb.Helper()
output, flags, prefix, debug := golog.Writer(), golog.Flags(), golog.Prefix(), D.Value()
tb.Cleanup(func() {
if err := Configure("text", output); err != nil {
tb.Error(err)
}
golog.SetFlags(flags)
golog.SetPrefix(prefix)
if debug {
D.Set()
} else {
D.Clear()
}
})
if err := Configure(format, w); err != nil {
tb.Fatal(err)
}
}
func TestJSONLevels(t *testing.T) {
var out bytes.Buffer
configureTestLog(t, "json", &out)
D.Set()
p := NewWithPlugin("test\"\nplugin")
for _, tc := range []struct {
level string
plain func(...any)
args func(string, ...any)
named func(...any)
fmt func(string, ...any)
}{
{"DEBUG", Debug, Debugf, p.Debug, p.Debugf},
{"INFO", Info, Infof, p.Info, p.Infof},
{"WARN", Warning, Warningf, p.Warning, p.Warningf},
{"ERROR", Error, Errorf, p.Error, p.Errorf},
} {
t.Run(tc.level, func(t *testing.T) {
msg := "quoted \"value\"\nnext\rline\t\\end"
tc.plain(msg)
tc.args("%s", msg)
tc.named(msg)
tc.fmt("%s", msg)
lines := strings.Split(strings.TrimSuffix(out.String(), "\n"), "\n")
if len(lines) != 4 {
t.Fatalf("expected four JSON lines, got %q", out.String())
}
for i, line := range lines {
var record map[string]any
if err := json.Unmarshal([]byte(line), &record); err != nil {
t.Fatalf("invalid JSON: %s: %v", line, err)
}
if record["level"] != tc.level || record["msg"] != msg {
t.Errorf("unexpected record: %v", record)
}
stamp, ok := record["time"].(string)
if !ok {
t.Fatal("missing timestamp")
}
if _, err := time.Parse(time.RFC3339Nano, stamp); err != nil {
t.Error(err)
}
if i < 2 {
if _, ok := record["plugin"]; ok {
t.Errorf("global log has plugin: %v", record)
}
} else if record["plugin"] != p.name {
t.Errorf("plugin identity lost: %v", record)
}
}
out.Reset()
})
}
D.Clear()
Debug("hidden")
Debugf("%s", "hidden")
p.Debug("hidden")
p.Debugf("%s", "hidden")
if out.Len() != 0 {
t.Fatalf("debug logs emitted while disabled: %s", &out)
}
}
func TestJSONStandardLoggerAndOutput(t *testing.T) {
var out, redirected bytes.Buffer
configureTestLog(t, "json", &out)
golog.Print("first\nsecond")
InfoAttrs("query", slog.Int("size", 12), slog.Bool("do", false))
dec := json.NewDecoder(&out)
for i := range 2 {
var record map[string]any
if err := dec.Decode(&record); err != nil {
t.Fatal(err)
}
if i == 0 && record["msg"] != "first\nsecond" {
t.Fatalf("standard logger message changed: %v", record)
}
if i == 1 && (record["size"] != float64(12) || record["do"] != false) {
t.Fatalf("attributes lost their types: %v", record)
}
}
SetOutput(&redirected)
golog.Print("redirected standard")
Info("redirected CoreDNS")
if strings.Count(redirected.String(), "\n") != 2 {
t.Fatalf("expected two redirected records, got %q", redirected.String())
}
before := redirected.String()
Discard()
golog.Print("discarded standard")
Info("discarded CoreDNS")
if redirected.String() != before {
t.Fatal("Discard did not stop all output")
}
}
func TestJSONListeners(t *testing.T) {
var out bytes.Buffer
configureTestLog(t, "json", &out)
listener := &jsonListener{mockListener: *NewMockListener("json-test")}
if err := RegisterListener(listener); err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
if err := DeregisterListener(listener); err != nil {
t.Error(err)
}
})
NewWithPlugin("example").Info("unchanged", 1)
InfoAttrs("query", slog.String("plugin", "log"))
if listener.calls != 1 || listener.plugin != "plugin/example: " || listener.msg != "unchanged1" {
t.Fatalf("listener contract changed: %+v", listener)
}
if strings.Count(out.String(), "\n") != 2 {
t.Fatalf("listener caused missing or duplicate output: %q", out.String())
}
}
type jsonListener struct {
mockListener
calls int
plugin string
msg string
}
func (l *jsonListener) Info(plugin string, v ...any) {
l.calls++
l.plugin, l.msg = plugin, fmt.Sprint(v...)
}
func TestJSONConcurrent(t *testing.T) {
var out bytes.Buffer
configureTestLog(t, "json", &out)
var wg sync.WaitGroup
for range 16 {
wg.Go(func() {
for i := range 100 {
InfoAttrs("query", slog.Int("id", i))
NewWithPlugin("concurrent").Errorf("error %d", i)
golog.Printf("legacy %d", i)
}
})
}
wg.Wait()
lines := bytes.Split(bytes.TrimSuffix(out.Bytes(), []byte("\n")), []byte("\n"))
if len(lines) != 4800 {
t.Fatalf("expected 4800 records, got %d", len(lines))
}
for _, line := range lines {
if !json.Valid(line) {
t.Fatalf("interleaved JSON record: %q", line)
}
}
}
func TestConfigureTextCompatibility(t *testing.T) {
var out bytes.Buffer
configureTestLog(t, "text", &out)
golog.SetFlags(0)
golog.SetPrefix("prefix: ")
emit := func() {
Info("a", 1)
NewWithPlugin("test").Infof("value %d", 2)
InfoAttrs("attrs", slog.Int("size", 3))
golog.Print("legacy")
}
want := "prefix: [INFO] a1\nprefix: [INFO] plugin/test: value 2\nprefix: [INFO] attrs\nprefix: legacy\n"
emit()
if out.String() != want {
t.Fatalf("text output changed: %q", out.String())
}
if err := Configure("json", io.Discard); err != nil {
t.Fatal(err)
}
if err := Configure("text", &out); err != nil {
t.Fatal(err)
}
out.Reset()
emit()
if out.String() != want || IsJSON() {
t.Fatalf("text settings not restored: %q", out.String())
}
if err := Configure("invalid", io.Discard); err == nil {
t.Fatal("invalid format accepted")
}
if err := Configure("json", nil); err == nil || IsJSON() {
t.Fatal("invalid configuration changed logging mode")
}
}
func TestJSONFatal(t *testing.T) {
for _, mode := range []string{"global", "globalf", "plugin", "pluginf"} {
t.Run(mode, func(t *testing.T) {
bin, err := os.Executable()
if err != nil {
t.Fatal(err)
}
cmd := exec.Command(bin, "-test.run=^TestJSONFatalHelper$")
cmd.Env = append(os.Environ(), "COREDNS_TEST_JSON_FATAL="+mode, "GORACE=atexit_sleep_ms=0")
out, err := cmd.CombinedOutput()
if exit, ok := err.(*exec.ExitError); !ok || exit.ExitCode() != 1 {
t.Fatalf("expected exit 1, got %v: %s", err, out)
}
var record map[string]any
if err := json.Unmarshal(out, &record); err != nil {
t.Fatalf("invalid fatal log %q: %v", out, err)
}
if record["level"] != "FATAL" || record["msg"] != "fatal\nmessage" {
t.Fatalf("unexpected fatal record: %v", record)
}
if strings.HasPrefix(mode, "plugin") && record["plugin"] != "test" {
t.Fatalf("missing plugin: %v", record)
}
})
}
}
func TestJSONFatalHelper(t *testing.T) {
mode := os.Getenv("COREDNS_TEST_JSON_FATAL")
if mode == "" {
return
}
if err := Configure("json", os.Stdout); err != nil {
t.Fatal(err)
}
switch mode {
case "global":
Fatal("fatal\nmessage")
case "globalf":
Fatalf("%s", "fatal\nmessage")
case "plugin":
NewWithPlugin("test").Fatal("fatal\nmessage")
case "pluginf":
NewWithPlugin("test").Fatalf("%s", "fatal\nmessage")
}
}
func BenchmarkLogFormat(b *testing.B) {
for _, format := range []string{"text", "json"} {
b.Run(format, func(b *testing.B) {
configureTestLog(b, format, io.Discard)
p := NewWithPlugin("test")
b.ReportAllocs()
for b.Loop() {
p.Infof("query %s returned %d", "example.org.", 0)
}
})
}
}

View File

@@ -1,7 +1,7 @@
// Package log implements a small wrapper around the std lib log package. It
// implements log levels by prefixing the logs with [INFO], [DEBUG], [WARNING]
// or [ERROR]. Debug logging is available and enabled if the *debug* plugin is
// used.
// used. Configure can opt into structured JSON output instead of text prefixes.
//
// log.Info("this is some logging"), will log on the Info level.
//
@@ -41,11 +41,19 @@ func (d *d) Value() bool {
// logf calls log.Printf prefixed with level.
func logf(level, format string, v ...any) {
if b := jsonBackend.Load(); b != nil {
b.log(level, "", fmt.Sprintf(format, v...))
return
}
golog.Print(level, fmt.Sprintf(format, v...))
}
// log calls log.Print prefixed with level.
func log(level string, v ...any) {
if b := jsonBackend.Load(); b != nil {
b.log(level, "", fmt.Sprint(v...))
return
}
golog.Print(level, fmt.Sprint(v...))
}
@@ -94,7 +102,7 @@ func Fatal(v ...any) { log(fatal, v...); os.Exit(1) }
func Fatalf(format string, v ...any) { logf(fatal, format, v...); os.Exit(1) }
// Discard sets the log output to /dev/null.
func Discard() { golog.SetOutput(io.Discard) }
func Discard() { SetOutput(io.Discard) }
const (
debug = "[DEBUG] "

View File

@@ -33,6 +33,7 @@ func TestDebug(t *testing.T) {
}
func TestDebugx(t *testing.T) {
t.Cleanup(D.Clear)
var f bytes.Buffer
golog.SetOutput(&f)

View File

@@ -8,17 +8,26 @@ import (
// P is a logger that includes the plugin doing the logging.
type P struct {
plugin string
name string
}
// NewWithPlugin returns a logger that includes "plugin/name: " in the log message.
// I.e [INFO] plugin/<name>: message.
func NewWithPlugin(name string) P { return P{"plugin/" + name + ": "} }
func NewWithPlugin(name string) P { return P{plugin: "plugin/" + name + ": ", name: name} }
func (p P) logf(level, format string, v ...any) {
if b := jsonBackend.Load(); b != nil {
b.log(level, p.name, fmt.Sprintf(format, v...))
return
}
log(level, p.plugin, fmt.Sprintf(format, v...))
}
func (p P) log(level string, v ...any) {
if b := jsonBackend.Load(); b != nil {
b.log(level, p.name, fmt.Sprint(v...))
return
}
log(level+p.plugin, v...)
}