detached 24781f9

feat: add access log output adapter with JSON/CLF/Combined formats

commit
24781f905366680a25b4848b3d405d0d7ad38dc6
author
brice · · 2026-04-03 15:11 UTC
parent
487e687
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
8 files · +891 -0
M go.mod +1 -0
@@ -42,6 +42,7 @@ require (
4242 github.com/clipperhouse/uax29/v2 v2.5.0 // indirect
4343 github.com/containerd/log v0.1.0 // indirect
4444 github.com/containerd/stargz-snapshotter/estargz v0.18.2 // indirect
45+ github.com/coreos/go-systemd/v22 v22.7.0 // indirect
4546 github.com/docker/cli v29.3.0+incompatible // indirect
4647 github.com/docker/docker-credential-helpers v0.9.3 // indirect
4748 github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
@@ -42,6 +42,7 @@ require (
42 github.com/clipperhouse/uax29/v2 v2.5.0 // indirect42 github.com/clipperhouse/uax29/v2 v2.5.0 // indirect
43 github.com/containerd/log v0.1.0 // indirect43 github.com/containerd/log v0.1.0 // indirect
44 github.com/containerd/stargz-snapshotter/estargz v0.18.2 // indirect44 github.com/containerd/stargz-snapshotter/estargz v0.18.2 // indirect
45+ github.com/coreos/go-systemd/v22 v22.7.0 // indirect
45 github.com/docker/cli v29.3.0+incompatible // indirect46 github.com/docker/cli v29.3.0+incompatible // indirect
46 github.com/docker/docker-credential-helpers v0.9.3 // indirect47 github.com/docker/docker-credential-helpers v0.9.3 // indirect
47 github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect48 github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
M go.sum +2 -0 generated file: collapsed
A internal/adapters/out/accesslog/formatter.go +119 -0
@@ -0,0 +1,119 @@
1+package accesslog
2+
3+import (
4+ "encoding/json"
5+ "fmt"
6+ "strings"
7+)
8+
9+// jsonEntry is the stable JSON schema for access log entries.
10+// Field order is controlled by the struct definition.
11+type jsonEntry struct {
12+ Time string `json:"time"`
13+ ClientIP string `json:"client_ip"`
14+ Method string `json:"method"`
15+ Host string `json:"host"`
16+ Path string `json:"path"`
17+ Query string `json:"query"`
18+ Status int `json:"status"`
19+ BytesSent int `json:"bytes_sent"`
20+ DurationMS float64 `json:"duration_ms"`
21+ UserAgent string `json:"user_agent"`
22+ Referer string `json:"referer"`
23+ RequestID string `json:"request_id"`
24+ Proto string `json:"proto"`
25+}
26+
27+// clfTimestamp is the standard Common Log Format timestamp layout.
28+const clfTimestamp = "02/Jan/2006:15:04:05 -0700"
29+
30+// jsonTimestamp is the access log JSON timestamp layout: RFC3339 with
31+// millisecond precision in UTC.
32+const jsonTimestamp = "2006-01-02T15:04:05.000Z07:00"
33+
34+// formatJSON serializes entry as a single-line JSON object.
35+// Timestamps are RFC3339 with millisecond precision in UTC.
36+// time.Time's default JSON marshaling is NOT used to ensure stable precision.
37+func formatJSON(e Entry) (string, error) {
38+ je := jsonEntry{
39+ Time: e.Time.UTC().Format(jsonTimestamp),
40+ ClientIP: e.ClientIP,
41+ Method: e.Method,
42+ Host: e.Host,
43+ Path: e.Path,
44+ Query: e.Query,
45+ Status: e.Status,
46+ BytesSent: e.BytesSent,
47+ DurationMS: e.DurationMS,
48+ UserAgent: e.UserAgent,
49+ Referer: e.Referer,
50+ RequestID: e.RequestID,
51+ Proto: e.Proto,
52+ }
53+ b, err := json.Marshal(je)
54+ if err != nil {
55+ return "", fmt.Errorf("formatJSON: %w", err)
56+ }
57+ return string(b), nil
58+}
59+
60+// formatCLF serializes entry in Common Log Format (NCSA).
61+//
62+// <ip> - - [timestamp] "METHOD target PROTO" status bytes
63+func formatCLF(e Entry) (string, error) {
64+ ts := e.Time.UTC().Format(clfTimestamp)
65+ target := buildRequestTarget(e.Path, e.Query)
66+ return fmt.Sprintf(`%s - - [%s] "%s %s %s" %d %d`,
67+ e.ClientIP,
68+ ts,
69+ escapeQuoted(e.Method),
70+ escapeQuoted(target),
71+ escapeQuoted(e.Proto),
72+ e.Status,
73+ e.BytesSent,
74+ ), nil
75+}
76+
77+// formatCombined serializes entry in Combined Log Format (CLF + referer + UA).
78+//
79+// <ip> - - [timestamp] "METHOD target PROTO" status bytes "referer" "ua"
80+func formatCombined(e Entry) (string, error) {
81+ ts := e.Time.UTC().Format(clfTimestamp)
82+ target := buildRequestTarget(e.Path, e.Query)
83+ referer := e.Referer
84+ if referer == "" {
85+ referer = "-"
86+ }
87+ ua := e.UserAgent
88+ if ua == "" {
89+ ua = "-"
90+ }
91+ return fmt.Sprintf(`%s - - [%s] "%s %s %s" %d %d "%s" "%s"`,
92+ e.ClientIP,
93+ ts,
94+ escapeQuoted(e.Method),
95+ escapeQuoted(target),
96+ escapeQuoted(e.Proto),
97+ e.Status,
98+ e.BytesSent,
99+ escapeQuoted(referer),
100+ escapeQuoted(ua),
101+ ), nil
102+}
103+
104+// buildRequestTarget builds the request target portion of a CLF/Combined line.
105+// When query is empty only the path is used; otherwise "path?query".
106+func buildRequestTarget(path, query string) string {
107+ if query == "" {
108+ return path
109+ }
110+ return path + "?" + query
111+}
112+
113+// escapeQuoted escapes backslashes and double-quotes inside a value that will
114+// be enclosed in double-quotes in the log line.
115+func escapeQuoted(s string) string {
116+ s = strings.ReplaceAll(s, `\`, `\\`)
117+ s = strings.ReplaceAll(s, `"`, `\"`)
118+ return s
119+}
@@ -0,0 +1,119 @@
1+package accesslog
2+
3+import (
4+ "encoding/json"
5+ "fmt"
6+ "strings"
7+)
8+
9+// jsonEntry is the stable JSON schema for access log entries.
10+// Field order is controlled by the struct definition.
11+type jsonEntry struct {
12+ Time string `json:"time"`
13+ ClientIP string `json:"client_ip"`
14+ Method string `json:"method"`
15+ Host string `json:"host"`
16+ Path string `json:"path"`
17+ Query string `json:"query"`
18+ Status int `json:"status"`
19+ BytesSent int `json:"bytes_sent"`
20+ DurationMS float64 `json:"duration_ms"`
21+ UserAgent string `json:"user_agent"`
22+ Referer string `json:"referer"`
23+ RequestID string `json:"request_id"`
24+ Proto string `json:"proto"`
25+}
26+
27+// clfTimestamp is the standard Common Log Format timestamp layout.
28+const clfTimestamp = "02/Jan/2006:15:04:05 -0700"
29+
30+// jsonTimestamp is the access log JSON timestamp layout: RFC3339 with
31+// millisecond precision in UTC.
32+const jsonTimestamp = "2006-01-02T15:04:05.000Z07:00"
33+
34+// formatJSON serializes entry as a single-line JSON object.
35+// Timestamps are RFC3339 with millisecond precision in UTC.
36+// time.Time's default JSON marshaling is NOT used to ensure stable precision.
37+func formatJSON(e Entry) (string, error) {
38+ je := jsonEntry{
39+ Time: e.Time.UTC().Format(jsonTimestamp),
40+ ClientIP: e.ClientIP,
41+ Method: e.Method,
42+ Host: e.Host,
43+ Path: e.Path,
44+ Query: e.Query,
45+ Status: e.Status,
46+ BytesSent: e.BytesSent,
47+ DurationMS: e.DurationMS,
48+ UserAgent: e.UserAgent,
49+ Referer: e.Referer,
50+ RequestID: e.RequestID,
51+ Proto: e.Proto,
52+ }
53+ b, err := json.Marshal(je)
54+ if err != nil {
55+ return "", fmt.Errorf("formatJSON: %w", err)
56+ }
57+ return string(b), nil
58+}
59+
60+// formatCLF serializes entry in Common Log Format (NCSA).
61+//
62+// <ip> - - [timestamp] "METHOD target PROTO" status bytes
63+func formatCLF(e Entry) (string, error) {
64+ ts := e.Time.UTC().Format(clfTimestamp)
65+ target := buildRequestTarget(e.Path, e.Query)
66+ return fmt.Sprintf(`%s - - [%s] "%s %s %s" %d %d`,
67+ e.ClientIP,
68+ ts,
69+ escapeQuoted(e.Method),
70+ escapeQuoted(target),
71+ escapeQuoted(e.Proto),
72+ e.Status,
73+ e.BytesSent,
74+ ), nil
75+}
76+
77+// formatCombined serializes entry in Combined Log Format (CLF + referer + UA).
78+//
79+// <ip> - - [timestamp] "METHOD target PROTO" status bytes "referer" "ua"
80+func formatCombined(e Entry) (string, error) {
81+ ts := e.Time.UTC().Format(clfTimestamp)
82+ target := buildRequestTarget(e.Path, e.Query)
83+ referer := e.Referer
84+ if referer == "" {
85+ referer = "-"
86+ }
87+ ua := e.UserAgent
88+ if ua == "" {
89+ ua = "-"
90+ }
91+ return fmt.Sprintf(`%s - - [%s] "%s %s %s" %d %d "%s" "%s"`,
92+ e.ClientIP,
93+ ts,
94+ escapeQuoted(e.Method),
95+ escapeQuoted(target),
96+ escapeQuoted(e.Proto),
97+ e.Status,
98+ e.BytesSent,
99+ escapeQuoted(referer),
100+ escapeQuoted(ua),
101+ ), nil
102+}
103+
104+// buildRequestTarget builds the request target portion of a CLF/Combined line.
105+// When query is empty only the path is used; otherwise "path?query".
106+func buildRequestTarget(path, query string) string {
107+ if query == "" {
108+ return path
109+ }
110+ return path + "?" + query
111+}
112+
113+// escapeQuoted escapes backslashes and double-quotes inside a value that will
114+// be enclosed in double-quotes in the log line.
115+func escapeQuoted(s string) string {
116+ s = strings.ReplaceAll(s, `\`, `\\`)
117+ s = strings.ReplaceAll(s, `"`, `\"`)
118+ return s
119+}
A internal/adapters/out/accesslog/formatter_test.go +193 -0
@@ -0,0 +1,193 @@
1+package accesslog
2+
3+import (
4+ "encoding/json"
5+ "strings"
6+ "testing"
7+ "time"
8+
9+ "github.com/stretchr/testify/assert"
10+ "github.com/stretchr/testify/require"
11+)
12+
13+var testTime = time.Date(2026, 4, 3, 14, 20, 54, 167_000_000, time.UTC)
14+
15+var baseEntry = Entry{
16+ Time: testTime,
17+ ClientIP: "135.181.213.219",
18+ Method: "GET",
19+ Host: "traefik.example.dev",
20+ Path: "/robots.txt",
21+ Query: "",
22+ Status: 404,
23+ BytesSent: 19,
24+ DurationMS: 0.167,
25+ UserAgent: "MJ12bot/v1.4.8",
26+ Referer: "",
27+ RequestID: "95126efcb65d4df81f7ae633e2a712cd",
28+ Proto: "HTTP/1.1",
29+}
30+
31+// --- JSON formatter ---
32+
33+func TestFormatJSON_AllFields(t *testing.T) {
34+ line, err := formatJSON(baseEntry)
35+ require.NoError(t, err)
36+
37+ var got map[string]any
38+ require.NoError(t, json.Unmarshal([]byte(line), &got))
39+
40+ assert.Equal(t, "2026-04-03T14:20:54.167Z", got["time"])
41+ assert.Equal(t, "135.181.213.219", got["client_ip"])
42+ assert.Equal(t, "GET", got["method"])
43+ assert.Equal(t, "traefik.example.dev", got["host"])
44+ assert.Equal(t, "/robots.txt", got["path"])
45+ assert.Equal(t, "", got["query"])
46+ assert.InDelta(t, 404, got["status"], 0)
47+ assert.InDelta(t, 19, got["bytes_sent"], 0)
48+ assert.InDelta(t, 0.167, got["duration_ms"], 0.0001)
49+ assert.Equal(t, "MJ12bot/v1.4.8", got["user_agent"])
50+ assert.Equal(t, "", got["referer"])
51+ assert.Equal(t, "95126efcb65d4df81f7ae633e2a712cd", got["request_id"])
52+ assert.Equal(t, "HTTP/1.1", got["proto"])
53+}
54+
55+func TestFormatJSON_OneLinePerEntry(t *testing.T) {
56+ line, err := formatJSON(baseEntry)
57+ require.NoError(t, err)
58+ assert.False(t, strings.Contains(line, "\n"), "JSON line must not contain newline")
59+}
60+
61+func TestFormatJSON_TimestampMillisecondPrecision(t *testing.T) {
62+ // Ensure we get .167Z and not RFC3339Nano (which would be .167000000Z)
63+ line, err := formatJSON(baseEntry)
64+ require.NoError(t, err)
65+ assert.Contains(t, line, `"2026-04-03T14:20:54.167Z"`)
66+}
67+
68+func TestFormatJSON_ClientIPNoPort(t *testing.T) {
69+ e := baseEntry
70+ e.ClientIP = "10.0.0.1" // ensure no port
71+ line, err := formatJSON(e)
72+ require.NoError(t, err)
73+ assert.Contains(t, line, `"client_ip":"10.0.0.1"`)
74+}
75+
76+func TestFormatJSON_EmptyOptionalFields(t *testing.T) {
77+ e := baseEntry
78+ e.Query = ""
79+ e.Referer = ""
80+ e.UserAgent = ""
81+ line, err := formatJSON(e)
82+ require.NoError(t, err)
83+
84+ var got map[string]any
85+ require.NoError(t, json.Unmarshal([]byte(line), &got))
86+ assert.Equal(t, "", got["query"])
87+ assert.Equal(t, "", got["referer"])
88+ assert.Equal(t, "", got["user_agent"])
89+}
90+
91+func TestFormatJSON_WithQuery(t *testing.T) {
92+ e := baseEntry
93+ e.Query = "foo=bar&baz=qux"
94+ line, err := formatJSON(e)
95+ require.NoError(t, err)
96+ assert.Contains(t, line, `"query":"foo=bar\u0026baz=qux"`)
97+}
98+
99+// --- CLF formatter ---
100+
101+func TestFormatCLF_BasicLine(t *testing.T) {
102+ line, err := formatCLF(baseEntry)
103+ require.NoError(t, err)
104+ assert.Equal(t, `135.181.213.219 - - [03/Apr/2026:14:20:54 +0000] "GET /robots.txt HTTP/1.1" 404 19`, line)
105+}
106+
107+func TestFormatCLF_WithQuery(t *testing.T) {
108+ e := baseEntry
109+ e.Query = "page=2"
110+ line, err := formatCLF(e)
111+ require.NoError(t, err)
112+ assert.Contains(t, line, `"GET /robots.txt?page=2 HTTP/1.1"`)
113+}
114+
115+func TestFormatCLF_NoRefererOrUA(t *testing.T) {
116+ // CLF format should not include referer or UA
117+ line, err := formatCLF(baseEntry)
118+ require.NoError(t, err)
119+ assert.NotContains(t, line, "MJ12bot")
120+}
121+
122+func TestFormatCLF_EscapesQuotesInTarget(t *testing.T) {
123+ e := baseEntry
124+ e.Path = `/path/"quoted"`
125+ line, err := formatCLF(e)
126+ require.NoError(t, err)
127+ // Forward slashes do not need escaping; only backslashes and double-quotes do.
128+ assert.Contains(t, line, `/path/\"quoted\"`)
129+}
130+
131+// --- Combined formatter ---
132+
133+func TestFormatCombined_BasicLine(t *testing.T) {
134+ line, err := formatCombined(baseEntry)
135+ require.NoError(t, err)
136+ assert.Equal(t,
137+ `135.181.213.219 - - [03/Apr/2026:14:20:54 +0000] "GET /robots.txt HTTP/1.1" 404 19 "-" "MJ12bot/v1.4.8"`,
138+ line,
139+ )
140+}
141+
142+func TestFormatCombined_EmptyRefererIsDash(t *testing.T) {
143+ e := baseEntry
144+ e.Referer = ""
145+ line, err := formatCombined(e)
146+ require.NoError(t, err)
147+ assert.Contains(t, line, `"-"`)
148+}
149+
150+func TestFormatCombined_EmptyUAIsDash(t *testing.T) {
151+ e := baseEntry
152+ e.UserAgent = ""
153+ line, err := formatCombined(e)
154+ require.NoError(t, err)
155+ // last quoted field should be "-"
156+ assert.True(t, strings.HasSuffix(line, `"-"`))
157+}
158+
159+func TestFormatCombined_WithReferer(t *testing.T) {
160+ e := baseEntry
161+ e.Referer = "https://example.com/"
162+ line, err := formatCombined(e)
163+ require.NoError(t, err)
164+ assert.Contains(t, line, `"https://example.com/"`)
165+}
166+
167+func TestFormatCombined_EscapesQuotesInUA(t *testing.T) {
168+ e := baseEntry
169+ e.UserAgent = `bot/"evil"`
170+ line, err := formatCombined(e)
171+ require.NoError(t, err)
172+ // Forward slashes don't need escaping; only backslashes and double-quotes do.
173+ assert.Contains(t, line, `"bot/\"evil\""`)
174+}
175+
176+// --- buildRequestTarget ---
177+
178+func TestBuildRequestTarget_NoQuery(t *testing.T) {
179+ assert.Equal(t, "/foo", buildRequestTarget("/foo", ""))
180+}
181+
182+func TestBuildRequestTarget_WithQuery(t *testing.T) {
183+ assert.Equal(t, "/foo?bar=1", buildRequestTarget("/foo", "bar=1"))
184+}
185+
186+// --- escapeQuoted ---
187+
188+func TestEscapeQuoted(t *testing.T) {
189+ assert.Equal(t, `hello`, escapeQuoted(`hello`))
190+ assert.Equal(t, `say \"hi\"`, escapeQuoted(`say "hi"`))
191+ assert.Equal(t, `back\\slash`, escapeQuoted(`back\slash`))
192+ assert.Equal(t, `\"\\\"`, escapeQuoted(`"\"`))
193+}
@@ -0,0 +1,193 @@
1+package accesslog
2+
3+import (
4+ "encoding/json"
5+ "strings"
6+ "testing"
7+ "time"
8+
9+ "github.com/stretchr/testify/assert"
10+ "github.com/stretchr/testify/require"
11+)
12+
13+var testTime = time.Date(2026, 4, 3, 14, 20, 54, 167_000_000, time.UTC)
14+
15+var baseEntry = Entry{
16+ Time: testTime,
17+ ClientIP: "135.181.213.219",
18+ Method: "GET",
19+ Host: "traefik.example.dev",
20+ Path: "/robots.txt",
21+ Query: "",
22+ Status: 404,
23+ BytesSent: 19,
24+ DurationMS: 0.167,
25+ UserAgent: "MJ12bot/v1.4.8",
26+ Referer: "",
27+ RequestID: "95126efcb65d4df81f7ae633e2a712cd",
28+ Proto: "HTTP/1.1",
29+}
30+
31+// --- JSON formatter ---
32+
33+func TestFormatJSON_AllFields(t *testing.T) {
34+ line, err := formatJSON(baseEntry)
35+ require.NoError(t, err)
36+
37+ var got map[string]any
38+ require.NoError(t, json.Unmarshal([]byte(line), &got))
39+
40+ assert.Equal(t, "2026-04-03T14:20:54.167Z", got["time"])
41+ assert.Equal(t, "135.181.213.219", got["client_ip"])
42+ assert.Equal(t, "GET", got["method"])
43+ assert.Equal(t, "traefik.example.dev", got["host"])
44+ assert.Equal(t, "/robots.txt", got["path"])
45+ assert.Equal(t, "", got["query"])
46+ assert.InDelta(t, 404, got["status"], 0)
47+ assert.InDelta(t, 19, got["bytes_sent"], 0)
48+ assert.InDelta(t, 0.167, got["duration_ms"], 0.0001)
49+ assert.Equal(t, "MJ12bot/v1.4.8", got["user_agent"])
50+ assert.Equal(t, "", got["referer"])
51+ assert.Equal(t, "95126efcb65d4df81f7ae633e2a712cd", got["request_id"])
52+ assert.Equal(t, "HTTP/1.1", got["proto"])
53+}
54+
55+func TestFormatJSON_OneLinePerEntry(t *testing.T) {
56+ line, err := formatJSON(baseEntry)
57+ require.NoError(t, err)
58+ assert.False(t, strings.Contains(line, "\n"), "JSON line must not contain newline")
59+}
60+
61+func TestFormatJSON_TimestampMillisecondPrecision(t *testing.T) {
62+ // Ensure we get .167Z and not RFC3339Nano (which would be .167000000Z)
63+ line, err := formatJSON(baseEntry)
64+ require.NoError(t, err)
65+ assert.Contains(t, line, `"2026-04-03T14:20:54.167Z"`)
66+}
67+
68+func TestFormatJSON_ClientIPNoPort(t *testing.T) {
69+ e := baseEntry
70+ e.ClientIP = "10.0.0.1" // ensure no port
71+ line, err := formatJSON(e)
72+ require.NoError(t, err)
73+ assert.Contains(t, line, `"client_ip":"10.0.0.1"`)
74+}
75+
76+func TestFormatJSON_EmptyOptionalFields(t *testing.T) {
77+ e := baseEntry
78+ e.Query = ""
79+ e.Referer = ""
80+ e.UserAgent = ""
81+ line, err := formatJSON(e)
82+ require.NoError(t, err)
83+
84+ var got map[string]any
85+ require.NoError(t, json.Unmarshal([]byte(line), &got))
86+ assert.Equal(t, "", got["query"])
87+ assert.Equal(t, "", got["referer"])
88+ assert.Equal(t, "", got["user_agent"])
89+}
90+
91+func TestFormatJSON_WithQuery(t *testing.T) {
92+ e := baseEntry
93+ e.Query = "foo=bar&baz=qux"
94+ line, err := formatJSON(e)
95+ require.NoError(t, err)
96+ assert.Contains(t, line, `"query":"foo=bar\u0026baz=qux"`)
97+}
98+
99+// --- CLF formatter ---
100+
101+func TestFormatCLF_BasicLine(t *testing.T) {
102+ line, err := formatCLF(baseEntry)
103+ require.NoError(t, err)
104+ assert.Equal(t, `135.181.213.219 - - [03/Apr/2026:14:20:54 +0000] "GET /robots.txt HTTP/1.1" 404 19`, line)
105+}
106+
107+func TestFormatCLF_WithQuery(t *testing.T) {
108+ e := baseEntry
109+ e.Query = "page=2"
110+ line, err := formatCLF(e)
111+ require.NoError(t, err)
112+ assert.Contains(t, line, `"GET /robots.txt?page=2 HTTP/1.1"`)
113+}
114+
115+func TestFormatCLF_NoRefererOrUA(t *testing.T) {
116+ // CLF format should not include referer or UA
117+ line, err := formatCLF(baseEntry)
118+ require.NoError(t, err)
119+ assert.NotContains(t, line, "MJ12bot")
120+}
121+
122+func TestFormatCLF_EscapesQuotesInTarget(t *testing.T) {
123+ e := baseEntry
124+ e.Path = `/path/"quoted"`
125+ line, err := formatCLF(e)
126+ require.NoError(t, err)
127+ // Forward slashes do not need escaping; only backslashes and double-quotes do.
128+ assert.Contains(t, line, `/path/\"quoted\"`)
129+}
130+
131+// --- Combined formatter ---
132+
133+func TestFormatCombined_BasicLine(t *testing.T) {
134+ line, err := formatCombined(baseEntry)
135+ require.NoError(t, err)
136+ assert.Equal(t,
137+ `135.181.213.219 - - [03/Apr/2026:14:20:54 +0000] "GET /robots.txt HTTP/1.1" 404 19 "-" "MJ12bot/v1.4.8"`,
138+ line,
139+ )
140+}
141+
142+func TestFormatCombined_EmptyRefererIsDash(t *testing.T) {
143+ e := baseEntry
144+ e.Referer = ""
145+ line, err := formatCombined(e)
146+ require.NoError(t, err)
147+ assert.Contains(t, line, `"-"`)
148+}
149+
150+func TestFormatCombined_EmptyUAIsDash(t *testing.T) {
151+ e := baseEntry
152+ e.UserAgent = ""
153+ line, err := formatCombined(e)
154+ require.NoError(t, err)
155+ // last quoted field should be "-"
156+ assert.True(t, strings.HasSuffix(line, `"-"`))
157+}
158+
159+func TestFormatCombined_WithReferer(t *testing.T) {
160+ e := baseEntry
161+ e.Referer = "https://example.com/"
162+ line, err := formatCombined(e)
163+ require.NoError(t, err)
164+ assert.Contains(t, line, `"https://example.com/"`)
165+}
166+
167+func TestFormatCombined_EscapesQuotesInUA(t *testing.T) {
168+ e := baseEntry
169+ e.UserAgent = `bot/"evil"`
170+ line, err := formatCombined(e)
171+ require.NoError(t, err)
172+ // Forward slashes don't need escaping; only backslashes and double-quotes do.
173+ assert.Contains(t, line, `"bot/\"evil\""`)
174+}
175+
176+// --- buildRequestTarget ---
177+
178+func TestBuildRequestTarget_NoQuery(t *testing.T) {
179+ assert.Equal(t, "/foo", buildRequestTarget("/foo", ""))
180+}
181+
182+func TestBuildRequestTarget_WithQuery(t *testing.T) {
183+ assert.Equal(t, "/foo?bar=1", buildRequestTarget("/foo", "bar=1"))
184+}
185+
186+// --- escapeQuoted ---
187+
188+func TestEscapeQuoted(t *testing.T) {
189+ assert.Equal(t, `hello`, escapeQuoted(`hello`))
190+ assert.Equal(t, `say \"hi\"`, escapeQuoted(`say "hi"`))
191+ assert.Equal(t, `back\\slash`, escapeQuoted(`back\slash`))
192+ assert.Equal(t, `\"\\\"`, escapeQuoted(`"\"`))
193+}
A internal/adapters/out/accesslog/journald.go +47 -0
@@ -0,0 +1,47 @@
1+package accesslog
2+
3+import (
4+ "fmt"
5+ "strings"
6+
7+ "github.com/coreos/go-systemd/v22/journal"
8+)
9+
10+// journalEnabledFunc and journalSendFunc are package-level variables so that
11+// tests can replace them with stubs without requiring a live systemd journal.
12+var (
13+ journalEnabledFunc = journal.Enabled
14+ journalSendFunc = func(msg string, priority journal.Priority, vars map[string]string) error {
15+ return journal.Send(msg, priority, vars)
16+ }
17+)
18+
19+// journaldSink writes access log lines directly to the systemd journal with a
20+// distinct SYSLOG_IDENTIFIER so security tools (CrowdSec, fail2ban) can filter
21+// on the identity without catching application logs.
22+type journaldSink struct {
23+ syslogID string
24+}
25+
26+func newJournaldSink(cfg Config) (*journaldSink, error) {
27+ id := strings.TrimSpace(cfg.SyslogIdentifier)
28+ if id == "" {
29+ return nil, fmt.Errorf("accesslog: journald output requires a non-empty syslog_identifier")
30+ }
31+ if !journalEnabledFunc() {
32+ return nil, fmt.Errorf("accesslog: journald output requested but journald is not available on this system")
33+ }
34+ return &journaldSink{syslogID: id}, nil
35+}
36+
37+func (s *journaldSink) WriteLine(line string) error {
38+ err := journalSendFunc(line, journal.PriInfo, map[string]string{
39+ "SYSLOG_IDENTIFIER": s.syslogID,
40+ })
41+ if err != nil {
42+ return fmt.Errorf("accesslog: journald write: %w", err)
43+ }
44+ return nil
45+}
46+
47+func (s *journaldSink) Close() error { return nil }
@@ -0,0 +1,47 @@
1+package accesslog
2+
3+import (
4+ "fmt"
5+ "strings"
6+
7+ "github.com/coreos/go-systemd/v22/journal"
8+)
9+
10+// journalEnabledFunc and journalSendFunc are package-level variables so that
11+// tests can replace them with stubs without requiring a live systemd journal.
12+var (
13+ journalEnabledFunc = journal.Enabled
14+ journalSendFunc = func(msg string, priority journal.Priority, vars map[string]string) error {
15+ return journal.Send(msg, priority, vars)
16+ }
17+)
18+
19+// journaldSink writes access log lines directly to the systemd journal with a
20+// distinct SYSLOG_IDENTIFIER so security tools (CrowdSec, fail2ban) can filter
21+// on the identity without catching application logs.
22+type journaldSink struct {
23+ syslogID string
24+}
25+
26+func newJournaldSink(cfg Config) (*journaldSink, error) {
27+ id := strings.TrimSpace(cfg.SyslogIdentifier)
28+ if id == "" {
29+ return nil, fmt.Errorf("accesslog: journald output requires a non-empty syslog_identifier")
30+ }
31+ if !journalEnabledFunc() {
32+ return nil, fmt.Errorf("accesslog: journald output requested but journald is not available on this system")
33+ }
34+ return &journaldSink{syslogID: id}, nil
35+}
36+
37+func (s *journaldSink) WriteLine(line string) error {
38+ err := journalSendFunc(line, journal.PriInfo, map[string]string{
39+ "SYSLOG_IDENTIFIER": s.syslogID,
40+ })
41+ if err != nil {
42+ return fmt.Errorf("accesslog: journald write: %w", err)
43+ }
44+ return nil
45+}
46+
47+func (s *journaldSink) Close() error { return nil }
A internal/adapters/out/accesslog/journald_test.go +137 -0
@@ -0,0 +1,137 @@
1+package accesslog
2+
3+import (
4+ "errors"
5+ "testing"
6+
7+ "github.com/coreos/go-systemd/v22/journal"
8+ "github.com/stretchr/testify/assert"
9+ "github.com/stretchr/testify/require"
10+)
11+
12+// capturedJournalCall records a single call to the stubbed journalSendFunc.
13+type capturedJournalCall struct {
14+ msg string
15+ priority journal.Priority
16+ vars map[string]string
17+}
18+
19+// withJournalStub replaces journalEnabledFunc and journalSendFunc for the
20+// duration of a test, restoring them afterwards.
21+func withJournalStub(t *testing.T, enabled bool, captures *[]capturedJournalCall) {
22+ t.Helper()
23+ origEnabled := journalEnabledFunc
24+ origSend := journalSendFunc
25+
26+ journalEnabledFunc = func() bool { return enabled }
27+ journalSendFunc = func(msg string, priority journal.Priority, vars map[string]string) error {
28+ *captures = append(*captures, capturedJournalCall{msg: msg, priority: priority, vars: vars})
29+ return nil
30+ }
31+
32+ t.Cleanup(func() {
33+ journalEnabledFunc = origEnabled
34+ journalSendFunc = origSend
35+ })
36+}
37+
38+func TestNewJournaldSink_JournaldUnavailable(t *testing.T) {
39+ var calls []capturedJournalCall
40+ withJournalStub(t, false, &calls)
41+
42+ _, err := New(Config{
43+ Format: "json",
44+ Output: "journald",
45+ SyslogIdentifier: "gordon-access",
46+ })
47+ require.Error(t, err)
48+ assert.Contains(t, err.Error(), "journald is not available")
49+}
50+
51+func TestNewJournaldSink_EmptySyslogIdentifier(t *testing.T) {
52+ var calls []capturedJournalCall
53+ withJournalStub(t, true, &calls)
54+
55+ _, err := New(Config{
56+ Format: "json",
57+ Output: "journald",
58+ SyslogIdentifier: "",
59+ })
60+ require.Error(t, err)
61+ assert.Contains(t, err.Error(), "syslog_identifier")
62+}
63+
64+func TestNewJournaldSink_WhitespaceSyslogIdentifier(t *testing.T) {
65+ var calls []capturedJournalCall
66+ withJournalStub(t, true, &calls)
67+
68+ _, err := New(Config{
69+ Format: "json",
70+ Output: "journald",
71+ SyslogIdentifier: " ",
72+ })
73+ require.Error(t, err)
74+ assert.Contains(t, err.Error(), "syslog_identifier")
75+}
76+
77+func TestJournaldSink_Write_CallsJournalSend(t *testing.T) {
78+ var calls []capturedJournalCall
79+ withJournalStub(t, true, &calls)
80+
81+ writer, err := New(Config{
82+ Format: "json",
83+ Output: "journald",
84+ SyslogIdentifier: "gordon-access",
85+ })
86+ require.NoError(t, err)
87+
88+ require.NoError(t, writer.Write(sampleEntry))
89+ require.NoError(t, writer.Close())
90+
91+ require.Len(t, calls, 1)
92+ assert.Equal(t, journal.PriInfo, calls[0].priority)
93+ assert.Equal(t, "gordon-access", calls[0].vars["SYSLOG_IDENTIFIER"])
94+ assert.NotEmpty(t, calls[0].msg, "message must be the serialized log line")
95+}
96+
97+func TestJournaldSink_Write_SendsCustomIdentifier(t *testing.T) {
98+ var calls []capturedJournalCall
99+ withJournalStub(t, true, &calls)
100+
101+ writer, err := New(Config{
102+ Format: "clf",
103+ Output: "journald",
104+ SyslogIdentifier: "my-app-access",
105+ })
106+ require.NoError(t, err)
107+
108+ require.NoError(t, writer.Write(sampleEntry))
109+
110+ require.Len(t, calls, 1)
111+ assert.Equal(t, "my-app-access", calls[0].vars["SYSLOG_IDENTIFIER"])
112+}
113+
114+func TestJournaldSink_Write_ReturnsErrorOnSendFailure(t *testing.T) {
115+ origEnabled := journalEnabledFunc
116+ origSend := journalSendFunc
117+ t.Cleanup(func() {
118+ journalEnabledFunc = origEnabled
119+ journalSendFunc = origSend
120+ })
121+
122+ journalEnabledFunc = func() bool { return true }
123+ journalSendFunc = func(_ string, _ journal.Priority, _ map[string]string) error {
124+ return errors.New("journal socket closed")
125+ }
126+
127+ writer, err := New(Config{
128+ Format: "json",
129+ Output: "journald",
130+ SyslogIdentifier: "gordon-access",
131+ })
132+ require.NoError(t, err)
133+
134+ err = writer.Write(sampleEntry)
135+ require.Error(t, err)
136+ assert.Contains(t, err.Error(), "journal socket closed")
137+}
@@ -0,0 +1,137 @@
1+package accesslog
2+
3+import (
4+ "errors"
5+ "testing"
6+
7+ "github.com/coreos/go-systemd/v22/journal"
8+ "github.com/stretchr/testify/assert"
9+ "github.com/stretchr/testify/require"
10+)
11+
12+// capturedJournalCall records a single call to the stubbed journalSendFunc.
13+type capturedJournalCall struct {
14+ msg string
15+ priority journal.Priority
16+ vars map[string]string
17+}
18+
19+// withJournalStub replaces journalEnabledFunc and journalSendFunc for the
20+// duration of a test, restoring them afterwards.
21+func withJournalStub(t *testing.T, enabled bool, captures *[]capturedJournalCall) {
22+ t.Helper()
23+ origEnabled := journalEnabledFunc
24+ origSend := journalSendFunc
25+
26+ journalEnabledFunc = func() bool { return enabled }
27+ journalSendFunc = func(msg string, priority journal.Priority, vars map[string]string) error {
28+ *captures = append(*captures, capturedJournalCall{msg: msg, priority: priority, vars: vars})
29+ return nil
30+ }
31+
32+ t.Cleanup(func() {
33+ journalEnabledFunc = origEnabled
34+ journalSendFunc = origSend
35+ })
36+}
37+
38+func TestNewJournaldSink_JournaldUnavailable(t *testing.T) {
39+ var calls []capturedJournalCall
40+ withJournalStub(t, false, &calls)
41+
42+ _, err := New(Config{
43+ Format: "json",
44+ Output: "journald",
45+ SyslogIdentifier: "gordon-access",
46+ })
47+ require.Error(t, err)
48+ assert.Contains(t, err.Error(), "journald is not available")
49+}
50+
51+func TestNewJournaldSink_EmptySyslogIdentifier(t *testing.T) {
52+ var calls []capturedJournalCall
53+ withJournalStub(t, true, &calls)
54+
55+ _, err := New(Config{
56+ Format: "json",
57+ Output: "journald",
58+ SyslogIdentifier: "",
59+ })
60+ require.Error(t, err)
61+ assert.Contains(t, err.Error(), "syslog_identifier")
62+}
63+
64+func TestNewJournaldSink_WhitespaceSyslogIdentifier(t *testing.T) {
65+ var calls []capturedJournalCall
66+ withJournalStub(t, true, &calls)
67+
68+ _, err := New(Config{
69+ Format: "json",
70+ Output: "journald",
71+ SyslogIdentifier: " ",
72+ })
73+ require.Error(t, err)
74+ assert.Contains(t, err.Error(), "syslog_identifier")
75+}
76+
77+func TestJournaldSink_Write_CallsJournalSend(t *testing.T) {
78+ var calls []capturedJournalCall
79+ withJournalStub(t, true, &calls)
80+
81+ writer, err := New(Config{
82+ Format: "json",
83+ Output: "journald",
84+ SyslogIdentifier: "gordon-access",
85+ })
86+ require.NoError(t, err)
87+
88+ require.NoError(t, writer.Write(sampleEntry))
89+ require.NoError(t, writer.Close())
90+
91+ require.Len(t, calls, 1)
92+ assert.Equal(t, journal.PriInfo, calls[0].priority)
93+ assert.Equal(t, "gordon-access", calls[0].vars["SYSLOG_IDENTIFIER"])
94+ assert.NotEmpty(t, calls[0].msg, "message must be the serialized log line")
95+}
96+
97+func TestJournaldSink_Write_SendsCustomIdentifier(t *testing.T) {
98+ var calls []capturedJournalCall
99+ withJournalStub(t, true, &calls)
100+
101+ writer, err := New(Config{
102+ Format: "clf",
103+ Output: "journald",
104+ SyslogIdentifier: "my-app-access",
105+ })
106+ require.NoError(t, err)
107+
108+ require.NoError(t, writer.Write(sampleEntry))
109+
110+ require.Len(t, calls, 1)
111+ assert.Equal(t, "my-app-access", calls[0].vars["SYSLOG_IDENTIFIER"])
112+}
113+
114+func TestJournaldSink_Write_ReturnsErrorOnSendFailure(t *testing.T) {
115+ origEnabled := journalEnabledFunc
116+ origSend := journalSendFunc
117+ t.Cleanup(func() {
118+ journalEnabledFunc = origEnabled
119+ journalSendFunc = origSend
120+ })
121+
122+ journalEnabledFunc = func() bool { return true }
123+ journalSendFunc = func(_ string, _ journal.Priority, _ map[string]string) error {
124+ return errors.New("journal socket closed")
125+ }
126+
127+ writer, err := New(Config{
128+ Format: "json",
129+ Output: "journald",
130+ SyslogIdentifier: "gordon-access",
131+ })
132+ require.NoError(t, err)
133+
134+ err = writer.Write(sampleEntry)
135+ require.Error(t, err)
136+ assert.Contains(t, err.Error(), "journal socket closed")
137+}
A internal/adapters/out/accesslog/writer.go +186 -0
@@ -0,0 +1,186 @@
1+// Package accesslog provides a dedicated HTTP access log writer for security
2+// and observability tooling. It supports JSON, CLF, and Combined formats with
3+// stdout, file, and journald output backends.
4+package accesslog
5+
6+import (
7+ "fmt"
8+ "os"
9+ "path/filepath"
10+ "sync"
11+ "time"
12+
13+ "gopkg.in/natefinch/lumberjack.v2"
14+)
15+
16+// Config holds configuration for the access log writer.
17+type Config struct {
18+ // Format is the output format: "json", "clf", or "combined".
19+ Format string
20+ // Output is the output backend: "stdout", "file", or "journald".
21+ Output string
22+ // FilePath is the path to the log file (only used when Output == "file").
23+ // If empty, defaults to {data_dir}/logs/access.log (caller must resolve).
24+ FilePath string
25+ // MaxSize is the maximum size in MB before rotation (only used when Output == "file").
26+ MaxSize int
27+ // MaxBackups is the number of old log files to retain (only used when Output == "file").
28+ MaxBackups int
29+ // MaxAge is the maximum number of days to retain old log files (only used when Output == "file").
30+ MaxAge int
31+ // SyslogIdentifier is the journald SYSLOG_IDENTIFIER (only used when Output == "journald").
32+ SyslogIdentifier string
33+}
34+
35+// Entry holds the data for a single HTTP access log record.
36+type Entry struct {
37+ Time time.Time
38+ ClientIP string
39+ Method string
40+ Host string
41+ Path string
42+ Query string
43+ Status int
44+ BytesSent int
45+ DurationMS float64
46+ UserAgent string
47+ Referer string
48+ RequestID string
49+ Proto string
50+}
51+
52+// sink is the internal interface for output backends.
53+type sink interface {
54+ WriteLine(line string) error
55+ Close() error
56+}
57+
58+// Writer serializes access log entries and writes them to a configured sink.
59+// It is safe for concurrent use.
60+type Writer struct {
61+ formatter func(Entry) (string, error)
62+ sink sink
63+ mu sync.Mutex
64+}
65+
66+// New creates a new Writer from cfg. Returns an error if the configuration is
67+// invalid or the output backend cannot be initialized.
68+func New(cfg Config) (*Writer, error) {
69+ formatter, err := newFormatter(cfg.Format)
70+ if err != nil {
71+ return nil, err
72+ }
73+
74+ s, err := newSink(cfg)
75+ if err != nil {
76+ return nil, err
77+ }
78+
79+ return &Writer{
80+ formatter: formatter,
81+ sink: s,
82+ }, nil
83+}
84+
85+// Write serializes entry and writes it to the configured output. It is safe
86+// for concurrent use. A write failure is returned as an error; callers should
87+// log the error and continue — Write never panics.
88+func (w *Writer) Write(entry Entry) error {
89+ line, err := w.formatter(entry)
90+ if err != nil {
91+ return fmt.Errorf("accesslog: format entry: %w", err)
92+ }
93+
94+ w.mu.Lock()
95+ defer w.mu.Unlock()
96+
97+ if err := w.sink.WriteLine(line); err != nil {
98+ return fmt.Errorf("accesslog: write line: %w", err)
99+ }
100+ return nil
101+}
102+
103+// Close releases any resources held by the writer (e.g. open file handles).
104+func (w *Writer) Close() error {
105+ w.mu.Lock()
106+ defer w.mu.Unlock()
107+ return w.sink.Close()
108+}
109+
110+// newFormatter returns the format function for the given format name.
111+func newFormatter(format string) (func(Entry) (string, error), error) {
112+ switch format {
113+ case "json":
114+ return formatJSON, nil
115+ case "clf":
116+ return formatCLF, nil
117+ case "combined":
118+ return formatCombined, nil
119+ default:
120+ return nil, fmt.Errorf("invalid logging.access_log.format: %q (must be json, clf, or combined)", format)
121+ }
122+}
123+
124+// newSink creates the output sink for the given config.
125+func newSink(cfg Config) (sink, error) {
126+ switch cfg.Output {
127+ case "stdout", "":
128+ return &stdoutSink{}, nil
129+ case "file":
130+ return newFileSink(cfg)
131+ case "journald":
132+ return newJournaldSink(cfg)
133+ default:
134+ return nil, fmt.Errorf("invalid logging.access_log.output: %q (must be stdout, file, or journald)", cfg.Output)
135+ }
136+}
137+
138+// stdoutSink writes lines to os.Stdout.
139+type stdoutSink struct{}
140+
141+func (s *stdoutSink) WriteLine(line string) error {
142+ _, err := fmt.Fprintln(os.Stdout, line)
143+ return err
144+}
145+
146+func (s *stdoutSink) Close() error { return nil }
147+
148+// fileSink writes lines to a rotating file via lumberjack.
149+type fileSink struct {
150+ logger *lumberjack.Logger
151+}
152+
153+func newFileSink(cfg Config) (*fileSink, error) {
154+ if cfg.FilePath == "" {
155+ return nil, fmt.Errorf("accesslog: file output requires a non-empty file_path")
156+ }
157+
158+ // Ensure parent directory exists.
159+ if err := os.MkdirAll(filepath.Dir(cfg.FilePath), 0o700); err != nil {
160+ return nil, fmt.Errorf("accesslog: create log directory: %w", err)
161+ }
162+
163+ maxSize := cfg.MaxSize
164+ if maxSize <= 0 {
165+ maxSize = 100
166+ }
167+
168+ return &fileSink{
169+ logger: &lumberjack.Logger{
170+ Filename: cfg.FilePath,
171+ MaxSize: maxSize,
172+ MaxBackups: cfg.MaxBackups,
173+ MaxAge: cfg.MaxAge,
174+ Compress: true,
175+ },
176+ }, nil
177+}
178+
179+func (s *fileSink) WriteLine(line string) error {
180+ _, err := fmt.Fprintln(s.logger, line)
181+ return err
182+}
183+
184+func (s *fileSink) Close() error {
185+ return s.logger.Close()
186+}
@@ -0,0 +1,186 @@
1+// Package accesslog provides a dedicated HTTP access log writer for security
2+// and observability tooling. It supports JSON, CLF, and Combined formats with
3+// stdout, file, and journald output backends.
4+package accesslog
5+
6+import (
7+ "fmt"
8+ "os"
9+ "path/filepath"
10+ "sync"
11+ "time"
12+
13+ "gopkg.in/natefinch/lumberjack.v2"
14+)
15+
16+// Config holds configuration for the access log writer.
17+type Config struct {
18+ // Format is the output format: "json", "clf", or "combined".
19+ Format string
20+ // Output is the output backend: "stdout", "file", or "journald".
21+ Output string
22+ // FilePath is the path to the log file (only used when Output == "file").
23+ // If empty, defaults to {data_dir}/logs/access.log (caller must resolve).
24+ FilePath string
25+ // MaxSize is the maximum size in MB before rotation (only used when Output == "file").
26+ MaxSize int
27+ // MaxBackups is the number of old log files to retain (only used when Output == "file").
28+ MaxBackups int
29+ // MaxAge is the maximum number of days to retain old log files (only used when Output == "file").
30+ MaxAge int
31+ // SyslogIdentifier is the journald SYSLOG_IDENTIFIER (only used when Output == "journald").
32+ SyslogIdentifier string
33+}
34+
35+// Entry holds the data for a single HTTP access log record.
36+type Entry struct {
37+ Time time.Time
38+ ClientIP string
39+ Method string
40+ Host string
41+ Path string
42+ Query string
43+ Status int
44+ BytesSent int
45+ DurationMS float64
46+ UserAgent string
47+ Referer string
48+ RequestID string
49+ Proto string
50+}
51+
52+// sink is the internal interface for output backends.
53+type sink interface {
54+ WriteLine(line string) error
55+ Close() error
56+}
57+
58+// Writer serializes access log entries and writes them to a configured sink.
59+// It is safe for concurrent use.
60+type Writer struct {
61+ formatter func(Entry) (string, error)
62+ sink sink
63+ mu sync.Mutex
64+}
65+
66+// New creates a new Writer from cfg. Returns an error if the configuration is
67+// invalid or the output backend cannot be initialized.
68+func New(cfg Config) (*Writer, error) {
69+ formatter, err := newFormatter(cfg.Format)
70+ if err != nil {
71+ return nil, err
72+ }
73+
74+ s, err := newSink(cfg)
75+ if err != nil {
76+ return nil, err
77+ }
78+
79+ return &Writer{
80+ formatter: formatter,
81+ sink: s,
82+ }, nil
83+}
84+
85+// Write serializes entry and writes it to the configured output. It is safe
86+// for concurrent use. A write failure is returned as an error; callers should
87+// log the error and continue — Write never panics.
88+func (w *Writer) Write(entry Entry) error {
89+ line, err := w.formatter(entry)
90+ if err != nil {
91+ return fmt.Errorf("accesslog: format entry: %w", err)
92+ }
93+
94+ w.mu.Lock()
95+ defer w.mu.Unlock()
96+
97+ if err := w.sink.WriteLine(line); err != nil {
98+ return fmt.Errorf("accesslog: write line: %w", err)
99+ }
100+ return nil
101+}
102+
103+// Close releases any resources held by the writer (e.g. open file handles).
104+func (w *Writer) Close() error {
105+ w.mu.Lock()
106+ defer w.mu.Unlock()
107+ return w.sink.Close()
108+}
109+
110+// newFormatter returns the format function for the given format name.
111+func newFormatter(format string) (func(Entry) (string, error), error) {
112+ switch format {
113+ case "json":
114+ return formatJSON, nil
115+ case "clf":
116+ return formatCLF, nil
117+ case "combined":
118+ return formatCombined, nil
119+ default:
120+ return nil, fmt.Errorf("invalid logging.access_log.format: %q (must be json, clf, or combined)", format)
121+ }
122+}
123+
124+// newSink creates the output sink for the given config.
125+func newSink(cfg Config) (sink, error) {
126+ switch cfg.Output {
127+ case "stdout", "":
128+ return &stdoutSink{}, nil
129+ case "file":
130+ return newFileSink(cfg)
131+ case "journald":
132+ return newJournaldSink(cfg)
133+ default:
134+ return nil, fmt.Errorf("invalid logging.access_log.output: %q (must be stdout, file, or journald)", cfg.Output)
135+ }
136+}
137+
138+// stdoutSink writes lines to os.Stdout.
139+type stdoutSink struct{}
140+
141+func (s *stdoutSink) WriteLine(line string) error {
142+ _, err := fmt.Fprintln(os.Stdout, line)
143+ return err
144+}
145+
146+func (s *stdoutSink) Close() error { return nil }
147+
148+// fileSink writes lines to a rotating file via lumberjack.
149+type fileSink struct {
150+ logger *lumberjack.Logger
151+}
152+
153+func newFileSink(cfg Config) (*fileSink, error) {
154+ if cfg.FilePath == "" {
155+ return nil, fmt.Errorf("accesslog: file output requires a non-empty file_path")
156+ }
157+
158+ // Ensure parent directory exists.
159+ if err := os.MkdirAll(filepath.Dir(cfg.FilePath), 0o700); err != nil {
160+ return nil, fmt.Errorf("accesslog: create log directory: %w", err)
161+ }
162+
163+ maxSize := cfg.MaxSize
164+ if maxSize <= 0 {
165+ maxSize = 100
166+ }
167+
168+ return &fileSink{
169+ logger: &lumberjack.Logger{
170+ Filename: cfg.FilePath,
171+ MaxSize: maxSize,
172+ MaxBackups: cfg.MaxBackups,
173+ MaxAge: cfg.MaxAge,
174+ Compress: true,
175+ },
176+ }, nil
177+}
178+
179+func (s *fileSink) WriteLine(line string) error {
180+ _, err := fmt.Fprintln(s.logger, line)
181+ return err
182+}
183+
184+func (s *fileSink) Close() error {
185+ return s.logger.Close()
186+}
A internal/adapters/out/accesslog/writer_test.go +206 -0
@@ -0,0 +1,206 @@
1+package accesslog
2+
3+import (
4+ "bytes"
5+ "encoding/json"
6+ "fmt"
7+ "os"
8+ "path/filepath"
9+ "strings"
10+ "sync"
11+ "testing"
12+ "time"
13+
14+ "github.com/stretchr/testify/assert"
15+ "github.com/stretchr/testify/require"
16+)
17+
18+var sampleEntry = Entry{
19+ Time: time.Date(2026, 4, 3, 14, 20, 54, 167_000_000, time.UTC),
20+ ClientIP: "10.0.0.1",
21+ Method: "POST",
22+ Host: "api.example.com",
23+ Path: "/api/v1/data",
24+ Query: "limit=10",
25+ Status: 200,
26+ BytesSent: 512,
27+ DurationMS: 3.5,
28+ UserAgent: "curl/7.88",
29+ Referer: "https://example.com",
30+ RequestID: "abc123",
31+ Proto: "HTTP/2.0",
32+}
33+
34+// --- New() validation ---
35+
36+func TestNew_InvalidFormat(t *testing.T) {
37+ _, err := New(Config{Format: "xml", Output: "stdout"})
38+ require.Error(t, err)
39+ assert.Contains(t, err.Error(), "invalid logging.access_log.format")
40+}
41+
42+func TestNew_InvalidOutput(t *testing.T) {
43+ _, err := New(Config{Format: "json", Output: "kafka"})
44+ require.Error(t, err)
45+ assert.Contains(t, err.Error(), "invalid logging.access_log.output")
46+}
47+
48+func TestNew_FileOutput_EmptyPath(t *testing.T) {
49+ _, err := New(Config{Format: "json", Output: "file", FilePath: ""})
50+ require.Error(t, err)
51+}
52+
53+func TestNew_FileOutput_CreatesDirectory(t *testing.T) {
54+ dir := t.TempDir()
55+ path := filepath.Join(dir, "sub", "access.log")
56+ w, err := New(Config{
57+ Format: "json",
58+ Output: "file",
59+ FilePath: path,
60+ MaxSize: 10,
61+ })
62+ require.NoError(t, err)
63+ defer w.Close()
64+
65+ assert.DirExists(t, filepath.Join(dir, "sub"))
66+}
67+
68+// --- stdout sink ---
69+
70+func TestWriter_Stdout_JSON(t *testing.T) {
71+ // Replace stdout to capture output.
72+ orig := os.Stdout
73+ r, w, _ := os.Pipe()
74+ os.Stdout = w
75+
76+ writer, err := New(Config{Format: "json", Output: "stdout"})
77+ require.NoError(t, err)
78+
79+ require.NoError(t, writer.Write(sampleEntry))
80+ require.NoError(t, writer.Close())
81+
82+ w.Close()
83+ os.Stdout = orig
84+
85+ var buf bytes.Buffer
86+ _, _ = buf.ReadFrom(r)
87+ line := strings.TrimSpace(buf.String())
88+
89+ var got map[string]any
90+ require.NoError(t, json.Unmarshal([]byte(line), &got), "output must be valid JSON: %s", line)
91+ assert.Equal(t, "10.0.0.1", got["client_ip"])
92+ assert.Equal(t, "POST", got["method"])
93+ assert.InDelta(t, 200, got["status"], 0)
94+}
95+
96+func TestWriter_Stdout_CLF(t *testing.T) {
97+ orig := os.Stdout
98+ r, w, _ := os.Pipe()
99+ os.Stdout = w
100+
101+ writer, err := New(Config{Format: "clf", Output: "stdout"})
102+ require.NoError(t, err)
103+ require.NoError(t, writer.Write(sampleEntry))
104+ require.NoError(t, writer.Close())
105+
106+ w.Close()
107+ os.Stdout = orig
108+
109+ var buf bytes.Buffer
110+ _, _ = buf.ReadFrom(r)
111+ line := strings.TrimSpace(buf.String())
112+
113+ assert.True(t, strings.HasPrefix(line, "10.0.0.1 - - ["))
114+ assert.Contains(t, line, `"POST /api/v1/data?limit=10 HTTP/2.0"`)
115+ assert.Contains(t, line, " 200 512")
116+}
117+
118+func TestWriter_Stdout_Combined(t *testing.T) {
119+ orig := os.Stdout
120+ r, w, _ := os.Pipe()
121+ os.Stdout = w
122+
123+ writer, err := New(Config{Format: "combined", Output: "stdout"})
124+ require.NoError(t, err)
125+ require.NoError(t, writer.Write(sampleEntry))
126+ require.NoError(t, writer.Close())
127+
128+ w.Close()
129+ os.Stdout = orig
130+
131+ var buf bytes.Buffer
132+ _, _ = buf.ReadFrom(r)
133+ line := strings.TrimSpace(buf.String())
134+
135+ assert.Contains(t, line, `"https://example.com"`)
136+ assert.Contains(t, line, `"curl/7.88"`)
137+}
138+
139+// --- file sink ---
140+
141+func TestWriter_File_WritesLines(t *testing.T) {
142+ dir := t.TempDir()
143+ path := filepath.Join(dir, "access.log")
144+
145+ writer, err := New(Config{
146+ Format: "json",
147+ Output: "file",
148+ FilePath: path,
149+ MaxSize: 10,
150+ MaxBackups: 1,
151+ })
152+ require.NoError(t, err)
153+
154+ require.NoError(t, writer.Write(sampleEntry))
155+ require.NoError(t, writer.Write(sampleEntry))
156+ require.NoError(t, writer.Close())
157+
158+ data, err := os.ReadFile(path)
159+ require.NoError(t, err)
160+
161+ lines := strings.Split(strings.TrimSpace(string(data)), "\n")
162+ assert.Len(t, lines, 2)
163+ for _, l := range lines {
164+ var obj map[string]any
165+ assert.NoError(t, json.Unmarshal([]byte(l), &obj), "each line must be valid JSON")
166+ }
167+}
168+
169+// --- concurrency ---
170+
171+func TestWriter_ConcurrentWrites(t *testing.T) {
172+ dir := t.TempDir()
173+ path := filepath.Join(dir, "concurrent.log")
174+
175+ writer, err := New(Config{
176+ Format: "json",
177+ Output: "file",
178+ FilePath: path,
179+ MaxSize: 100,
180+ })
181+ require.NoError(t, err)
182+ defer writer.Close()
183+
184+ const goroutines = 50
185+ var wg sync.WaitGroup
186+ wg.Add(goroutines)
187+ for i := range goroutines {
188+ go func(n int) {
189+ defer wg.Done()
190+ e := sampleEntry
191+ e.RequestID = fmt.Sprintf("req-%d", n)
192+ assert.NoError(t, writer.Write(e))
193+ }(i)
194+ }
195+ wg.Wait()
196+
197+ data, err := os.ReadFile(path)
198+ require.NoError(t, err)
199+
200+ lines := strings.Split(strings.TrimSpace(string(data)), "\n")
201+ assert.Len(t, lines, goroutines, "each goroutine should produce exactly one line")
202+ for _, l := range lines {
203+ var obj map[string]any
204+ assert.NoError(t, json.Unmarshal([]byte(l), &obj), "each line must be valid JSON")
205+ }
206+}
@@ -0,0 +1,206 @@
1+package accesslog
2+
3+import (
4+ "bytes"
5+ "encoding/json"
6+ "fmt"
7+ "os"
8+ "path/filepath"
9+ "strings"
10+ "sync"
11+ "testing"
12+ "time"
13+
14+ "github.com/stretchr/testify/assert"
15+ "github.com/stretchr/testify/require"
16+)
17+
18+var sampleEntry = Entry{
19+ Time: time.Date(2026, 4, 3, 14, 20, 54, 167_000_000, time.UTC),
20+ ClientIP: "10.0.0.1",
21+ Method: "POST",
22+ Host: "api.example.com",
23+ Path: "/api/v1/data",
24+ Query: "limit=10",
25+ Status: 200,
26+ BytesSent: 512,
27+ DurationMS: 3.5,
28+ UserAgent: "curl/7.88",
29+ Referer: "https://example.com",
30+ RequestID: "abc123",
31+ Proto: "HTTP/2.0",
32+}
33+
34+// --- New() validation ---
35+
36+func TestNew_InvalidFormat(t *testing.T) {
37+ _, err := New(Config{Format: "xml", Output: "stdout"})
38+ require.Error(t, err)
39+ assert.Contains(t, err.Error(), "invalid logging.access_log.format")
40+}
41+
42+func TestNew_InvalidOutput(t *testing.T) {
43+ _, err := New(Config{Format: "json", Output: "kafka"})
44+ require.Error(t, err)
45+ assert.Contains(t, err.Error(), "invalid logging.access_log.output")
46+}
47+
48+func TestNew_FileOutput_EmptyPath(t *testing.T) {
49+ _, err := New(Config{Format: "json", Output: "file", FilePath: ""})
50+ require.Error(t, err)
51+}
52+
53+func TestNew_FileOutput_CreatesDirectory(t *testing.T) {
54+ dir := t.TempDir()
55+ path := filepath.Join(dir, "sub", "access.log")
56+ w, err := New(Config{
57+ Format: "json",
58+ Output: "file",
59+ FilePath: path,
60+ MaxSize: 10,
61+ })
62+ require.NoError(t, err)
63+ defer w.Close()
64+
65+ assert.DirExists(t, filepath.Join(dir, "sub"))
66+}
67+
68+// --- stdout sink ---
69+
70+func TestWriter_Stdout_JSON(t *testing.T) {
71+ // Replace stdout to capture output.
72+ orig := os.Stdout
73+ r, w, _ := os.Pipe()
74+ os.Stdout = w
75+
76+ writer, err := New(Config{Format: "json", Output: "stdout"})
77+ require.NoError(t, err)
78+
79+ require.NoError(t, writer.Write(sampleEntry))
80+ require.NoError(t, writer.Close())
81+
82+ w.Close()
83+ os.Stdout = orig
84+
85+ var buf bytes.Buffer
86+ _, _ = buf.ReadFrom(r)
87+ line := strings.TrimSpace(buf.String())
88+
89+ var got map[string]any
90+ require.NoError(t, json.Unmarshal([]byte(line), &got), "output must be valid JSON: %s", line)
91+ assert.Equal(t, "10.0.0.1", got["client_ip"])
92+ assert.Equal(t, "POST", got["method"])
93+ assert.InDelta(t, 200, got["status"], 0)
94+}
95+
96+func TestWriter_Stdout_CLF(t *testing.T) {
97+ orig := os.Stdout
98+ r, w, _ := os.Pipe()
99+ os.Stdout = w
100+
101+ writer, err := New(Config{Format: "clf", Output: "stdout"})
102+ require.NoError(t, err)
103+ require.NoError(t, writer.Write(sampleEntry))
104+ require.NoError(t, writer.Close())
105+
106+ w.Close()
107+ os.Stdout = orig
108+
109+ var buf bytes.Buffer
110+ _, _ = buf.ReadFrom(r)
111+ line := strings.TrimSpace(buf.String())
112+
113+ assert.True(t, strings.HasPrefix(line, "10.0.0.1 - - ["))
114+ assert.Contains(t, line, `"POST /api/v1/data?limit=10 HTTP/2.0"`)
115+ assert.Contains(t, line, " 200 512")
116+}
117+
118+func TestWriter_Stdout_Combined(t *testing.T) {
119+ orig := os.Stdout
120+ r, w, _ := os.Pipe()
121+ os.Stdout = w
122+
123+ writer, err := New(Config{Format: "combined", Output: "stdout"})
124+ require.NoError(t, err)
125+ require.NoError(t, writer.Write(sampleEntry))
126+ require.NoError(t, writer.Close())
127+
128+ w.Close()
129+ os.Stdout = orig
130+
131+ var buf bytes.Buffer
132+ _, _ = buf.ReadFrom(r)
133+ line := strings.TrimSpace(buf.String())
134+
135+ assert.Contains(t, line, `"https://example.com"`)
136+ assert.Contains(t, line, `"curl/7.88"`)
137+}
138+
139+// --- file sink ---
140+
141+func TestWriter_File_WritesLines(t *testing.T) {
142+ dir := t.TempDir()
143+ path := filepath.Join(dir, "access.log")
144+
145+ writer, err := New(Config{
146+ Format: "json",
147+ Output: "file",
148+ FilePath: path,
149+ MaxSize: 10,
150+ MaxBackups: 1,
151+ })
152+ require.NoError(t, err)
153+
154+ require.NoError(t, writer.Write(sampleEntry))
155+ require.NoError(t, writer.Write(sampleEntry))
156+ require.NoError(t, writer.Close())
157+
158+ data, err := os.ReadFile(path)
159+ require.NoError(t, err)
160+
161+ lines := strings.Split(strings.TrimSpace(string(data)), "\n")
162+ assert.Len(t, lines, 2)
163+ for _, l := range lines {
164+ var obj map[string]any
165+ assert.NoError(t, json.Unmarshal([]byte(l), &obj), "each line must be valid JSON")
166+ }
167+}
168+
169+// --- concurrency ---
170+
171+func TestWriter_ConcurrentWrites(t *testing.T) {
172+ dir := t.TempDir()
173+ path := filepath.Join(dir, "concurrent.log")
174+
175+ writer, err := New(Config{
176+ Format: "json",
177+ Output: "file",
178+ FilePath: path,
179+ MaxSize: 100,
180+ })
181+ require.NoError(t, err)
182+ defer writer.Close()
183+
184+ const goroutines = 50
185+ var wg sync.WaitGroup
186+ wg.Add(goroutines)
187+ for i := range goroutines {
188+ go func(n int) {
189+ defer wg.Done()
190+ e := sampleEntry
191+ e.RequestID = fmt.Sprintf("req-%d", n)
192+ assert.NoError(t, writer.Write(e))
193+ }(i)
194+ }
195+ wg.Wait()
196+
197+ data, err := os.ReadFile(path)
198+ require.NoError(t, err)
199+
200+ lines := strings.Split(strings.TrimSpace(string(data)), "\n")
201+ assert.Len(t, lines, goroutines, "each goroutine should produce exactly one line")
202+ for _, l := range lines {
203+ var obj map[string]any
204+ assert.NoError(t, json.Unmarshal([]byte(l), &obj), "each line must be valid JSON")
205+ }
206+}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 7

Tags 106

↑↓ moveenter openesc close