Log shipping¶
The exporter can ship structured OPNsense events (firewall log lines, IDS
alerts, audit entries, and similar) to Loki, separately from the metrics it
exposes at /metrics. This is opt-in and off by default: it runs only when
--logs.enabled is set.
Log shipping is a long-lived background pipeline, not a scrape-time collector.
Registered sources poll OPNsense event APIs on their own cadence, records pass
through a bounded in-memory queue, and an emitter ships batches to the configured
sink. It is independent of OTLP metrics export (--otlp.enabled): metrics
and logs are gated by separate flags and neither turns the other on.
High-cardinality event data (IP addresses, ports, Suricata SIDs, domains) is
shipped as log body and Loki structured metadata - never as a metric and
never as a Loki label. The only labels are the resource identity, plus opnsense.source
and opnsense.subsystem if you promote them (see Loki label model).
The pipeline is implemented in
internal/logship/ on GitHub;
parsers live one file per program, so
adding a parser is a
self-contained contribution.
Sources are added incrementally
Enabling --logs.enabled starts the pipeline, but nothing is shipped until at
least one source is also enabled (each source has its own
--logs.<source>.enabled flag). With the pipeline enabled and no source
enabled, the exporter logs a warning and ships nothing.
Sinks¶
Select the sink with --logs.sink:
otlp(default) - ships over OTLP logs. One sink covers both the Grafana Cloud OTLP gateway (which routes/v1/logsto Loki) and a self-hosted Loki 3.x native OTLP endpoint. It reuses the exporter's existing--otlp.*transport family (endpoint, protocol, headers, TLS/mTLS, and the Grafana Cloud shortcut), so no separate endpoint configuration is needed. Because the transport is shared but the gates are separate, the--otlp.*flags configure the logs endpoint even when--otlp.enabled(metrics export) is off.stdout- writes one compact JSON line per event to standard output. This is the zero-dependency path for container/Kubernetes setups where a node log collector already ships stdout.
When --logs.sink=otlp and no OTLP endpoint is resolvable (no --otlp.endpoint,
no Grafana Cloud endpoint, and no OTEL_EXPORTER_OTLP_ENDPOINT /
OTEL_EXPORTER_OTLP_LOGS_ENDPOINT environment variable), startup fails with an
error naming the missing flag rather than silently shipping nothing.
Sources¶
Syslog receiver (--logs.syslog.enabled)¶
The receiver is a push source: OPNsense forwards its logs to the exporter and
the exporter enriches them from the API. It supersedes the old firewall and
diaglog poll lanes, which spawned configd on the box and re-read log files the
firewall will happily push. See Syslog receiver for the
full setup, including the target you must configure on the firewall.
In short: it listens for RFC5424 or RFC3164 syslog over UDP, TCP and/or TLS (port
5514 by default), parses filterlog records into structured fields, ships every
other program as a generic record, and enriches everything it can from the OPNsense
API. It also derives low-cardinality Prometheus counters (opnsense_log_events_*)
from what it parses, and can optionally sample high-volume raw lines away once
their metric is counted - see Derived metrics and sampling
and TLS transport.
Enrichment is the reason this lives in the exporter rather than a generic syslog collector. The exporter already holds an authenticated API client, so it can resolve what Alloy, Vector or rsyslog structurally cannot:
| Attribute | Resolved from |
|---|---|
rule.description |
diagnostics/firewall/list_rule_ids |
interface.name |
the interface overview (vtnet0 → LAN) |
src.hostname / dst.hostname |
DHCPv4/DHCPv6/Kea/dnsmasq leases |
src.mac / dst.mac |
the ARP and NDP tables |
src.scope / dst.scope |
self, local or remote, from the firewall's own subnets |
src.service / dst.service |
a compiled-in well-known-port table |
The rule description matters more than it looks. A filterlog rule id is either a
rule UUID (for rules you wrote) or a content hash (for the auto-generated ones -
anti-lockout, default-deny, bogon blocks, DHCP-allow). The config-level rule
inventory only contains the former, so it cannot label the majority of the lines a
real box emits. list_rule_ids resolves both.
Lookups read a lock-free snapshot refreshed on its own goroutine; the receive path never makes an API call. Enrichment failure never drops a record - a cold or stale snapshot ships the line unenriched.
Fidelity: the parser uses filterlog's true nine-field TCP tail (srcport,
dstport, datalen, tcpflags, seq, ack, window, urg, options).
OPNsense's own log reader declares eight, which mislabels the TCP window as the
urgent pointer and drops the options entirely - so the receiver recovers data the
API path silently loses.
Caveat: the filter log records the first packet of a flow only - it is an event stream, not flow accounting. Do not read event counts as byte/connection totals.
Zenarmor (--logs.zenarmor.enabled)¶
The exporter can also pose as an Elasticsearch node and receive Zenarmor's reporting data directly - a second push source, and the first high-volume one. See Zenarmor receiver for the full setup, including the GUI fields on the Zenarmor side and a warning about a similarly-named but destructive install-time Elasticsearch option you must not confuse this with.
In short: it accepts Elasticsearch _bulk writes on port 9200 by default, ships all six
of Zenarmor's reporting families (conn, dns, tls, http, alert, sip - mapped
to opnsense.subsystem values flow, dns, tls, web, ids, voip) as enriched
OTLP logs, and derives a Prometheus counter (opnsense_log_events_zenarmor_total) from
every document. Volume is substantial - measured at ~39 docs/sec on a live box, ~4–6
GB/day of raw JSON - so prefer cutting families in Zenarmor's own indexes setting over
--logs.zenarmor.families: data cut at the source never crosses the wire.
IDS (Suricata EVE alerts)¶
--logs.ids.enabled (off by default; requires --logs.enabled) ships full
Suricata EVE alert records - not just the aggregate counts the IDS metrics
collector exposes (opnsense_ids_recent_alerts, gated separately by
--exporter.enable-ids-alerts). It polls POST api/ids/service/query_alerts,
the same endpoint the metrics collector's opt-in alert-activity series already
uses, reading up to the newest 500 eve rows per poll (the same cap the metrics
collector's alert count is a floor against).
- Body is the compact JSON of the complete raw eve alert record exactly as
Suricata wrote it - every field it emits, not a reconstructed subset. This
includes fields the exporter never parses into typed metadata (the nested
flowobject,app_proto,event_type,filepos/fileid, …). - Structured metadata (never a label):
alert_sid,alert_action,src_ip,dest_ip,in_iface,proto,signature. - Severity is
warnfor ablockedalert,infootherwise. - Cursor: the record's own
timestampis the true cursor, with aflow_id+alert_siddedupe ring scoped to records sharing the cursor's exact timestamp.filepos/fileidare never used to cursor - log rotation shiftsfileids, so they are fragile across restarts. - Gap accounting:
query_alertsis a windowed, saturating read. If a poll returns a full 500-row window whose oldest row is still newer than the prior cursor, the read could not reach back far enough to cover everything since the last poll - some alerts in that range were never observed. This is accepted, bounded loss: the source ships one synthetic gap record (event=gap_detectedstructured metadata,warnseverity, a JSON body naming the gap bounds) instead of silently dropping it, so the loss is visible and queryable in Loki (e.g.{service_name="opnsense-exporter"} | opnsense_source="ids" | json | event="gap_detected"). - First poll: with no prior cursor (fresh start, or
--logs.state-filenot set/empty/corrupt), the whole initial window ships as a startup catch-up rather than being silently skipped or treated as a gap.
Prefer native syslog_eve where it fits
OPNsense's IDS settings also offer syslog_eve (ships the identical EVE
JSON via syslog - alerts only, with metadata and a community id) and
syslog (fastlog lines). If the box already forwards EVE JSON via
syslog_eve to your log pipeline, enable that instead of this source -
running both double-ships the same alerts. syslog_eve shares the
suricata/local5 facility with engine logs, so a demux step is needed on
that path (not a concern for the API-polling source documented here).
CrowdSec (--logs.crowdsec.enabled)¶
Ships CrowdSec alert and decision records. There is no native syslog
path for these - the plugin registers no syslog scope, so alerts and
decisions live only in the local API (LAPI). Off by default; enable with
--logs.crowdsec.enabled (requires --logs.enabled). Silent when the
os-crowdsec plugin is absent. Polls at a 60s floor regardless of
--logs.poll-interval - each poll is a full cscli alerts/decisions dump
(one configd exec each), so polling faster buys nothing at homelab/SMB event
volumes.
- Cursor: alert ids and decision ids are each a separate, server-side monotonic counter. The source tracks the highest id shipped per record kind and ships only rows whose id is greater on the next poll - a plain id-diff, no timestamp windowing. On a cold start every currently-active alert/decision is shipped once, so enabling the source surfaces current state instead of silently starting from a blank slate.
- Body: compact JSON of the alert or decision (
kind,id,scope_value,scenario, plus alert-onlydecisions/createdor decision-onlyalert_id/action/expiration/events_count). - Attributes (structured metadata, never labels):
scenario,value(the scope:ip the alert/decision concerns - high cardinality, so metadata only, never a label),country,as(both often empty without a GeoIP database configured), plusdecisions(alerts: atype:countsummary, e.g.ban:1) ordecision_typeandduration(decisions: the CrowdSec action and the remaining-duration string, e.g.693h46m29s). - Timestamps: alerts carry an RFC3339
createdfield, used as the record's timestamp. Decisions carry no absolute timestamp (only a remaining-duration string), so the record is stamped at emit time.
Unbound (per-query DNS log)¶
--logs.unbound.enabled (default false) ships a pi-hole-style per-query DNS
log: domain, client, action (Pass/Block/Drop), resolution source
(Recursion/Local/Local-data/Cache), blocklist/policy enrichment and
DNSSEC status per query - data unavailable anywhere else in OPNsense's API
(syslog only carries raw unbound daemon lines). It requires Unbound
reporting/statistics to be enabled on the firewall, and raises the poll floor
to 15s (IntervalSource) because each poll spawns python+pandas+DuckDB on the
box (~1s CPU).
Accepted sampling loss - read this before enabling on a busy resolver.
Unbound's query-log backend (api/unbound/overview/search_queries) has no true
cursor: without a per-client filter it only ever returns the newest 1000
rows across the whole resolver, newest first. This source reconstructs a
best-effort cursor from each row's time (unix seconds) plus a dedup
fingerprint for rows sharing the same second - the uuid field is part of the
documented schema but has been observed to always be null in practice, so it
is used only when present. On a resolver sustaining more than roughly 1000
queries between two polls, the oldest rows in the next page will all be newer
than the previous cursor - a full discontinuity - meaning some number of
queries fell out of the window before this exporter ever fetched them. That
loss is never silent: it is counted via
opnsense_exporter_logs_possible_gap_total{source="unbound"} (see
Self-metrics) every time it is detected. Homelab/SMB query
volumes (a handful to ~100 qps) are fine; a busy enterprise resolver should not
enable this source.
Loki structured metadata for this source: client, domain, qtype,
action, query_source, rcode, blocklist, dnssec_status. (Unbound's own
source field - where the answer came from - is deliberately mapped to the
query_source attribute rather than a bare source, to keep it clear of this
pipeline's own opnsense.source stamp; see Loki label model.) The
log body is a compact JSON encoding of the full row, including fields not
promoted to structured metadata (family, resolve_time_ms, ttl, policy).
Loki label model¶
Loki promotes only OTLP resource attributes to index labels. Scope and log
attributes can never become labels - otlp_config has no index_label action for
them, whatever the tenant config says. Cardinality discipline therefore falls out of
where an attribute is put, not out of policy:
On the resource (promotable - a closed, code-defined set):
| attribute | value | indexed by default? |
|---|---|---|
service.name |
--otlp.service-name |
yes |
service.instance.id |
the resolved instance label | yes |
service.version |
the exporter version | no |
opnsense.source |
syslog, unbound, ids, crowdsec, zenarmor |
no - opt in below |
opnsense.subsystem |
firewall, dns, auth, dhcp, vpn, … (~22) |
no - opt in below |
opnsense.action |
pass, block |
no - opt in below |
opnsense.device_category |
laptop, camera, iot, server, … (9) |
no - opt in below |
opnsense.interface |
the interface descriptions - LAN, IOT, MGMT |
no - opt in below |
The last two are Zenarmor-only: they are absent on every other source's records, and
absent rather than empty, so they never pool unrelated records into one empty-valued
stream. opnsense.device_category is clamped to a code-defined vocabulary before it
reaches the resource - the receiver is a push endpoint, so an unrecognised value is a
sender minting streams rather than data, and it folds into Zenarmor's own other
bucket. opnsense.interface needs no clamp because it is resolved from the exporter's
own view of the firewall's interface config: an interface the box does not have simply
does not resolve. Note it is the interface description space, not the kernel device
space (igb0, ixl0) - the two are disjoint.
Each lane's own verbatim keys (device.category, interface.name) stay on the record
untouched, so queries already written against them keep working either way.
service.name and service.instance.id are indexed because they are on Loki's
default promotion list. No host or SDK resource detectors are
attached, so nothing else can leak into that set. The two custom keys are namespaced
(opnsense.source, opnsense.subsystem) so they can never collide with a
semconv/Loki-reserved key; Loki mangles the dot, so in LogQL they read
opnsense_source and opnsense_subsystem.
On the record (structured metadata - never promotable): everything else.
program, action, rule_id, rule_description, IPs, ports, MACs, hostnames,
SIDs, tcp_*, dhcp_*, auth_*. Note program in particular: it comes off the
syslog wire and any process on the firewall can pick its own tag with logger(1),
so it is deliberately kept where it cannot become a label.
Promoting the opnsense.* attributes (optional)¶
Out of the box they all land in structured metadata, so you filter with |:
That scans every chunk for the instance. To turn it into a stream selection instead, promote them on the Loki side. Promotion is a tenant-side opt-in and costs the exporter nothing: it changes nothing about what is shipped, only how Loki indexes what arrives. Promote none, some or all of them, in any combination.
Self-hosted, put this in the Loki config:
limits_config:
otlp_config:
resource_attributes:
# leave ignore_defaults false, or service.name stops being a label too
attributes_config:
- action: index_label
attributes:
- opnsense.source
- opnsense.subsystem
- opnsense.action
- opnsense.device_category
- opnsense.interface
On Grafana Cloud there is no config file, but the same otlp_config is settable
per-tenant through the OTLP config self-serve API - PUT the
resource_attributes block above to
/loki/api/v1/config/limits/otlp_config with your Loki write token. No support ticket.
The change is queued and can take a couple of business days to apply.
Then the same query becomes:
Cost: bounded, because every one of these is a closed set. The combinations are a product, but most never occur - a printer does not appear on the WAN, a syslog record has no device at all - so the real figure is far below the theoretical one: 79 distinct streams per instance measured over an hour on a live box with all five promoted. Promoting fewer costs proportionally less: 16 with the original three.
Promotion does not reduce ingest cost. Loki bills on volume, and labels only change what a query has to scan.
A promoted attribute moves out of structured metadata and into the label, so
| opnsense_subsystem=… stops matching once {opnsense_subsystem=…} starts - switch
your queries when you switch the config. This applies per attribute, so a partial
promotion leaves the rest filterable with | exactly as before.
Two different limits get conflated here, and conflating them invents a scarcity that is not
there. max_label_names_per_series (default 15) bounds the label names on a single
stream - it is per stream, so another exporter writing to the same tenant does not consume
any of it, because its streams never carry an opnsense_* label. The exporter's widest stream
carries seven. Separately, the tenant's promoted-attribute list is one shared list, so
adding entries to it is a coordinated change - but adding another exporter's attributes cannot
affect an opnsense_* stream. See OTLP attribute contract.
Do not promote anything else. src_ip as a label is one stream per address: the
classic Loki cardinality footgun, and the reason the exporter keeps it out of reach.
Delivery semantics¶
Stated honestly, because this pipeline is pull-based over a lossy source:
- Within a run: at-least-once. Each source tracks its own cursor and a dedup
ring, so rotation overlap does not duplicate and normal operation does not lose.
The sink exports each batch synchronously and reports the real outcome: a record
is counted
opnsense_exporter_logs_shipped_totalonly once the OTLP endpoint has acknowledged it, never merely on enqueue. When one resource partition succeeds and a sibling fails, the acknowledged partition is removed and only the retryable remainder is sent again. Permanent HTTP/gRPC failures and OTLP partial-success rejections are terminal: they are counted once underreason="rejected"and are not retried. Transient failures are retried in memory (the records stay queued, never re-fetched) until the endpoint recovers or--logs.ship-max-attemptsis reached, so a transient outage is ridden out for as long as the bounded queue behind it holds. Every loss path is counted: queue overflow, an oversized input record, a terminal destination rejection, exhausted retries, or a batch abandoned during shutdown. Each attempt that does not fully deliver is visible asopnsense_exporter_logs_ship_errors_total- degraded but never silent. - Across restarts: at-most-once by default. Cursors are in memory, so a restart
resumes from now. The in-memory retry does not survive a process restart: records
polled but not yet delivered when the process exits mid-outage are lost (counted
ship_failedif a graceful shutdown abandons them, unrecoverable on a hard kill). Set--logs.state-fileto persist cursors (atomic JSON, rewritten only when a cursor changes) for best-effort resume across restarts. - Never exactly-once.
- One path per log type: do not both ship a log type through this pipeline and forward the same type via native syslog - that double-ships. Pick one path per log type.
- One logs-enabled instance per firewall: running multiple logs-enabled replicas against the same firewall double-ships.
Debug capture¶
When a receiver reports a signal the exporter does not model - a Zenarmor Elasticsearch endpoint it does not implement, a document whose family or shape it does not recognise, a syslog line from a program with no parser - that signal is counted but its payload is not kept. Debug capture is an opt-in escape hatch that dumps those unmodelled payloads to disk as NDJSON, so you can see the real data and decide to model it or drop it without a packet capture on a multi-GB/day stream.
It captures only signals the exporter does not already handle - never the parsed, shipped stream:
| Receiver | Captured kind |
What it is |
|---|---|---|
zenarmor |
unhandled_endpoint |
an ES route the receiver does not implement (method, path, headers, bounded body) |
zenarmor |
unknown_family |
a bulk document addressed to an index whose family is not recognised |
zenarmor |
parse_error |
a document that would not parse (still shipped with its raw body) |
syslog |
unparsed |
a line whose program has no parser, whose parser did not match, or whose envelope would not parse (still shipped as a generic record) |
Enable it with a shared directory plus a per-receiver toggle:
| Flag | Env | Default |
|---|---|---|
--logs.debug-capture.dir |
OPNSENSE_EXPORTER_LOGS_DEBUG_CAPTURE_DIR |
(empty - off) |
--logs.debug-capture.max-bytes |
OPNSENSE_EXPORTER_LOGS_DEBUG_CAPTURE_MAX_BYTES |
256MiB |
--logs.zenarmor.debug-capture |
OPNSENSE_EXPORTER_LOGS_ZENARMOR_DEBUG_CAPTURE |
false |
--logs.syslog.debug-capture |
OPNSENSE_EXPORTER_LOGS_SYSLOG_DEBUG_CAPTURE |
false |
A per-receiver toggle with no --logs.debug-capture.dir set is a startup error - it
would read as "on" but write nowhere. When Zenarmor capture is on, the receiver's
"unhandled endpoint" warning is suppressed (the capture file carries the same
signal); the logs_rejected_total{reason="unhandled_endpoint"} counter still fires,
so the rate is never lost.
Files land as <dir>/<receiver>/capture-NNN.ndjson, rotating at 32 MiB. The
--logs.debug-capture.max-bytes cap governs the whole directory, counting bytes
left by previous runs: when it is reached capture stops and keeps the oldest
samples (the first time an unknown appears is the sample worth having) - it never
deletes to make room, so a debug capture can never fill the disk. Capture writes are
best-effort and never block ingest: if the disk cannot keep up, entries are dropped
and counted rather than stalling a receiver.
The capture files carry real network data - client addresses, DNS queries, TLS
SNI, HTTP hosts - so they are written 0600 and should be treated as sensitive.
Point the directory at a writable bind mount and remove the captures once you are
done with them. For the containerised exporter (runs as UID/GID 65532 nonroot):
# docker-compose.yml
services:
opnsense-exporter:
volumes:
- ./capture:/capture
environment:
OPNSENSE_EXPORTER_LOGS_DEBUG_CAPTURE_DIR: /capture
OPNSENSE_EXPORTER_LOGS_ZENARMOR_DEBUG_CAPTURE: "true"
Watch capture activity with logs_debug_captured_total{receiver,kind} and
logs_debug_capture_dropped_total{receiver,reason} (see Self-metrics).
Configuration¶
The pipeline flags are listed in the Configuration reference.
The complete top-level pipeline set is --logs.enabled, --logs.sink,
--logs.poll-interval (floor 5s), --logs.buffer-size,
--logs.buffer-max-bytes, --logs.batch-max, --logs.max-record-bytes,
--logs.ship-max-attempts, --logs.ship-concurrency, --logs.max-metric-keys,
and --logs.state-file.
Per-source --logs.<source>.enabled flags are documented alongside each source
above as they land.
Sink throughput¶
The OTLP sink ships one resource partition per wire request — a partition being
one distinct (source, subsystem, action, device_category, interface) tuple in the
batch. That is what makes a partial-success rejection attributable to a single
source, and it is not negotiable. It does mean a batch costs one round-trip per
distinct partition, so throughput is governed by round-trips rather than by record
count:
records/sec = ship-concurrency x (batch-max / partitions per batch) / latency
Two levers follow from that shape, and they multiply:
--logs.ship-concurrency(default 8) exports that many partitions at once instead of strictly one after another.--logs.batch-max(default 5000) amortises the fixed per-partition round-trip. Distinct partitions plateau with batch duration while records keep growing linearly, so a larger batch costs barely more round-trips and carries far more records per round-trip.
Raise --logs.buffer-size if bursts still outrun the sink; it absorbs a burst but
does nothing for sustained rate. If the queue is chronically deep rather than
spiky, the sink rate is the problem, not the buffer.
A batch is bounded by bytes as well as by --logs.batch-max, at half the OTLP
exporter's serialized-request ceiling. That bound is not configurable and is not
about memory — the queue's own --logs.buffer-max-bytes covers memory. It exists
because an oversized request is refused by the exporter before it reaches the
wire, which yields no delivery outcome to classify, so the batch would be retried
to exhaustion against bytes that can never be accepted. Note that gzip does not
raise that ceiling: it is checked against the uncompressed protobuf. A single
record larger than the bound is still shipped on its own rather than stalling the
queue behind it; --logs.max-record-bytes is the bound that rejects those, at
ingest.
Over HTTP the client's idle-connection pool is sized to the concurrency automatically. Note that an endpoint which does not negotiate HTTP/2 needs one TCP connection per concurrent flush — the Grafana Cloud OTLP gateway is HTTP/1.1-only, so this matters there. The gRPC transport multiplexes every flush over one connection and is unaffected.
Self-metrics¶
The pipeline exposes its own health metrics (visible at /metrics and on the
Log Shipping dashboard tab). Every series carries the stable
opnsense_instance identity label, matching the collector metrics. The remaining
labels are listed exactly below; source, reason, stage, table, receiver,
kind, and rule are not substitutes for that box identity.
opnsense_exporter_logs_shipped_total{source}- records the sink confirmed exported (an acknowledged OTLP delivery, not merely enqueued).opnsense_exporter_logs_dropped_total{source,reason}- records dropped before delivery. Its closedreasonvalues areoverflow(the count or byte queue bound evicted the oldest record),record_too_large(rejected at ingest),rejected(terminal destination refusal),ship_failed_permanent(the configured retry bound was exhausted), andship_failed(shutdown abandoned the retryable remainder).opnsense_exporter_logs_ship_errors_total- attempts that did not fully deliver. Acknowledged partitions are removed before retry, so this is an attempt counter rather than a record-loss counter.opnsense_exporter_logs_poll_errors_total{source}- source poll failures.opnsense_exporter_logs_last_received_timestamp_seconds{source}- exporter-clock time when this source most recently admitted a record to the queue: source-input liveness, not an origin event timestamp.opnsense_exporter_logs_last_exported_timestamp_seconds{source}- exporter-clock time when the sink most recently acknowledged a record from this source: delivery liveness, not queue admission or an origin event timestamp.opnsense_exporter_logs_queue_length/opnsense_exporter_logs_queue_capacity- record depth and configured record-count capacity;opnsense_exporter_logs_queue_bytes/opnsense_exporter_logs_queue_max_bytesare retained bytes and the aggregate byte budget (a max of 0 disables the byte bound).opnsense_exporter_logs_parse_errors_total{source,stage}- lines that failed to parse. Its closed stages are syslogenvelopeand Zenarmorbulk/document. These are not dropped: they ship with their raw body, so this counts fidelity lost, not data lost.opnsense_exporter_logs_rejected_total{source,reason}- receiver input refused before shipping. Closed syslog reasons arepeer,oversized,filtered,sampled,conn_limit,tls_timeout,tls_auth_failed, andtls_deadline_error; closed Zenarmor reasons arepeer,auth,unhandled_endpoint,body,overloaded,index_limit,unknown_family,filtered,self_traffic, andexcluded.opnsense_exporter_logs_enrich_misses_total{table}- enrichment lookups that missed. A steady rate ontable=rulesmeans the snapshot is behind the box's ruleset.opnsense_exporter_logs_enrich_refresh_errors_total{table}- failed enrichment refreshes. The previous snapshot keeps serving, so records still ship - enriched with increasingly stale data.opnsense_exporter_logs_enrich_last_refresh_timestamp_seconds{table}- when each lookup table last refreshed successfully. Alert ontime() - ...to catch a silently-stale cache.opnsense_exporter_logs_possible_gap_total{source}- a bounded-window source found no continuity with its previous cursor, so an unknown amount may have been skipped.opnsense_exporter_logs_resource_capped_total- records shipped without theiropnsense.*index labels because the resource-label budget was exceeded; the record still arrives, but label-scoped queries under-report.opnsense_exporter_logs_debug_captured_total{receiver,kind}- unmodelled signals written to the debug-capture dir (see Debug capture). Zero unless capture is enabled.opnsense_exporter_logs_debug_capture_dropped_total{receiver,reason}- capture entries dropped rather than written:reason=buffer_full(disk could not keep up),reason=cap_reached(--logs.debug-capture.max-byteshit; capture paused),reason=write_error,reason=duplicate_shape(a repeat of a message shape already captured this window - see below).opnsense_exporter_logs_zenarmor_excluded_total{rule}- Zenarmor records dropped by each configured exclusion rule; this is docs-only because it is a deliberate operator-configured blind spot, not a general pipeline-health panel.opnsense_exporter_logs_zenarmor_bulk_requests_total/opnsense_exporter_logs_zenarmor_bulk_bytes_total- accepted Zenarmor_bulkrequest count and decompressed request bytes; these are docs-only receiver-volume diagnostics.
opnsense_log_events_observation_dropped_total{reason} is not a Log Shipping
pipeline self-metric. It belongs to the separate Log-derived Events collector's
bounded receiver-to-collector handoff. It is documented in the generated metric reference and
charted in the Derived Metric Budget row of the health dashboard's Log Shipping
tab, with the closed reason handoff_full. The rest of that collector's counters are
firewall data, so they sit on the operational dashboard beside the subsystem each one
describes (filterlog events on Firewall & PF, tunnel lifecycle on VPN, and so on).
Why the syslog capture keeps one example per shape¶
The syslog lane captures any line whose program has no parser, and on a real firewall
that is most lines: one reference box produced 60,073 entries and 31 MB in a day, 70%
of it three repeated shapes (arpresolve failures, a chatty cron job, unbound
blocklist updates). The byte cap governs the whole directory and stops when
reached, so that one lane would fill it and permanently starve the NetFlow and
Zenarmor captures sharing it - and a starved capture looks exactly like a quiet one.
So the syslog lane keeps the first example of each message shape per 15-minute
window and counts the rest as reason=duplicate_shape. A shape is the message with
its varying parts collapsed, so arpresolve: ... for 86.31.203.106 and the same line
for another address are one shape. A shape never seen before is always captured
immediately, and the suppressed count is what tells you the difference between a quiet
lane and a busy one - a small capture file no longer means silence.
The NetFlow and Zenarmor lanes are not deduplicated. Both are bounded by design - a couple of datagrams at startup and nothing after - so a window would throw away samples they need to be complete.
See also¶
- Native Log Export - the syslog-ng/Alloy/NetFlow alternative to this pipeline, and the decision matrix for choosing between the two paths per log type.
opnsense_exporter_logs_possible_gap_total{source}- possible sampling gaps detected by a source whose only view of its data is a bounded window (e.g. the unbound source's latest-1000-row DNS query log, see Sources above): incremented when a poll's page shows no continuity with the previous cursor, meaning an unknown amount of data was skipped between polls.