@marcel91200 / caddy Mirror · read-only / commits / d293dcfe
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, 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 +}