Architecture¶
This page covers the exporter's internal architecture: package structure, data flow, and extension points.
It describes the code in the rknightion/opnsense-exporter repository on GitHub, which is Apache-2.0 licensed. Package and file names below are paths in that repository.
Package structure¶
graph TD
A[main.go] --> B[internal/options]
A --> C[opnsense client]
A --> D[internal/collector]
A --> E[prometheus registry]
A --> F[HTTP server]
A --> G[internal/logship]
A --> N[internal/flow]
A --> W[internal/webui]
Push[OPNsense syslog / Zenarmor / NetFlow] -.push.-> G
Push -.push.-> N
B -->|OPNSenseConfig| C
B -->|CollectorsSwitches| D
C -->|*opnsense.Client| D
D -->|prometheus.Collector| E
E -->|promhttp.Handler| F
E -->|Tee| M[internal/metricsnap]
M -->|Capture| W
N -->|Tee: rollup metrics| D
N -->|Tee: correlated flow logs| G
G -->|OTLP logs, own export path| O[OTLP log collector]
subgraph "opnsense/"
C
C1[client.go]
C2[gateways.go]
C3[interfaces.go]
C4[firewall.go]
C5[... per-subsystem]
end
subgraph "internal/collector/"
D
D1[collector.go]
D6[scheduler.go]
D7[snapshot.go]
D8[interval_tiers.go]
D2[arp_table.go]
D3[gateways.go]
D4[... per-subsystem]
end
subgraph "internal/options/"
B
B1[ops.go]
B2[exporter.go]
B3[collectors.go]
end
subgraph "internal/logship/"
G
G1[pipeline.go]
G2[syslog/]
G3[zenarmor/]
G4[enrich/]
G5[sink_otlp.go]
end
subgraph "internal/flow/"
N
N1[netflow/]
N2[correlate.go]
N3[rollup.go]
end
subgraph "internal/webui/"
W
W1[server.go]
W2[status.go]
W3[... per-tab]
end
Seven packages¶
opnsense/ - API client¶
The API client layer handles all communication with the OPNsense REST API. Each subsystem has a dedicated Fetch*() method (e.g., FetchGateways(), FetchWireguardConfig(), FetchSystemTemperature()).
Client features:
- TLS support with configurable certificate verification
- Basic authentication using API key and secret
- Automatic retries up to 3 attempts on transient failures
- Gzip decompression for compressed API responses
- Registered endpoints tracked for error reporting
Data structs for JSON unmarshaling live alongside each Fetch*() method.
internal/collector/ - Prometheus collectors¶
This package contains the top-level Collector struct and all 63 sub-collectors. Each sub-collector lives in its own file and implements the CollectorInstance interface:
type CollectorInstance interface {
Register(namespace, instance string, log *slog.Logger)
Name() string
Describe(ch chan<- *prometheus.Desc)
Update(ctx context.Context, client *opnsense.Client, ch chan<- prometheus.Metric) *opnsense.APICallError
}
Key design decisions:
- Auto-registration via
init()- Each sub-collector file has aninit()function that appends itself to the globalcollectorInstancesslice. No central registry to maintain. - Poll/serve split (#336) - Sub-collectors do not run on the scrape. An internal poll scheduler (
scheduler.go) runs each collector'sUpdate()on its own timer into an in-memory snapshot (snapshot.go); serving/metricsreplays that snapshot and makes no API call. See Data flow. - Per-collector poll tiers - Each collector's timer follows its data-volatility tier (
interval_tiers.go): fast 15s, medium 60s (default), slow 5m, cold 15m. An operator overrides one with--collector.poll-interval-override=<collector>=<dur>. - Option pattern - Sub-collectors are removed or configured via functional options (e.g.,
WithoutArpTableCollector(),WithFirewallRulesDetails()).
internal/options/ - Configuration¶
kingpin CLI flags handle configuration, each with a corresponding environment variable:
ops.go- OPNsense connection config (protocol, address, API key/secret, TLS)exporter.go- Server config (listen address, metrics path, instance label)collectors.go- Per-collector disable/enable switches
All environment variables use the OPNSENSE_EXPORTER_ prefix, except for OPS_API_KEY_FILE and OPS_API_SECRET_FILE.
internal/logship/ - Log shipping¶
Backs the README's syslog and Zenarmor receiver rows. Unlike the packages above, this one is push-based: OPNsense (and, separately, Zenarmor) send data to the exporter over the network; the exporter never polls for it. Its subpackages:
syslog/— a UDP/TLS syslog listener (listener.go,framing.go) plus a per-program parser registry (one file per program:filterlog.go,dhcp.go,sshd.go,haproxy.go,suricata.go,audit.go, and more), each turning a raw syslog line into a structured record.zenarmor/— a dedicated HTTP receiver (server.go) for Zenarmor's own event stream, with its own parsing and derived metrics.enrich/— attaches device/service context (MAC, hostname, service name) to log records from a periodically refreshed snapshot, independent of the collector snapshot ininternal/collector.capture/— optional raw-line capture for debugging, with de-duplication.flowlog/— the sink thatinternal/flow's correlator writes merged flow logs into, so flow logs travel the same pipeline and OTLP sink as syslog-derived logs.- Top-level files (
pipeline.go,queue.go,sink.go,sink_otlp.go,sink_stdout.go) run the receive → parse → enrich → queue → ship loop and hand records to the configuredSink, most commonly the OTLP log sink (sink_otlp.go), which exports overotlploggrpc/otlploghttpindependently of the metrics path.
internal/flow/ - NetFlow processing¶
Backs the README's NetFlow/flow-volume row (#346). Also push-based: OPNsense's ng_netflow exports NetFlow datagrams to a UDP listener here, rather than being polled. netflow/ decodes the wire format; processor.go runs each datagram through normalize → repair → enrich → rollup, in that order (repair must run before enrich so a de-duplicated VLAN copy is dropped before it would be double-named and double-counted). rollup.go turns records into the netflow_* Prometheus metrics collected via internal/collector. correlate.go separately collapses NetFlow's 1:N fragmentation into one flow record per connection-window, merging in Zenarmor's L7 classification when a matching conn document arrives, and emits the merged record as a flow log into internal/logship/flowlog on window expiry — never a live metric, and never a second copy of the Zenarmor-only side.
internal/webui/ - Operator console¶
Backs the README's operator console row (#302): a single server-rendered page at / (Overview / Collectors / API / Cardinality / Devices / Config tabs), self-registering routes via init(). No console request may call Gather() on the live Prometheus registry — that would mean a page load in a browser tab silently triggers a firewall scrape. Instead every tab renders from already-passive sources: the internal/metricsnap capture, collector.StatusTracker, and the API-client cache view. The Devices tab is the one deliberate exception (it hits ARP/DHCP directly on tab-open), and the Config tab reads secret files from disk once per page load — neither touches the collector/metric path.
internal/metricsnap/ - Passive metric capture¶
A single Recorder that wraps a prometheus.Gatherer via Tee, transparently recording whatever families a real gather already produced. It is teed at both places a real gather happens — the /metrics handler and the OTLP export path — so internal/webui can read a Capture (families, capture time, and whether it arrived with a gather error) of the last actual scrape without ever issuing a Gather() of its own. This is the mechanism, not a policy choice per caller: it exists specifically so the webui constraint above is enforced structurally rather than by convention.
Data flow¶
Since #336 the API poll is decoupled from the Prometheus scrape. A background scheduler polls each subsystem on its own timer into an in-memory snapshot; a scrape reads that snapshot back. The two halves run independently.
sequenceDiagram
participant A as OPNsense API
participant Sched as Poll scheduler
participant St as Snapshot store
participant C as Collector
participant H as HTTP Server
participant P as Prometheus
rect rgb(240, 244, 255)
note over Sched,St: Poll loop (per-collector timers, background)
Sched->>A: Health check (global interval)
A-->>Sched: System status → health gauges
Sched->>A: FetchGateways() [gateways timer, 15s]
A-->>Sched: JSON → prometheus.Metric buffer
Sched->>St: put("gateways", metrics, duration, ok)
Sched->>A: FetchFirmware() [firmware timer, 15m]
A-->>Sched: JSON → prometheus.Metric buffer
Sched->>St: put("firmware", metrics, duration, ok)
end
rect rgb(240, 255, 244)
note over C,P: Serve path (per scrape, no API call)
P->>H: GET /metrics
H->>C: Collect(ch)
C->>C: emitHealth() from last poll
C->>St: replay every enabled collector's buffered metrics
St-->>C: stored prometheus.Metric
C->>C: emit per-collector poll metadata + scrape counter
C-->>H: metrics
H-->>P: text/plain metrics
end
Poll lifecycle¶
StartPolling launches one goroutine per enabled collector plus a health goroutine, each on its own timer (its data-volatility tier, jittered at startup to spread the cold-start herd). A shared semaphore caps concurrent API calls at min(GOMAXPROCS, 8) so a box with many collectors is never dialled by dozens of pollers at once.
- A collector's timer fires. If the last health poll found the box unreachable, the poll is skipped and the previous snapshot entry is retained (#127).
- Otherwise the collector's
Update()runs under the concurrency cap and the per-poll timeout (--exporter.max-scrape-duration, now a per-poll bound), invoking one or moreFetch*()methods and emitting metrics into a buffer. - The buffer, its duration, and its success flag are stored under the collector's name (
snapshot.put), replacing that collector's previous entry. - The health goroutine polls the system status API on the global interval and sets the persistent health gauges:
opnsense_up(API reachability),opnsense_system_status_code, theopnsense_firewall_status/opnsense_crash_reporter_statusgauges, andopnsense_system_subsystem_status_code(one series per subsystem the payload reports - disk space, root lock, plugin overrides, and anything beyond the two dedicated gauges). A transport-level failure flags the box unreachable, which pauses the collector polls until it recovers.
Serve lifecycle¶
- Prometheus sends
GET /metrics. The request path makes no API call. Collect()re-emits the health gauges from the last health poll.opnsense_upis 0 only when that poll's API call failed; a reachable but degraded box staysopnsense_up=1.- For every enabled collector it replays the metrics buffered in the snapshot, plus per-collector poll metadata: the configured interval, and once a collector has polled at least once, its latest scheduled poll duration, success flag, and last/next poll timestamps. The duration/success metric names retain their historical
scrape_collectorprefix for compatibility. - It increments the scrape counter and emits the always-on exporter-meta metrics (scrapes, endpoint errors, API request counts, cache hits/misses).
A scrape taken during cold start replays only the collectors that have already polled; readiness gates on SnapshotWarm so an ordered startup waits for a complete first snapshot rather than serving a partial one. The OTLP bridge replays the same snapshot the same way.
Error handling¶
- Health poll failure: Sets
opnsense_up=0(the only condition that does so). A transport-level failure also pauses collector polls until the box is reachable again; the last-good snapshot keeps serving in the meantime. - Collector poll failure: Logs the error, increments
opnsense_exporter_endpoint_errors_totalfor the failing endpoint, and recordsscrape_collector_success=0for that collector. Its previous snapshot entry is replaced with the (empty or partial) result of the failed poll; other collectors are unaffected. - Partial failure tolerance: Several collectors (system info, mbuf memory stats, firewall interface hits, network diagnostics pfsync) are partially failure tolerant - if an optional supplementary API call fails, the collector still emits metrics from successful calls.
Metric namespace¶
All metrics use the opnsense namespace prefix. The subsystem constants define the second-level grouping:
| Constant | Value |
|---|---|
ArpTableSubsystem |
arp_table |
GatewaysSubsystem |
gateways |
InterfacesSubsystem |
interfaces |
ProtocolSubsystem |
protocol |
FirewallSubsystem |
firewall |
FirewallRulesSubsystem |
firewall_rule |
FirmwareSubsystem |
firmware |
SystemSubsystem |
system |
TemperatureSubsystem |
temperature |
UnboundDNSSubsystem |
unbound_dns |
DnsmasqSubsystem |
dnsmasq |
MbufSubsystem |
mbuf |
NTPSubsystem |
ntp |
CertificatesSubsystem |
certificate |
CARPSubsystem |
carp |
ActivitySubsystem |
activity |
KeaSubsystem |
kea |
CronTableSubsystem |
cron |
WireguardSubsystem |
wireguard |
IPsecSubsystem |
ipsec |
OpenVPNSubsystem |
openvpn |
ServicesSubsystem |
services |
NetworkDiagSubsystem |
network_diag |
NetflowSubsystem |
netflow |
PFStatsSubsystem |
pf_stats |
NDPSubsystem |
ndp |
Dhcpv4Subsystem |
dhcpv4 |
ACMESubsystem |
acme |
SMARTSubsystem |
smart |
DynDNSSubsystem |
dyndns |
Profiling¶
The exporter supports opt-in continuous profiling that pushes profiles to Grafana Cloud Pyroscope via the pyroscope-go SDK. It is disabled by default and activates only when --pyroscope.server-address is set; see Configuration → Continuous profiling (Pyroscope) for the flags and authentication.
All profile types are collected by default when profiling is enabled:
- CPU profiling
- Memory (heap) profiling: alloc/inuse, objects/space
- Goroutine profiling
- Mutex contention profiling
- Block profiling
- Goroutine-leak profiling
Set --pyroscope.disable-mutex-block to drop the two contention profiles (mutex and block) and their process-global sampling rates - the one set with non-trivial per-event overhead. CPU, memory, goroutine and goroutine-leak profiling are unaffected.
Goroutine-leak profiling relies on the Go goroutineleakprofile runtime experiment, which the release binaries and container image are built with (GOEXPERIMENT=goroutineleakprofile). A binary built without it silently omits that one profile type; everything else is unchanged. It periodically runs a short stop-the-world analysis (at each upload) to surface goroutines that are blocked forever - cheap for an exporter's modest goroutine count.
There are no unauthenticated /debug/pprof/* HTTP endpoints; the exporter dropped the previously always-on pprof/godeltaprof handlers in favour of authenticated push.