Skip to content

Commit 6a57a9d

Browse files
ErenAriclaude
andcommitted
feat(metrics): opt-in Prometheus /metrics HTTP endpoint + textfile collector
The Prometheus exposition already existed as the `aegisbpf metrics` CLI (35+ low-cardinality families read from the pinned maps). This makes it scrape-able. - src/metrics_server.{hpp,cpp}: a minimal opt-in HTTP/1.0 server. Off unless AEGIS_METRICS_ADDR=<host:port>. Routes GET /metrics and GET /healthz; binds loopback by default (bind :9635 to expose, restrict via firewall/NetworkPolicy). No BPF/kernel dependency — the body comes from a callback — so it is unit-tested over a real loopback socket. - src/commands_metrics.{cpp,hpp}: refactor the builder out of `cmd_metrics` into a shared `build_metrics_report(BpfState&, bool)` so the CLI, the textfile collector, and the HTTP endpoint emit identical output. Add an `aegisbpf_deny_ttl_entries` gauge (control-API denies awaiting TTL expiry). - src/daemon.cpp: start the endpoint when AEGIS_METRICS_ADDR is set, reusing the daemon's already-loaded state (no per-scrape reload). Reads touch only pinned maps + files, so they run concurrently with the poll loop safely; in-process counters (pin_heal_*) are intentionally deferred until made scrape-safe. - packaging/systemd/aegisbpf-metrics.{service,timer}: node_exporter textfile collector — the no-open-port alternative (atomic temp+rename write every 30s). - tests/test_metrics_server.cpp: bind-addr parsing + loopback round-trip (/metrics, /healthz, 404, no-callback 503, non-GET 405). - docs/METRICS.md (+ index link), CHANGELOG. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent f92d51b commit 6a57a9d

13 files changed

Lines changed: 644 additions & 20 deletions

CMakeLists.txt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -367,6 +367,7 @@ set(AEGIS_SOURCES
367367
src/kernel_features.cpp
368368
src/network_ops.cpp
369369
src/otlp_exporter.cpp
370+
src/metrics_server.cpp
370371
src/socket_api.cpp
371372
src/ttl_registry.cpp
372373
src/policy.cpp
@@ -563,6 +564,7 @@ set(AEGIS_LIB_SOURCES
563564
src/kernel_features.cpp
564565
src/network_ops.cpp
565566
src/otlp_exporter.cpp
567+
src/metrics_server.cpp
566568
src/socket_api.cpp
567569
src/ttl_registry.cpp
568570
src/policy.cpp
@@ -697,6 +699,7 @@ if(BUILD_TESTING)
697699
tests/test_net_block_event_dedup.cpp
698700
tests/test_event_decode.cpp
699701
tests/test_posture_gate.cpp
702+
tests/test_metrics_server.cpp
700703
tests/test_socket_api.cpp
701704
tests/test_ttl_registry.cpp
702705
)

docs/CHANGELOG.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Added — Prometheus metrics endpoint
11+
- **Opt-in HTTP `/metrics` endpoint** (`AEGIS_METRICS_ADDR=<host:port>`,
12+
`src/metrics_server.{hpp,cpp}`, `src/daemon.cpp` wiring, `docs/METRICS.md`) —
13+
serves the agent's Prometheus exposition over HTTP so Prometheus /
14+
kube-prometheus can scrape it directly, reusing the daemon's already-loaded BPF
15+
state (no per-scrape reload). Off by default; routes `GET /metrics` and
16+
`GET /healthz`. Binds loopback by default; bind `:9635` to expose and restrict
17+
with a firewall / NetworkPolicy (no auth, standard for a scrape target). The
18+
Prometheus builder was refactored out of the `metrics` CLI command into a shared
19+
`build_metrics_report(BpfState&, bool)` so the CLI, the textfile collector, and
20+
the HTTP endpoint all emit identical output. A new `aegisbpf_deny_ttl_entries`
21+
gauge exposes the count of control-API denies awaiting TTL expiry.
22+
- **node_exporter textfile-collector units** (`packaging/systemd/aegisbpf-metrics.{service,timer}`)
23+
— the no-open-port alternative: a 30 s timer writes the exposition atomically to
24+
`${AEGIS_METRICS_TEXTFILE}` for node_exporter to serve.
25+
- New GTest suite `tests/test_metrics_server.cpp` (bind-addr parsing, real
26+
loopback round-trip for `/metrics` `/healthz` 404, no-callback 503, non-GET 405).
27+
1028
## [0.10.0] - 2026-08-11
1129

1230
Ecosystem integration and programmatic enforcement: AegisBPF now plugs into the

docs/METRICS.md

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
# Metrics
2+
3+
AegisBPF exposes its enforcement state as Prometheus metrics. There are two ways
4+
to get them — pick one:
5+
6+
## 1. Built-in HTTP endpoint (opt-in)
7+
8+
Set `AEGIS_METRICS_ADDR` on the agent and it serves the exposition over HTTP:
9+
10+
```bash
11+
AEGIS_METRICS_ADDR=127.0.0.1:9635 aegisbpf run --enforce
12+
curl -s http://127.0.0.1:9635/metrics
13+
```
14+
15+
- `GET /metrics` → Prometheus exposition (`text/plain; version=0.0.4`).
16+
- `GET /healthz``ok` (liveness).
17+
- Off unless `AEGIS_METRICS_ADDR` is set. The daemon reuses its already-loaded
18+
BPF state, so a scrape does not reload anything.
19+
20+
**Security.** The endpoint has no authentication (standard for a Prometheus
21+
scrape target). Bind **loopback** (`127.0.0.1:9635`) unless a scraper needs the
22+
node/pod IP; to expose it, bind `:9635` (all interfaces) and restrict access with
23+
a firewall or Kubernetes `NetworkPolicy`. In Kubernetes, add a container port and
24+
a `ServiceMonitor`/`PodMonitor` pointing at `/metrics`.
25+
26+
## 2. node_exporter textfile collector (no open port)
27+
28+
If you'd rather not open a port, write the exposition to a file that
29+
node_exporter serves. Ships as a systemd timer:
30+
31+
```bash
32+
systemctl enable --now aegisbpf-metrics.timer # runs `aegisbpf metrics` every 30s
33+
```
34+
35+
It writes `${AEGIS_METRICS_TEXTFILE}` (default
36+
`/var/lib/node_exporter/textfile_collector/aegisbpf.prom`) atomically (temp +
37+
rename). Point node_exporter at that directory with
38+
`--collector.textfile.directory`. You can also run it by hand:
39+
40+
```bash
41+
aegisbpf metrics --out /var/lib/node_exporter/textfile_collector/aegisbpf.prom
42+
aegisbpf metrics # or just print to stdout
43+
aegisbpf metrics --detailed # high-cardinality per-path / per-inode / per-ip series
44+
```
45+
46+
## What's exposed
47+
48+
Low-cardinality by default (35+ families), read straight from the pinned BPF
49+
maps and agent state. Highlights:
50+
51+
| Metric | Type | Meaning |
52+
|---|---|---|
53+
| `aegisbpf_blocks_total` | counter | Total blocked file operations |
54+
| `aegisbpf_ringbuf_drops_total` | counter | Dropped ring-buffer events |
55+
| `aegisbpf_net_blocks_total{type=…}` | counter | Blocked network ops by direction |
56+
| `aegisbpf_deny_inode_entries` / `aegisbpf_deny_path_entries` | gauge | Active file-deny map sizes |
57+
| `aegisbpf_deny_ttl_entries` | gauge | Control-API denies with a pending TTL (auto-expiry) |
58+
| `aegisbpf_map_utilization{map=…}` | gauge | Per-map fill ratio (capacity pressure) |
59+
| `aegisbpf_runtime_state{state=…}` | gauge | Posture: ENFORCE / ENFORCE_SIGNAL / AUDIT_FALLBACK / DEGRADED |
60+
| `aegisbpf_hook_latency_max_ns` | gauge | Worst-case LSM hook latency |
61+
| `aegisbpf_backpressure_*` | counter | Dual-path telemetry submit/drop counters |
62+
| `aegisbpf_enforce_capable` | gauge | Whether the kernel/config can enforce |
63+
64+
`--detailed` (CLI) adds high-cardinality per-path / per-inode / per-ip / per-port
65+
series — use sparingly.
66+
67+
> **Note.** Daemon in-process health counters (e.g. `pin_heal_*`) are surfaced via
68+
> structured logs today; exposing them as metrics is a planned follow-up (the
69+
> counters are made scrape-safe first).

docs/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ A categorized map of the `docs/` tree. For a high-level overview start with the
4343
- [BTF Fallback](BTF_FALLBACK.md)
4444

4545
## Operations & Runbooks
46-
- [Monitoring & Alerting Guide](MONITORING_GUIDE.md) · [Metrics Operations](METRICS_OPERATIONS.md)
46+
- [Monitoring & Alerting Guide](MONITORING_GUIDE.md) · [Metrics Operations](METRICS_OPERATIONS.md) · [Metrics endpoint (Prometheus)](METRICS.md)
4747
- [Troubleshooting Guide](TROUBLESHOOTING.md) · [Error Handling Guidelines](ERROR_HANDLING.md)
4848
- [Emergency Recovery Runbook](RUNBOOK_RECOVERY.md) · [Incident Response Runbook](INCIDENT_RESPONSE.md)
4949
- [Staging Canary Runbook](CANARY_RUNBOOK.md) · [Release Drill Runbook](RELEASE_DRILL.md)

docs/man/aegisbpf.1.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -266,6 +266,7 @@ Exported metrics:
266266
- `aegisbpf_perf_slo_failed_rows`
267267
- `aegisbpf_deny_inode_entries`
268268
- `aegisbpf_deny_path_entries`
269+
- `aegisbpf_deny_ttl_entries`
269270
- `aegisbpf_allow_cgroup_entries`
270271
- `aegisbpf_allow_exec_inode_entries`
271272
- `aegisbpf_map_capacity{map}`
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
# AegisBPF metrics — Prometheus node_exporter textfile-collector writer.
2+
#
3+
# Runs `aegisbpf metrics` and writes the exposition to a .prom file that the
4+
# node_exporter textfile collector picks up. This is the zero-network-port
5+
# alternative to the agent's built-in HTTP endpoint (AEGIS_METRICS_ADDR): no
6+
# port is opened; node_exporter serves the metrics for you.
7+
#
8+
# Enable the paired timer to run it periodically:
9+
# systemctl enable --now aegisbpf-metrics.timer
10+
#
11+
# Override the output directory via /etc/default/aegisbpf (AEGIS_METRICS_TEXTFILE).
12+
[Unit]
13+
Description=AegisBPF Prometheus metrics (textfile collector writer)
14+
After=aegisbpf.service
15+
ConditionPathExists=/sys/fs/bpf
16+
17+
[Service]
18+
Type=oneshot
19+
EnvironmentFile=-/etc/default/aegisbpf
20+
Environment=AEGIS_METRICS_TEXTFILE=/var/lib/node_exporter/textfile_collector/aegisbpf.prom
21+
# Write to a temp file and rename so node_exporter never reads a partial file.
22+
ExecStart=/bin/sh -c 'd=$(dirname "$AEGIS_METRICS_TEXTFILE"); mkdir -p "$d"; /usr/bin/aegisbpf metrics --out "$AEGIS_METRICS_TEXTFILE.tmp" && mv "$AEGIS_METRICS_TEXTFILE.tmp" "$AEGIS_METRICS_TEXTFILE"'
23+
LimitMEMLOCK=infinity
24+
NoNewPrivileges=true
25+
ProtectSystem=strict
26+
ProtectHome=true
27+
ProtectKernelModules=true
28+
ReadWritePaths=/sys/fs/bpf /var/lib/aegisbpf /var/lib/node_exporter
29+
CapabilityBoundingSet=CAP_SYS_ADMIN CAP_SYS_RESOURCE CAP_BPF CAP_PERFMON
30+
AmbientCapabilities=CAP_SYS_ADMIN CAP_SYS_RESOURCE CAP_BPF CAP_PERFMON
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
# Periodically refresh the AegisBPF Prometheus textfile-collector output.
2+
#
3+
# systemctl enable --now aegisbpf-metrics.timer
4+
#
5+
# Pairs with aegisbpf-metrics.service. 30s cadence matches a typical Prometheus
6+
# scrape interval; adjust OnUnitActiveSec to taste.
7+
[Unit]
8+
Description=Refresh AegisBPF Prometheus metrics textfile every 30s
9+
10+
[Timer]
11+
OnBootSec=30s
12+
OnUnitActiveSec=30s
13+
AccuracySec=5s
14+
Unit=aegisbpf-metrics.service
15+
16+
[Install]
17+
WantedBy=timers.target

src/commands_metrics.cpp

Lines changed: 35 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
#include "logging.hpp"
2424
#include "network_ops.hpp"
2525
#include "tracing.hpp"
26+
#include "ttl_registry.hpp"
2627
#include "types.hpp"
2728
#include "utils.hpp"
2829

@@ -321,24 +322,14 @@ int cmd_stats(bool detailed)
321322
return 0;
322323
}
323324

324-
int cmd_metrics(const std::string& out_path, bool detailed)
325+
Result<std::string> build_metrics_report(BpfState& state, bool detailed)
325326
{
326-
const std::string trace_id = make_span_id("trace-metrics");
327-
ScopedSpan span("cli.metrics", trace_id);
328-
329-
BpfState state;
330-
auto load_result = load_bpf(true, false, state);
331-
if (!load_result) {
332-
logger().log(SLOG_ERROR("Failed to load BPF object").field("error", load_result.error().to_string()));
333-
return fail_span(span, load_result.error().to_string());
334-
}
335-
336327
std::ostringstream oss;
337328

338329
auto stats_result = read_block_stats_map(state.block_stats);
339330
if (!stats_result) {
340331
logger().log(SLOG_ERROR("Failed to read block stats").field("error", stats_result.error().to_string()));
341-
return fail_span(span, stats_result.error().to_string());
332+
return stats_result.error();
342333
}
343334
const auto& stats = *stats_result;
344335
append_metric_header(oss, "aegisbpf_blocks_total", "counter", "Total number of blocked operations");
@@ -353,7 +344,7 @@ int cmd_metrics(const std::string& out_path, bool detailed)
353344
if (!cgroup_stats_result) {
354345
logger().log(SLOG_ERROR("Failed to read cgroup block stats")
355346
.field("error", cgroup_stats_result.error().to_string()));
356-
return fail_span(span, cgroup_stats_result.error().to_string());
347+
return cgroup_stats_result.error();
357348
}
358349
auto cgroup_stats = *cgroup_stats_result;
359350
std::sort(cgroup_stats.begin(), cgroup_stats.end(),
@@ -372,7 +363,7 @@ int cmd_metrics(const std::string& out_path, bool detailed)
372363
if (!inode_stats_result) {
373364
logger().log(
374365
SLOG_ERROR("Failed to read inode block stats").field("error", inode_stats_result.error().to_string()));
375-
return fail_span(span, inode_stats_result.error().to_string());
366+
return inode_stats_result.error();
376367
}
377368
auto inode_stats = *inode_stats_result;
378369
std::sort(inode_stats.begin(), inode_stats.end(), [](const auto& a, const auto& b) {
@@ -390,7 +381,7 @@ int cmd_metrics(const std::string& out_path, bool detailed)
390381
if (!path_stats_result) {
391382
logger().log(
392383
SLOG_ERROR("Failed to read path block stats").field("error", path_stats_result.error().to_string()));
393-
return fail_span(span, path_stats_result.error().to_string());
384+
return path_stats_result.error();
394385
}
395386
auto path_stats = *path_stats_result;
396387
std::sort(path_stats.begin(), path_stats.end(), [](const auto& a, const auto& b) { return a.first < b.first; });
@@ -405,7 +396,7 @@ int cmd_metrics(const std::string& out_path, bool detailed)
405396
if (!net_stats_result) {
406397
logger().log(
407398
SLOG_ERROR("Failed to read network block stats").field("error", net_stats_result.error().to_string()));
408-
return fail_span(span, net_stats_result.error().to_string());
399+
return net_stats_result.error();
409400
}
410401

411402
const auto& net_stats = *net_stats_result;
@@ -427,7 +418,7 @@ int cmd_metrics(const std::string& out_path, bool detailed)
427418
if (!net_ip_stats_result) {
428419
logger().log(SLOG_ERROR("Failed to read network IP stats")
429420
.field("error", net_ip_stats_result.error().to_string()));
430-
return fail_span(span, net_ip_stats_result.error().to_string());
421+
return net_ip_stats_result.error();
431422
}
432423
auto net_ip_stats = *net_ip_stats_result;
433424
std::sort(net_ip_stats.begin(), net_ip_stats.end(),
@@ -444,7 +435,7 @@ int cmd_metrics(const std::string& out_path, bool detailed)
444435
if (!net_port_stats_result) {
445436
logger().log(SLOG_ERROR("Failed to read network port stats")
446437
.field("error", net_port_stats_result.error().to_string()));
447-
return fail_span(span, net_port_stats_result.error().to_string());
438+
return net_port_stats_result.error();
448439
}
449440
auto net_port_stats = *net_port_stats_result;
450441
std::sort(net_port_stats.begin(), net_port_stats.end(),
@@ -685,7 +676,32 @@ int cmd_metrics(const std::string& out_path, bool detailed)
685676
(perf_slo_sample.summary_present && perf_slo_sample.parse_ok) ? perf_slo_sample.failed_rows
686677
: 0);
687678

688-
std::string metrics = oss.str();
679+
// Timed-deny registry size (control-API TTL denies awaiting expiry). File-based
680+
// (deny_ttl.db), so it is correct from both the CLI and the daemon endpoint.
681+
append_metric_header(oss, "aegisbpf_deny_ttl_entries", "gauge",
682+
"Number of control-API denies with a pending TTL (auto-expiry)");
683+
append_metric_sample(oss, "aegisbpf_deny_ttl_entries", static_cast<uint64_t>(read_ttl_db(kTtlDbPath).size()));
684+
685+
return oss.str();
686+
}
687+
688+
int cmd_metrics(const std::string& out_path, bool detailed)
689+
{
690+
const std::string trace_id = make_span_id("trace-metrics");
691+
ScopedSpan span("cli.metrics", trace_id);
692+
693+
BpfState state;
694+
auto load_result = load_bpf(true, false, state);
695+
if (!load_result) {
696+
logger().log(SLOG_ERROR("Failed to load BPF object").field("error", load_result.error().to_string()));
697+
return fail_span(span, load_result.error().to_string());
698+
}
699+
700+
auto report = build_metrics_report(state, detailed);
701+
if (!report) {
702+
return fail_span(span, report.error().to_string());
703+
}
704+
const std::string& metrics = *report;
689705

690706
if (out_path.empty() || out_path == "-") {
691707
std::cout << metrics;

src/commands_metrics.hpp

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,20 @@
33

44
#include <string>
55

6+
#include "bpf_ops.hpp"
7+
#include "result.hpp"
68
#include "types.hpp"
79

810
namespace aegis {
911

1012
int cmd_stats(bool detailed = false);
1113
int cmd_metrics(const std::string& out_path, bool detailed = false);
1214

15+
// Build the full Prometheus exposition text from a loaded BpfState. Shared by the
16+
// `metrics` CLI command and the daemon's optional HTTP /metrics endpoint (which
17+
// reuses its already-loaded state instead of reloading per scrape).
18+
Result<std::string> build_metrics_report(BpfState& state, bool detailed);
19+
1320
std::string build_block_metrics_output(const BlockStats& stats);
1421
std::string build_net_metrics_output(const NetBlockStats& stats);
1522

src/daemon.cpp

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
#include "bpf_ops.hpp"
3030
#include "capabilities.hpp"
3131
#include "commands_block_allow.hpp"
32+
#include "commands_metrics.hpp"
3233
#include "commands_network.hpp"
3334
#include "daemon_policy_gate.hpp"
3435
#include "daemon_posture.hpp"
@@ -40,6 +41,7 @@
4041
#include "landlock.hpp"
4142
#include "logging.hpp"
4243
#include "map_monitor.hpp"
44+
#include "metrics_server.hpp"
4345
#include "posture_gate.hpp"
4446
#include "proc_scan.hpp"
4547
#include "seccomp.hpp"
@@ -1111,6 +1113,31 @@ int daemon_run(bool audit_only, bool enable_seccomp, bool enable_landlock, bool
11111113
}
11121114
}
11131115

1116+
// Optional Prometheus metrics endpoint (opt-in via AEGIS_METRICS_ADDR=<host:port>).
1117+
// Off by default. Serves the same exposition as `aegisbpf metrics`, reusing the
1118+
// daemon's already-loaded BPF state (no per-scrape reload). Reads are limited to
1119+
// pinned maps + files, so they run concurrently with the poll loop safely.
1120+
std::unique_ptr<aegis::MetricsServer> metrics_server;
1121+
if (const char* metrics_addr = std::getenv("AEGIS_METRICS_ADDR");
1122+
metrics_addr != nullptr && metrics_addr[0] != '\0') {
1123+
aegis::MetricsServer::Config metrics_cfg;
1124+
metrics_cfg.bind_addr = metrics_addr;
1125+
metrics_server = std::make_unique<aegis::MetricsServer>(metrics_cfg);
1126+
metrics_server->set_metrics_callback([&state]() -> std::string {
1127+
auto report = build_metrics_report(state, false);
1128+
if (!report) {
1129+
return "# metrics unavailable: " + report.error().to_string() + "\n";
1130+
}
1131+
return *report;
1132+
});
1133+
if (metrics_server->start()) {
1134+
logger().log(SLOG_INFO("Prometheus metrics endpoint enabled").field("addr", metrics_addr));
1135+
} else {
1136+
logger().log(SLOG_ERROR("Failed to start metrics endpoint").field("addr", metrics_addr));
1137+
metrics_server.reset();
1138+
}
1139+
}
1140+
11141141
ScopedSpan event_loop_span("daemon.event_loop", trace_id, root_span.span_id());
11151142
while (!exit_requested()) {
11161143
err = ring_buffer__poll(rb.get(), 250);

0 commit comments

Comments
 (0)