Skip to main content

Load-test scenario REST API

The load-test scenario subsystem is driven entirely over REST under /api/v1/scenarios. A scenario moves through a fixed lifecycle — submitted → armed → running → stopped (with armed → canceled and running → aborted branches) — and finalizes an immutable report an operator diffs against a monitor's received counts.

See the scenarios guide for how to use these endpoints to run a fidelity check and troubleshoot failures.

Conventions

  • JSON everywhere, snake_case field names, direct objects (no envelope).
  • Success returns the resource directly. Errors return {"error": "<message>"}, plus "field": "<json.key>" for 400 validation failures (fail-fast — the first offending field only). 409 bodies name the current phase, the scenario ID, and the verb that could not resolve.
  • Timestamps are RFC 3339 with millisecond precision (2006-01-02T15:04:05.000Z07:00).
  • Request bodies are decoded with DisallowUnknownFields (a typo'd or unknown key is a 400) and capped at 64 KiB.

Endpoints

VerbPathSuccessErrors
POST/api/v1/scenarios202 {id, config_sha256}400, 409
POST/api/v1/scenarios/{id}/arm200 readiness404, 409
POST/api/v1/scenarios/{id}/start200 status404, 409
POST/api/v1/scenarios/{id}/stop200 report404, 409
GET/api/v1/scenarios/{id}/report200 report404, 409
GET/api/v1/scenarios/{id}/metrics200 Prometheus text404
GET/api/v1/scenarios200 {scenarios:[{id,phase}]}
GET/api/v1/scenarios/{id}200 status404
DELETE/api/v1/scenarios/{id}200 {}404, 409

{id} is the zero-padded monotonic scenario ID (s-000001).

POST /api/v1/scenarios — submit

Registers a scenario and validates it structurally. Returns the allocated ID and the config fingerprint. Refused (409) while another scenario is non-terminal (only one active scenario at a time; a terminal scenario is transparently replaced).

Request body:

{
"participants": ["10.42.0.1", "10.42.0.2"],
"protocol": "syslog",
"rate": 10,
"window": "2s",
"drain": "500ms",
"seed": 42
}
FieldTypeRequiredMeaning
participants[]stringyesDevice management IPs (dotted quad). Non-empty; each must parse as an IP. Existence is not checked here — that is an arm-time concern (see readiness excluded).
protocolstringyesParticipating push protocol: "syslog", "netflow9", "ipfix", "gnmi-dialout", "snmp-trap", "sflow", or "netflow5".
ratenumberyesPer-device events/second. Finite, > 0, ≤ 1000 (the scheduler's 1 ms floor). Drives the emission cadence for syslog and the flow-tick cadence for flow protocols (ipfix/netflow9): during [T0,T1) the scenario ticks each participant's flow exporter every 1/rate s, and the fleet's own flow ticker yields to it (D1 flow-cadence adaptation). Always required and fingerprinted.
windowstringyesMeasurement window length as a Go duration ("2s", "5m"). > 0, ≤ 24h. T1 = T0 + window; the window is half-open [T0, T1).
drainstringnoGrace period after T1 for in-flight sends to complete (bucketed drain). ≥ 0; omitted/0 selects the 2 s default.
seednumbernoPins every random draw the scenario makes (determinism / reproducibility).
rate_profileobjectnoTime-varying intensity λ(t) (see below). Omitted or {"kind":"constant"} keeps the flat rate.
abort_predicateobjectnoSelf-abort a runaway run when a mid-run ledger metric crosses a threshold (see below).

Abort predicate — abort_predicate

An optional guard that aborts the scenario through the standard running → aborted pipeline when a fleet-wide ledger metric stays over a threshold for a grace period — so a bad experiment stops itself before drowning the collector.

{"metric": "send_failures", "threshold": 100, "grace": "5s"}
FieldMeaning
metricLedger counter to watch: send_failures | dropped | deferred | sent (= in_window + drain).
thresholdAbort when the fleet-wide sum of metric exceeds this. > 0.
graceThe threshold must hold this long before aborting (Go duration; omitted = 0).

The predicate is evaluated on a 1 s cadence using approximate mid-run reads of the live atomics (no drain barrier). The resulting report is a normal aborted artifact (see the scenarios guide).

Rate profiles — rate_profile

By default a scenario emits at the flat rate with an exact fixed-interval cadence. A rate_profile instead shapes emission over the window as a non-homogeneous Poisson process drawn by inversion of the integrated intensity Λ(t) — production-shaped load, not a flat trickle. Every profile's peak rate is capped at 1000 events/s and must stay strictly positive.

kindFieldsλ(t)
constant (default)flat rate, exact cadence (deterministic count)
linearstart_rate, end_rateramps linearly from start_rate at T0 to end_rate at T1
sinemean_rate (default rate), amplitude (< mean_rate), period (duration)mean_rate + amplitude·sin(2π·t/period)
stagedstages: [{duration, rate}, …]piecewise-constant; the last stage extends to T1
{ "participants": ["10.42.0.1"], "protocol": "syslog", "rate": 10, "window": "5m", "seed": 42,
"rate_profile": { "kind": "linear", "start_rate": 5, "end_rate": 200 } }

rate is still required (it is the fixed-cadence rate and the sine mean default). A given (seed, profile) is fully reproducible: the per-device arrival stream is seeded from (seed, device IP), so the fleet is deterministic yet devices are not phase-locked. An over-cap profile is still governed by the shared global rate limiter.

Response 202:

{"id": "s-000001", "config_sha256": "9b8d8c9c…969314"}

config_sha256 is the SHA-256 over the canonicalized (key-sorted) request JSON, so two byte-different-but-equal submissions fingerprint identically.

400 examples:

{"error": "scenario: unknown protocol \"snmp\" (supported: syslog)"}
{"error": "invalid window \"nope\": …", "field": "window"}
{"error": "json: unknown field \"bogus\""}

POST /api/v1/scenarios/{id}/arm — arm

Resolves participants against the live fleet, installs the per-device participation handles, and publishes the armed gate (which suppresses the fleet's ordinary background cadence for participants). Unknown or ineligible devices are reported in excluded rather than failing the arm.

Response 200 (readiness):

{
"id": "s-000001",
"phase": "armed",
"participants_armed": 1,
"excluded": [
{"device": "10.42.0.9", "reason": "device not found",
"remediation_hint": "create the device before arming, or remove it from the scenario"}
]
}

POST /api/v1/scenarios/{id}/start — start

Freezes fleet membership (device create/delete is rejected while running — so counter deltas cannot be corrupted mid-window), publishes the running gate at T0, and starts the scenario-owned scheduler. Refused (409) when 0/N participants armed. The window self-closes at T1 (auto-stop). Returns the status object.

An optional body schedules the start at an absolute T0 so a run aligns to a wall-clock schedule without a warm operator:

{"at": "2026-07-18T22:00:00Z"}

The scenario stays armed and a controller timer fires the start at at (surfaced as scheduled_start in the status). A past at (or an unparseable one) is a 400 {"error","field":"at"}. A DELETE before T0 cancels cleanly — the timer is stopped, transports released, and no report is produced. Omit the body to start immediately.

POST /api/v1/scenarios/{id}/stop — stop

Ends emission, drains in-flight fires, finalizes the immutable report, and unfreezes the fleet. Idempotent: a scenario that already auto-closed at T1 returns 200 with the same report (only a stop with no finalized result — e.g. before start — is a 409). Returns the report.

GET /api/v1/scenarios/{id}/report — report

Returns the finalized report. 409 while the scenario has not reached a terminal phase (submitted / armed / running).

sFlow (sflow) counts raw samplesflow_sample flow-records, never samples × sampling_rate — so the synthetic sampling-rate extrapolation can't manufacture phantom loss. The flow_sample data path is gated (suppressed pre-T0/post-window, counted in-window); the periodic counters_sample is a keepalive — it flows continuously (including before T0) to signal agent liveness and is never counted as scenario sent. Consequence: because counters_sample keeps advancing the agent's datagram sequence number pre-T0, the sequence is not 0 at T0 (expected; the collector should key on sample sequence, not datagram sequence, for the measured window).

gNMI dial-out (gnmi-dialout) is gated at both producers (SAMPLE + ON_CHANGE): arming requires a live Publish stream (a device whose stream is not established is excluded — the collector is unreachable); no updates flow before T0 (silent arming); in-window a notification counts sent when written to a live stream, or send_failures when the stream is down (a collector blip is visible, never masked). rate is not used for dial-out (its SAMPLE cadence comes from the device's dial-out config), but is still required + fingerprinted.

The format query parameter selects the representation (default JSON):

  • ?format=csv — a flat text/csv projection of counters[] (header row + one row per participant, join-ready on (protocol, source_ip, collector)); see the CSV projection.
  • ?format=html — a self-contained text/html page (embedded CSS, no external fonts / JS / frameworks): stat cards, the per-time-bucket loss- localization bar chart, run-tag panel, identity totals, and the participant table with per-row status. The human-readable view of the same data — open it in a browser or attach it to a run; JSON stays the machine source of truth.

GET /api/v1/scenarios/{id}/metrics — live gauges (Prometheus)

Returns the scenario's live state in Prometheus text-exposition format (text/plain; version=0.0.4) — no third-party client dependency, scrape it directly. Deterministic ordering (participants sorted by source IP). 404 for an unknown id; valid in every phase (a submitted scenario has no participant rows yet, only the two gauges).

MetricTypeLabelsMeaning
nl6_scenario_phasegaugeid, phase1 on the active phase label (info-style)
nl6_scenario_target_rategaugeidconfigured base rate (events/s); for a rate_profile scenario this is the constant base rate, not the instantaneous λ(t)
nl6_scenario_sent_totalcounterid, protocol, source_ip, collectorrecords sent (in_window + drain) per participant
nl6_scenario_emitted_totalnl6_scenario_dropped_totalcounter(same tuple)the remaining ledger-identity buckets, per participant

Every counter is labeled on the report join tuple (protocol, source_ip, collector), so summing a family reproduces the matching report summary total — e.g. sum(nl6_scenario_sent_total) equals summary.sent. Because each counter only advances in-window, a Prometheus range query (increase(nl6_scenario_sent_total[…]) over [T0,T1]) reproduces the report totals. Every lifecycle transition is also written to the process log as a structured scenario=<id> phase=<phase> line for correlation.

GET /api/v1/scenarios/{id} — status

{
"id": "s-000001",
"phase": "running",
"config_sha256": "9b8d8c9c…969314",
"seed": 42,
"transitions": [
{"phase": "submitted", "at": "2026-07-18T09:00:00.000Z"},
{"phase": "armed", "at": "2026-07-18T09:00:03.000Z"},
{"phase": "running", "at": "2026-07-18T09:00:05.000Z"}
]
}

transitions is the ordered lifecycle log; it is how a SIGTERM-driven aborted is observable after the fact.

While a scenario is running (or after it finalizes), status also carries the live window and a counts block for unattended observability:

{
"id": "s-000001", "phase": "running", "protocol": "syslog", "window": "30s",
"t0": "2026-07-18T09:00:05.000Z", "t1": "2026-07-18T09:00:35.000Z",
"elapsed": "12.3s", "remaining": "17.7s",
"counts": {"participants_armed": 2, "emitted": 1220, "sent": 1220,
"in_window": 1220, "drain": 0, "suppressed_pre_window": 0,
"send_failures": 0, "dropped": 0}
}

counts uses approximate mid-run atomic reads (no drain barrier) — a live progress snapshot that may lag an in-flight fire; the finalized report is the exact record. t0/t1 are the actual window bounds (the running gate's, or the finalized result's).

GET /api/v1/scenarios — list

{"scenarios": [{"id": "s-000001", "phase": "running"}]}

Lists the active scenarios with their phases (0 or 1 — one active at a time). Empty scenarios: [] when none.

DELETE /api/v1/scenarios/{id} — cancel / release

Releases the scenario. An armed scenario is canceled — transports release and no report is produced. A submitted or terminal scenario is simply dropped, freeing the single-active slot. A running scenario is refused (409) — stop or abort it first. After a successful DELETE, the ID returns 404.

Phase / verb matrix

PhasearmstartstopreportDELETE
submitted→ armed409409409drop
armedidempotent→ running (409 if 0/N)409409cancel
running409409→ stopped (report)409409
stopped / aborted409409200 (idempotent)200drop
canceled409409409409drop

This matrix is enforced by the table-driven contract test (scenario_api_test.go).