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
+}