Skip to content

Commit ecfe96b

Browse files
authored
Ship request Host as a dedicated botlog Event field (#8)
- Add Event.Host (JSON: host) carrying the request \$host header alongside the existing Event.ServerName (\$server_name vhost config). Both ship side by side so receiver-side joins can pick the right one — no silent semantic shift on serverName - Add `host` to botlog.RequiredFields(); app.registerLogCollector merges it into ExtractFields; Observer resolves idxHost at startup like the other field indices - New normalizeHost: lowercase + strip optional `:port`, handling bracketed IPv6 literals correctly via net.SplitHostPort. 256-byte length cap applied first so a hostile 8 KB Host header can't make SplitHostPort scan the full payload - BuildEvent runs normalizeHost so Fields.Host can stay raw — single source of truth; non-Observer callers don't have to know the contract - Reuse the existing truncate() helper instead of duplicating the length-cap branch - Tests: TestNormalizeHost (9 cases incl. bracketed IPv6 with/ without port and the 256-byte cap), TestObserver_HostAndServer NameIndependent (Host normalized, ServerName raw), TestObserver_HostMissingShipsEmpty (log_format without \$host) - Fix stale "4 RequiredFields" wording in nginx/log.go; docs/metrics.md gets an Event payload notes section documenting the host vs serverName distinction and the normalization rules
1 parent 8f39382 commit ecfe96b

5 files changed

Lines changed: 98 additions & 16 deletions

File tree

docs/metrics.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -271,6 +271,12 @@ Metrics from Angie JSON API (`/status/`). Requires `api /status/;` directive in
271271

272272
Opt-in. Emitted when `[BotLogs].Enabled = true`. The agent matches every parsed nginx access-log line against a built-in UA fingerprint table (38 families) and ships matched events as gzipped ndjson to the topsrv.io `/v1/bot-logs` endpoint, with disk-backed WAL spool for retry on transient send failures.
273273

274+
Event payload notes:
275+
276+
- `host` — the request `$host` header, normalized (lowercased, optional `:port` stripped, bracketed IPv6 preserved). Client-controlled. Use this when grouping by the actual domain the client requested.
277+
- `serverName` — nginx `$server_name` of the matched virtual host (config-controlled). Use this when grouping by the operator-configured vhost.
278+
- Both ship side by side; downstream dashboards/joins should pick the one that fits the question.
279+
274280
| Metric | Type | Labels | Description |
275281
|--------|------|--------|-------------|
276282
| `topsrv_botlog_events_total` | counter | state, reason | Event lifecycle counts. `state=enqueued` (entered queue), `sent` (acked by ingest), `spooled` (written to WAL after transient failure), `dropped` (`reason` splits cause). For non-dropped states `reason=""`. For `state=dropped` the `reason` label is one of `queue_full` (Enqueue while queue full → raise `BatchSize`), `permanent` (4xx, payload bad), `spool_write` (mkdir/write/missing SpoolDir), `spool_evict` (trim by `MaxSpoolMB` or foreign-owned file). |

internal/topsrv/botlog/event.go

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import (
1313
// wire and save bytes.
1414
type Event struct {
1515
TS time.Time `json:"ts"`
16+
Host string `json:"host,omitempty"`
1617
ServerName string `json:"serverName,omitempty"`
1718
AgentHostname string `json:"agentHostname"`
1819
RemoteAddr string `json:"remoteAddr,omitempty"`
@@ -40,7 +41,8 @@ type Fields struct {
4041
UpstreamResponseTime string // may be a comma-separated chain on retries
4142
UpstreamCacheStatus string
4243
UserAgent string
43-
ServerName string
44+
Host string // raw request Host header — BuildEvent runs normalizeHost
45+
ServerName string // matched nginx vhost config name
4446
RemoteAddr string
4547
Referer string
4648
Method string
@@ -62,6 +64,7 @@ func NewEvent(now time.Time, agentHostname string, f Fields, extraUAPatterns []s
6264
func BuildEvent(now time.Time, agentHostname string, f Fields, family, name string, uaTruncate int) Event {
6365
return Event{
6466
TS: now,
67+
Host: normalizeHost(f.Host),
6568
ServerName: f.ServerName,
6669
AgentHostname: agentHostname,
6770
RemoteAddr: dashToEmpty(f.RemoteAddr),

internal/topsrv/botlog/observer.go

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
package botlog
22

33
import (
4+
"net"
45
"slices"
6+
"strings"
57
"time"
68

79
"github.com/vmkteam/topsrv/internal/topsrv/nginx"
@@ -10,6 +12,7 @@ import (
1012
// nginx variable names botlog needs from the log_format.
1113
const (
1214
fieldUserAgent = "http_user_agent"
15+
fieldHost = "host"
1316
fieldServerName = "server_name"
1417
fieldRemoteAddr = "remote_addr"
1518
fieldReferer = "http_referer"
@@ -21,7 +24,25 @@ const (
2124
// LogConfig.ExtractFields, leaving operator-supplied ExtraLabels (low
2225
// cardinality) as the only Prometheus label set.
2326
func RequiredFields() []string {
24-
return []string{fieldUserAgent, fieldServerName, fieldRemoteAddr, fieldReferer}
27+
return []string{fieldUserAgent, fieldHost, fieldServerName, fieldRemoteAddr, fieldReferer}
28+
}
29+
30+
// maxHostLen caps the request Host header value retained on an Event. Picked
31+
// to fit any legal FQDN (DNS RFC 1035 = 253 chars) with headroom for IDN
32+
// punycode plus a bracketed IPv6 literal — anything longer is hostile input.
33+
// Operator-tunable UA truncation lives separately as Config.UATruncate.
34+
const maxHostLen = 256
35+
36+
// normalizeHost lowercases the host and strips an optional `:port` suffix,
37+
// handling bracketed IPv6 literals (`[::1]:80` → `[::1]`). The length cap is
38+
// applied first so SplitHostPort doesn't scan a hostile 8 KB Host header
39+
// (nginx accepts up to large_client_header_buffers without escaping).
40+
func normalizeHost(s string) string {
41+
s = truncate(s, maxHostLen)
42+
if h, _, err := net.SplitHostPort(s); err == nil {
43+
s = h
44+
}
45+
return strings.ToLower(s)
2546
}
2647

2748
// Observer implements nginx.LogObserver. On every parsed access log line it
@@ -36,7 +57,7 @@ type Observer struct {
3657

3758
// Resolved at construction from the LogCollector's ExtractFields. -1 when
3859
// the operator's log_format doesn't carry that variable.
39-
idxUA, idxServerName, idxRemoteAddr, idxReferer int
60+
idxUA, idxHost, idxServerName, idxRemoteAddr, idxReferer int
4061
}
4162

4263
// NewObserver wires an Observer against an already-constructed Pusher.
@@ -49,6 +70,7 @@ func NewObserver(p *Pusher, cfg Config, hostname string, extractFields []string)
4970
uaTruncate: cfg.UATruncate,
5071
extraPatterns: cfg.ExtraUAPatterns,
5172
idxUA: slices.Index(extractFields, fieldUserAgent),
73+
idxHost: slices.Index(extractFields, fieldHost),
5274
idxServerName: slices.Index(extractFields, fieldServerName),
5375
idxRemoteAddr: slices.Index(extractFields, fieldRemoteAddr),
5476
idxReferer: slices.Index(extractFields, fieldReferer),
@@ -83,6 +105,7 @@ func (o *Observer) OnLogLine(p *nginx.ParsedLine, _ string) {
83105
UpstreamResponseTime: p.UpstreamResponseTime,
84106
UpstreamCacheStatus: p.UpstreamCacheStatus,
85107
UserAgent: ua,
108+
Host: o.field(p, o.idxHost),
86109
ServerName: o.field(p, o.idxServerName),
87110
RemoteAddr: o.field(p, o.idxRemoteAddr),
88111
Referer: o.field(p, o.idxReferer),

internal/topsrv/botlog/observer_test.go

Lines changed: 62 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package botlog
33
import (
44
"net/http"
55
"net/http/httptest"
6+
"strings"
67
"testing"
78
"time"
89

@@ -17,11 +18,11 @@ import (
1718
)
1819

1920
func TestRequiredFieldsContents(t *testing.T) {
20-
// RequiredFields contract: the four nginx variables botlog needs read into
21+
// RequiredFields contract: nginx variables botlog needs read into
2122
// ParsedLine.Extras. Order is no longer load-bearing (Observer resolves
2223
// indices at runtime), but the set must stay stable across versions.
2324
assert.ElementsMatch(t,
24-
[]string{fieldUserAgent, fieldServerName, fieldRemoteAddr, fieldReferer},
25+
[]string{fieldUserAgent, fieldHost, fieldServerName, fieldRemoteAddr, fieldReferer},
2526
RequiredFields())
2627
}
2728

@@ -35,37 +36,37 @@ func newObserverPair(t *testing.T) (*Observer, *Pusher) {
3536
}
3637
require.NoError(t, cfg.Validate(topsrv.PushConfig{}))
3738
p := NewPusher(embedlog.Logger{}, "topsrv-test", "test", cfg, prometheus.NewRegistry())
38-
// Default order matches botParsedLine's Extras layout (ua, serverName,
39-
// remoteAddr, referer) so existing tests' positional assumptions hold.
39+
// Tests use the canonical RequiredFields() order: ua, host, server_name,
40+
// remote_addr, referer (see botParsedLine).
4041
o := NewObserver(p, cfg, "web01", RequiredFields())
4142
return o, p
4243
}
4344

44-
func botParsedLine(ua, serverName, remoteAddr, referer string) *nginx.ParsedLine {
45+
func botParsedLine(ua, host, serverName, remoteAddr, referer string) *nginx.ParsedLine {
4546
return &nginx.ParsedLine{
4647
Status: "200",
4748
URI: "/api",
4849
BodyBytesSent: "1234",
4950
RequestTime: "0.150",
50-
Extras: [nginx.MaxExtras]string{ua, serverName, remoteAddr, referer},
51-
NExtras: 4,
51+
Extras: [nginx.MaxExtras]string{ua, host, serverName, remoteAddr, referer},
52+
NExtras: 5,
5253
}
5354
}
5455

5556
func TestObserver_EnqueuesBotEvent(t *testing.T) {
5657
o, p := newObserverPair(t)
5758

58-
o.OnLogLine(botParsedLine("Mozilla/5.0 GPTBot/1.0", "api.example.com", "203.0.113.5", "-"), "")
59+
o.OnLogLine(botParsedLine("Mozilla/5.0 GPTBot/1.0", "api.example.com", "vhost_cfg", "203.0.113.5", "-"), "")
5960

6061
assert.InDelta(t, 1, testutil.ToFloat64(p.eventsTotal.WithLabelValues(stateEnqueued, "")), 0.01)
6162
assert.InDelta(t, 1, testutil.ToFloat64(p.matchTotal.WithLabelValues("openai")), 0.01)
6263

63-
// Pull the event off the queue and inspect.
6464
select {
6565
case ev := <-p.queue:
6666
assert.Equal(t, "openai", ev.BotFamily)
6767
assert.Equal(t, "gptbot", ev.BotName)
68-
assert.Equal(t, "api.example.com", ev.ServerName)
68+
assert.Equal(t, "api.example.com", ev.Host, "Host carries the request $host header")
69+
assert.Equal(t, "vhost_cfg", ev.ServerName, "ServerName carries the matched $server_name")
6970
assert.Equal(t, "203.0.113.5", ev.RemoteAddr)
7071
assert.Empty(t, ev.Referer, "dash referer dropped")
7172
assert.Equal(t, "web01", ev.AgentHostname)
@@ -111,7 +112,7 @@ func TestObserver_FallsBackToURIWhenRawPathEmpty(t *testing.T) {
111112
func TestObserver_NonBotIgnored(t *testing.T) {
112113
o, p := newObserverPair(t)
113114

114-
o.OnLogLine(botParsedLine("Mozilla/5.0 (Macintosh) Safari/605", "x.example.com", "1.2.3.4", "-"), "")
115+
o.OnLogLine(botParsedLine("Mozilla/5.0 (Macintosh) Safari/605", "x.example.com", "", "1.2.3.4", "-"), "")
115116

116117
assert.InDelta(t, 0, testutil.ToFloat64(p.eventsTotal.WithLabelValues(stateEnqueued, "")), 0.01)
117118
assert.InDelta(t, 0, testutil.ToFloat64(p.matchTotal.WithLabelValues("openai")), 0.01)
@@ -120,7 +121,7 @@ func TestObserver_NonBotIgnored(t *testing.T) {
120121
func TestObserver_EmptyUAIgnored(t *testing.T) {
121122
o, p := newObserverPair(t)
122123

123-
o.OnLogLine(botParsedLine("", "x.example.com", "1.2.3.4", "-"), "")
124+
o.OnLogLine(botParsedLine("", "x.example.com", "", "1.2.3.4", "-"), "")
124125

125126
assert.InDelta(t, 0, testutil.ToFloat64(p.eventsTotal.WithLabelValues(stateEnqueued, "")), 0.01)
126127
}
@@ -238,7 +239,56 @@ func TestObserver_MissingFieldsSafe(t *testing.T) {
238239
require.Len(t, p.queue, 1)
239240
ev := <-p.queue
240241
assert.Equal(t, "openai", ev.BotFamily)
242+
assert.Empty(t, ev.Host)
241243
assert.Empty(t, ev.ServerName)
242244
assert.Empty(t, ev.RemoteAddr)
243245
assert.Empty(t, ev.Referer)
244246
}
247+
248+
func TestNormalizeHost(t *testing.T) {
249+
cases := []struct {
250+
name, in, want string
251+
}{
252+
{"empty", "", ""},
253+
{"lowercase", "Example.COM", "example.com"},
254+
{"strip port", "example.com:8080", "example.com"},
255+
{"strip port + lowercase", "API.Example.com:443", "api.example.com"},
256+
{"no port", "example.com", "example.com"},
257+
{"trailing colon — SplitHostPort accepts empty port", "example.com:", "example.com"},
258+
{"IPv6 bracketed with port", "[::1]:8080", "::1"},
259+
// SplitHostPort needs a port to unwrap brackets; bare bracketed
260+
// literals pass through verbatim. Not a design choice — artifact.
261+
{"IPv6 bracketed no port", "[2001:db8::1]", "[2001:db8::1]"},
262+
{"truncate over maxHostLen", strings.Repeat("a", 300), strings.Repeat("a", 256)},
263+
}
264+
for _, tc := range cases {
265+
t.Run(tc.name, func(t *testing.T) {
266+
assert.Equal(t, tc.want, normalizeHost(tc.in))
267+
})
268+
}
269+
}
270+
271+
// Host and ServerName are independent: Host carries the (normalized) request
272+
// $host, ServerName carries the matched nginx $server_name. Either may be
273+
// empty if the log_format doesn't include it.
274+
func TestObserver_HostAndServerNameIndependent(t *testing.T) {
275+
o, p := newObserverPair(t)
276+
o.OnLogLine(botParsedLine("GPTBot/1.0", "Real.Example.COM:443", "vhost_cfg", "1.2.3.4", "https://prev.example.com/page"), "")
277+
278+
require.Len(t, p.queue, 1)
279+
ev := <-p.queue
280+
assert.Equal(t, "real.example.com", ev.Host, "Host = normalizeHost($host)")
281+
assert.Equal(t, "vhost_cfg", ev.ServerName, "ServerName = raw $server_name")
282+
assert.Equal(t, "https://prev.example.com/page", ev.Referer, "non-dash referer passes through")
283+
}
284+
285+
// log_format without $host: Event.Host is empty, ServerName still ships.
286+
func TestObserver_HostMissingShipsEmpty(t *testing.T) {
287+
o, p := newObserverPair(t)
288+
o.OnLogLine(botParsedLine("GPTBot/1.0", "", "vhost_cfg", "1.2.3.4", "-"), "")
289+
290+
require.Len(t, p.queue, 1)
291+
ev := <-p.queue
292+
assert.Empty(t, ev.Host)
293+
assert.Equal(t, "vhost_cfg", ev.ServerName)
294+
}

internal/topsrv/nginx/log.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -114,7 +114,7 @@ type statusURI struct {
114114
}
115115

116116
// MaxExtras caps the per-line Extras slot count. Sized to fit operator
117-
// ExtraLabels (typically ≤3) plus botlog's 4 RequiredFields() with room.
117+
// ExtraLabels (typically ≤3) plus botlog's RequiredFields() with room.
118118
const MaxExtras = 8
119119

120120
type taggedStatusKey struct {

0 commit comments

Comments
 (0)