Skip to content

Commit 7390cff

Browse files
sarg3ntclaude
andauthored
feat(#91): agent — phase 4/5/7 metrics collection (nginx, Apache, Caddy, Traefik) + access-log (#101)
* feat(#91): agent — phase 4/5/7 metrics collection (nginx, Apache, Caddy, Traefik) + access-log endpoint Builds on PR #100's detection layer with periodic metric scraping for the four web servers plus a structured access-log endpoint. This finishes the agent-side work for issue #91; the dashboard side (per-source chart cards, multi-source Error Insights, DB migration) ships as the next PR. Per-source collectors (each adds a CollectorGear collector + /api/v1/{name}/stats endpoint, cached snapshot, force=true synchronous re-scrape): - nginx: parses stub_status (active/reading/writing/waiting + monotonic accepts/handled/requests). - apache: parses mod_status?auto's key-value format (Total Accesses, worker pool, CPU load, ReqPerSec, etc.). - caddy: scrapes Prometheus at :2019/metrics; sums caddy_http_requests_total + request_errors_total; flags admin status via caddy_admin_http_requests_total presence. - traefik: scrapes Prometheus; buckets traefik_router_requests_total by status-class label so the dashboard gets a real 2xx/3xx/4xx/5xx breakdown; also surfaces the entrypoints list. Access-log endpoint (Phase 5): - New internal/framework/services/accesslog/ package with 5 profile parsers: haproxy, nginx-combined, apache-common, apache-combined, caddy-json. The dashboard's existing parseHAProxyLogLine is ported into the haproxy profile byte-for-byte (with one regex tightening: the syslog [pid] bracket no longer claims the date match). - New internal/gears/accesslog/ gear: GET /api/v1/access-log/{source}/recent?status_min=500&limit=500 reads the last N lines of the source's access log via tail, parses each line with the matching profile, filters by status_min, returns newest-first. - 4 new env vars to override default log paths (HAPROXY_ACCESS_LOG, NGINX_ACCESS_LOG, APACHE_ACCESS_LOG, CADDY_ACCESS_LOG). Apache falls back from /var/log/apache2/ to /var/log/httpd/ for RHEL hosts. Capability manifest reports which sources have a readable log on this host. Shared helper: internal/framework/services/promtext/ — minimal Prometheus exposition-format parser (samples + label maps; counter sums; SumByNameWithLabel for status-class extraction). Scoped to the agent's needs to avoid pulling in prometheus/common's 50+-package transitive footprint just for two scrape routines. Test coverage: - Each new collector has unit tests covering parser correctness, scrape success / failure modes, 503 before first scrape, cached response shape, force=true behaviour, override resolution. - Access-log gear tests cover probe verdict, capabilities map, unknown source 404, no-log available=false envelope, status_min filtering, limit cap, tail-failure surfacing, isReadable's non-regular-file rejection. - 5 parser profiles each have happy-path + reject-noise tests including HAProxy negative-Tt handling and Caddy non-HTTP entries. - promtext tests cover summation, label-value escapes, malformed lines, trailing scrape timestamps. 23 files added, 7 modified. go build / vet / test / gofmt all clean. Out of scope (Phase 6, 8 + dashboard wire-up): - DB migration adding source column to traffic_flows. - Multi-source Error Insights (dashboard refactor). - Cross-source aggregates (Phase 8 — optional). - Source-aware ingest from these endpoints to traffic_flows. - Per-source chart cards / KPIs / capability gates. These all live in the dashboard repo and ship as the next PR. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * 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> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 8b9b469 commit 7390cff

26 files changed

Lines changed: 3540 additions & 32 deletions

gearbox-agent/cmd/gearbox-agent/main.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ import (
4141
"github.com/sarg3nt/gearbox-agent/internal/framework/services/sync"
4242

4343
// Import plugins - blank identifier triggers init() registration
44+
_ "github.com/sarg3nt/gearbox-agent/internal/gears/accesslog"
4445
_ "github.com/sarg3nt/gearbox-agent/internal/gears/apache"
4546
_ "github.com/sarg3nt/gearbox-agent/internal/gears/caddy"
4647
_ "github.com/sarg3nt/gearbox-agent/internal/gears/certs"
@@ -397,6 +398,10 @@ func main() {
397398
CaddyAdminURL: cfg.CaddyAdminURL,
398399
TraefikMetricsURL: cfg.TraefikMetricsURL,
399400
DockerSocket: cfg.DockerSocket,
401+
HAProxyAccessLog: cfg.HAProxyAccessLog,
402+
NginxAccessLog: cfg.NginxAccessLog,
403+
ApacheAccessLog: cfg.ApacheAccessLog,
404+
CaddyAccessLog: cfg.CaddyAccessLog,
400405
}
401406

402407
// Create plugin manager

gearbox-agent/internal/framework/config/config.go

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,17 @@ type Config struct {
101101
CaddyAdminURL string // CADDY_ADMIN_URL — force the admin/Prometheus URL
102102
TraefikMetricsURL string // TRAEFIK_METRICS_URL — force the Prometheus endpoint URL
103103
DockerSocket string // DOCKER_SOCKET — force a specific docker socket path
104+
105+
// Access-log paths per source. The /api/v1/access-log/{source}/recent
106+
// endpoint reads the most recent N lines from these files and parses
107+
// each with the matching profile. Empty (the default) means the
108+
// endpoint falls back to a well-known path; if that doesn't exist
109+
// the endpoint reports "no readable log file" rather than failing
110+
// the agent. See [docs/source-detection.md] / issue #91 Phase 5.
111+
HAProxyAccessLog string // HAPROXY_ACCESS_LOG
112+
NginxAccessLog string // NGINX_ACCESS_LOG
113+
ApacheAccessLog string // APACHE_ACCESS_LOG
114+
CaddyAccessLog string // CADDY_ACCESS_LOG
104115
}
105116

106117
// DefaultConfig returns the default configuration.
@@ -212,6 +223,13 @@ func Load() (*Config, error) {
212223
cfg.TraefikMetricsURL = strings.TrimSpace(os.Getenv("TRAEFIK_METRICS_URL"))
213224
cfg.DockerSocket = strings.TrimSpace(os.Getenv("DOCKER_SOCKET"))
214225

226+
// Access-log path overrides — trimmed but case-preserved (paths
227+
// are case-sensitive on most filesystems).
228+
cfg.HAProxyAccessLog = strings.TrimSpace(os.Getenv("HAPROXY_ACCESS_LOG"))
229+
cfg.NginxAccessLog = strings.TrimSpace(os.Getenv("NGINX_ACCESS_LOG"))
230+
cfg.ApacheAccessLog = strings.TrimSpace(os.Getenv("APACHE_ACCESS_LOG"))
231+
cfg.CaddyAccessLog = strings.TrimSpace(os.Getenv("CADDY_ACCESS_LOG"))
232+
215233
return cfg, nil
216234
}
217235

gearbox-agent/internal/framework/gear/dependencies.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,15 @@ type Dependencies struct {
6666
CaddyAdminURL string // CADDY_ADMIN_URL
6767
TraefikMetricsURL string // TRAEFIK_METRICS_URL
6868
DockerSocket string // DOCKER_SOCKET
69+
70+
// Per-source access-log paths. Empty means "fall back to the
71+
// gear's well-known default"; an explicit value bypasses the
72+
// fallback (and a non-existent path then surfaces as
73+
// "log file not readable" through the access-log endpoint).
74+
HAProxyAccessLog string // HAPROXY_ACCESS_LOG
75+
NginxAccessLog string // NGINX_ACCESS_LOG
76+
ApacheAccessLog string // APACHE_ACCESS_LOG
77+
CaddyAccessLog string // CADDY_ACCESS_LOG
6978
}
7079

7180
// Common event types used across plugins.
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
package accesslog
2+
3+
import (
4+
"regexp"
5+
"strconv"
6+
"time"
7+
)
8+
9+
// Apache CLF (common log format):
10+
//
11+
// %h %l %u %t \"%r\" %>s %b
12+
//
13+
// Concrete example:
14+
//
15+
// 192.168.1.1 - - [28/Aug/2025:10:24:13 +0000] "GET /path HTTP/1.1" 200 1234
16+
//
17+
// Combined format adds two trailing quoted fields for Referer and
18+
// User-Agent — see ApacheCombinedProfile, which delegates to
19+
// parseCombined for that shape.
20+
//
21+
// We use a single regex for CLF: it's strict about the leading fields
22+
// (IP, dash, dash, bracketed date, quoted request) but tolerant about
23+
// what comes after the byte count, since some operators append %D
24+
// (duration in µs) or trace IDs.
25+
var reApacheCommon = regexp.MustCompile(
26+
`^([^ ]+) [^ ]+ [^ ]+ \[([^\]]+)\] "([A-Z]+) ([^ "]+)[^"]*" (\d{3}) (\d+|-)`,
27+
)
28+
29+
// apacheTimeLayout is identical to nginx's `%t` format — both
30+
// projects use the NCSA Common Log convention.
31+
const apacheTimeLayout = "02/Jan/2006:15:04:05 -0700"
32+
33+
// ApacheCommonProfile parses Apache CLF lines (without Referer /
34+
// User-Agent). Returns nil for lines that don't carry a valid HTTP
35+
// status code.
36+
type ApacheCommonProfile struct{}
37+
38+
// Profile satisfies Parser.
39+
func (ApacheCommonProfile) Profile() string { return ProfileApacheCommon }
40+
41+
// Parse returns a Record for one Apache CLF log line, or nil on
42+
// shape mismatch.
43+
func (ApacheCommonProfile) Parse(raw string) *Record {
44+
m := reApacheCommon.FindStringSubmatch(raw)
45+
if len(m) < 7 {
46+
return nil
47+
}
48+
status, err := strconv.Atoi(m[5])
49+
if err != nil || !validStatusCode(status) {
50+
return nil
51+
}
52+
53+
rec := &Record{
54+
Profile: ProfileApacheCommon,
55+
SourceIP: m[1],
56+
TimestampRaw: m[2],
57+
Method: m[3],
58+
Path: m[4],
59+
StatusCode: status,
60+
Raw: trimRaw(raw),
61+
}
62+
63+
if m[6] != "-" {
64+
if n, err := strconv.ParseInt(m[6], 10, 64); err == nil {
65+
rec.BytesSent = n
66+
}
67+
}
68+
69+
if t, err := time.Parse(apacheTimeLayout, m[2]); err == nil {
70+
rec.Timestamp = t
71+
}
72+
73+
return rec
74+
}
75+
76+
// ApacheCombinedProfile parses Apache "combined" format — CLF plus
77+
// Referer and User-Agent. Same byte-for-byte shape as nginx's
78+
// combined format (the Apache directive `combined` was inherited
79+
// from NCSA), so we delegate to parseCombined and only differ in the
80+
// Profile identifier we stamp on each Record.
81+
type ApacheCombinedProfile struct{}
82+
83+
// Profile satisfies Parser.
84+
func (ApacheCombinedProfile) Profile() string { return ProfileApacheCombined }
85+
86+
// Parse returns a Record for one Apache combined-format log line.
87+
func (ApacheCombinedProfile) Parse(raw string) *Record {
88+
return parseCombined(raw, ProfileApacheCombined)
89+
}
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
package accesslog
2+
3+
import (
4+
"encoding/json"
5+
"time"
6+
)
7+
8+
// caddyAccessLog is the shape Caddy's `http.log.access` logger emits
9+
// when the operator hasn't disabled fields. We only need a handful
10+
// of values for the Record; everything else is left in the raw line
11+
// for the dashboard's "see the original" affordance.
12+
//
13+
// Field-by-field (Caddy v2.x):
14+
//
15+
// ts: float Unix seconds with sub-second precision
16+
// duration: float seconds (we convert to ms)
17+
// size: int response body bytes
18+
// status: int HTTP status code
19+
// request: embedded {remote_ip, method, uri, host, headers{User-Agent,Referer}}
20+
type caddyAccessLog struct {
21+
TS float64 `json:"ts"`
22+
Duration float64 `json:"duration"`
23+
Size int64 `json:"size"`
24+
Status int `json:"status"`
25+
Request struct {
26+
RemoteIP string `json:"remote_ip"`
27+
Method string `json:"method"`
28+
URI string `json:"uri"`
29+
Host string `json:"host"`
30+
Headers map[string][]string `json:"headers"`
31+
} `json:"request"`
32+
}
33+
34+
// CaddyJSONProfile parses Caddy's structured JSON access log. Each
35+
// line must be one valid JSON object; multi-line / pretty-printed
36+
// output returns nil. Returns nil for any object that's missing a
37+
// recognisable HTTP status code (heartbeats, admin events, etc.
38+
// that Caddy may also write to the same logger if misconfigured).
39+
type CaddyJSONProfile struct{}
40+
41+
// Profile satisfies Parser.
42+
func (CaddyJSONProfile) Profile() string { return ProfileCaddyJSON }
43+
44+
// Parse returns a Record for one Caddy JSON access-log entry, or nil
45+
// when the line isn't a recognisable HTTP access event.
46+
func (CaddyJSONProfile) Parse(raw string) *Record {
47+
var entry caddyAccessLog
48+
if err := json.Unmarshal([]byte(raw), &entry); err != nil {
49+
return nil
50+
}
51+
if !validStatusCode(entry.Status) {
52+
return nil
53+
}
54+
55+
rec := &Record{
56+
Profile: ProfileCaddyJSON,
57+
StatusCode: entry.Status,
58+
BytesSent: entry.Size,
59+
DurationMs: entry.Duration * 1000.0,
60+
SourceIP: entry.Request.RemoteIP,
61+
Method: entry.Request.Method,
62+
Path: entry.Request.URI,
63+
Host: entry.Request.Host,
64+
Raw: trimRaw(raw),
65+
}
66+
67+
if entry.TS > 0 {
68+
// Caddy's `ts` is float Unix seconds with sub-second
69+
// precision; time.UnixMicro keeps that precision when the
70+
// caller wants to render at ms granularity downstream.
71+
rec.Timestamp = time.UnixMicro(int64(entry.TS * 1e6)).UTC()
72+
rec.TimestampRaw = rec.Timestamp.Format(time.RFC3339Nano)
73+
}
74+
75+
if h := entry.Request.Headers; h != nil {
76+
if v := h["User-Agent"]; len(v) > 0 {
77+
rec.UserAgent = v[0]
78+
}
79+
if v := h["Referer"]; len(v) > 0 {
80+
rec.Referer = v[0]
81+
}
82+
}
83+
84+
return rec
85+
}
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
package accesslog
2+
3+
import (
4+
"regexp"
5+
"strconv"
6+
)
7+
8+
// HAProxy HTTP log format (from `option httplog` / the default
9+
// log-format), as the field positions appear in practice:
10+
//
11+
// <date> <host> haproxy[<pid>]: <client_ip>:<port> [<accept_date>] <frontend>~ <backend>/<server> <Tq>/<Tw>/<Tc>/<Tr>/<Tt> <status> <bytes_read> ... "<method> <path> HTTP/1.x"
12+
//
13+
// We parse defensively — fields can vary by HAProxy version and the
14+
// operator's custom log-format. Anything we can't pull out stays
15+
// zero-valued on the Record. This is a direct port of the dashboard's
16+
// original `parseHAProxyLogLine`, kept identical in behaviour so the
17+
// metrics-page Error Insights panel sees no diff when the dashboard
18+
// flips to consuming the agent endpoint instead of parsing locally.
19+
var (
20+
reHAProxyStatus = regexp.MustCompile(`\s(\d{3})\s+\d+\s`)
21+
reHAProxyReq = regexp.MustCompile(`"([A-Z]+)\s+([^\s"]+)`)
22+
reHAProxyBkSvr = regexp.MustCompile(`\s([A-Za-z0-9_.\-]+)/([A-Za-z0-9_.\-]+)\s+\d+\/\-?\d+\/\-?\d+\/\-?\d+\/\-?\d+\s`)
23+
reHAProxyClient = regexp.MustCompile(`(?:^|\s)(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}|[0-9a-fA-F:]+):\d+\s+\[`)
24+
// Anchored on the date shape (day/Mon/year:hh:mm:ss) so the
25+
// syslog-style `haproxy[1234]:` PID bracket doesn't claim the
26+
// match. The dashboard-side original had this latent bug; the
27+
// fixtures there never carried the syslog wrapper.
28+
reHAProxyDate = regexp.MustCompile(`\[(\d{1,2}/\w{3}/\d{4}:[^\]]+)\]`)
29+
// Tt is the total time field; it appears as the fifth slash-
30+
// separated number in Tq/Tw/Tc/Tr/Tt. Negative values mean the
31+
// session was aborted before that timer was set; we surface ms
32+
// only when Tt is non-negative.
33+
reHAProxyTimings = regexp.MustCompile(`\s\-?\d+\/\-?\d+\/\-?\d+\/\-?\d+\/(\-?\d+)\s+\d{3}\s`)
34+
)
35+
36+
// HAProxyProfile parses HAProxy HTTP access log lines. Returns nil
37+
// for lines that don't carry a valid HTTP status code (SSL handshake
38+
// errors, connection diagnostics, etc.).
39+
type HAProxyProfile struct{}
40+
41+
// Profile satisfies Parser.
42+
func (HAProxyProfile) Profile() string { return ProfileHAProxy }
43+
44+
// Parse pulls structured fields out of one HAProxy HTTP log line.
45+
func (HAProxyProfile) Parse(raw string) *Record {
46+
statusMatch := reHAProxyStatus.FindStringSubmatch(raw)
47+
if len(statusMatch) < 2 {
48+
return nil
49+
}
50+
status, _ := strconv.Atoi(statusMatch[1])
51+
if !validStatusCode(status) {
52+
return nil
53+
}
54+
55+
rec := &Record{
56+
Profile: ProfileHAProxy,
57+
StatusCode: status,
58+
Raw: trimRaw(raw),
59+
}
60+
61+
if m := reHAProxyDate.FindStringSubmatch(raw); len(m) > 1 {
62+
rec.TimestampRaw = m[1]
63+
}
64+
if m := reHAProxyClient.FindStringSubmatch(raw); len(m) > 1 {
65+
rec.SourceIP = m[1]
66+
}
67+
if m := reHAProxyBkSvr.FindStringSubmatch(raw); len(m) > 2 {
68+
rec.Backend = m[1]
69+
rec.Server = m[2]
70+
}
71+
if m := reHAProxyReq.FindStringSubmatch(raw); len(m) > 2 {
72+
rec.Method = m[1]
73+
rec.Path = m[2]
74+
}
75+
if m := reHAProxyTimings.FindStringSubmatch(raw); len(m) > 1 {
76+
if tt, err := strconv.Atoi(m[1]); err == nil && tt >= 0 {
77+
rec.DurationMs = float64(tt)
78+
}
79+
}
80+
81+
return rec
82+
}

0 commit comments

Comments
 (0)