From 5f44ea0748b9612bc09d274f5553c3cc9e624296 Mon Sep 17 00:00:00 2001 From: Zen Dodd Date: Sat, 11 Apr 2026 09:09:12 +1000 Subject: [PATCH] logging: add journald encoder wrapper (#7623) --- .../log_journald_encoder.caddyfiletest | 47 ++++ modules/logging/journaldencoder.go | 221 ++++++++++++++++++ modules/logging/journaldencoder_test.go | 155 ++++++++++++ 3 files changed, 423 insertions(+) create mode 100644 caddytest/integration/caddyfile_adapt/log_journald_encoder.caddyfiletest create mode 100644 modules/logging/journaldencoder.go create mode 100644 modules/logging/journaldencoder_test.go diff --git a/caddytest/integration/caddyfile_adapt/log_journald_encoder.caddyfiletest b/caddytest/integration/caddyfile_adapt/log_journald_encoder.caddyfiletest new file mode 100644 index 000000000..89ac3ed2b --- /dev/null +++ b/caddytest/integration/caddyfile_adapt/log_journald_encoder.caddyfiletest @@ -0,0 +1,47 @@ +{ + log { + format journald { + wrap console + } + } +} + +:80 { + respond "Hello, World!" +} +---------- +{ + "logging": { + "logs": { + "default": { + "encoder": { + "format": "journald", + "wrap": { + "format": "console" + } + } + } + } + }, + "apps": { + "http": { + "servers": { + "srv0": { + "listen": [ + ":80" + ], + "routes": [ + { + "handle": [ + { + "body": "Hello, World!", + "handler": "static_response" + } + ] + } + ] + } + } + } + } +} diff --git a/modules/logging/journaldencoder.go b/modules/logging/journaldencoder.go new file mode 100644 index 000000000..5826c6950 --- /dev/null +++ b/modules/logging/journaldencoder.go @@ -0,0 +1,221 @@ +// Copyright 2015 Matthew Holt and The Caddy Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package logging + +import ( + "encoding/json" + "fmt" + "os" + + "go.uber.org/zap/buffer" + "go.uber.org/zap/zapcore" + "golang.org/x/term" + + "github.com/caddyserver/caddy/v2" + "github.com/caddyserver/caddy/v2/caddyconfig" + "github.com/caddyserver/caddy/v2/caddyconfig/caddyfile" +) + +func init() { + caddy.RegisterModule(JournaldEncoder{}) +} + +// JournaldEncoder wraps another encoder and prepends a systemd/journald +// priority prefix to each emitted log line. This lets journald classify +// stdout/stderr log lines by severity while leaving the underlying log +// structure to the wrapped encoder. +// +// This encoder does not write directly to journald; it only changes the +// encoded output by adding the priority marker that journald understands. +// The wrapped encoder still controls the actual log format, such as JSON +// or console output. +type JournaldEncoder struct { + zapcore.Encoder `json:"-"` + + // The underlying encoder that actually encodes the log entries. + // If not specified, defaults to "json", unless the output is a + // terminal, in which case it defaults to "console". + WrappedRaw json.RawMessage `json:"wrap,omitempty" caddy:"namespace=caddy.logging.encoders inline_key=format"` + + wrappedIsDefault bool + ctx caddy.Context +} + +// CaddyModule returns the Caddy module information. +func (JournaldEncoder) CaddyModule() caddy.ModuleInfo { + return caddy.ModuleInfo{ + ID: "caddy.logging.encoders.journald", + New: func() caddy.Module { return new(JournaldEncoder) }, + } +} + +// Provision sets up the encoder. +func (je *JournaldEncoder) Provision(ctx caddy.Context) error { + je.ctx = ctx + + if je.WrappedRaw == nil { + je.Encoder = &JSONEncoder{} + if p, ok := je.Encoder.(caddy.Provisioner); ok { + if err := p.Provision(ctx); err != nil { + return fmt.Errorf("provisioning fallback encoder module: %v", err) + } + } + je.wrappedIsDefault = true + } else { + val, err := ctx.LoadModule(je, "WrappedRaw") + if err != nil { + return fmt.Errorf("loading wrapped encoder module: %v", err) + } + je.Encoder = val.(zapcore.Encoder) + } + + suppressEncoderTimestamp(je.Encoder) + + return nil +} + +// ConfigureDefaultFormat will set the default wrapped format to "console" +// if the writer is a terminal. If already configured, it passes through +// the writer so a deeply nested encoder can configure its own default format. +func (je *JournaldEncoder) ConfigureDefaultFormat(wo caddy.WriterOpener) error { + if !je.wrappedIsDefault { + if cfd, ok := je.Encoder.(caddy.ConfiguresFormatterDefault); ok { + return cfd.ConfigureDefaultFormat(wo) + } + return nil + } + + if caddy.IsWriterStandardStream(wo) && term.IsTerminal(int(os.Stderr.Fd())) { + je.Encoder = &ConsoleEncoder{} + if p, ok := je.Encoder.(caddy.Provisioner); ok { + if err := p.Provision(je.ctx); err != nil { + return fmt.Errorf("provisioning fallback encoder module: %v", err) + } + } + } + + suppressEncoderTimestamp(je.Encoder) + + return nil +} + +// UnmarshalCaddyfile sets up the module from Caddyfile tokens. Syntax: +// +// journald { +// wrap +// } +// +// Example: +// +// log { +// format journald { +// wrap json +// } +// } +func (je *JournaldEncoder) UnmarshalCaddyfile(d *caddyfile.Dispenser) error { + d.Next() // consume encoder name + if d.NextArg() { + return d.ArgErr() + } + + for d.NextBlock(0) { + if d.Val() != "wrap" { + return d.Errf("unrecognized subdirective %s", d.Val()) + } + if !d.NextArg() { + return d.ArgErr() + } + moduleName := d.Val() + moduleID := "caddy.logging.encoders." + moduleName + unm, err := caddyfile.UnmarshalModule(d, moduleID) + if err != nil { + return err + } + enc, ok := unm.(zapcore.Encoder) + if !ok { + return d.Errf("module %s (%T) is not a zapcore.Encoder", moduleID, unm) + } + je.WrappedRaw = caddyconfig.JSONModuleObject(enc, "format", moduleName, nil) + } + + return nil +} + +// Clone implements zapcore.Encoder. +func (je JournaldEncoder) Clone() zapcore.Encoder { + return JournaldEncoder{ + Encoder: je.Encoder.Clone(), + } +} + +// EncodeEntry implements zapcore.Encoder. +func (je JournaldEncoder) EncodeEntry(ent zapcore.Entry, fields []zapcore.Field) (*buffer.Buffer, error) { + encoded, err := je.Encoder.Clone().EncodeEntry(ent, fields) + if err != nil { + return nil, err + } + + out := bufferpool.Get() + out.AppendString(journaldPriorityPrefix(ent.Level)) + out.AppendBytes(encoded.Bytes()) + encoded.Free() + + return out, nil +} + +func journaldPriorityPrefix(level zapcore.Level) string { + switch level { + case zapcore.InvalidLevel: + return "<6>" + case zapcore.DebugLevel: + return "<7>" + case zapcore.InfoLevel: + return "<6>" + case zapcore.WarnLevel: + return "<4>" + case zapcore.ErrorLevel: + return "<3>" + case zapcore.DPanicLevel, zapcore.PanicLevel, zapcore.FatalLevel: + return "<2>" + default: + return "<6>" + } +} + +func suppressEncoderTimestamp(enc zapcore.Encoder) { + empty := "" + + switch e := enc.(type) { + case *ConsoleEncoder: + e.TimeKey = &empty + _ = e.Provision(caddy.Context{}) + case *JSONEncoder: + e.TimeKey = &empty + _ = e.Provision(caddy.Context{}) + case *AppendEncoder: + suppressEncoderTimestamp(e.wrapped) + case *FilterEncoder: + suppressEncoderTimestamp(e.wrapped) + case *JournaldEncoder: + suppressEncoderTimestamp(e.Encoder) + } +} + +// Interface guards +var ( + _ zapcore.Encoder = (*JournaldEncoder)(nil) + _ caddyfile.Unmarshaler = (*JournaldEncoder)(nil) + _ caddy.ConfiguresFormatterDefault = (*JournaldEncoder)(nil) +) diff --git a/modules/logging/journaldencoder_test.go b/modules/logging/journaldencoder_test.go new file mode 100644 index 000000000..7b9a17d77 --- /dev/null +++ b/modules/logging/journaldencoder_test.go @@ -0,0 +1,155 @@ +package logging + +import ( + "context" + "encoding/json" + "strings" + "testing" + "time" + + "github.com/caddyserver/caddy/v2" + "go.uber.org/zap/buffer" + "go.uber.org/zap/zapcore" + + "github.com/caddyserver/caddy/v2/caddyconfig/caddyfile" +) + +func TestJournaldPriorityPrefix(t *testing.T) { + tests := []struct { + level zapcore.Level + want string + }{ + {level: zapcore.InvalidLevel, want: "<6>"}, + {level: zapcore.DebugLevel, want: "<7>"}, + {level: zapcore.InfoLevel, want: "<6>"}, + {level: zapcore.WarnLevel, want: "<4>"}, + {level: zapcore.ErrorLevel, want: "<3>"}, + {level: zapcore.DPanicLevel, want: "<2>"}, + {level: zapcore.PanicLevel, want: "<2>"}, + {level: zapcore.FatalLevel, want: "<2>"}, + } + + for _, tt := range tests { + t.Run(tt.level.String(), func(t *testing.T) { + if got := journaldPriorityPrefix(tt.level); got != tt.want { + t.Fatalf("got %s, want %s", got, tt.want) + } + }) + } +} + +func TestJournaldEncoderEncodeEntry(t *testing.T) { + tests := []struct { + name string + level zapcore.Level + want string + }{ + {name: "debug", level: zapcore.DebugLevel, want: "<7>wrapped\n"}, + {name: "info", level: zapcore.InfoLevel, want: "<6>wrapped\n"}, + {name: "warn", level: zapcore.WarnLevel, want: "<4>wrapped\n"}, + {name: "error", level: zapcore.ErrorLevel, want: "<3>wrapped\n"}, + {name: "panic", level: zapcore.PanicLevel, want: "<2>wrapped\n"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + enc := JournaldEncoder{Encoder: staticEncoder{output: "wrapped\n"}} + buf, err := enc.EncodeEntry(zapcore.Entry{Level: tt.level}, nil) + if err != nil { + t.Fatalf("EncodeEntry() error = %v", err) + } + defer buf.Free() + + if got := buf.String(); got != tt.want { + t.Fatalf("got %q, want %q", got, tt.want) + } + }) + } +} + +func TestJournaldEncoderUnmarshalCaddyfile(t *testing.T) { + d := caddyfile.NewTestDispenser(` +journald { + wrap console +} +`) + + var enc JournaldEncoder + if err := enc.UnmarshalCaddyfile(d); err != nil { + t.Fatalf("UnmarshalCaddyfile() error = %v", err) + } + + var got map[string]any + if err := json.Unmarshal(enc.WrappedRaw, &got); err != nil { + t.Fatalf("unmarshal wrapped encoder: %v", err) + } + + if got["format"] != "console" { + t.Fatalf("wrapped format = %v, want console", got["format"]) + } +} + +func TestJournaldEncoderSuppressesJSONTimestamp(t *testing.T) { + enc := &JournaldEncoder{ + Encoder: &JSONEncoder{}, + } + if err := enc.Provision(caddy.Context{Context: context.Background()}); err != nil { + t.Fatalf("Provision() error = %v", err) + } + + buf, err := enc.EncodeEntry(zapcore.Entry{ + Level: zapcore.InfoLevel, + Time: fixedEntryTime(), + Message: "hello", + }, nil) + if err != nil { + t.Fatalf("EncodeEntry() error = %v", err) + } + defer buf.Free() + + got := buf.String() + if strings.Contains(got, `"ts"`) { + t.Fatalf("got JSON output with ts field: %q", got) + } +} + +func TestJournaldEncoderSuppressesConsoleTimestamp(t *testing.T) { + enc := &JournaldEncoder{ + Encoder: &ConsoleEncoder{}, + } + if err := enc.Provision(caddy.Context{Context: context.Background()}); err != nil { + t.Fatalf("Provision() error = %v", err) + } + + buf, err := enc.EncodeEntry(zapcore.Entry{ + Level: zapcore.InfoLevel, + Time: fixedEntryTime(), + Message: "hello", + }, nil) + if err != nil { + t.Fatalf("EncodeEntry() error = %v", err) + } + defer buf.Free() + + got := buf.String() + if strings.Contains(got, "2001/02/03") { + t.Fatalf("got console output with timestamp: %q", got) + } +} + +type staticEncoder struct { + nopEncoder + output string +} + +func (se staticEncoder) Clone() zapcore.Encoder { return se } + +func (se staticEncoder) EncodeEntry(zapcore.Entry, []zapcore.Field) (*buffer.Buffer, error) { + buf := bufferpool.Get() + buf.AppendString(se.output) + return buf, nil +} + +func fixedEntryTime() (ts time.Time) { + return time.Date(2001, 2, 3, 4, 5, 6, 0, time.UTC) +}