@marcel91200 / caddy Mirror · read-only / commits / 5f44ea07
Fork 0

Fast and extensible multi-platform HTTP/1-2-3 web server with automatic HTTPS

Mirrored from https://github.com/Slazee/caddy.git

Synced Nov 11, 12:15 UTC

logging: add journald encoder wrapper (#7623)

Zen Dodd committed Apr 11, 2026 at 09:09 UTC 5f44ea0748b9612bc09d274f5553c3cc9e624296
3 files changed +423
caddytest/integration/caddyfile_adapt/log_journald_encoder.caddyfiletest new
+47
@@ -0,0 +1,47 @@
1 +{
2 + log {
3 + format journald {
4 + wrap console
5 + }
6 + }
7 +}
8 +
9 +:80 {
10 + respond "Hello, World!"
11 +}
12 +----------
13 +{
14 + "logging": {
15 + "logs": {
16 + "default": {
17 + "encoder": {
18 + "format": "journald",
19 + "wrap": {
20 + "format": "console"
21 + }
22 + }
23 + }
24 + }
25 + },
26 + "apps": {
27 + "http": {
28 + "servers": {
29 + "srv0": {
30 + "listen": [
31 + ":80"
32 + ],
33 + "routes": [
34 + {
35 + "handle": [
36 + {
37 + "body": "Hello, World!",
38 + "handler": "static_response"
39 + }
40 + ]
41 + }
42 + ]
43 + }
44 + }
45 + }
46 + }
47 +}
modules/logging/journaldencoder.go new
+221
@@ -0,0 +1,221 @@
1 +// Copyright 2015 Matthew Holt and The Caddy Authors
2 +//
3 +// Licensed under the Apache License, Version 2.0 (the "License");
4 +// you may not use this file except in compliance with the License.
5 +// You may obtain a copy of the License at
6 +//
7 +// http://www.apache.org/licenses/LICENSE-2.0
8 +//
9 +// Unless required by applicable law or agreed to in writing, software
10 +// distributed under the License is distributed on an "AS IS" BASIS,
11 +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12 +// See the License for the specific language governing permissions and
13 +// limitations under the License.
14 +
15 +package logging
16 +
17 +import (
18 + "encoding/json"
19 + "fmt"
20 + "os"
21 +
22 + "go.uber.org/zap/buffer"
23 + "go.uber.org/zap/zapcore"
24 + "golang.org/x/term"
25 +
26 + "github.com/caddyserver/caddy/v2"
27 + "github.com/caddyserver/caddy/v2/caddyconfig"
28 + "github.com/caddyserver/caddy/v2/caddyconfig/caddyfile"
29 +)
30 +
31 +func init() {
32 + caddy.RegisterModule(JournaldEncoder{})
33 +}
34 +
35 +// JournaldEncoder wraps another encoder and prepends a systemd/journald
36 +// priority prefix to each emitted log line. This lets journald classify
37 +// stdout/stderr log lines by severity while leaving the underlying log
38 +// structure to the wrapped encoder.
39 +//
40 +// This encoder does not write directly to journald; it only changes the
41 +// encoded output by adding the priority marker that journald understands.
42 +// The wrapped encoder still controls the actual log format, such as JSON
43 +// or console output.
44 +type JournaldEncoder struct {
45 + zapcore.Encoder `json:"-"`
46 +
47 + // The underlying encoder that actually encodes the log entries.
48 + // If not specified, defaults to "json", unless the output is a
49 + // terminal, in which case it defaults to "console".
50 + WrappedRaw json.RawMessage `json:"wrap,omitempty" caddy:"namespace=caddy.logging.encoders inline_key=format"`
51 +
52 + wrappedIsDefault bool
53 + ctx caddy.Context
54 +}
55 +
56 +// CaddyModule returns the Caddy module information.
57 +func (JournaldEncoder) CaddyModule() caddy.ModuleInfo {
58 + return caddy.ModuleInfo{
59 + ID: "caddy.logging.encoders.journald",
60 + New: func() caddy.Module { return new(JournaldEncoder) },
61 + }
62 +}
63 +
64 +// Provision sets up the encoder.
65 +func (je *JournaldEncoder) Provision(ctx caddy.Context) error {
66 + je.ctx = ctx
67 +
68 + if je.WrappedRaw == nil {
69 + je.Encoder = &JSONEncoder{}
70 + if p, ok := je.Encoder.(caddy.Provisioner); ok {
71 + if err := p.Provision(ctx); err != nil {
72 + return fmt.Errorf("provisioning fallback encoder module: %v", err)
73 + }
74 + }
75 + je.wrappedIsDefault = true
76 + } else {
77 + val, err := ctx.LoadModule(je, "WrappedRaw")
78 + if err != nil {
79 + return fmt.Errorf("loading wrapped encoder module: %v", err)
80 + }
81 + je.Encoder = val.(zapcore.Encoder)
82 + }
83 +
84 + suppressEncoderTimestamp(je.Encoder)
85 +
86 + return nil
87 +}
88 +
89 +// ConfigureDefaultFormat will set the default wrapped format to "console"
90 +// if the writer is a terminal. If already configured, it passes through
91 +// the writer so a deeply nested encoder can configure its own default format.
92 +func (je *JournaldEncoder) ConfigureDefaultFormat(wo caddy.WriterOpener) error {
93 + if !je.wrappedIsDefault {
94 + if cfd, ok := je.Encoder.(caddy.ConfiguresFormatterDefault); ok {
95 + return cfd.ConfigureDefaultFormat(wo)
96 + }
97 + return nil
98 + }
99 +
100 + if caddy.IsWriterStandardStream(wo) && term.IsTerminal(int(os.Stderr.Fd())) {
101 + je.Encoder = &ConsoleEncoder{}
102 + if p, ok := je.Encoder.(caddy.Provisioner); ok {
103 + if err := p.Provision(je.ctx); err != nil {
104 + return fmt.Errorf("provisioning fallback encoder module: %v", err)
105 + }
106 + }
107 + }
108 +
109 + suppressEncoderTimestamp(je.Encoder)
110 +
111 + return nil
112 +}
113 +
114 +// UnmarshalCaddyfile sets up the module from Caddyfile tokens. Syntax:
115 +//
116 +// journald {
117 +// wrap <another encoder>
118 +// }
119 +//
120 +// Example:
121 +//
122 +// log {
123 +// format journald {
124 +// wrap json
125 +// }
126 +// }
127 +func (je *JournaldEncoder) UnmarshalCaddyfile(d *caddyfile.Dispenser) error {
128 + d.Next() // consume encoder name
129 + if d.NextArg() {
130 + return d.ArgErr()
131 + }
132 +
133 + for d.NextBlock(0) {
134 + if d.Val() != "wrap" {
135 + return d.Errf("unrecognized subdirective %s", d.Val())
136 + }
137 + if !d.NextArg() {
138 + return d.ArgErr()
139 + }
140 + moduleName := d.Val()
141 + moduleID := "caddy.logging.encoders." + moduleName
142 + unm, err := caddyfile.UnmarshalModule(d, moduleID)
143 + if err != nil {
144 + return err
145 + }
146 + enc, ok := unm.(zapcore.Encoder)
147 + if !ok {
148 + return d.Errf("module %s (%T) is not a zapcore.Encoder", moduleID, unm)
149 + }
150 + je.WrappedRaw = caddyconfig.JSONModuleObject(enc, "format", moduleName, nil)
151 + }
152 +
153 + return nil
154 +}
155 +
156 +// Clone implements zapcore.Encoder.
157 +func (je JournaldEncoder) Clone() zapcore.Encoder {
158 + return JournaldEncoder{
159 + Encoder: je.Encoder.Clone(),
160 + }
161 +}
162 +
163 +// EncodeEntry implements zapcore.Encoder.
164 +func (je JournaldEncoder) EncodeEntry(ent zapcore.Entry, fields []zapcore.Field) (*buffer.Buffer, error) {
165 + encoded, err := je.Encoder.Clone().EncodeEntry(ent, fields)
166 + if err != nil {
167 + return nil, err
168 + }
169 +
170 + out := bufferpool.Get()
171 + out.AppendString(journaldPriorityPrefix(ent.Level))
172 + out.AppendBytes(encoded.Bytes())
173 + encoded.Free()
174 +
175 + return out, nil
176 +}
177 +
178 +func journaldPriorityPrefix(level zapcore.Level) string {
179 + switch level {
180 + case zapcore.InvalidLevel:
181 + return "<6>"
182 + case zapcore.DebugLevel:
183 + return "<7>"
184 + case zapcore.InfoLevel:
185 + return "<6>"
186 + case zapcore.WarnLevel:
187 + return "<4>"
188 + case zapcore.ErrorLevel:
189 + return "<3>"
190 + case zapcore.DPanicLevel, zapcore.PanicLevel, zapcore.FatalLevel:
191 + return "<2>"
192 + default:
193 + return "<6>"
194 + }
195 +}
196 +
197 +func suppressEncoderTimestamp(enc zapcore.Encoder) {
198 + empty := ""
199 +
200 + switch e := enc.(type) {
201 + case *ConsoleEncoder:
202 + e.TimeKey = &empty
203 + _ = e.Provision(caddy.Context{})
204 + case *JSONEncoder:
205 + e.TimeKey = &empty
206 + _ = e.Provision(caddy.Context{})
207 + case *AppendEncoder:
208 + suppressEncoderTimestamp(e.wrapped)
209 + case *FilterEncoder:
210 + suppressEncoderTimestamp(e.wrapped)
211 + case *JournaldEncoder:
212 + suppressEncoderTimestamp(e.Encoder)
213 + }
214 +}
215 +
216 +// Interface guards
217 +var (
218 + _ zapcore.Encoder = (*JournaldEncoder)(nil)
219 + _ caddyfile.Unmarshaler = (*JournaldEncoder)(nil)
220 + _ caddy.ConfiguresFormatterDefault = (*JournaldEncoder)(nil)
221 +)
modules/logging/journaldencoder_test.go new
+155
@@ -0,0 +1,155 @@
1 +package logging
2 +
3 +import (
4 + "context"
5 + "encoding/json"
6 + "strings"
7 + "testing"
8 + "time"
9 +
10 + "github.com/caddyserver/caddy/v2"
11 + "go.uber.org/zap/buffer"
12 + "go.uber.org/zap/zapcore"
13 +
14 + "github.com/caddyserver/caddy/v2/caddyconfig/caddyfile"
15 +)
16 +
17 +func TestJournaldPriorityPrefix(t *testing.T) {
18 + tests := []struct {
19 + level zapcore.Level
20 + want string
21 + }{
22 + {level: zapcore.InvalidLevel, want: "<6>"},
23 + {level: zapcore.DebugLevel, want: "<7>"},
24 + {level: zapcore.InfoLevel, want: "<6>"},
25 + {level: zapcore.WarnLevel, want: "<4>"},
26 + {level: zapcore.ErrorLevel, want: "<3>"},
27 + {level: zapcore.DPanicLevel, want: "<2>"},
28 + {level: zapcore.PanicLevel, want: "<2>"},
29 + {level: zapcore.FatalLevel, want: "<2>"},
30 + }
31 +
32 + for _, tt := range tests {
33 + t.Run(tt.level.String(), func(t *testing.T) {
34 + if got := journaldPriorityPrefix(tt.level); got != tt.want {
35 + t.Fatalf("got %s, want %s", got, tt.want)
36 + }
37 + })
38 + }
39 +}
40 +
41 +func TestJournaldEncoderEncodeEntry(t *testing.T) {
42 + tests := []struct {
43 + name string
44 + level zapcore.Level
45 + want string
46 + }{
47 + {name: "debug", level: zapcore.DebugLevel, want: "<7>wrapped\n"},
48 + {name: "info", level: zapcore.InfoLevel, want: "<6>wrapped\n"},
49 + {name: "warn", level: zapcore.WarnLevel, want: "<4>wrapped\n"},
50 + {name: "error", level: zapcore.ErrorLevel, want: "<3>wrapped\n"},
51 + {name: "panic", level: zapcore.PanicLevel, want: "<2>wrapped\n"},
52 + }
53 +
54 + for _, tt := range tests {
55 + t.Run(tt.name, func(t *testing.T) {
56 + enc := JournaldEncoder{Encoder: staticEncoder{output: "wrapped\n"}}
57 + buf, err := enc.EncodeEntry(zapcore.Entry{Level: tt.level}, nil)
58 + if err != nil {
59 + t.Fatalf("EncodeEntry() error = %v", err)
60 + }
61 + defer buf.Free()
62 +
63 + if got := buf.String(); got != tt.want {
64 + t.Fatalf("got %q, want %q", got, tt.want)
65 + }
66 + })
67 + }
68 +}
69 +
70 +func TestJournaldEncoderUnmarshalCaddyfile(t *testing.T) {
71 + d := caddyfile.NewTestDispenser(`
72 +journald {
73 + wrap console
74 +}
75 +`)
76 +
77 + var enc JournaldEncoder
78 + if err := enc.UnmarshalCaddyfile(d); err != nil {
79 + t.Fatalf("UnmarshalCaddyfile() error = %v", err)
80 + }
81 +
82 + var got map[string]any
83 + if err := json.Unmarshal(enc.WrappedRaw, &got); err != nil {
84 + t.Fatalf("unmarshal wrapped encoder: %v", err)
85 + }
86 +
87 + if got["format"] != "console" {
88 + t.Fatalf("wrapped format = %v, want console", got["format"])
89 + }
90 +}
91 +
92 +func TestJournaldEncoderSuppressesJSONTimestamp(t *testing.T) {
93 + enc := &JournaldEncoder{
94 + Encoder: &JSONEncoder{},
95 + }
96 + if err := enc.Provision(caddy.Context{Context: context.Background()}); err != nil {
97 + t.Fatalf("Provision() error = %v", err)
98 + }
99 +
100 + buf, err := enc.EncodeEntry(zapcore.Entry{
101 + Level: zapcore.InfoLevel,
102 + Time: fixedEntryTime(),
103 + Message: "hello",
104 + }, nil)
105 + if err != nil {
106 + t.Fatalf("EncodeEntry() error = %v", err)
107 + }
108 + defer buf.Free()
109 +
110 + got := buf.String()
111 + if strings.Contains(got, `"ts"`) {
112 + t.Fatalf("got JSON output with ts field: %q", got)
113 + }
114 +}
115 +
116 +func TestJournaldEncoderSuppressesConsoleTimestamp(t *testing.T) {
117 + enc := &JournaldEncoder{
118 + Encoder: &ConsoleEncoder{},
119 + }
120 + if err := enc.Provision(caddy.Context{Context: context.Background()}); err != nil {
121 + t.Fatalf("Provision() error = %v", err)
122 + }
123 +
124 + buf, err := enc.EncodeEntry(zapcore.Entry{
125 + Level: zapcore.InfoLevel,
126 + Time: fixedEntryTime(),
127 + Message: "hello",
128 + }, nil)
129 + if err != nil {
130 + t.Fatalf("EncodeEntry() error = %v", err)
131 + }
132 + defer buf.Free()
133 +
134 + got := buf.String()
135 + if strings.Contains(got, "2001/02/03") {
136 + t.Fatalf("got console output with timestamp: %q", got)
137 + }
138 +}
139 +
140 +type staticEncoder struct {
141 + nopEncoder
142 + output string
143 +}
144 +
145 +func (se staticEncoder) Clone() zapcore.Encoder { return se }
146 +
147 +func (se staticEncoder) EncodeEntry(zapcore.Entry, []zapcore.Field) (*buffer.Buffer, error) {
148 + buf := bufferpool.Get()
149 + buf.AppendString(se.output)
150 + return buf, nil
151 +}
152 +
153 +func fixedEntryTime() (ts time.Time) {
154 + return time.Date(2001, 2, 3, 4, 5, 6, 0, time.UTC)
155 +}