v0.1.23 An alert for monitoring's own blind spot, a support bundle, and an RCA that investigates
📋 Full release notes: RELEASE_NOTES.md
Container images (published by CI to GHCR):
ghcr.io/horryworks/yagra-core:v0.1.23
ghcr.io/horryworks/yagra-poller:v0.1.23
ghcr.io/horryworks/yagra-web:v0.1.23
This release also moves :latest.
Breaking changes
- An alert's
nodefield is now its subject, and is no longer always a UUID. For a
poller-pool alert it readspool:<name>. This affectsGET /api/v1/alerts,GET /api/v1/alerts/history, the/api/v1/stream/alertsframes and the MCP alert tools. Every
response carrying an alert now also carriessubject_kind(node|pool) and, for a named
subject,subject_name— branch onsubject_kindbefore treatingnodeas a node id.- On history rows and in the MCP DTOs,
node/node_idisnullfor a non-node subject rather
than a made-up UUID. POST /api/v1/alerts/acknow takes eithernode(unchanged) orsubject, the alert's
flat subject form.nodeis no longer required; sending neither is a400 invalid_subject.- Node-oriented aggregates are unaffected and stay node-only:
/api/v1/alerts/top-nodesand
/api/v1/alerts/transitionsnever report a pool.
- On history rows and in the MCP DTOs,
- Four previously unbounded tables now declare a retention policy (ADR-040). Troubleshoot
analysis runs and their findings, generated AI root-cause reports, and monitoring-gap records are
pruned on a schedule; the interface map is declared kept-until-node-deletion, with its reason. The
first three had grown without limit since they were introduced — migration 0026 says "no auto-trim
yet" in as many words, and scheduled analyses have been writing to that table on a cadence since.- A new Diagnostic data window (default 90 days) in Settings ▸ System settings covers analysis
runs and RCA reports. It is deliberately separate from "Report runs": a window's name must not
silently govern a second kind of data. - Monitoring gaps follow the alert-linked window instead, because a gap explains an absence of
alerts and is only readable beside the history it explains. - The interface map is not pruned by age, and the reason is in the policy table: an orphaned
(node, ifIndex)row is a stale identity rather than old data, andlast_seenonly advances
while interface collection is running — so an age-based sweep would erase the names and speeds
of every interface on a node whose polling was merely paused. Those rows disappear with their
node. The real fix belongs in the poller and is a separate change.
- A new Diagnostic data window (default 90 days) in Settings ▸ System settings covers analysis
New Features
- Yagra now alerts when a poller pool stops having a poller. This was monitoring's own blind
spot. The alert engine reasons about poll results, so when a pool loses its last live poller
there is nothing for it to reason about: the scheduler falls back to publishing jobs on a subject
nothing is subscribed to, plain NATS discards them, and the nodes drift to unknown rather than
down. An entire site stops being monitored and every dashboard stays calm. Until now the
condition was visible only to someone already looking at Settings ▸ Pollers.- Delivered over your configured notification channels at critical severity, so an existing
critical → PagerDutyrouting rule reaches it, and closed automatically when a poller returns. - A pool must be uncovered for five minutes before it notifies — a poller announces its own
departure, so an ordinary rolling restart raises the condition instantly and the debounce is
what stops that paging anyone. Tune or disable it with
YAGRA_POOL_COVERAGE_ALERT_AFTER_SECS(default300,0= off). - Two new gauges regardless of that setting:
yagra_pools_without_live_poller(unlabelled — the
one to alert on, or to drive a scale-up from) andyagra_pool_nodes_without_live_poller{pool},
which reports0for a healthy pool rather than disappearing. - Meraki-managed nodes are excluded, as they are from the Pollers page: core's org collector
polls them, so they do not depend on a pool. - It is a full alert, not only a notification. It appears on Active alerts and in alert
history, streams live, is acknowledgeable from your incident tool, and renders through your
notification templates. Two new template variables come with it —subject_kindand an
always-presentsubject_name— so a template can read correctly for both a device and a pool. - A group-scoped operator sees the pools their own nodes are polled by, which is exactly the
person whose site went dark. Pools holding no node they can see stay invisible to them. - Two things it is deliberately not: it never rolls into a node's displayed state (it belongs
to no node), and it cannot be muted — a mute names a node.
- Delivered over your configured notification channels at critical severity, so an existing
- The MCP surface can now read Yagra's own configuration —
get_config(kind=…). One tool over
28 reads: thresholds, event rules and sources, notification channels and routing rules, profiles
and collection templates, a node's collected metrics, classification rules, the MIB catalog, a
node's URL/DNS check, discovery candidates and scans, Meraki orgs/networks/polling, forwarding
destinations, report definitions and schedules, and the retention / adjacency / LLM / roles /
OIDC / LDAP settings. This closes the ADR-042 read-parity backlog: every read the WebUI can reach
is now reachable from/mcpexcept the four live SSE streams, which have no subscription
transport.- Read-only does not mean readable-by-anyone. Each
kinddemands the same permission its REST
counterpart does —manage-usersfor OIDC and LDAP,manage-configfor fourteen of them, and
viewfor the rest — so a Viewer is served the role matrix and refused the threshold ruleset
from the same tool. - No stored secret is returned. A node's URL check reports whether a credential is bound
(has_credential), never which one; the REST body is unchanged.
- Read-only does not mean readable-by-anyone. Each
- The LLM root-cause analysis can now look things up for itself. Previously it was handed a
fixed set of facts — the alert, the dependents, the upstream chain, a signal timeline, recent
config changes — and answered in one shot, so it could only reason about what had been decided in
advance to include. It now gets the read-only MCP tools and asks: pull the interface series, check
whether the poller was even up, read what syslog said, look at the threshold that fired.- It runs under the caller's own visibility scope, so a group-scoped operator's analysis
cannot read a node they cannot see, and under a view-only tool allow-list — the write tools,
run_analysis,run_rcaand the audit log are all out of reach, checked per folded branch
rather than per tool. - What it looked up is stored with the answer (
transcripton the report body) and replayed
on both surfaces, for the same reason the evidence always was: an explanation whose reader
cannot check what it was based on is an assertion. - Bounded by turns (
YAGRA_RCA_MAX_TURNS, default 6), wall clock
(YAGRA_RCA_TASK_BUDGET_SECS, default 240) and total tool output. Hitting a bound returns the
model's last answer rather than failing the request. SetYAGRA_RCA_MAX_TURNS=1to get the
previous single-shot behaviour back exactly — no tools are offered and the request sent to the
provider is byte-identical to before. - Tool output is device-supplied text and is fenced as such: a syslog line that says "ignore your
instructions" arrives inside the same untrusted-output markers a device'ssysDescralways did.
- It runs under the caller's own visibility scope, so a group-scoped operator's analysis
- Support bundle (Settings ▸ System Health). One download containing everything needed to
diagnose a deployment from outside it: which binary is actually running (image source ref and
build profile, not just the version), every system-health section, the allow-listed environment,
applied migrations with their checksums, per-table sizes and connection counts, active alerts, the
audit tail, core's Prometheus scrape, and core's own rotated log files. Also at
GET /api/v1/system/support-bundle?since_hours=N.- It is built for a site where nobody can open a shell and data does not leave casually, so the
archive is designed to be reviewed before it is released: every entry is JSON or plain text,
andMANIFEST.jsonlists what is carried and what is deliberately left out, with reasons. - Secrets are handled two ways. The environment is carried by an allow-list — a deny-list of
password-shaped names would miss the credential insideYAGRA_DATABASE_URL's userinfo, which is
the one that actually ships. Then every assembled byte is scanned, and a match aborts the
export rather than redacting it: the strongest rule is the set of literal secret values the
core process can see in its own environment, so a credential arriving through an unanticipated
path is caught too. A refusal answers500 support_bundle_redaction_failednaming the file and
the rule, never the value. - It requires ManageConfig + ManageCredentials + ViewAudit — all three, so this cannot become
a way to read the audit log or the credential report through an endpoint whose name mentions
neither. In practice that means Admin.
- It is built for a site where nobody can open a shell and data does not leave casually, so the
- Core's log is now written to disk as well as stdout. Hourly JSON-lines files under
YAGRA_LOG_DIR,YAGRA_LOG_RETAIN_HOURSof them (default 48, pruned automatically), on a named
volume so they outlive the container. Readingdocker logsneeds a shell on the host, which is
exactly what a locked-down deployment does not grant — so a panic or an OOM used to leave nothing
retrievable. A support bundle taken after a recovery now carries the run that died.- On by default in
docker-compose.ymlanddocker-compose.deploy.yml; setYAGRA_LOG_DIR
empty in.envto turn it off. Writes are non-blocking and drop rather than stall the poll loop,
and an unwritable directory degrades to stdout-only with a warning instead of failing startup. - Pollers can opt in the same way (the image has the directory), but no compose file mounts one:
the support bundle carries core's logs only. A poller's log body would have to cross the bus to
reach one, which is a new bus message rather than a read. Poller heartbeat counters, poll-loop
statistics and host resources are in the bundle already.
- On by default in
incident_correlatenow correlates across topology neighbours. An incident is assembled from
a node and its directly-linked upstream/downstream peers when their signals coincide in time
(within five minutes), so a failed uplink reads as one incident with its downstream peers named
instead of a row of unrelated single-node findings. Each finding lists the corroborating
neighbours, and a peer's entries in the timeline are labelled with the node they came from.- It uses the auto-derived connectivity graph and hand-authored parents, regardless of the
topology mode. The mode gate exists because a wrong derived edge would suppress a real alert
and silence is unrecoverable; a diagnostic that names an extra peer only errs toward noise. So
this works on a deployment still inmanualmode, which is the default. - A node still needs a signal of its own to produce a finding — neighbours corroborate, they do
not manufacture. Peers are capped per finding, and a node whose alerts are opted out of
suppression is not reasoned about as anyone's upstream. - An incident spanning two devices produces a finding on each, naming the other. Both devices are
affected, and the per-node attribution keeps the report's node counts honest.
- It uses the auto-derived connectivity graph and hand-authored parents, regardless of the
Improvements
- Troubleshoot's passive-event analyses read the event log store when one is configured. They
had been reading PostgreSQL directly, which holds only alert-linked rows once VictoriaLogs is
enabled (ADR-024) — so they were answering about the subset of events that had already alerted.
rule_gapwas the extreme case: its entire purpose is finding high-volume unmatched events,
and unmatched events never reach PostgreSQL on a log-store deployment, so it was structurally
guaranteed to return nothing on exactly the deployments that generate enough syslog to need it.event_storm,severity_shift,auth_probeandincident_correlate's event lane now count
the full firehose rather than the alert-linked subset. The MCPevent_statstool, which was
built from three of the same queries, inherits the corrected answer.event_flapis unchanged and was already complete: every action it counts is alert-linked, so
PostgreSQL keeps all of them either way. That is now pinned by a test rather than a comment.- A log-store failure fails the analysis rather than falling back to PostgreSQL. Falling back
would answer from the subset again with nothing to say so, which is the defect being fixed.
- Unmatched events now cluster on the device's own event code. The Troubleshoot Rule gap
analysis (and MCPevent_stats) grouped events by trap OID, else syslog APP-NAME. A large class
of real network gear supplies neither — its timestamp format falls outside both RFC 3164 and
RFC 5424, so the datagram parses as raw text and no APP-NAME is extracted — and such a device can
emit six figures of events a day while producing zero rule-gap findings. "0 gaps" reads as
"nothing is unrouted" when it actually meant "not measurable". Yagra now lifts the vendor's own
code out of the message at ingest (%%01URL/4/FILTER(l):→URL/4/FILTER,%LINEPROTO-5-UPDOWN:
→LINEPROTO-5-UPDOWN, a leadingSNMP_TRAP_LINK_DOWN:) and clusters on it.- The extracted code is always a verbatim slice of the message, so a signature named in a
finding can be pasted straight into asubstringevent rule and will match the events — which
is the action a rule gap exists to prompt. Rules are matched in-process against the message
text, so this holds on every deployment. ⚠️ Free-text search for a whole signature is not guaranteed on a VictoriaLogs deployment.
LogsQL matches whole tokens, so a vendor prefix that runs into the code —%%01ATK/4/…
tokenizes with01ATK, notATK— will not match on the leading segment. Search the
distinctive tail instead (FIREWALLATCK). PostgreSQL-only deployments match on substrings and
are unaffected; this is the same backend difference already documented for plain search terms.- Clustering precedence is trap OID → device event code → APP-NAME. Deployments whose devices
already send an APP-NAME keep working; a device that sends both now clusters on the more
specific code, so an existing rule-gap finding may split into several finer ones. - Extraction applies to newly received events only — there is no backfill and none is needed, as
the analysis reads only unmatched events and those age out within one retention window.
- The extracted code is always a verbatim slice of the message, so a signature named in a
GET /api/v1/mib-catalogacceptslimitand is now bounded. The query had no row cap on
either edge, which was survivable while its only caller was a settings screen and stopped being so
once an AI client could ask for the whole table.limitis 1–2000 and defaults to 2000, so an
existing caller sees no change unless the catalog holds more than that.MANIFEST.jsonreports both size caps that can bite — log files dropped for size, and files
outside the requested window — because a silently truncated log reads as "nothing was logged",
which is a wrong answer rather than a missing one.- The redaction report carries
secret_literals_skipped_shortbeside
secret_literals_enforced, andREADME.txtinterprets the pair in words. On its own, an
enforcedcount of zero has three meanings a reviewer cannot tell apart: this deployment holds
no secrets in its environment, it holds some that were too short to enforce safely, or the
collection broke. The first is a clean bill of health and the third is the strongest rule in the
scan silently not running. Found on the first real bundle, where establishing which it was took
a shell session on the deployment — the exact work a support bundle exists to remove.- The eight-character floor is not lowered: a lab
POSTGRES_PASSWORD=yagrawould forbid the
substringyagra, which appears in every path and table name in the archive, and the scan
would refuse every bundle forever. Counting the declined values is the fix; the README says
plainly when the scan has fallen back to pattern matching alone, and what to do about it.
- The eight-character floor is not lowered: a lab
- A group-scoped
rule_gaporauth_proberestricts at the store rather than afterwards. Both
used to group fleet-wide and then keep a row only if its representative node was in scope, so a
signature genuinely occurring inside your group vanished whenever some node outside it happened to
sort lower.auth_probeadditionally hid every auth-failure source that mapped to no inventory
node — which is exactly what an external prober looks like. Expect more rows than before; the
extra ones were always yours. PUT /api/v1/settings/retentiongaineddiagnostic_days. It is optional, so a client sending the
previous four-field body keeps working — but note that such a body is a full replace and therefore
resets this window to the default (90). The WebUI always sends every field.
Bug Fixes
db/connections.jsonno longer reports a negative connection age.now()is the transaction's
start time and the backend running the query sets its ownstate_changeafter it, so the
activerow reliably came back a few milliseconds below zero — an artefact of measuring from
inside, not a fact about the deployment.
Security
- An OIDC login is now refused when the IdP delivers the groups claim out-of-band — Microsoft
Entra's "group overage", where a user in more than roughly 200 groups gets_claim_names/
_claim_sources(orhasgroups) instead of their groups. Yagra reads groups from the ID token
only, so it previously saw an empty group list and fell through to the provider'sdefault_role.
That is a silent role change, not a failure: an administrator signed in as whatever the
default was, and where the default is Admin, a user who should have been a viewer signed in as an
administrator. Neither left any trace an operator could find.- The client still receives the same generic 401 as every other SSO refusal — the callback
deliberately does not tell a stranger which step failed. The audit log records
auth.oidc.group_overagewith the username (reaching this branch requires a verified ID
token, so there is no prober to help), and the core log names the claim and the remediation. ⚠️ If your tenant relies on this fallback, affected users will stop being able to sign in.
Configure the app registration to emit only the groups assigned to it, or use a group-filtering
claim. LDAP role mapping is unchanged.
- The client still receives the same generic 401 as every other SSO refusal — the callback