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, 03:30 UTC
httpcaddyfile: Add log_level directive for access logs (#8201)
* httpcaddyfile: Add log_level directive for access logs * Remove AI assistance file * httpcaddyfile: Sort log_level with broader matchers first * caddytest: Cover log_level Caddyfile adaptation
Francis Lavoie committed
Oct 12, 2026 at 11:54 UTC
d293dcfe86c1282e548b6cab94132be409d7300f
12 files changed
+373
-2
caddyconfig/httpcaddyfile/builtins.go
+23
@@ -53,6 +53,7 @@ func init() {
53
RegisterHandlerDirective("skip_log", parseLogSkip)
54
RegisterHandlerDirective("log_skip", parseLogSkip)
55
RegisterHandlerDirective("log_name", parseLogName)
56
+ RegisterHandlerDirective("log_level", parseLogLevel)
57
}
58
59
// parseBind parses the bind directive. Syntax:
@@ -1253,3 +1254,25 @@ func parseLogName(h Helper) (caddyhttp.MiddlewareHandler, error) {
1254
caddyhttp.AccessLoggerNameVarKey: h.RemainingArgs(),
1255
}, nil
1256
}
1257
+
1258
+// parseLogLevel parses the log_level directive. Syntax:
1259
+//
1260
+// log_level [<matcher>] <level>
1261
+//
1262
+// The level overrides the access log severity for matching requests.
1263
+// Supported levels are DEBUG, INFO, WARN, and ERROR (case-insensitive).
1264
+func parseLogLevel(h Helper) (caddyhttp.MiddlewareHandler, error) {
1265
+ h.Next() // consume directive name
1266
+ args := h.RemainingArgs()
1267
+ if len(args) != 1 {
1268
+ return nil, h.ArgErr()
1269
+ }
1270
+ if h.NextBlock(0) {
1271
+ return nil, h.Err("log_level directive does not accept blocks")
1272
+ }
1273
+ level, err := zapcore.ParseLevel(args[0])
1274
+ if err != nil || level < zapcore.DebugLevel || level > zapcore.ErrorLevel {
1275
+ return nil, h.Errf("invalid log level %q: expected DEBUG, INFO, WARN, or ERROR", args[0])
1276
+ }
1277
+ return caddyhttp.VarsMiddleware{caddyhttp.LogLevelVar: level.String()}, nil
1278
+}
caddyconfig/httpcaddyfile/builtins_test.go
+100
@@ -1,6 +1,7 @@
1
package httpcaddyfile
2
3
import (
4
+ "encoding/json"
5
"strings"
6
"testing"
7
@@ -409,3 +410,102 @@ func TestNestedImport(t *testing.T) {
410
}
411
}
412
}
413
+
414
+func TestLogLevelDirectiveSyntax(t *testing.T) {
415
+ for _, tc := range []struct {
416
+ directive string
417
+ wantError bool
418
+ }{
419
+ {"log_level DEBUG", false}, {"log_level info", false},
420
+ {"log_level WARN", false}, {"log_level ERROR", false},
421
+ {"log_level /warnings WARN", false},
422
+ {"log_level", true}, {"log_level INFO WARN", true},
423
+ {"log_level invalid", true}, {"log_level DPANIC", true},
424
+ {"log_level PANIC", true}, {"log_level FATAL", true},
425
+ {"log_level WARN {\n foo\n}", true},
426
+ } {
427
+ t.Run(tc.directive, func(t *testing.T) {
428
+ adapter := caddyfile.Adapter{ServerType: ServerType{}}
429
+ result, _, err := adapter.Adapt([]byte(":8080 {\n"+tc.directive+"\n}"), nil)
430
+ if tc.wantError {
431
+ if err == nil {
432
+ t.Fatal("expected error")
433
+ }
434
+ return
435
+ }
436
+ if err != nil {
437
+ t.Fatal(err)
438
+ }
439
+ var config struct {
440
+ Apps struct {
441
+ HTTP struct {
442
+ Servers map[string]struct {
443
+ Routes []struct {
444
+ Match []map[string]any
445
+ Handle []struct {
446
+ Handler string
447
+ Level string `json:"log_level"`
448
+ }
449
+ }
450
+ }
451
+ }
452
+ }
453
+ }
454
+ if err := json.Unmarshal(result, &config); err != nil {
455
+ t.Fatal(err)
456
+ }
457
+ routes := config.Apps.HTTP.Servers["srv0"].Routes
458
+ if len(routes) != 1 || len(routes[0].Handle) != 1 {
459
+ t.Fatalf("unexpected routes: %s", result)
460
+ }
461
+ handler := routes[0].Handle[0]
462
+ args := strings.Fields(tc.directive)
463
+ if handler.Handler != "vars" || handler.Level != strings.ToLower(args[len(args)-1]) {
464
+ t.Fatalf("unexpected handler: %s", result)
465
+ }
466
+ if strings.Contains(tc.directive, "/warnings") && len(routes[0].Match) != 1 {
467
+ t.Fatalf("missing matcher: %s", result)
468
+ }
469
+ })
470
+ }
471
+}
472
+
473
+func TestLogLevelDirectiveOrder(t *testing.T) {
474
+ for _, input := range []string{
475
+ "log_level /api/private/* ERROR\nlog_level /api/* DEBUG\nlog_level INFO",
476
+ "log_level INFO\nlog_level /api/* DEBUG\nlog_level /api/private/* ERROR",
477
+ } {
478
+ t.Run(input, func(t *testing.T) {
479
+ adapter := caddyfile.Adapter{ServerType: ServerType{}}
480
+ result, _, err := adapter.Adapt([]byte(":8080 {\n"+input+"\n}"), nil)
481
+ if err != nil {
482
+ t.Fatal(err)
483
+ }
484
+ var config struct {
485
+ Apps struct {
486
+ HTTP struct {
487
+ Servers map[string]struct {
488
+ Routes []struct {
489
+ Handle []struct {
490
+ Level string `json:"log_level"`
491
+ }
492
+ }
493
+ }
494
+ }
495
+ }
496
+ }
497
+ if err := json.Unmarshal(result, &config); err != nil {
498
+ t.Fatal(err)
499
+ }
500
+ var levels []string
501
+ for _, route := range config.Apps.HTTP.Servers["srv0"].Routes {
502
+ for _, handler := range route.Handle {
503
+ levels = append(levels, handler.Level)
504
+ }
505
+ }
506
+ if got := strings.Join(levels, ","); got != "info,debug,error" {
507
+ t.Fatalf("expected broadest log_level first, got %q: %s", got, result)
508
+ }
509
+ })
510
+ }
511
+}
caddyconfig/httpcaddyfile/directives.go
+3
-2
@@ -56,6 +56,7 @@ var defaultDirectiveOrder = []string{
56
"skip_log", // TODO: deprecated, renamed to log_skip
57
"log_skip",
58
"log_name",
59
+ "log_level",
60
61
"header",
62
"copy_response_headers", // only in reverse_proxy's handle_response
@@ -470,7 +471,7 @@ func sortRoutes(routes []ConfigValue, order []string) {
471
// that the least-specific matcher is first, allowing the last
472
// matching one to win; we swap i and j instead of using !,
473
// so matchers that can't be compared keep their order
473
- if iDir == "vars" {
474
+ if iDir == "vars" || iDir == "log_level" {
475
i, j = j, i
476
}
477
@@ -544,7 +545,7 @@ func sortRoutes(routes []ConfigValue, order []string) {
545
return len(iRoute.MatcherSetsRaw) > 0 && len(jRoute.MatcherSetsRaw) == 0
546
}()
547
547
- // most-specific matcher first (or last for vars, see above)
548
+ // most-specific matcher first (or last for vars and log_level, see above)
549
return sortByPath
550
})
551
}
caddytest/integration/caddyfile_adapt/log_level.caddyfiletest
new
+42
@@ -0,0 +1,42 @@
1
+:80
2
+
3
+log_level debug
4
+log_level INFO
5
+log_level warn
6
+log_level ERROR
7
+----------
8
+{
9
+ "apps": {
10
+ "http": {
11
+ "servers": {
12
+ "srv0": {
13
+ "listen": [
14
+ ":80"
15
+ ],
16
+ "routes": [
17
+ {
18
+ "handle": [
19
+ {
20
+ "handler": "vars",
21
+ "log_level": "debug"
22
+ },
23
+ {
24
+ "handler": "vars",
25
+ "log_level": "info"
26
+ },
27
+ {
28
+ "handler": "vars",
29
+ "log_level": "warn"
30
+ },
31
+ {
32
+ "handler": "vars",
33
+ "log_level": "error"
34
+ }
35
+ ]
36
+ }
37
+ ]
38
+ }
39
+ }
40
+ }
41
+ }
42
+}
caddytest/integration/caddyfile_adapt/log_level_block.caddyfiletest
new
+7
@@ -0,0 +1,7 @@
1
+:80
2
+
3
+log_level WARN {
4
+ foo
5
+}
6
+----------
7
+log_level directive does not accept blocks
caddytest/integration/caddyfile_adapt/log_level_fatal.caddyfiletest
new
+5
@@ -0,0 +1,5 @@
1
+:80
2
+
3
+log_level FATAL
4
+----------
5
+invalid log level "FATAL": expected DEBUG, INFO, WARN, or ERROR
caddytest/integration/caddyfile_adapt/log_level_invalid.caddyfiletest
new
+5
@@ -0,0 +1,5 @@
1
+:80
2
+
3
+log_level INVALID
4
+----------
5
+invalid log level "INVALID": expected DEBUG, INFO, WARN, or ERROR
caddytest/integration/caddyfile_adapt/log_level_sort_named_matchers.caddyfiletest
new
+63
@@ -0,0 +1,63 @@
1
+:80
2
+
3
+@first header X-Test a
4
+@second header X-Test a
5
+@third header X-Other b
6
+
7
+log_level @first INFO
8
+log_level @second WARN
9
+log_level @third ERROR
10
+----------
11
+{
12
+ "apps": {
13
+ "http": {
14
+ "servers": {
15
+ "srv0": {
16
+ "listen": [
17
+ ":80"
18
+ ],
19
+ "routes": [
20
+ {
21
+ "match": [
22
+ {
23
+ "header": {
24
+ "X-Test": [
25
+ "a"
26
+ ]
27
+ }
28
+ }
29
+ ],
30
+ "handle": [
31
+ {
32
+ "handler": "vars",
33
+ "log_level": "info"
34
+ },
35
+ {
36
+ "handler": "vars",
37
+ "log_level": "warn"
38
+ }
39
+ ]
40
+ },
41
+ {
42
+ "match": [
43
+ {
44
+ "header": {
45
+ "X-Other": [
46
+ "b"
47
+ ]
48
+ }
49
+ }
50
+ ],
51
+ "handle": [
52
+ {
53
+ "handler": "vars",
54
+ "log_level": "error"
55
+ }
56
+ ]
57
+ }
58
+ ]
59
+ }
60
+ }
61
+ }
62
+ }
63
+}
caddytest/integration/caddyfile_adapt/log_level_sort_paths.caddyfiletest
new
+75
@@ -0,0 +1,75 @@
1
+:80
2
+
3
+log_level /foobar ERROR
4
+log_level /foo WARN
5
+log_level /foo* DEBUG
6
+log_level INFO
7
+----------
8
+{
9
+ "apps": {
10
+ "http": {
11
+ "servers": {
12
+ "srv0": {
13
+ "listen": [
14
+ ":80"
15
+ ],
16
+ "routes": [
17
+ {
18
+ "handle": [
19
+ {
20
+ "handler": "vars",
21
+ "log_level": "info"
22
+ }
23
+ ]
24
+ },
25
+ {
26
+ "match": [
27
+ {
28
+ "path": [
29
+ "/foo*"
30
+ ]
31
+ }
32
+ ],
33
+ "handle": [
34
+ {
35
+ "handler": "vars",
36
+ "log_level": "debug"
37
+ }
38
+ ]
39
+ },
40
+ {
41
+ "match": [
42
+ {
43
+ "path": [
44
+ "/foo"
45
+ ]
46
+ }
47
+ ],
48
+ "handle": [
49
+ {
50
+ "handler": "vars",
51
+ "log_level": "warn"
52
+ }
53
+ ]
54
+ },
55
+ {
56
+ "match": [
57
+ {
58
+ "path": [
59
+ "/foobar"
60
+ ]
61
+ }
62
+ ],
63
+ "handle": [
64
+ {
65
+ "handler": "vars",
66
+ "log_level": "error"
67
+ }
68
+ ]
69
+ }
70
+ ]
71
+ }
72
+ }
73
+ }
74
+ }
75
+}
modules/caddyhttp/logging.go
+4
@@ -312,6 +312,10 @@ const (
312
// should be omitted from the access logs
313
LogSkipVar string = "log_skip"
314
315
+ // LogLevelVar is the request variable used to override access log severity.
316
+ // Supported string values are DEBUG, INFO, WARN, and ERROR (case-insensitive).
317
+ LogLevelVar string = "log_level"
318
+
319
// For adding additional fields to the access logs
320
ExtraLogFieldsCtxKey caddy.CtxKey = "extra_log_fields"
321
modules/caddyhttp/server.go
+5
@@ -1259,6 +1259,11 @@ func (s *Server) logRequest(
1259
if status >= 500 {
1260
logLevel = zapcore.ErrorLevel
1261
}
1262
+ if levelName, ok := GetVar(ctx, LogLevelVar).(string); ok {
1263
+ if level, err := zapcore.ParseLevel(levelName); err == nil && level >= zapcore.DebugLevel && level <= zapcore.ErrorLevel {
1264
+ logLevel = level
1265
+ }
1266
+ }
1267
1268
var fields []zapcore.Field
1269
for _, logger := range loggers {
modules/caddyhttp/server_test.go
+41
@@ -21,6 +21,7 @@ import (
21
"github.com/stretchr/testify/require"
22
"go.uber.org/zap"
23
"go.uber.org/zap/zapcore"
24
+ "go.uber.org/zap/zaptest/observer"
25
)
26
27
type syncBuffer struct {
@@ -1569,3 +1570,43 @@ func TestServer_ServeHTTP_IdleTimeouts(t *testing.T) {
1570
})
1571
}
1572
}
1573
+
1574
+func TestServerLogLevel(t *testing.T) {
1575
+ for _, tc := range []struct {
1576
+ name string
1577
+ level any
1578
+ status int
1579
+ minimum zapcore.Level
1580
+ want zapcore.Level
1581
+ skip bool
1582
+ count int
1583
+ }{
1584
+ {"default info", nil, 200, zapcore.DebugLevel, zapcore.InfoLevel, false, 1},
1585
+ {"default error", nil, 500, zapcore.DebugLevel, zapcore.ErrorLevel, false, 1},
1586
+ {"debug", "debug", 200, zapcore.DebugLevel, zapcore.DebugLevel, false, 1},
1587
+ {"warn", "WARN", 200, zapcore.DebugLevel, zapcore.WarnLevel, false, 1},
1588
+ {"error", "ERROR", 200, zapcore.DebugLevel, zapcore.ErrorLevel, false, 1},
1589
+ {"override error", "INFO", 500, zapcore.DebugLevel, zapcore.InfoLevel, false, 1},
1590
+ {"invalid", "invalid", 500, zapcore.DebugLevel, zapcore.ErrorLevel, false, 1},
1591
+ {"unsafe level", "FATAL", 200, zapcore.DebugLevel, zapcore.InfoLevel, false, 1},
1592
+ {"wrong type", 2, 200, zapcore.DebugLevel, zapcore.InfoLevel, false, 1},
1593
+ {"filtered", "INFO", 200, zapcore.WarnLevel, zapcore.InfoLevel, false, 0},
1594
+ {"promoted", "WARN", 200, zapcore.WarnLevel, zapcore.WarnLevel, false, 1},
1595
+ {"skipped", "ERROR", 200, zapcore.DebugLevel, zapcore.ErrorLevel, true, 0},
1596
+ } {
1597
+ t.Run(tc.name, func(t *testing.T) {
1598
+ core, logs := observer.New(tc.minimum)
1599
+ ctx := context.WithValue(context.Background(), ExtraLogFieldsCtxKey, new(ExtraLogFields))
1600
+ ctx = context.WithValue(ctx, VarsCtxKey, map[string]any{LogLevelVar: tc.level, LogSkipVar: tc.skip})
1601
+ req := httptest.NewRequest(http.MethodGet, "/", nil).WithContext(ctx)
1602
+ rec := NewResponseRecorder(httptest.NewRecorder(), nil, nil)
1603
+ rec.WriteHeader(tc.status)
1604
+ duration := time.Millisecond
1605
+ (&Server{}).logRequest(zap.New(core), req, rec, &duration, nil, NewTestReplacer(req), nil, false)
1606
+ require.Equal(t, tc.count, logs.Len())
1607
+ if tc.count > 0 {
1608
+ assert.Equal(t, tc.want, logs.All()[0].Level)
1609
+ }
1610
+ })
1611
+ }
1612
+}