Skip to content

Commit 33ef56f

Browse files
sarg3ntclaude
andcommitted
fix(#91): address Copilot review findings on PR #101
- access-log: implement the documented Apache CLF fallback. Previously the handler used a single parser from sourceProfile (ApacheCombined) and the comments + PR body claimed a per-record fallback to ApacheCommon that didn't exist. RHEL hosts running default CLF would have produced zero parsed records. New parseWithFallback helper + sourceFallbackProfile map drive the actual fallback now; Apache is the only source using it today. - access-log: status_min query parameter now accepts an explicit 0 to disable the filter. Was previously clamped to a 100 minimum, which silently coerced 0 to 100 and broke the "give me all records" intent. Default when the param is absent stays 500 (the dashboard's primary use case). Lock the new defaults in via two new tests. - caddy: drop the AdminRunning field. The previous heuristic relied on caddy_admin_http_requests_total existing, which Prometheus doesn't emit for counters with zero increments. A freshly-started Caddy with admin enabled but no admin traffic yet would have falsely read "admin disconnected." The real signal is "did the scrape succeed?" — which the handler already conveys via 503 before the first successful scrape — so the field was redundant on success and misleading on cold start. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 330e4d9 commit 33ef56f

4 files changed

Lines changed: 179 additions & 31 deletions

File tree

gearbox-agent/internal/gears/accesslog/plugin.go

Lines changed: 43 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -58,17 +58,28 @@ var defaultLogPaths = map[string]string{
5858
// "did we try multiple paths?" logic explicit at the call site.
5959
const apacheFallbackPath = "/var/log/httpd/access_log"
6060

61-
// sourceProfile maps a source identifier to the parser profile the
62-
// endpoint applies to each line. Apache lines are tried as
63-
// "combined" first (most operators run that format); ApacheCommon
64-
// is the fallback handled inside the per-record retry.
61+
// sourceProfile maps a source identifier to the primary parser
62+
// profile the endpoint tries first. Apache uniquely also has a
63+
// fallback profile (CLF without Referer / User-Agent) tried when
64+
// the primary returns nil — see sourceFallbackProfile and
65+
// parseWithFallback.
6566
var sourceProfile = map[string]string{
6667
"haproxy": accesslog.ProfileHAProxy,
6768
"nginx": accesslog.ProfileNginxCombined,
6869
"apache": accesslog.ProfileApacheCombined,
6970
"caddy": accesslog.ProfileCaddyJSON,
7071
}
7172

73+
// sourceFallbackProfile names the second profile tried when the
74+
// primary returns nil for a line. Today only Apache has one: many
75+
// RHEL-style installs ship CLF (no Referer / User-Agent) by
76+
// default, so we try ApacheCombined first (covers the Debian
77+
// default + custom combined-format ops) and fall back to
78+
// ApacheCommon. Lines that match neither stay rejected as noise.
79+
var sourceFallbackProfile = map[string]string{
80+
"apache": accesslog.ProfileApacheCommon,
81+
}
82+
7283
// maxLimit caps the per-request `limit` parameter so a buggy
7384
// dashboard call can't make the agent shell `tail -n 100000` against
7485
// a 10 GB log file. 10 000 records matches the existing logs gear's
@@ -205,8 +216,17 @@ func (g *Gear) handleRecent(w http.ResponseWriter, r *http.Request) {
205216
http.Error(w, "no parser registered for source "+source, http.StatusInternalServerError)
206217
return
207218
}
208-
209-
statusMin := parseIntDefault(r.URL.Query().Get("status_min"), 0, 100, 599, 500)
219+
// fallback may be nil — most sources have a single profile.
220+
// parseWithFallback handles the nil case as "no second try".
221+
fallback := accesslog.ProfileByName(sourceFallbackProfile[source])
222+
223+
// status_min defaults to 500 (the dashboard's primary use case
224+
// is 5xx insights) but lets callers pass an explicit 0 to
225+
// disable the filter entirely. Min clamp is 0 (not 100) so
226+
// "explicitly disable" works; the gating logic compares with
227+
// rec.StatusCode < statusMin, which is a no-op when statusMin
228+
// is 0.
229+
statusMin := parseIntDefault(r.URL.Query().Get("status_min"), 500, 0, 599, 500)
210230
limit := parseIntDefault(r.URL.Query().Get("limit"), defaultLimit, 1, maxLimit, defaultLimit)
211231
lines := parseIntDefault(r.URL.Query().Get("lines"), defaultLines, 1, maxLines, defaultLines)
212232

@@ -230,7 +250,7 @@ func (g *Gear) handleRecent(w http.ResponseWriter, r *http.Request) {
230250
// the slice in reverse.
231251
matches := make([]accesslog.Record, 0, limit)
232252
for i := len(raw) - 1; i >= 0; i-- {
233-
rec := parser.Parse(raw[i])
253+
rec := parseWithFallback(parser, fallback, raw[i])
234254
if rec == nil {
235255
continue
236256
}
@@ -249,6 +269,22 @@ func (g *Gear) handleRecent(w http.ResponseWriter, r *http.Request) {
249269
writeJSON(w, resp)
250270
}
251271

272+
// parseWithFallback tries primary first; if primary returns nil and
273+
// a fallback parser was registered for this source, it tries the
274+
// fallback. Returns nil only when both reject the line. The Apache
275+
// source uses this to handle both combined (default Debian) and
276+
// CLF (default RHEL) without the caller needing to pre-detect
277+
// which format the operator's running.
278+
func parseWithFallback(primary, fallback accesslog.Parser, raw string) *accesslog.Record {
279+
if rec := primary.Parse(raw); rec != nil {
280+
return rec
281+
}
282+
if fallback == nil {
283+
return nil
284+
}
285+
return fallback.Parse(raw)
286+
}
287+
252288
// resolveLogPath returns the access-log path for src: the operator
253289
// override if set and readable, the well-known default if readable,
254290
// or "" when neither exists. Apache gets a second-chance lookup

gearbox-agent/internal/gears/accesslog/plugin_test.go

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -247,6 +247,122 @@ func TestHandleRecentSurfacesTailFailure(t *testing.T) {
247247
}
248248
}
249249

250+
func TestHandleRecentApacheFallsBackToCommonLogFormat(t *testing.T) {
251+
// RHEL-style Apache emits CLF by default (no Referer/UA). Our
252+
// primary apache profile is combined; the gear must fall back
253+
// to ApacheCommon for any line the combined regex rejects so
254+
// the dashboard sees records on those hosts instead of an empty
255+
// envelope.
256+
g := newTestGear()
257+
g.stat = statExisting("/var/log/apache2/access.log")
258+
g.tail = staticTail([]string{
259+
// CLF line — no trailing quoted fields. ApacheCombined
260+
// rejects this; ApacheCommon must catch it.
261+
`192.168.1.10 - - [01/Jan/2026:00:00:00 +0000] "GET /clf-route HTTP/1.1" 500 1234`,
262+
// Combined line — both profiles would accept; primary wins.
263+
`192.168.1.11 - - [01/Jan/2026:00:00:01 +0000] "GET /combined-route HTTP/1.1" 503 100 "-" "curl/8.0"`,
264+
})
265+
266+
r := chi.NewRouter()
267+
g.RegisterRoutes(r)
268+
req := httptest.NewRequest(http.MethodGet, "/api/v1/access-log/apache/recent?status_min=500", nil)
269+
rr := httptest.NewRecorder()
270+
r.ServeHTTP(rr, req)
271+
272+
var resp Response
273+
if err := json.NewDecoder(rr.Body).Decode(&resp); err != nil {
274+
t.Fatalf("decode: %v", err)
275+
}
276+
if resp.MatchCount != 2 {
277+
t.Fatalf("MatchCount = %d, want 2 (combined + CLF fallback)", resp.MatchCount)
278+
}
279+
// Newest-first ordering: combined line is the second tail
280+
// entry (index 1, newer). The records carry their actual
281+
// profile so the dashboard can tell which parser matched —
282+
// CLF lines come back tagged ProfileApacheCommon, combined as
283+
// ProfileApacheCombined.
284+
if resp.Records[0].Profile != accesslog.ProfileApacheCombined {
285+
t.Errorf("first record profile = %q, want %q (combined wins for that line)", resp.Records[0].Profile, accesslog.ProfileApacheCombined)
286+
}
287+
if resp.Records[1].Profile != accesslog.ProfileApacheCommon {
288+
t.Errorf("second record profile = %q, want %q (CLF fallback)", resp.Records[1].Profile, accesslog.ProfileApacheCommon)
289+
}
290+
}
291+
292+
func TestHandleRecentStatusMinZeroDisablesFilter(t *testing.T) {
293+
// Explicit status_min=0 must return EVERY parsed record,
294+
// including 2xx. The previous clamp at 100 silently coerced 0
295+
// up to 100, which is correct for HTTP statuses but blocked the
296+
// "give me everything" use case the dashboard wants for
297+
// general-purpose log browsing.
298+
g := newTestGear()
299+
g.stat = statExisting("/var/log/nginx/access.log")
300+
g.tail = staticTail([]string{
301+
`1.1.1.1 - - [01/Jan/2026:00:00:00 +0000] "GET /a HTTP/1.1" 200 100 "-" "-"`,
302+
`1.1.1.1 - - [01/Jan/2026:00:00:01 +0000] "GET /b HTTP/1.1" 304 0 "-" "-"`,
303+
`1.1.1.1 - - [01/Jan/2026:00:00:02 +0000] "GET /c HTTP/1.1" 500 100 "-" "-"`,
304+
})
305+
306+
r := chi.NewRouter()
307+
g.RegisterRoutes(r)
308+
req := httptest.NewRequest(http.MethodGet, "/api/v1/access-log/nginx/recent?status_min=0", nil)
309+
rr := httptest.NewRecorder()
310+
r.ServeHTTP(rr, req)
311+
312+
var resp Response
313+
if err := json.NewDecoder(rr.Body).Decode(&resp); err != nil {
314+
t.Fatalf("decode: %v", err)
315+
}
316+
if resp.MatchCount != 3 {
317+
t.Errorf("MatchCount = %d with status_min=0, want 3 (all records)", resp.MatchCount)
318+
}
319+
}
320+
321+
func TestHandleRecentStatusMinDefaultIs500(t *testing.T) {
322+
// No status_min param → defaults to 500 (the dashboard's main
323+
// use case). Locks in the documented default so a future tweak
324+
// to parseIntDefault doesn't quietly change behaviour for
325+
// existing callers.
326+
g := newTestGear()
327+
g.stat = statExisting("/var/log/nginx/access.log")
328+
g.tail = staticTail([]string{
329+
`1.1.1.1 - - [01/Jan/2026:00:00:00 +0000] "GET /a HTTP/1.1" 200 100 "-" "-"`,
330+
`1.1.1.1 - - [01/Jan/2026:00:00:01 +0000] "GET /b HTTP/1.1" 500 100 "-" "-"`,
331+
})
332+
r := chi.NewRouter()
333+
g.RegisterRoutes(r)
334+
req := httptest.NewRequest(http.MethodGet, "/api/v1/access-log/nginx/recent", nil)
335+
rr := httptest.NewRecorder()
336+
r.ServeHTTP(rr, req)
337+
338+
var resp Response
339+
_ = json.NewDecoder(rr.Body).Decode(&resp)
340+
if resp.MatchCount != 1 {
341+
t.Errorf("MatchCount without explicit status_min = %d, want 1 (default 500 keeps only the 500-line)", resp.MatchCount)
342+
}
343+
}
344+
345+
func TestParseWithFallbackTriesPrimaryFirst(t *testing.T) {
346+
// Direct test of the helper to keep the contract pinned even
347+
// if the handler rewires which sources use which fallback.
348+
primary := accesslog.NginxCombinedProfile{}
349+
fallback := accesslog.ApacheCommonProfile{}
350+
351+
combinedLine := `1.1.1.1 - - [01/Jan/2026:00:00:00 +0000] "GET /x HTTP/1.1" 200 100 "-" "curl/8.0"`
352+
clfLine := `1.1.1.1 - - [01/Jan/2026:00:00:00 +0000] "GET /x HTTP/1.1" 200 100`
353+
354+
if got := parseWithFallback(primary, fallback, combinedLine); got == nil || got.Profile != accesslog.ProfileNginxCombined {
355+
t.Errorf("combined line should match primary; got %+v", got)
356+
}
357+
if got := parseWithFallback(primary, fallback, clfLine); got == nil || got.Profile != accesslog.ProfileApacheCommon {
358+
t.Errorf("CLF line should fall through to fallback; got %+v", got)
359+
}
360+
// No fallback registered → only primary attempted.
361+
if got := parseWithFallback(primary, nil, clfLine); got != nil {
362+
t.Errorf("nil fallback should not match CLF line; got %+v", got)
363+
}
364+
}
365+
250366
func TestParseIntDefaultClamps(t *testing.T) {
251367
cases := []struct {
252368
name string

gearbox-agent/internal/gears/caddy/collector.go

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -37,30 +37,30 @@ type Stats struct {
3737
// double-counting.
3838
RequestErrorsTotal int64 `json:"request_errors_total"`
3939

40-
// AdminRunning is 1 when Caddy's admin endpoint reports itself
41-
// alive, 0 otherwise. Lets the dashboard surface "Caddy
42-
// running but admin disconnected" without a second probe.
43-
AdminRunning bool `json:"admin_running"`
44-
4540
// CollectedAt is when the agent scraped, in RFC3339.
4641
CollectedAt string `json:"collected_at"`
4742
}
4843

4944
// ParsePrometheusOutput extracts a Stats struct from raw Caddy
5045
// `:2019/metrics` Prometheus output. Exported for unit testing.
46+
//
47+
// We deliberately do NOT report a separate "admin reachable"
48+
// boolean here: the scrape itself goes against the admin endpoint
49+
// (default `:2019/metrics`), so a Stats record landing in the
50+
// dashboard's cache already proves admin is reachable. Surfacing it
51+
// as a field would either always be true (redundant) or depend on
52+
// request-driven metrics like `caddy_admin_http_requests_total`,
53+
// which a freshly-started Caddy with admin enabled but no admin
54+
// traffic yet would not emit — confusing the dashboard with a false
55+
// "admin disconnected" reading. The 503 response from the handler
56+
// before the first scrape covers the "not reachable" state.
5157
func ParsePrometheusOutput(body string) Stats {
5258
samples := promtext.Parse(body)
5359
stats := Stats{CollectedAt: time.Now().UTC().Format(time.RFC3339)}
5460

5561
stats.RequestsTotal = int64(promtext.SumByName(samples, "caddy_http_requests_total"))
5662
stats.RequestErrorsTotal = int64(promtext.SumByName(samples, "caddy_http_request_errors_total"))
5763

58-
if s := promtext.FirstByName(samples, "caddy_admin_http_requests_total"); s != nil {
59-
// admin_http_requests_total existing at all means the
60-
// admin endpoint is up — the metric is only registered
61-
// when admin is enabled.
62-
stats.AdminRunning = true
63-
}
6464
return stats
6565
}
6666

gearbox-agent/internal/gears/caddy/collector_test.go

Lines changed: 9 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -30,25 +30,21 @@ func TestParsePrometheusOutputSumsCounters(t *testing.T) {
3030
want := Stats{
3131
RequestsTotal: 59, // 42 + 17
3232
RequestErrorsTotal: 3,
33-
AdminRunning: true,
3433
}
3534
if got != want {
3635
t.Errorf("Stats = %+v, want %+v", got, want)
3736
}
3837
}
3938

40-
func TestParsePrometheusOutputHandlesMissingAdminMetric(t *testing.T) {
41-
// Admin endpoint disabled (admin off in Caddyfile) — the
42-
// `caddy_admin_*` metric won't appear; AdminRunning must be
43-
// false even though the rest of the scrape is fine.
44-
body := `caddy_http_requests_total{server="srv0"} 7
45-
`
46-
got := ParsePrometheusOutput(body)
47-
if got.AdminRunning {
48-
t.Error("AdminRunning should be false when admin metric is absent")
49-
}
50-
if got.RequestsTotal != 7 {
51-
t.Errorf("RequestsTotal = %d, want 7", got.RequestsTotal)
39+
func TestParsePrometheusOutputBackgroundEmptyBody(t *testing.T) {
40+
// A scrape that returns 200 with no recognisable Caddy metric
41+
// must parse cleanly to zero counters (NOT an error). The
42+
// "admin reachable" question is answered by whether a scrape
43+
// succeeded at all, which the handler conveys via 503 — not by
44+
// any field on the returned Stats.
45+
got := ParsePrometheusOutput("")
46+
if got.RequestsTotal != 0 || got.RequestErrorsTotal != 0 {
47+
t.Errorf("empty body should yield zero-valued counters, got %+v", got)
5248
}
5349
}
5450

0 commit comments

Comments
 (0)