# Metrics Ingestion and Audit

## 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.

```mermaid
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`](../configuration/#securityprobessniffing).
- **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](../security_probes/). 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.

```mermaid
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.

{{% notice style="note" title="SSH events need verbose sshd logging" icon="lightbulb" %}}
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.
{{% /notice %}}

## 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.

{{< staticImage "edge_products/axiom_border/console-supervisions.png" "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.

{{% notice style="warning" title="Protect the ingestion port" icon="triangle-exclamation" %}}
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.
{{% /notice %}}

### 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.

```bash
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.

{{% notice style="note" title="A 200 does not mean the rules ran" icon="lightbulb" %}}
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.
{{% /notice %}}

{{% notice style="warning" title="This endpoint feeds the pipeline, not the history" icon="triangle-exclamation" %}}
`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.
{{% /notice %}}

## 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.

```bash
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"}'
```

```json
{
  "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

```bash
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.

```json
{
  "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`.

{{% notice style="warning" title="Read randomAccess before drawing a pager" icon="triangle-exclamation" %}}
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.
{{% /notice %}}

Pagination limits are configurable — see [Configuration](../configuration/#pagination).

### Exports — for taking data elsewhere

**Streaming CSV**, which is the one to use:

```bash
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:

```bash
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>"]}'
```

{{% notice style="warning" title="Parquet export writes server-side and overwrites" icon="triangle-exclamation" %}}
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.
{{% /notice %}}

### 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:

```bash
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:

```bash
# 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.

{{% notice style="note" title="Auditing follows the rule script" icon="lightbulb" %}}
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](../alarms_and_rules/).
{{% /notice %}}

## 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.
