Metrics ingestion and audit

Axiom Border’s data comes from two origins. The first one the probe captures by itself: the traffic it sniffs and the scheduled scans it runs against the network around it, with no cooperation from the equipment it watches. The second one is reported to it: every monitored machine runs an oda-lite agent that watches the machine from the inside and sends what it sees.

flowchart TB
    NET["The network around<br>the probe"]:::ext
    MACH["The monitored machines<br><i>an oda-lite agent on each</i>"]:::ext
    NET -->|"sniffing and<br>scheduled scans"| P["<b>Axiom Border</b>"]
    MACH -->|"SSH · USB · interface<br>· health events"| P
    P --> ST[("Metrics store<br><i>history and exports</i>")]
    P --> LP["Live pipeline<br><i>rules · alarms · audit</i>"]
    classDef ext fill:#e9edfa,stroke:#486ac9,color:#101010

Whatever the origin, the data ends in the same two places: the metrics store, which keeps the history behind queries and exports, and the live pipeline, where rules are evaluated, alarms are raised and the audit trail is written. This page follows that flow — the two origins first, then the pipeline, then how to read everything back.

What the probe captures by itself

The network-facing half needs nothing installed on the monitored equipment:

  • Traffic capture — continuous sniffing of the configured interfaces, persisted periodically to the networktraffic bucket. Configured under securityProbes.sniffing.
  • Scheduled assessments — the discovery, vulnerability and SNMP probes run on their configured intervals, keep the asset inventory current, and record per-scan aggregates (scanmetrics) and host up/down state (availability).

Both are documented in Security assessment. What matters on this page is that their results are data like any other: they land in the store, their executions are audited, and the audit log’s agent filter accepts the probe names (network, vulns, snmp, sniff) alongside the agent channels.

What the agents report

The host-facing half is delivered by oda-lite, a lightweight monitoring agent that ships alongside Axiom Border as a separate component with its own release cycle — the version you have is the one your package includes. It is the whole of the monitoring deployment role: a machine with that role runs only the agent, reporting to a central probe. The probe’s own machine runs one too, so the central host is watched exactly like every other node.

An agent watches four things, and each maps to a channel — the “Guards” of the console views:

Channel In the console What arrives
ssh SSH-Guard SSH activity: connections, successful and failed logins, logouts, per-session byte counters — tagged with user, ip and port
iface IFACE-Guard Network interfaces appearing, disappearing or changing state, with the interface name, type and MAC address
usb USB-Guard USB devices connected and disconnected, with device names, ID and manufacturer
metrics METRIC-Guard The node’s reporting-health heartbeat: ram_usage, prepared_vars and sent_vars. Its silence is what a node stopped reporting alarm keys on

Those four names are what the API calls {agentType}: they appear in the read paths (/recent/{agentType}), in the audit log’s agent filter, and as the group routing tag on every event.

flowchart TB
    AG["oda-lite agents<br>on each machine"]:::ext
    OTH["Other reporters<br><i>optional</i>"]:::ext
    AG -->|":9090/agents<br>line protocol"| COL["oda-lite on the probe host<br><i>central profile</i>"]
    OTH -->|":9091/metrics<br>JSON"| COL
    COL -->|"one bucket<br>per channel"| ST[("Metrics store")]
    COL -->|"POST /telegraf"| LP["Live pipeline<br>rules · alarms · audit"]
    classDef ext fill:#e9edfa,stroke:#486ac9,color:#101010

On the probe host, oda-lite runs in its central profile: besides watching its own host, it listens for everyone else on two ports —

Listener Format Who posts there
:9090/agents InfluxDB line protocol The oda-lite agents on the monitored machines
:9091/metrics JSON Other reporting processes. A metric arriving without a channel tag that carries the reporting-health fields is routed to the metrics channel automatically

Everything it receives — its own guards’ events included — goes two ways at once: each channel is written to its bucket in the metrics store, and every event is forwarded to the probe’s live pipeline, so rules, alarms, audit and the recent feeds see it immediately.

Two operational handles worth knowing:

  • The agent is a systemd service on every node: systemctl status oda-lite. Its configuration is generated by the installer at /etc/oda-lite/oda-lite.conf — this is also where the listener ports move if 9090 or 9091 collide with something else.
  • Agents buffer briefly and flush every few seconds, so an event appears in the console with at most a few seconds of delay — instantly is the wrong expectation, but a minute is a problem.
SSH events need verbose sshd logging

The SSH monitor reads authentication logs, which means sshd must log at LogLevel VERBOSE and rsyslog must be populating /var/log/auth.log. The installer configures both for the roles that need them. On a host where SSH events never appear, check those two things first.

Seeing what is reporting in

Supervisions in the console lists the channels the probe is receiving data from. Each card is one channel, with a status dot, the last event it delivered, and the nodes it is monitoring.

The Supervisions view, with one card per channel: the interface, SSH and USB monitors reporting in, and the metrics one with no data

This is the first place to look when data is not arriving: a channel with no recent event means the agent has either stopped, or never reached the probe. In the capture above METRIC-Guard is in exactly that state — there is no data to display — which is the same silence that raised the node stopped reporting alarms on Current status. All events opens the full history for that channel.

The live pipeline: rules, alarms and audit

Events reach the probe itself through a single door, POST /telegraf — the same endpoint the collector forwards into. It accepts no authentication, because the processes reporting into it are unattended.

Protect the ingestion port

Anyone who can reach the API port can inject measurements, which means they can also trigger rules and raise alarms. Bind the API to a management network or firewall it. This is a deployment responsibility, not something the probe can enforce for you.

Payload format

The body is {"metrics": [...]} and every metric requires all four fields:

Field Type Meaning
name string Becomes the measurement in the store. In practice the machine ID of the reporting node
timestamp int64 Epoch in seconds, not milliseconds
tags map of string Must include group, which routes the metric to a channel
fields map The event’s values, numeric or string

The group tag is the routing key:

group value Channel History bucket
SSH ssh ssh_bucket
USBS usb usb_bucket
IFACES iface network_bucket
METRICS metrics metrics_bucket
SECSCAN Routed by the additional probe tag scanmetrics

Beyond group, the conventional tags are eventType, ip, port and user — these are what rules and queries filter on.

curl -s -X POST http://192.168.1.10:8083/telegraf \
  -H 'Content-Type: application/json' \
  -d '{
    "metrics": [
      {
        "name": "<machine-id>",
        "timestamp": 1746535617,
        "tags": {
          "group": "SSH",
          "eventType": "conn",
          "ip": "192.168.1.80",
          "port": "56390",
          "user": "unknown"
        },
        "fields": { "authevent": 1 }
      },
      {
        "name": "<machine-id>",
        "timestamp": 1746535623,
        "tags": {
          "group": "SSH",
          "eventType": "login_attempt_fail",
          "ip": "192.168.1.80",
          "port": "56390",
          "user": "operator"
        },
        "fields": { "authevent": 1 }
      }
    ]
  }'

The response is 200 with an empty body, or 400 if the JSON does not parse.

A 200 does not mean the rules ran

The recent feeds are updated immediately, so a /recent/* query straight after ingestion will show the event. Rule evaluation, auditing and alarm generation are asynchronous, so the 200 confirms acceptance, not evaluation. If you are testing a rule, allow a moment and check the alarms feed rather than inferring from the ingestion response.

This endpoint feeds the pipeline, not the history

POST /telegraf drives the recent feeds, the rules engine and the audit trail — it does not write the event itself to the metrics store. History is written by the collector. An event posted directly here can raise alarms and will show in the recent feeds, but it leaves no history and will not appear in range queries or exports. To feed your own data in fully, post to the collector’s listeners — :9090/agents in line protocol or :9091/metrics in JSON — and let it fan out to both places.

Reading data back

There are three ways to read, and choosing the right one is the difference between a responsive UI and a slow one.

Recent feeds — for live views

These answer in microseconds, serving the most recent events the probe already holds without running a historical query. Feed depth is capped by pagination.maxRecentEvents, default 1000.

curl -s -X POST http://192.168.1.10:8083/recent/ssh \
  -H "Authorization: Bearer <jwt-token>" \
  -H 'Content-Type: application/json' \
  -d '{"limit": 50, "orderBy": "desc"}'
{
  "count": 50,
  "warming": false,
  "elements": [ { "_measurement": "<machine-id>", "_time": "2026-05-06T13:27:03.000Z", "eventType": "login_attempt_fail", "user": "operator", "authevent": 1 } ]
}

Optional request fields: limit, uuids to filter by node, and orderBy for presentation order. Note that orderBy only affects how the returned page is sorted — the feed always yields the most recent events, so asc does not page backwards through history.

The warming flag is true while the feed is still being seeded after a restart. Surface it in a UI as a loading state rather than presenting a partial feed as complete.

Three feeds exist: /recent/{agentType} for events, /recent/alarms for alarms, and /recent/auditlog for audit entries. The audit variant takes timeOrder instead of orderBy and adds eventsId and agent filters — the latter also accepting the probe names network, vulns, snmp and sniff.

There is no total or pages in the response. The client paginates locally over what it received.

Paginated ranges — for historical queries

curl -s -X POST http://192.168.1.10:8083/datapagination/ssh \
  -H "Authorization: Bearer <jwt-token>" \
  -H 'Content-Type: application/json' \
  -d '{
        "from": "2026-08-01T00:00:00Z",
        "to": "2026-08-03T00:00:00Z",
        "page": 1,
        "pageSize": 100,
        "orderBy": "desc"
      }'

A range read is answered in one of two ways, and the response metadata tells you which one you got:

meta field Meaning
randomAccess true: page numbers work, so you can jump to any page. false: follow nextCursor instead
exact true: total and pages are exact. false: total is an upper bound

Narrow ranges answer with full random access. Wide ones switch to sequential reading, and nextCursor carries the position of the following page.

{
  "elements": [ "..." ],
  "meta": {
    "total": 3412, "pages": 35, "page": 1, "pageSize": 100,
    "randomAccess": true, "exact": true,
    "range": { "from": "2026-08-01T00:00:00Z", "to": "2026-08-03T00:00:00Z" }
  }
}

To continue a sequential read, send only the cursor field — no from, no to, no page.

Read randomAccess before drawing a pager

When randomAccess is false, page numbers are meaningless and total is an upper bound, not a count. A UI that renders “page 12 of 340” from that metadata will be wrong. Read meta.randomAccess on every response and switch between a numbered pager and a “load more” control accordingly.

Pagination limits are configurable — see Configuration.

Exports — for taking data elsewhere

Streaming CSV, which is the one to use:

curl -s -X POST http://192.168.1.10:8083/data/ssh \
  -H "Authorization: Bearer <jwt-token>" \
  -H 'Content-Type: application/json' \
  -d '{
        "uuids": ["<machine-id>"],
        "orderby": "desc",
        "range": { "initTime": "2026-08-01T00:00:00Z", "finishTime": "2026-08-03T00:00:00Z" }
      }' -o export.csv

The range is mandatory here, and a range exceeding pagination.rangeExportMaxRows (default 500 000) is rejected with 400 asking you to narrow it. That guard is deliberate.

Parquet, which behaves differently:

curl -s -X POST http://192.168.1.10:8083/parquet/ssh \
  -H "Authorization: Bearer <jwt-token>" \
  -H 'Content-Type: application/json' \
  -d '{"uuids": ["<machine-id>"]}'
Parquet export writes server-side and overwrites

This endpoint returns 200 with an empty body — it does not return the file. It writes to a fixed path, <outputParquetPath>/influxquery.parquet, so every call overwrites the previous one. Unlike the CSV export, the range is optional and no row-count limit applies, so a broad query can produce a very large export. Always pass a range on a populated bucket, and collect the file from the server before the next call.

Other read helpers

Endpoint Purpose
GET /lastevent/{agentType} Latest event per device as semicolon-separated CSV. Sets X-Recent-Warming: true while the feed is still seeding
GET /uuids/{agentType} Node UUIDs present in one channel’s bucket
GET /uuids Map of UUID to the channels it appears in

Aliases — making UUIDs readable

Nodes are identified by machine ID, which is unreadable. An alias maps one to a name, and every read endpoint accepts ?alias=true to perform the translation:

curl -s -X POST http://192.168.1.10:8083/alias \
  -H "Authorization: Bearer <jwt-token>" \
  -H 'Content-Type: application/json' \
  -d '{"alias": "plc-line-1", "uuid": "<machine-id>"}'

With ?alias=true, the uuids filter in a request body also accepts alias names, so a client can work entirely in readable names. Note that DELETE /alias takes the alias name, as {"name": "..."}, not the UUID.

Audit

Every significant action is audited to the audit_logs bucket. Two ways to read it:

# Live tail
curl -s -X POST http://192.168.1.10:8083/recent/auditlog \
  -H "Authorization: Bearer <jwt-token>" \
  -H 'Content-Type: application/json' \
  -d '{"limit": 100, "timeOrder": "desc"}'

# Historical, filtered to vulnerability scans
curl -s -X POST http://192.168.1.10:8083/auditlog \
  -H "Authorization: Bearer <jwt-token>" \
  -H 'Content-Type: application/json' \
  -d '{
        "from": "2026-08-01T00:00:00Z",
        "to": "2026-08-03T00:00:00Z",
        "agent": "vulns",
        "pageSize": 100
      }'

The agent filter accepts the four channel names and the four probe names. Filtering by vulns is how you answer “what was scanned, when, and by whom” — including the warning entry that every intrusive OT scan generates.

Auditing follows the rule script

An agent’s or probe’s activity is audited when its rule script is enabled. Disabling a script therefore costs you the audit records for that agent or probe as well as its rules — the events are still ingested and stored, but they leave no audit trail.

If you need an audit trail for a particular agent, check the enabled state with GET /scripts and make sure its script exists and is enabled, even if the script itself does nothing. See Alarms and the rules engine.

Storage layout

Where each kind of data ends up. The four channel buckets are written by the collector; everything else is written by the probe itself:

Bucket Contents
ssh_bucket, usb_bucket, network_bucket, metrics_bucket Per-channel agent events
eg_alarms Raised alarms
audit_logs Audit trail
networktraffic Passive sniffing flow data
availability Host up/down state from the probes
scanmetrics Aggregated scan metrics

Buckets are created on first start if absent. Note that no retention policy is applied by default — configure retention in InfluxDB according to your disk budget, or the metrics buckets will grow without bound. An Influx bucket with no retention is a common cause of a probe slowly filling its disk.