otto monitor

otto monitor collects CPU, memory, disk, and network metrics from remote hosts, and serves a web dashboard for reviewing what it collected.

The review dashboard's fleet grid: element-grouped host tiles with astatus dot, a health-rollup bar per element, and a labeled headlinemetric

Two commands live under one binary:

  • otto monitor --live [OPTIONS] — the only hardware-touching path (it runs the reservation gate before touching any host, and needs --lab to resolve which hosts to poll). Collects from lab hosts and serves the dashboard against that live collector. Add --db PATH to persist the run as a session; reusing the same --db path on a later run appends another session to the same archive rather than overwriting it.

  • otto monitor <SOURCE> — review mode. SOURCE is a .json export or a .db session archive; no hosts are touched, no reservation gate runs, and no --lab is needed — SOURCE is a self-contained document, so this works for a hand-carried archive on a machine with no lab configured at all. The dashboard auto-loads the document the moment the page opens — no Import click needed — and a multi-session archive gets a session picker.

Bare otto monitor (neither --live nor a source) prints usage and exits 2; --live together with a source is a mutually exclusive error, also exit 2. See Web dashboard below for what the dashboard shows and how it gets loaded either way — including live streaming straight into an open dashboard tab, watching a running --live session’s charts grow in real time rather than requiring a reload.

Live mode

--live is the explicit opt-in that touches hardware; it is never the default. By default it polls every real host in the lab:

otto --lab my_lab monitor --live

Docker container hosts are excluded — they aren’t operated on as part of the host fleet. Embedded targets without an snmp block are also excluded: the monitored set is Unix hosts (shell metrics) plus any host that declares snmp (polled over SNMP — see SNMP monitoring below).

Selecting hosts

Pass a regex to --hosts (matched against host IDs via re.search) to narrow the live host set:

otto --lab my_lab monitor --live --hosts 'router|switch'
otto --lab my_lab monitor --live --hosts router1

Omit the option to monitor every real host in the lab (Docker containers excluded).

Collection interval

Control how often metrics are collected with --interval (default: 5 seconds, minimum: 1 second):

otto --lab my_lab monitor --live --interval 2.0

The 1-second floor is deliberate: a host needs time to answer every query in the interval without being taxed by the polling itself. It’s enforced at every human-facing boundary that names an interval — otto monitor --interval above, otto test --monitor-interval (see Monitoring during a test run below), and OttoSuite.start_monitor() (see Monitoring from test suites below) all reject anything lower. MetricCollector itself is deliberately exempt — it’s the mechanism, not a knob a human sets, and otto’s own tests drive it as fast as 0.01s against fake hosts.

Persisting data — sessions

Add --db to persist the run as a session — this run’s lab snapshot, chart/tab layout, and every collected point — into a SQLite archive:

otto --lab my_lab monitor --live --db metrics.db

Reusing the same --db path on a later run doesn’t overwrite it: each --live --db metrics.db invocation appends one more session, so a single archive can accumulate a whole day’s worth of separately-labeled runs. Tag a session for later review with --label (short, shown in the dashboard’s session picker) and --note (free-form, shown as that picker entry’s tooltip):

otto --lab my_lab monitor --live --db metrics.db \
    --label "fan fix" --note "post-repair burn-in, rack 3"

Review a captured archive later with otto monitor metrics.db — see Reviewing a capture below.

Running otto on shared/NFS storage

otto is safe to run with its log/artifact root (OTTO_XDIR) on a shared mount (NFS, CIFS/SMB, sshfs, …):

  • Monitor database. SQLite’s WAL journaling is not supported over a network filesystem, so when the --db path is on one otto automatically uses the DELETE journal mode instead (logged at debug level). This is transparent and lossless for monitoring’s write pattern.

  • Multi-machine, one shared database. The “another instance is already writing” guard relies on flock, whose semantics on network filesystems are same-host only. If several machines may write to the same database file, put that database on local disk (or give each machine its own --db path).

  • Logs and artifacts. Per-run log directories are fine on shared storage. Old-log rotation is wall-clock budgeted, so even a very large log tree cannot stall a run — any backlog is pruned across subsequent runs.

  • Lab data and settings (lab.json, .otto/settings.toml) are read once per run and are unaffected.

If otto cannot determine the filesystem type, it assumes local disk and keeps its default behaviour.

Reviewing a capture

The positional <SOURCE> argument serves a previously captured run without touching any hosts — no reservation gate, no collection, and no --lab:

otto monitor metrics.db
otto monitor metrics.json

That last point matters for a hand-carried archive: a teammate who receives metrics.db can open it with the command above on a machine with no lab configured anywhere — SOURCE is a self-contained document, and review mode never resolves, loads, or even looks for a lab.

SOURCE must be a .db session archive written by --live --db, or a .json export — either downloaded from a running dashboard’s ⋯ → Export, or written by otto test --monitor (see Monitoring during a test run below). Anything else is a fast, clear CLI error — there is no silent partial load:

  • An unrecognized suffix, or a .json/.db that doesn’t parse as a format:1 document, exits 1 with a message naming what was expected.

  • A path that doesn’t exist exits 2 with a usage banner — the argument is validated before the command body runs, so it fails the same way any other bad invocation does.

Breaking change, no migration. A .db/.json written by an otto build before sessions existed used a different, unversioned shape and is no longer readable — otto monitor on one of those fails loud naming the expected format rather than misrendering silently. There is no converter; re-capture with the current build. The GET /api/export/json endpoint changed the same way (it now emits this same format:1 shape), which is a breaking change for anything that scraped it directly. One narrower caveat, specific to this feature’s early rollout: a .db archive captured by a pre-release build of --live --db (before its session metadata persistence was corrected) replays with no chart specs and a null interval — it looks like a valid archive but the dashboard renders it as one ungrouped, unit-less chart per series. That has no migration either; re-capture.

Web dashboard

In both modes, otto monitor serves a web server: it binds an OS-assigned free port and logs the dashboard URL at startup (Server running at http://<ip>:<port>, one URL per non-loopback interface).

On load, the dashboard shell asks that same server one question — GET /api/mode — then, regardless of the answer, follows up with GET /api/monitor_sessions and renders the result, exactly as if you’d used Import yourself: no click needed. Live and review servers hydrate through that same endpoint and the same format:1 shape — a live monitor session is simply one whose end is still open, exactly like a crashed session found on disk — so the fleet grid and charts populate immediately either way, not just in review mode. In live mode, once that initial hydrate succeeds the shell also opens GET /api/stream (Server-Sent Events) and grows the loaded session in place by appending each fragment as it arrives — the wire fragments carry the same field names as the payload they append to, so there is no separate live shape to reconcile. The same boot fetch is also why the dashboard still works when served by a bare static file server with no /api/* routes at all (used for the screenshots on this page, and for ad-hoc demos): any failure — connection refused, a non-JSON body, whatever a dumb server hands back — is swallowed and falls back to the same empty Import screen, never a broken page.

Feed it a monitor export document yourself at any time — drag a file onto the window, or use the overflow menu’s Import — and it renders that document entirely in the browser, exactly like a boot-fetched monitor session:

  • Fleet grid. Element-grouped host tiles, each with a status dot, an element-level health-rollup bar, and a labeled headline metric; a down tile shows its outage duration instead.

  • Health, scoped to the viewed range. Every status, rollup, and headline reflects whichever time window the review bar is currently showing — narrow the range and a host that’s healthy across the full session can show down (or vice versa) if that’s what the narrower window actually contains.

  • Per-subject charts. Drilling into a host (or an element) stacks its metrics as synced chart panels — panning or zooming one follows the rest of the stack, so a spike is easy to correlate across series.

    A subject page's synced chart stack: one panel per metric group and akernel log table, all sharing one time axis with eventmarkers

  • Series and source filtering. A per-subject series tree toggles individual metrics on and off; chip filters narrow by metric group or by data source (a series’ own host vs. an external management host — externally-sourced series carry a provenance badge).

  • Events. A reverse-chronological slide-over lists every event in the loaded document; clicking a row re-scopes the review bar’s range to that event’s span.

  • Multiple sessions. A document spanning more than one session (a config change captured mid-run, or a --db archive several --live --db runs appended into, for example) exposes a session picker; each entry’s tooltip is that session’s --label/--note, and each session renders under the lab configuration it was captured under, so drift between sessions never bleeds into the wrong one’s view.

  • Export. The menu re-downloads whatever document is currently loaded, unchanged.

Loading a session — automatically at boot in either mode, growing live via SSE, or by hand via Import — is covered by the browser e2e suite (tests/e2e/monitor/dashboard/, see the behavior-spec contract below).

Live status, pause, and reconnect

While --live, the app bar’s status dot and text track the SSE connection: Live while the stream is open and receiving fragments, Reconnecting… while a dropped connection is retrying with backoff, and Reviewing for a review-mode server. (A client-side Import with no backing server keeps the pre-existing “Historical”/”No data” wording, unaffected by any of this.)

Pause is a view control, not a data control. Clicking Pause freezes the visible time window; it does not stop ingestion — fragments keep applying to the loaded session in the background, so clicking Resume catches up immediately with no gap to backfill. “Paused” is derived rather than a separately stored flag: it is exactly “live mode with a pinned range,” so pausing and manually picking a custom range (a chart drag-zoom, for example) are the same state and can never disagree with each other — toggling pause from either one resumes following the tail.

Reconnect re-fetches; it never replays. When the SSE connection drops, the client backs off and retries, and immediately before reopening the stream it re-fetches the whole /api/monitor_sessions payload rather than trying to replay whatever fragments it missed while disconnected — the fresh snapshot is already the truth, so there’s no sequence-number bookkeeping and no way for client and server to disagree about history.

A silent host dims. Health (see Health, scoped to the viewed range above) is derived from the gap since a host’s last sample: a host goes down once that gap exceeds HEALTH_K (3) times its collection cadence. In live mode that evaluation runs against a moving “now” rather than a fixed range boundary, driven by a clock that ticks at the collection interval — polling the health check faster than the collector itself couldn’t learn anything sooner anyway.

Frontend development

The dashboard’s frontend is a React + Vite + TypeScript single-page app in web/. Vite builds it into src/otto/monitor/static/dist/, the only frontend MonitorServer serves — there is no legacy fallback, so a checkout without a build fails loudly with a make web pointer rather than silently serving something stale.

make web-install   # npm ci, from web/package-lock.json
make web-dev       # Vite dev server with hot reload; proxies /api to a
                    # running `otto monitor` (default http://127.0.0.1:8080,
                    # override with VITE_OTTO_TARGET=http://host:port)
make web           # production build: regenerates + diffs the generated
                    # wire types against the live pydantic models, builds,
                    # then gates the output against absolute http(s) URLs
                    # (labs are air-gapped)
make web-test      # vitest — store reducers, SSE handling, chart-series
                    # grouping, PID-trace retirement, etc.

make web-dev’s proxy target is a running server process — an otto monitor --live collector or an otto monitor <source> review server both serve /api/* — useful for developing against real backend responses, live or historical. make web is what actually ships in the wheel.

Behavior-spec contract. tests/e2e/monitor/dashboard/ is a Playwright suite that pins the dashboard’s observable surface through data-testid attributes only — styling and DOM structure are free to change underneath them. Those pins adjudicate, not this page or the source: if a doc description and a pin ever disagree, fix the doc. Run them locally with make dashboard (Chromium only — the fast per-task check; needs make browsers once) or make dashboard-all for the full cross-engine matrix: Chromium (Blink), Firefox (Gecko), and WebKit (Safari). The one Safari-specific test runs on WebKit only via @only_browser("webkit"). make release runs all three; CI runs them as a parallel per-engine matrix.

Monitoring during a test run

Pass --monitor to otto test to collect metrics for the entire run. Per-test start/end events are emitted automatically and the captured data is written to <output_dir>/monitor.json at exit:

otto --lab my_lab test --monitor TestPerformance
otto --lab my_lab test --monitor --monitor-interval 2 --monitor-hosts router TestPerformance
otto --lab my_lab test --monitor --monitor-output run.db TestPerformance

otto monitor <path> opens either output in the same review dashboard described in Reviewing a capture above — the document loads automatically the moment the page opens, no Import click needed.

Monitoring from test suites

You can also start the monitor programmatically from within a single test:

class TestPerformance(OttoSuite[_Options]):

    async def test_load(self, suite_options: _Options) -> None:
        await self.start_monitor(hosts=[host1, host2])
        await self.add_monitor_event("Load started", color="green")

        # ... run workload ...

        await self.add_monitor_event("Load complete", color="red")
        await self.stop_monitor()

When both per-suite and --monitor-driven session collectors are active, the per-suite collector takes precedence for that test. Events appear as markers on the dashboard timeline, making it easy to correlate metric changes with test actions.

Built-in metrics

Every Unix host in the monitored set runs DEFAULT_PARSERS unless a custom registration says otherwise (see Custom parsers below):

Command

Series

Chart

Tab

Notes

top -d 0.5 -bn2

Overall CPU; proc/<pid> for the top 5 processes by CPU%

CPU

CPU

Runs two top iterations per tick and discards the first, so %CPU reflects the tick interval rather than the process’s lifetime average.

free -b

Memory Usage; Swap

Memory Usage

Memory

The Swap series only appears when the host has swap configured — it is omitted, not charted as a flat 0.

df -h

one series per mounted filesystem, labelled by mount point

Disk Usage

Disk

cat /proc/loadavg

Load (1m), Load (5m), Load (15m)

Load

CPU

cat /proc/net/dev

rx <iface>, tx <iface> (B/s) per interface

Network I/O

Network

Loopback (lo) is skipped. Packet counts and error/drop rates ride along in each series’ hover meta rather than charting separately.

ss -s

Established, Time-wait

Sockets

Network

A host without ss simply has no Sockets series — see Parser health.

cat /proc/diskstats

read <device>, write <device> (B/s) per device

Disk I/O

Disk

Whole devices only — partitions (sda1, nvme0n1p2, …) and virtual/noise devices (loop*, ram*, dm-*, zram*, sr*) are skipped.

cat /proc/stat

core <N> (%) per CPU core

Per-core CPU

CPU

The aggregate line is skipped; overall CPU is already charted by the top-CPU parser above.

cat /proc/loadavg /proc/stat

Runnable, Total procs, Blocked

Processes

CPU

Network I/O and Disk I/O are rate metrics: computed from monotonic counter deltas, they need two samples before they can chart anything, so the first tick for a given interface or device emits no point. A host reboot resets those counters; otto detects the resulting negative delta, skips one tick, and re-baselines from the new counters — a reboot never shows up as a spike.

Note

otto tunnel discovery (discover_tunnels) is built as a (command, pure parser) pair for exactly this reason — it maps 1:1 onto the MetricParser shape below (command / parse / interval) so a future collector integration can reuse it as-is. That wiring (scheduling discovery on the collection interval, storing edges, topology views) is a later phase; today otto.tunnel stays monitor-free and otto tunnel list is the only live view of tunnels.

Custom parsers

The monitor uses parsers to extract metrics from command output. By default, all hosts use DEFAULT_PARSERS. Subclass MetricParser and implement parse(self, output, *, ctx) to extract one or more data points from a command’s raw output, then register it for specific hosts:

from otto.monitor.collector import MonitorTarget
from otto.monitor.parsers import DEFAULT_PARSERS, MetricDataPoint, MetricParser, ParseContext


class NvidiaGpuParser(MetricParser):
    y_title = "Usage %"
    unit = "%"
    chart = "GPU"
    command = "nvidia-smi --query-gpu=utilization.gpu --format=csv,noheader,nounits"

    def parse(self, output: str, *, ctx: ParseContext) -> dict[str, MetricDataPoint]:
        return {self.chart: MetricDataPoint(value=float(output.strip()))}


MonitorTarget(
    host=gpu_host,
    parsers={
        **DEFAULT_PARSERS,
        NvidiaGpuParser.command: NvidiaGpuParser(),
    },
)

ctx (a ParseContext) carries tick-local input such as the target host’s core count; most parsers ignore it. See otto.monitor.parsers for the built-in parsers and the MetricParser protocol.

Per-host parsers

Register a custom parser dict for one host — or a family of hosts matched by a compiled regex — from an init module listed in .otto/settings.toml. Registration matches on the host id (the unique key, as in lab.hosts), not the human-readable display name shown in the dashboard:

from otto.examples.monitor import UptimeParser
from otto.monitor.parsers import DEFAULT_PARSERS, register_host_parsers

register_host_parsers(
    "router1",
    {**DEFAULT_PARSERS, UptimeParser().command: UptimeParser()},
)

UptimeParser (in otto.examples.monitor) is a complete, runnable example: it charts cat /proc/uptime as a single “Uptime” series in seconds, and otto’s own test suite registers it exactly this way.

A compiled pattern instead of a host id scopes the same registration to every host whose id matches — for example, giving a family of busybox-* hosts (whose ss doesn’t support -s) a netstat-based sockets parser in place of the default ss -s one:

import re

from otto.monitor.parsers import DEFAULT_PARSERS, register_host_parsers
from my_repo.parsers import NetstatSocketsParser  # your own ss-free implementation

parsers = {k: v for k, v in DEFAULT_PARSERS.items() if k != "ss -s"}
parsers[NetstatSocketsParser.command] = NetstatSocketsParser()
register_host_parsers(re.compile(r"busybox-.*"), parsers)

Patterns are matched with re.fullmatch against the host id. Precedence is exact id > pattern > project-level > DEFAULT_PARSERS: an exact-id registration always wins outright for that host, and a host matched by two different patterns raises at resolution time rather than picking a silent, import-order-dependent winner.

Project-level parsers

Register parsers that apply to every monitored host from an init module (listed in .otto/settings.toml):

from otto.monitor.parsers import register_parsers
from my_repo.parsers import SocketParser

register_parsers([SocketParser()])

A parser whose command matches a built-in overrides it; new commands extend the set. Per-host registrations (register_host_parsers) still take total precedence for their host. Registering the same command twice raises.

Per-parser collection intervals

Set interval (seconds) on a parser class to poll its command on its own cadence; parsers without one use the global --interval:

class SocketParser(MetricParser):
    command = "ss -s"
    interval = 30.0  # poll sockets every 30s regardless of --interval
    ...

Parser health

The collector watches each parser’s command for two kinds of trouble and logs a warning — edge-triggered, so a flapping command logs every transition while a steady outage logs only once.

Failing command. The first tick a command starts failing (nonzero exit) logs a warning naming the metrics that will be missing; recovery logs once more when the command starts succeeding again:

Monitor: 'ss -s' failed on test1 (exit 127): ss: command not found — Sockets metrics will be missing
Monitor: 'ss -s' recovered on test1 after 4 failed tick(s)

Silent command. A command that keeps exiting 0 but never yields a data point — a bad regex, an unfamiliar output format, nothing to report — gets a one-time backstop warning after three succeeding ticks with no output:

Monitor: parser SocketsParser ('ss -s') has produced no data on test1 after 3 ticks

Only succeeding ticks count toward those three; a failing command is already covered by the warning above and isn’t double-counted here. The same backstop watches SNMP OIDs that never return a value.

Either way, a missing tool or unreachable metric is not an error otto tries to recover from: the affected series is simply absent from the dashboard, same as any other tick that produced no data.

Log-sourced data

Some systems don’t expose live values through a poll-able command: a cron job digests performance counters into a timestamped file every few minutes, or the interesting record is a log file’s event stream rather than a number. Both ride the same shell acquisition path as every other parser — the command is the reduction step (cat/tail/awk/grep/jq on the host ships back only the lines otto needs) — but instead of one untimed value per tick, each row or line carries its own timestamp. The design assumes source data is textually reducible on the host; binary or otherwise irreducible formats are out of scope.

CSV metric files

CsvMetricParser charts a cron-digested CSV file. Register it like any other parser (see Custom parsers):

from otto.monitor.log_sourced import CsvMetricParser
from otto.monitor.parsers import register_parsers

register_parsers([
    CsvMetricParser(
        "cat /var/log/perf/net.csv",
        columns=["rx_kbps", "tx_kbps"],
        chart="Cron net digest",
        tab="network",
        tab_label="Network",
        unit="kb/s",
        interval=60,
    ),
])

Line format: the first column is an ISO-8601 or epoch-seconds timestamp (naive values are treated as UTC); the remaining columns are numeric values matching columns, comma-separated, in file order. Header and otherwise malformed lines are skipped outright. The final line of each read is provisional rather than trusted immediately — a mid-write read can torn-truncate it into something that still parses — so it only emits once a later read shows it unchanged; worst case this delays the newest row by one poll interval, and a torn line itself never emits (see Timestamps below for the high-water mark this protects).

Because points carry their own data-carried timestamps rather than the collector’s tick time, a file already holding the last hour of digests backfills the dashboard and DB with a full hour of real history the moment monitor starts, not just whatever arrives after that.

One instance per file: the command string is the parser registry key, so monitoring “a couple of CSV files” means two registered instances. Give a slow-cadence file its own interval (seconds; see Per-parser collection intervals) so otto doesn’t re-read an unchanged file on every tick.

A cron job maintaining such a file might look like this:

#!/bin/sh
# Example cron digest: append "epoch,val1,val2", prune to the last hour.
# Cron entry (every 5 minutes):  */5 * * * *  root  /usr/local/bin/perf_digest.sh
FILE=/var/log/perf/net.csv
printf '%s,%s,%s\n' "$(date -u +%s)" "$(cat /sys/class/net/eth0/statistics/rx_bytes)" \
    "$(cat /sys/class/net/eth0/statistics/tx_bytes)" >> "$FILE"
tail -n 12 "$FILE" > "$FILE.tmp" && mv "$FILE.tmp" "$FILE"   # 12 lines = 1 h at 5-min cadence

Provisioning a script like this on a bed is a manual demo step — otto’s own test suite exercises CsvMetricParser entirely against fixture-written files, never a live cron job.

Log-event tables

RegexLogEventParser turns matching log lines into table rows instead of chart points. A worked syslog example, using the same pattern otto’s own test suite registers:

from otto.monitor.log_sourced import RegexLogEventParser
from otto.monitor.parsers import register_parsers

SYSLOG_PATTERN = r"^(?P<ts>\S+) (?P<loghost>\S+) (?P<proc>[^:\[]+)(?:\[\d+\])?: (?P<message>.*)$"

register_parsers([
    RegexLogEventParser(
        "tail -n 200 /var/log/syslog",
        SYSLOG_PATTERN,
        tab="syslog",
        tab_label="Syslog",
    ),
])

Every named group in pattern besides the timestamp group becomes a table column, in pattern order (loghost, proc, message above). A line that doesn’t match is skipped entirely — a wrong pattern therefore produces zero rows ever, which the Parser health silent-command backstop surfaces by the third tick.

ts_group (default "ts") names the group holding the timestamp; ts_format (default "iso") tells parse_timestamp how to read it: "iso" for ISO-8601, "epoch" for Unix epoch seconds, or anything else as a strptime format. Classic syslog timestamps (Jan  2 15:04:05, no year) need a strptime format — otto injects the current UTC year before parsing those, so they parse correctly instead of rejecting outright. If that injected year would land the row more than 2 days in the future (a Dec 31 line read just after New Year rolls over to next year’s Jan 1 under the current-year injection), otto subtracts one year — the standard syslog-consumer rollover guard, so a New Year boundary can’t wedge the high-water mark a year ahead of every real row.

Each RegexLogEventParser contributes one kind="table" tab on the dashboard and no chart. Rows render newest-first with a client-side, case-insensitive substring filter; the browser keeps roughly the last 500 rows on screen even though the database keeps every row ever collected — reload that database with otto monitor <path> (see Reviewing a capture) and the full history replays as a table too, not just as charts.

Table parsers must declare their own tab id: a table tab can’t share an id with a chart tab, or with another table tab (see TabSpec). Registering a colliding tab id is a configuration error that otto raises loudly rather than silently picking a winner.

LogEvent rows are a deliberately separate data path from MonitorEvent markers: log events are per-host, high-volume, columnar table data, while MonitorEvents are the global, low-volume annotations that mark moments on the chart timeline (see Monitoring from test suites).

Timestamps

Every log-sourced row carries its own data-carried timestamp instead of the collector’s tick time; a naive value (no timezone) is always treated as UTC, whether it comes from a CSV’s first column or a regex’s timestamp group. A row with no parseable timestamp is dropped — log-sourced parsers have no tick-time fallback, so an empty or unrecognized timestamp field means that row never appears at all.

Each parser instance keeps a high-water mark: the newest row timestamp it has emitted so far. Re-reading a rolling window (the usual tail -n N) drops everything at or below the mark, so ticks that overlap the previous read are deduplicated rather than double-counted. The mark is keyed on the row’s own timestamp, not a file offset or byte count, so log rotation and truncation need no special handling — a rotated file’s new rows are still newer than the mark and come straight through.

Large files

An append-only log fits at any size: a fixed tail -n N window bounds what one tick reads, and the high-water mark discards whatever overlaps the previous read, so N only needs to comfortably cover one poll interval’s worth of new lines, not the file’s total size.

Because a parser’s command string is a static registry key, one parser can’t vary its command per tick — reading from a byte offset that grows over time, for example, is unsupported by design; size tail -n N to the interval instead. A large regenerated file (a digest script that rewrites the whole thing on every run rather than appending) fits the same way any verbose command output does: reduce at the source with awk/jq/a product CLI, and give the parser its own slower interval (see Per-parser collection intervals) if the file itself only changes infrequently — each parser rides its own bucket, so a slow file never blocks faster ones.

SNMP monitoring

Some targets expose performance metrics over SNMP rather than via a shell interface. Otto supports SNMP v2c polling for any standards-compliant agent — a Zephyr device running otto’s test-bed agent, a Linux box running net-snmp, or network gear — on a separate channel from command execution.

When to use it

Use SNMP monitoring when a host either has no shell (embedded Zephyr targets) or when you prefer to pull metrics through a dedicated management channel rather than shell commands. See Embedded Hosts for embedded host setup and Lab Configuration for the snmp field reference.

Configuring the snmp block in lab.json

Add an snmp object to a host entry in lab.json to enable SNMP collection for that host:

{
    "ip": "192.0.2.1",
    "element": "sprout",
    "os_type": "zephyr",
    "snmp": {
        "address": "10.10.200.14",
        "port": 16101,
        "community": "public",
        "oids": [
            "1.3.6.1.2.1.1.3.0",
            "1.3.6.1.4.1.63245.1.1.0",
            "1.3.6.1.4.1.63245.1.2.0",
            "1.3.6.1.4.1.63245.1.3.0",
            "1.3.6.1.4.1.63245.1.4.0"
        ]
    }
}

The address and port are the endpoint reachable from the otto host — for an embedded device behind a hop this is typically the local end of a UDP relay on the hop host, not the device’s own address. community defaults to "public". oids is the list of OIDs to poll each tick — raw dotted OIDs, otto’s named bundles (otto-core, otto-net:N, otto-fs:N), or a mix of both; see the snmp.oids field reference in Lab Configuration for the full bundle syntax. Presentation (label, chart group, unit) is supplied by the descriptor registry, not by lab data.

How otto reads SNMP data

SnmpClient (in otto.monitor.snmp) is a thin async SNMP v2c GET wrapper. It issues a single GET PDU per poll tick for all configured OIDs and returns a {oid: float | None} mapping. The pysnmp library is imported lazily inside SnmpClient.get, so the SNMP path is entirely optional — otto imports cleanly without pysnmp installed, and unit tests can mock at the get boundary.

Built-in metric descriptors

Otto ships descriptors for a standard OID set. Each descriptor (an SnmpMetric) carries the label, chart group, y-axis title, unit, tab, and a scale factor that converts the raw integer varbind to a real value.

OID

Label

Chart

Unit

Notes

1.3.6.1.2.1.1.3.0

Uptime

Uptime

s

Standard sysUpTime (TimeTicks ÷ 100); works against any compliant agent

1.3.6.1.4.1.63245.1.1.0

Overall CPU

CPU

%

Otto enterprise OID (centi-percent ÷ 100)

1.3.6.1.4.1.63245.1.2.0

Heap Used

Memory Usage

B

Otto enterprise OID

1.3.6.1.4.1.63245.1.3.0

Heap Free

Memory Usage

B

Otto enterprise OID

1.3.6.1.4.1.63245.1.4.0

Threads

Threads

Otto enterprise OID

The enterprise OIDs are served by otto’s Zephyr test-bed agent. The enterprise base is 1.3.6.1.4.1.63245 (PEN 63245, a placeholder — a real IANA PEN has not yet been assigned).

kind governs how a raw varbind becomes a chart point: gauge (the default, e.g. Heap Used above) charts raw * scale directly; counter treats the varbind as a monotonic counter and converts it to a per-second rate — first sighting and post-reboot re-baselining emit nothing, the same rule the Unix Network I/O/Disk I/O parsers follow (see Built-in metrics).

Per-interface and per-filesystem OIDs

Network and filesystem metrics live in an indexed subtree rather than a handful of fixed leaves: a small agent has a known, fixed set of interfaces and filesystems, 0-indexed by the firmware, and otto polls one scalar per value with a plain GET — no table walk. This layout is the firmware/manager contract: the agent and otto must agree on it exactly, the same way both sides agree on the core .1 scalars above.

OID

Leaf

Kind

Notes

1.3.6.1.4.1.63245.2.<i>.1.0

rx bytes

counter

Charted as rx if<i> (B/s) on the Network tab.

1.3.6.1.4.1.63245.2.<i>.2.0

tx bytes

counter

Charted as tx if<i> (B/s) on the Network tab.

1.3.6.1.4.1.63245.2.<i>.3.0

rx packets

counter

Rides the rx-bytes series’ hover meta, not its own chart.

1.3.6.1.4.1.63245.2.<i>.4.0

tx packets

counter

Rides the tx-bytes series’ hover meta.

1.3.6.1.4.1.63245.2.<i>.5.0

rx+tx errors (combined)

counter

Charted as errors if<i> on the “Net errors” chart.

1.3.6.1.4.1.63245.2.<i>.6.0

drops

counter

Charted as drops if<i> on the “Net errors” chart.

1.3.6.1.4.1.63245.3.<i>.1.0

filesystem used bytes

gauge

Charted as fs<i> used on the Storage tab.

1.3.6.1.4.1.63245.3.<i>.2.0

filesystem total bytes

gauge

Rides the used-bytes series’ hover meta as a human-readable total, not its own chart.

<i> is the interface or filesystem index (0, 1, …). The generated labels above (rx if0, fs1 used, …) come from the same descriptor registry as the core scalars, so they can be renamed per device — see Extending: registering custom descriptors below. Lab data never spells out these OIDs directly; the otto-net:N / otto-fs:N bundles (see Lab Configuration) expand them and register their descriptors together.

An OID present in oids but without a registered descriptor falls back to default styling via resolve_snmp_metric: the OID string is used as the label and chart name on the generic metrics tab, so a host can poll a bare OID with zero code and still get a chart.

Extending: registering custom descriptors

Register a descriptor for a private or device-specific OID from an init module listed in .otto/settings.toml:

from otto.monitor.snmp import SnmpMetric, register_snmp_metric

register_snmp_metric(
    SnmpMetric(
        oid='1.3.6.1.4.1.99999.1.5.0',
        label='Fan Speed',
        chart='Fan',
        y_title='RPM',
        unit='rpm',
        tab='fans',
        tab_label='Fans',
        scale=1.0,
    )
)

This follows the same extension pattern as register_host_parsers and register_command_frame. The SnmpMetric fields are oid, label, chart, y_title, unit, tab, tab_label, and scale; everything after chart has a default, so a private OID only needs the first three:

>>> from otto.monitor.snmp import SnmpMetric
>>> m = SnmpMetric(oid='1.3.6.1.4.1.99999.1.5.0', label='Fan Speed', chart='Fan',
...                y_title='RPM', unit='rpm', tab='fans', tab_label='Fans')
>>> m.tab, m.tab_label, m.scale
('fans', 'Fans', 1.0)
>>> SnmpMetric(oid='1.2.3', label='X', chart='C').tab
'metrics'

register_snmp_metric always overwrites, so the same call renames a built-in descriptor too — including the auto-generated per-index labels from Per-interface and per-filesystem OIDs (rx if0, fs1 used, …): register a new SnmpMetric for that exact OID with a more meaningful label (e.g. rx wan0) and it replaces the default.