Configuration
InfraSigns is configured via a single YAML file. Pass the path with --config (default: config/config.yaml).
Full example
locale: en # LLM report language (ISO code): en | ru. Empty defaults to en.
severity_aliases: # optional: map your own severity labels onto info|warning|critical
p1: critical # without it, any other label reads as a warning
server:
port: 8080 # HTTP server: health/metrics/API/webhook (+ /app UI)
database:
# The shipped config injects this from the environment: dsn: '${DATABASE_DSN}'.
# A literal value works too; the bundled compose Postgres uses
# postgres://infrasigns:infrasigns@postgres:5432/infrasigns?sslmode=disable
dsn: '${DATABASE_DSN}'
sources:
- name: production
url: http://prometheus:9090
- name: staging
url: http://staging-prometheus:9090
llm:
provider: openai # openai | anthropic | none (deterministic engine, no key)
api_key: sk-...
model: gpt-4o-mini
notify:
telegram:
token: "123456:ABC-..."
chat_id: "-100123456789"
slack: # optional; omit the block to disable. Pick ONE transport:
mode: webhook # "webhook" (incoming webhook) or "api" (Web API, threads per episode)
webhook_url: "https://hooks.slack.com/services/T00/B00/xxxx" # required when mode is "webhook"
# mode: api
# token: "xoxb-…" # bot token (chat:write scope); required when mode is "api"
# channel: "C0123ABCD" # channel ID; required when mode is "api"
email: # optional; empty smtp_host disables the channel
smtp_host: ""
from: "InfraSigns <[email protected]>"
to: [[email protected]]
pagerduty: # optional; INCIDENTS ONLY (digests are not paged)
routing_key: '${PAGERDUTY_ROUTING_KEY}' # Events API v2 integration key; empty disables the channel
severity: critical # default for unspecified severity: info|warning|error|critical (default info)
reports:
digest:
schedule: "0 8 * * *" # cron expression, UTC
trends: # optional weekly-style trends report
enabled: true
schedule: "0 9 * * 1"
window: "168h"
step: "1h"
retention_days: 90 # prune archived reports after N days; 0/absent = keep forever
webhook: # inbound alert receiver — see Webhook receiver page
token: '${WEBHOOK_TOKEN}' # min 16 chars; empty disables the endpoint
dedupe_window: "5m"
incidents: # incident history (webhook episodes)
retention_days: 90 # prune RESOLVED episodes after N days; 0/absent = keep forever
timeline: # source timeline journal (web UI per-source Timeline)
retention_days: 90 # prune events after N days; 0/absent = keep forever
source_health: # frozen per-source rows: health, and severity readings (#358)
retention_days: 90 # prune removed sources' rows after N days; 0/absent = keep forever
api:
token: '${API_TOKEN}' # guards POST /api/digest/trigger and /mcp; empty disables both
ui:
enabled: false # experimental web UI under /app
charts:
enabled: true # source-detail live metric charts; false disables them + the /series endpoint
heartbeat:
url: '${HEARTBEAT_URL}' # dead-man's-switch ping; empty disables
checks: # natural-language health checks — see Health checks page
- name: disk-headroom
schedule: "*/30 * * * *"
source: production
query: "Is disk usage below 85%?"
mode: alert # alert = notify on transitions; monitor (the default) records only
severity: warning # info|warning|critical — tier a FAILING check pages at
for_runs: 1 # consecutive non-pass runs before the episode opens
repeat_interval: "30m" # re-assert an open failing episode; omit = never
# the three above are notification policy — inert in monitor mode
deploys: # post-deploy verification — see its page
enabled: true
source: production
delay: "5m"
bot: # Q&A bot — see its page
telegram:
allowed_chat_ids: [123456789] # non-empty enables this transport (fail-closed allowlist)
# token: "" # falls back to notify.telegram.token when empty
slack: # Socket Mode; operators @-mention the bot
allowed_channel_ids: [C0123ABCD] # non-empty enables this transport
app_token: "xapp-..." # required (Socket Mode app-level token)
# bot_token: "" # falls back to notify.slack.token when empty
Language (locale)
locale is a top-level setting (per-org in the hosted service, a deployment
default when self-hosted). It sets the language of the LLM-generated report
prose — the digest, incident, trends, and inbound-alert summaries: the model
is instructed to write the summary and observation text in the configured
language, while severity levels and other machine-readable fields stay canonical.
The LLM summary providers (openai, anthropic) honor it for the report
prose, and the deterministic providers (llm.provider: none and the
mock/budget-exhausted fallbacks) localize their fixed chrome — headers,
verdict labels, recommendations, alert counts — through the built-in catalog,
while leaving in English the interpolated values (metric names, trend words,
engine finding details) and the change-tracking and recent-log-line sections
(shared verbatim with the English LLM prompt).
The notification report chrome is also localized across every channel
(Telegram, Slack, email, plain-text): the report title, the verdict line
(counts, severity words and recommended-action clause, with CLDR-correct
plurals), the section labels, the value-column direction word, and the email
subject/<html lang>. Locale-neutral symbols stay fixed — the status glyphs,
the value arrows (↑/↓/→), the ASCII badges ([OK]/[WARN]/[CRIT]) and the
INFRASIGNS wordmark — and the claim prose and metric values remain canonical.
One known residual: the report period line (the analysis-window label and
timestamp, e.g. 24h to 17 Jul, 09:00 UTC) uses English date formatting and
connector regardless of locale.
The web UI localizes its chrome progressively. Localized so far: the app
shell (sidebar and topbar navigation, the account and organization menus, ARIA
labels), the Dashboard, the sources, reports, incidents, incident-detail, and
settings pages (headings, filters, empty/error states), the add-source wizard
(shared by the first-run onboarding page), and the source-detail and
notifications pages (status/endpoint/queries/timeline chrome, the channel cards
and every channel edit form), and the interleaved-<code>/<a>/<strong> form
hints and channel intros across those pages (the settings intro link, the wizard
and edit-form hints, the notifications channel intros, and the dashboard
run-digest CTA), and the Go view-model prose (the Settings and Notifications
read-only view models — channel field labels, feed names and status lines,
settings rows with CLDR plurals — the source-card state labels/detail, the
browser-tab page titles, and the sign-in landing). The app-shell, onboarding, and
sign-in pages set <html lang> to the configured locale.
These surfaces still render English pending the final web slices (#201): the
Alpine runtime labels that travel via data-* to JavaScript (the two reports
toggle labels, the wizard and healthcheck-editor runtime labels — Slice 3e-3);
the health-check and deploy-verification LLM rationales (a separate verdict
prompt, not localized yet even on the real providers); and a follow-up
Go-handler-prose slice for the members/billing/organization standalone pages and
the dashboard check-card / incident-row timeline prefixes ("since …"). A locale
you set there validates and is accepted, but those surfaces stay English until
then.
Severity aliases (severity_aliases)
InfraSigns understands three severity labels — info, warning and critical.
Anything else your alert rules write reads as a warning, which is
conservative but costly if your fleet is on a p1/sev1/page scheme: a
collection cycle can never become an incident summary, and a pushed alert is
delivered and paged at warning urgency instead of critical (see
the alert webhook). Teach it your vocabulary instead of rewriting
every alert rule:
- The label is rewritten once, as the alert arrives — on collection and on
the inbound webhook alike — so everything downstream sees the canonical label:
the incident decision, the notification tier and PagerDuty urgency, the
ordering the LLM prompt cuts by, and the
get_active_alertsMCP tool. - Your original label is not destroyed. Only the alert's severity reading
changes; the alert's own
severitylabel keeps whatever your rule wrote. That is also why turning the table on, editing it, or removing it cannot re-open or re-key an incident episode: episode identity is built from the labels, which the rewrite never touches. - Names are matched the way the label is read — case-insensitively and
whitespace-trimmed, so one
p1:entry coversP1and a padded" p1 ". Two entries that differ only in case or padding are rejected at startup rather than letting one of them win at random. - Targets are
info,warningorcritical, spelled exactly. A typo is a startup error, not a label that quietly keeps reading as a warning at 3am. - The canonical labels themselves cannot be aliased.
critical: warningis rejected: every notification tier and every claim in these docs rests on those three words meaning what they say.warn— which InfraSigns already reads as a warning — is aliasable, since it is not one of the three. - An aliased label stops being unrecognized, which is the point: the WARN
line and
infrasigns_alerts_unrecognized_severity_totalgo quiet for it, because you answered the question they were asking. Labels you have not aliased still report exactly as before. - On the hosted service the table is bounded: at most 200 mappings, and a name of at most 500 runes (#366). Both are far above any real vocabulary — the schemes this feature exists for name a handful of labels — and the name limit is the same one applied to a sender's severity label when it is recorded, with every surface that shows a label truncating far shorter still. The name is measured as you wrote it, padding included, not as the trimmed form the matching uses. Targets need no limit of their own: they must be one of the three words above, spelled exactly.
These are limits on saving, not on loading, and they do not apply to this file: a self-hosted config is the operator's own input and is not size-checked at all. On the hosted service they are checked wherever an organization's configuration is written, so no save can leave behind a table its own editor cannot then edit.
The editor sends the whole table on every save, so that request is size-capped too. Since #370 the cap is sized to carry any table the store will accept, so a valid table is never too large to submit; a genuinely runaway paste still meets a legible limit instead of a generic bad request.
If a hosted organization is upgrading with a stored table already over one of the
two limits, nothing about its monitoring changes — collection, reports and every
page carry on. What stops is saving: any configuration change is refused until the
table is smaller. The editor still lists the whole table, and a Clear all button
(alongside each row's own delete) shrinks it; a table over the count limit now
posts and comes back naming that limit, so deleting rows until what remains will save
converges either way.
- A table that matches nothing validates cleanly. The names are checked for
shape, not against your fleet — p-1: where your rules write p1 passes
validation and does nothing. The startup log names the entry count and the
mapping it loaded — the first 50 entries, with aliases_omitted counting any
beyond that — but reading back what you loaded rules out a typo you can see,
not one you can't.
- What the table is measured against is on Settings (#358). An Unmapped
severity labels your sources send card lists, per source, the labels that
source's most recent collection cycle carried and no alias maps, each with how many alerts
carried it. An inert entry is then visible by contrast: the label you mapped is
absent from the list, and the label your fleet actually writes is still on it.
Three states are kept apart on purpose, because collapsing them is the failure
this card exists to prevent — a source reads as recognized only when its last
cycle actually assessed something (at least one alert carrying a severity:
label, all of them mapped), a source whose last cycle carried no alert with a
severity label gets its own neutral line covering both a quiet cycle and a
fleet that omits severity: entirely (neither concludes anything about your
vocabulary, and the second is read as warning by the very fallback this card
exposes), and a source with no reading at all to show is simply absent — it has
never scraped successfully, you removed it from config, or retention reaped its
last reading. A source that scrapes fine but has no alert firing is not absent;
it lands in the neutral line. Dropping a removed source is deliberate: a stale
reading must not pad the all-clear.
The counts are that cycle's reading, not a running total, and the same WARN line
and infrasigns_alerts_unrecognized_severity_total above remain the surface to
alert on.
Scraped sources only. An alert pushed to
the alert webhook is not attributed to a source, so its labels
cannot appear on this card — an empty card is not evidence that a push-only
fleet's vocabulary is understood
(#350 tracks keying that
signal on the sender).
- Editable in the hosted UI, per organization (#355). On the hosted service
the table lives on Settings: an Alert severity section
reports how many labels are mapped, and a Severity aliases card below it lists
every mapping — read-only for every member, edited a row at a time by an
owner (#363). The card appears once
the organization has saved a configuration at all — in practice, once it has
added a source, since the add-source wizard is the only step that creates one.
Only an owner can change the table; every other member reads it. The
severity for a new row starts unset rather than pre-picked: this table
escalates, so choosing a tier is always deliberate. A save takes effect on the
organization's next collection cycle, and it never rewrites history — rows
already recorded keep the tier they were recorded with. Self-hosted, the file
above stays the only way to change the table, and Settings reports the loaded
entry count there too, for the same reason the rest of that page exists: to see
what the process actually loaded without shelling into the container.
- Editing through the UI leaves the rest of your configuration untouched. A
save replaces the whole table and rewrites nothing else; clearing every row
removes the table rather than storing an empty one. The reverse holds as well
and always has: a severity_aliases: block an operator seeded by hand survives
every other UI edit, because each form merges into the stored document rather
than replacing it.
- Every path that reads an alert's severity label honors the table. There are
two: the collection cycle behind digests and incidents, and the
get_active_alerts MCP tool. Trends read metric series and never see an alert,
and a health check derives its own severity from whether the probe passed, so
neither has a label to alias. On the hosted service only the collection cycle
applies, because MCP is not offered there — so an organization's own table covers
everything it can see. Pushed alerts are not an exception so much as outside the
question: the alert webhook is process-global, so an inbound alert is the
deployment's — recorded and delivered at the operator level and read with the
operator's table. That is the same reason incidents.retention_days is not
applied per organization; see the Settings page.
Escalation is the intended effect: mapping p1: critical means a p1 alert now
opens an incident summary and pages at critical urgency. That is a deliberate
inversion of the conservative default, and it is why the table is validated
strictly — you are declaring what your fleet already meant.
Database
PostgreSQL 13+ is required. The DSN role must own the schema and hold
CREATEROLE (migration 017 creates the infrasigns_app group role; managed
Postgres master users — RDS, Cloud SQL — have it, a hand-provisioned owner may
need ALTER ROLE ... CREATEROLE). The connection pool is capped at 10
connections (30 m max lifetime) — no tuning knobs; open an issue if a
deployment actually saturates it.
Row-level security (defense-in-depth). Tenant tables carry PostgreSQL RLS policies in addition to the application's own scoping; every statement runs in a transaction pinned to an organization. With the default single-role DSN this is transparent. Two operational notes:
- Backups:
pg_dumpof a database with forced RLS must run as a superuser or aBYPASSRLSrole — as a plain table-owner role it fails closed on the tenant tables. Do not work around a failing dump with--enable-row-security: it would silently dump zero tenant rows. - Least-privilege role (groundwork): migrations create a
NOLOGINgroup roleinfrasigns_appholding exactly the DML grants the server needs; a deployment can mint a login member of it (CREATE ROLE ... LOGIN PASSWORD '...' IN ROLE infrasigns_app;). Todayserveruns migrations at startup on its one DSN and migrations need the owner, sodatabase.dsnmust stay an owner-role DSN — the group role exists for the hosted multi-tenant deployment (where migrations and serving separate) and is what the integration tests run under. Self-hosted loses no protection:FORCE ROW LEVEL SECURITYsubjects even the table owner to the policies (only superusers bypass, e.g. the bundled compose's bootstrap user).
Sources
sources:
- name: production # display name in digests and logs
url: http://prometheus:9090 # Prometheus HTTP API base URL (bundled compose hostname)
- Source names may use only letters, digits, dots, underscores, and dashes
(
^[A-Za-z0-9._-]+$) — they double as metric label values, deep-link IDs, and the source's web detail-page URL.newandgridare reserved (they collide with UI routes). The same rule applies to check names. - Every URL key must name a host, checked on the host NAME rather than on the
authority.
https://:8080— a port and nothing else — parses, looks well-formed and used to load; but a hostless authority resolves to localhost, so such a value silently points InfraSigns at its own machine.config validaterejects it for a sourceurl, a healthcheckendpoints[].url,heartbeat.url,bot.mcp_servers[].url,auth.base_urlandauth.github.enterprise_urlalike; the add-source wizard,infrasigns digest trigger --addrand the Slack webhook channel refuse it where they meet it. A wildcard address (https://0.0.0.0:8080,https://[::]:8080) reaches localhost the same way and is not rejected (#398). typeselects the collector:prometheus(the default when omitted),cloudwatch(see AWS CloudWatch below),digitalocean(see DigitalOcean below),hetzner(see Hetzner Cloud below),healthcheck(see Healthcheck URLs below), orloki(see Loki (LogQL) below). A Prometheus source needsurl(and takes an optional bearertoken, see Authenticated Prometheus-compatible backends below); a CloudWatch source needsregionand nourl; a DigitalOcean or Hetzner source needs atokenand one or moreresources; a healthcheck source needs one or moreendpoints; a loki source needs aurland one or more metricqueries(with optionallog_queriesfor log context, and an optional bearertoken).- Multiple sources are supported — each produces an independent digest, and Prometheus, CloudWatch, DigitalOcean, Hetzner, healthcheck and loki sources can be mixed freely
- The
severity:label on your alerts decides how a collection cycle is reported: a firingcriticalalert makes it an incident summary, delivered at critical severity, instead of the regular digest. On a Prometheus source the label is whatever your alert rules wrote, so it is read case-insensitively and whitespace-trimmed —CriticalandCRITICALcount. An unrecognized scheme (p1,sev1,page) and an alert carrying noseverity:label at all are read as a warning: they ride the next digest and never turn a cycle into an incident summary. That is the default, not the only outcome: it is deliberate — escalating every label InfraSigns does not recognize would turn a typo into an incident — so a fleet on a non-standard scheme gets digests only until it declares the scheme withseverity_aliases, which rewrites such a label as it arrives and lets it open an incident like any other critical. Everything below describes the labels no alias covers. On a collection cycle — scheduled, triggered manually through the digest API, or requested through the MCP tool — you do not have to read this page to find out: a cycle carrying an unrecognized label logs a WARN line containing the phraseunrecognized severity labels rank as warning— each path then spells out its own consequence, so grep the phrase, not a whole line — with thesourceand the labels themselves, and incrementsinfrasigns_alerts_unrecognized_severity_total{path="scheduler",source}on every such cycle — the un-throttled surface to alert or graph on. The log line is throttled to at most once per source per 24h, so a label that first appears inside that window waits it out; each line names up to five labels plus the count of the ones it cut, so the next warn describes the remaining vocabulary rather than only what changed — with the residual that a label past the cap is counted but never named, so a source with more than five unrecognized spellings shows the five carried by the most alerts and a count (#358 — before it, the five that sorted first alphabetically). The throttle is in memory on the running scheduler, so it is not a promise about a source for all time: a process restart re-warns on the next cycle, and in cloud a rebuilt org runtime — a config save, a plan change, or an org idle long enough to age out of the runtime cache — does the same. What it bounds is the repetition that made the line unreadable: a handful of lines a day for a misconfigured source instead of one on every cycle. Two labels that rank as warning are deliberately not reported, because neither is something to fix:warn, which InfraSigns already understands as a warning, and noseverity:label at all, which is just an alert rule that never set one. A healthcheck source's down alerts take the same path, but theirseverity:is configuration rather than a free-form label and is validated case-exactly at startup (see Healthcheck URLs).The inbound alert webhook raises the same signal, with a worse consequence and a coarser key (#345). Everything above is about collection cycles. A pushed alert's
severity:goes through the very same reading and falls back to warning the same way, and the webhook receiver now logs a WARN line prefixedwebhook:and carrying the sameunrecognized severity labels rank as warningphrase, and incrementsinfrasigns_alerts_unrecognized_severity_total{path="webhook",source}on every such request. Two things differ. What goes wrong is worse: a pushed alert is not merely capped at digests, it is delivered — every channel renders it as a warning and PagerDuty pages at warning urgency instead of critical, since onlycriticalmaps to a PagerDuty critical and this is the only path that carries a sender-supplied severity there (a health-check transition also pages, but at the tier its ownseverity:sets — your config, not a label a sender can distort). Andsourcemeans something else there: the detected payload format, not the fleet that pushed — in practice alwaysalertmanager, since a CloudWatch payload carries noseverity:label for the receiver to fail to recognize. So the 24h log throttle is shared across senders: two AlertManager fleets pushing to one endpoint let whichever warns first silence the naming of the other's labels until the cooldown passes, and only a process restart clears it. The counter moves for both, which is why it, not the line, is the surface to alert on. (#350 tracks keying the signal on the sending fleet instead of the format.) The remedy is the same one the collection path uses:severity_aliasesapplies to pushed alerts too, and it is the only way to make this path page at critical urgency for a label InfraSigns does not know on its own. log_source(optional) — the name of another configured source whose logs ground an incident on this one. See Grounding an incident in another source's logs below.- InfraSigns is read-only — a Prometheus source calls only
GET /api/v1/alertsandGET /api/v1/query; a CloudWatch source calls onlyListMetrics/GetMetricData; a DigitalOcean source calls only the read-only Monitoring and resource-listing endpoints; a Hetzner source calls only the read-only Metrics and resource-listing endpoints; a healthcheck source only issuesGET/HEADrequests to the URLs you configure; a loki source calls onlyGET /loki/api/v1/query_range - No changes to your Prometheus configuration are required
Authenticated Prometheus-compatible backends (token)
Many OpenTelemetry-native and hosted backends expose an authenticated
Prometheus-compatible query API — Grafana Cloud, Dash0,
Grafana Mimir, VictoriaMetrics Cloud, and Thanos behind an auth proxy. Point a
Prometheus (or Loki) source at their query endpoint and add an optional bearer
token:
sources:
- name: grafana-cloud
url: https://prometheus-prod-01.grafana.net/api/prom # the query API base
token: '${GRAFANA_TOKEN}' # Authorization: Bearer
queries:
- name: http_error_rate
query: 'sum(rate(http_requests_total{status=~"5.."}[5m]))'
signal: errors
tokenis sent asAuthorization: Bearer <token>on every query. Leave it unset for an unauthenticated Prometheus — the field is optional. When set it must be at least 16 characters, and theurlmust behttps(a bearer is refused over cleartexthttpto a non-loopback host, so it can't be sniffed on the wire — a loopback host is exempt for local dev:localhost(alsoLOCALHOSTandlocalhost., the same name), or an IP literal that parses as loopback, including an IPv6 zone such as[::1%25eth0]). Short address literals like127.1,0177.0.0.1and2130706433are not loopback here — Go's parsers do not implement that syntax, so the value goes to DNS as a name, and a resolver search domain could expand127.1to127.1.corp.example.comand carry the bearer token to a remote host in cleartext. The same goes for a Unicode look-alike of the name (localhoſt).- The token is a secret: inject it via an environment variable
(
token: '${GRAFANA_TOKEN}') exactly like every other credential (see Kubernetes). Self-hosted YAML and the cloud add-source wizard both collect the token (#251); in the wizard, entering a token skips the live probe (it verifies on first collection, like the cloud sources) and the source-detail page lets an owner rotate it. A cloud organization stores the token in its config; self-hosted copy-paste emits a commented${PROM_TOKEN}placeholder to uncomment and wire from the environment. Never put a bearer in theurlquery string — a?token=…(or a bare trailing?) is rejected at config load, because a query parameter is a credential carrier that can't be reliably redacted from error logs. Put it intoken: the bearer rides anAuthorizationheader, so it never appears in the source URL, in metric labels, in the web UI, or in sanitized error logs. - The header is scoped to the configured host: if the endpoint issues an HTTP redirect to a different host, the token is not forwarded (it stays on your backend, never a redirect target).
- Digests, incidents, trends, and the source-detail page's live-charts widget all read the authenticated source normally — the charts dial sends the same bearer header (#252). (In cloud the live-charts endpoint is not yet served per organization, so those charts show no data there regardless of authentication.)
- This is the deliberate shape of OpenTelemetry support — InfraSigns is a view + analysis layer over your existing backend, not a TSDB. Apps instrumented with OTel reach InfraSigns through a backend that ingests OTLP (Prometheus ≥3.x native OTLP endpoint, Dash0, Grafana Cloud, …). See OpenTelemetry for end-to-end recipes and metric-name normalization notes.
Incident attribution (alert_labels)
Inbound webhook alerts can be attributed to a configured source, so its detail-page timeline shows them under the Incidents chip:
sources:
- name: production
url: http://prometheus:9090
alert_labels: # attribute alerts whose labels contain ALL pairs (AND)
cluster: production
- With exactly one source and no
alert_labelsanywhere, every alert is attributed to it automatically — zero config for the common single-source install. Adding a second source (or anyalert_labels) switches to explicit label matching. - An alert matching no predicate stays unattributed (it still appears on the Incidents page, and its "Attributed to" filter has an explicit Unattributed option); an alert matching more than one source is left unattributed and logged, never guessed. Predicates that make ambiguity inevitable (one a subset of, or equal to, another) are rejected at config load.
- CloudWatch alarms carry only a
regionlabel, soalert_labels: {region: eu-west-1}is the only usable predicate for them; AlertManager and Grafana alerts pass their full label set through. - Attribution happens at receive time — there is no bulk backfill for
incidents recorded before it was configured. An episode still delivering
events catches up on its next delivery that reaches the store (a repeat
firing past
webhook.dedupe_windowrefreshes the open episode — dedupe-suppressed repeats don't; a re-sent resolution fills an unattributed history row), but fully quiet history stays unattributed.
Query hints for the analysis engine
Each custom query optionally carries hints for the deterministic analysis
engine (used in every mode; with llm.provider: none they drive the report
directly):
sources:
- name: production
url: http://prometheus:9090
queries:
- name: cpu_usage_percent
query: '(1 - avg(rate(node_cpu_seconds_total{mode="idle"}[5m]))) * 100'
signal: utilization # latency | traffic | errors | saturation | utilization
warn: 70 # optional static thresholds; higher-is-worse —
crit: 90 # a finding fires at value >= threshold
unit: "%" # optional display suffix for signal value columns
signaloverrides the name-based golden-signal classification (unhinted metrics are classified by substring heuristics: "latency", "error", "queue", "cpu", "request", …).unitis an optional display suffix appended verbatim to every number of this query's value column, in a digest, a trends report or an incident alert alike (23.7 → 11.2 ↓becomes23.7% → 11.2% ↓; a threshold breach's95.4 (limit 90)becomes95.4% (limit 90%)). Use"%"for a percentage," GB"(leading space) for a word unit; omit it to render bare numbers. It is cosmetic only — it never affects a threshold or the analysis (a metric value carries no unit on its own, and a name is not a reliable unit). DigitalOcean, CloudWatch, and the node fallback set carry a unit from their curated metric definitions where the API guarantees one (cpu/memory%, storage/network bytes, latenciess/ms); metrics with no reliably-known unit render bare (#237). Hetzner attaches none — itscpuis summed across vCPUs (a%there would misstate the range) and its other metrics are rates.warn/critproduce threshold-breach findings in digests and trends reports. Thresholds are higher-is-worse; for lower-is-worse metrics (free space, availability) invert the query expression instead.warnmust be <=critwhen both are set.- Baseline anomaly detection (recent window vs the rest of the fetch window,
z-score >= 3) and trend direction need no hints; anomalies need at least
16 data points in the analysis window (for trends:
window / step >= 16— below that only trend and threshold findings fire; the digest's derived window always yields enough points). Escalation to critical additionally requires the deviation to be large relative to the metric's own level (≈30% of the baseline mean), so a rock-stable gauge whose tiny variance turns a benign excursion into a many-σ outlier stays a warning instead of escalating to critical. This is range-free by design; for a bounded metric where danger is proximity to a ceiling (a disk filling to 95%), configure acritthreshold — that fires independently of the anomaly detector.
AWS CloudWatch
A cloudwatch source pulls metrics natively from AWS, for teams without a
self-hosted Prometheus:
sources:
- name: aws-prod
type: cloudwatch
region: eu-west-1
# uses the standard AWS credential chain: env vars, ~/.aws, or an IAM role
namespaces: [AWS/EC2, AWS/RDS] # optional; defaults to all supported
- Credentials come from the standard AWS chain (env vars, shared
~/.aws/config/credentials, or an IAM role / IRSA on EKS) — never from this file. The minimal read-only IAM policy:
{
"Effect": "Allow",
"Action": ["cloudwatch:GetMetricData", "cloudwatch:ListMetrics"],
"Resource": "*"
}
role_arn(optional) makes the source assume a specific IAM role viasts:AssumeRoleinstead of using the ambient chain above:
sources:
- name: aws-prod
type: cloudwatch
region: eu-west-1
role_arn: arn:aws:iam::123456789012:role/infrasigns-readonly
The assumed role carries the read-only policy above; the process's own identity
needs only sts:AssumeRole on that role. In self-hosted this is optional (leave
it out to use the ambient chain). In the hosted multi-tenant service it is
required — see the cross-account note below.
namespacesscopes which curated metric sets are fetched. Supported:AWS/EC2,AWS/RDS,AWS/Lambda,AWS/ApplicationELB,AWS/ECS. Omitting it fetches all of them. For each namespace InfraSigns discovers the running resources viaListMetricsand fetches the standard golden-signal metrics (CPU, latency, errors, saturation) in one batchedGetMetricDatacall. To bound cost, at most 500 series are fetched per cycle (excess is dropped with a log warning).metricsoptionally replaces the curated set with an explicit list, e.g. a custom or application namespace:
sources:
- name: aws-prod
type: cloudwatch
region: eu-west-1
metrics:
- name: queue_depth
namespace: AWS/SQS
metric_name: ApproximateNumberOfMessagesVisible
stat: Average # Average | Sum | Minimum | Maximum | SampleCount
dimensions: { QueueName: jobs } # optional; omit to fetch every instance
signal: saturation # same engine hints as Prometheus queries
- CloudWatch alarms are ingested on the push side, not here: point an SNS
topic at an InfraSigns webhook subscription (
X-Infrasigns-Source: cloudwatch). Metrics (this section) and alarms (the webhook) are independent — configure whichever you need. - Cross-account (hosted service). In the multi-tenant hosted service a
CloudWatch source must set
role_arn: without it the source would resolve against the worker's own AWS identity, reading the operator's metrics rather than yours (a confused-deputy), so a role-less cloudwatch source is skipped for hosted organizations. To grant access, create a role in your AWS account that trusts the InfraSigns hosting account and pins your organization id as the STS external ID:
{
"Effect": "Allow",
"Principal": { "AWS": "arn:aws:iam::<infrasigns-hosting-account>:root" },
"Action": "sts:AssumeRole",
"Condition": { "StringEquals": { "sts:ExternalId": "<your-organization-id>" } }
}
InfraSigns always sends your organization id as the external ID (it is
server-supplied, never taken from your config), so a role scoped to your org id
can be assumed only for your organization — no other tenant can name a role
under it. Self-hosted deployments need no external ID (a single AWS account); a
role_arn there is assumed directly. The add-source wizard
collects the region, role_arn, and namespaces and shows your organization id
as the external ID to pin here (with a ready-made trust-policy snippet).
Operator hardening (hosted-service deployments). role_arn is
tenant-supplied and can name any account — including the hosting account. The
per-tenant isolation above holds only if the worker's own IAM principal cannot
assume a role that ignores the external ID. Scope the worker's
sts:AssumeRole grant to external customer roles (not Resource: "*"), and
ensure no role in the hosting account trusts the worker/account root without
an sts:ExternalId condition — otherwise a tenant naming such a role would be
assumed despite the external-ID mismatch, re-opening the confused-deputy one
level up. This is a deployment-side obligation InfraSigns cannot enforce in
code.
DigitalOcean
A digitalocean source pulls metrics natively from the DigitalOcean Monitoring
API, for teams on Droplets, managed databases, and load balancers without a
self-hosted Prometheus:
sources:
- name: my-do-account
type: digitalocean
token: ${DO_TOKEN} # personal access token, read scope
resources:
- type: droplet
tags: [production] # optional: only droplets carrying ALL listed tags
- type: database # managed MySQL clusters
ids: [db-abc123] # optional: these exact ids (skips listing)
- type: load_balancer # optional: omit ids/tags for all of the type
tokenis a DigitalOcean personal access token with read scope. It is expanded from the environment like every secret (${DO_TOKEN}) and rides anAuthorizationheader — it never appears in logs or error messages. No write scope is needed; InfraSigns only reads metrics and lists resources.resourcesselects what to monitor — at least one selector is required. Each names atype(droplet,database, orload_balancer) and optionally narrows it:ids: [...]— monitor exactly these resource ids (no listing performed).tags: [...]— list resources of the type and keep only those carrying every listed tag (AND). DigitalOcean load balancers and databases also support tags.- neither — monitor all resources of the type in the account.
- Curated metrics per type (the analysis engine finds anomalies and trends on all of them; only the percentage metrics carry static thresholds):
- droplet — CPU, available memory, free filesystem, 1-minute load, public inbound/outbound bandwidth.
- database — CPU %, memory %, disk % (with 80/95 warn/crit thresholds). DigitalOcean exposes monitoring metrics only for MySQL clusters, so non-MySQL databases are skipped during discovery (logged once per cycle). Per-service metrics (connections, query latency) need a per-cluster service identifier and are not wired yet.
- load_balancer — requests/sec, current connections, average response time, and HTTP responses (for error-rate analysis).
- No alarms. DigitalOcean has no AlertManager, so InfraSigns anomaly detection is the only alert path for these sources — there is no push/webhook side.
- Cost bound. At most 200 (resource × metric) Monitoring calls are issued per
cycle (excess is dropped with a log warning), fanned out with bounded
concurrency. Each cycle additionally makes one reachability probe plus the
resource-discovery listing calls (paged), so the DO API call count is ~200
metric calls plus discovery — a
tagsfilter over a large account pages the fleet to find matches. - Self-hosted only (for now). A DigitalOcean source uses the single configured token, with no per-tenant credential — run it in a deployment you control, scoped to one DigitalOcean account. (Unlike CloudWatch, it has no per-source cross-account mechanism for the hosted service yet.)
Hetzner Cloud
A hetzner source pulls metrics natively from the Hetzner Cloud Metrics API —
the cost-conscious choice for EU/Go teams on Hetzner without a self-hosted
Prometheus:
sources:
- name: my-hetzner
type: hetzner
token: ${HETZNER_TOKEN} # API token, read scope
resources:
- type: server
labels: {env: production} # optional: only servers with ALL these labels
- type: load_balancer
ids: ["4711"] # optional: these exact numeric ids (skips listing)
tokenis a Hetzner Cloud API token with read permission. It is expanded from the environment like every secret (${HETZNER_TOKEN}) and rides anAuthorizationheader — it never appears in logs or error messages. No write permission is needed; InfraSigns only reads metrics and lists resources.resourcesselects what to monitor — at least one selector is required. Each names atype(serverorload_balancer) and optionally narrows it:ids: [...]— monitor exactly these numeric resource ids (no listing).labels: {k: v, ...}— list resources of the type and keep only those whose labels contain every listedkey=valuepair (AND), applied server-side as a Hetzner label selector.- neither — monitor all resources of the type in the project.
- Curated metrics per type (the analysis engine finds anomalies and trends on all of them; none carries a static warn/crit threshold — Hetzner's server CPU is summed across vCPUs rather than a normalized 0-100%, and every other metric is a raw rate or count, so a fixed threshold would misfire):
- server — CPU (summed across vCPUs), disk read/write IOPS and bandwidth,
network in/out packets-per-second and bandwidth. Multi-disk / multi-NIC
servers produce one series per device (a
devicelabel disambiguates). - load_balancer — open connections, connections/sec, requests/sec, and inbound/outbound bandwidth.
- 5-minute resolution. The Hetzner Metrics API returns 5-minute averages for anything but very short ranges, not raw samples — so anomaly detection over Hetzner series is coarser than over a fast-scrape Prometheus. This is a Hetzner API limitation, not a configuration knob.
- No alarms. Hetzner has no AlertManager, so InfraSigns anomaly detection is the only alert path for these sources — there is no push/webhook side.
- Cost bound. At most 200 resources are monitored per cycle (excess is dropped with a log warning). Hetzner returns every requested metric type for a resource in one Metrics call, so a cycle makes at most ~200 metric calls (fanned out with bounded concurrency) plus one reachability probe and the paged resource-discovery listings.
- Self-hosted only (for now). Like DigitalOcean, a Hetzner source uses the single configured token, with no per-tenant credential — run it in a deployment you control, scoped to one Hetzner Cloud project.
Healthcheck URLs
A healthcheck source periodically probes a list of HTTP endpoints and reports
each one's availability and response latency — synthetic monitoring for a URL
with no metrics backend, or for an operator with no Prometheus at all.
sources:
- name: public-endpoints
type: healthcheck
endpoints:
- url: https://api.example.com/healthz # required; http or https
- url: https://example.com/status
method: HEAD # GET (default) or HEAD
expect_status: 204 # exact status; omit for "any 2xx"
timeout: 5s # default 10s, max 30s
severity: warning # down-alert severity: critical (default) | warning | info
endpoints— one or more probes. Each needs aurl;method,expect_status,timeoutandseverityare optional (defaults above) — but the UI editors require an explicitseverity(#357): a form row has a select the user simply never touched, and the tier is what pages, so the add-source wizard and the source-detail edit form refuse an unset one while this file keeps its default. A stored source that omits it keeps working and opens its edit form showingcritical, the tier in force. A probe is up when the endpoint answers with the expected status (any2xxwhenexpect_statusis unset), down on any other status, a transport error, or a timeout. Redirects are followed and the final status is checked, so anexpect_statusin the3xxrange is rejected at config load (it can never be the final status).- No secrets in the URL. The
urlis shown in labels, the web UI, and every digest/incident delivered to your channels and the LLM. Userinfo (user:pass@) and the entire query string are stripped from that displayed/labelled form, so a?token=…would silently vanish there — put credentials in the endpoint's own auth, not the URL. Because two endpoints that differ only in userinfo or query then look identical, they are rejected as duplicates at config load: distinguish endpoints by scheme/host/path, not by a query string. - Metrics. Each endpoint emits
up(1/0) andlatency_msseries, labelled by that strippedurl— so the digest shows current availability and response time. - Down = alert. A down endpoint raises an alert at the endpoint's
severity; acriticalone (the default) makes the cycle a paged incident, awarningorinfoone rides the next regular digest. - No history / limited trends. A healthcheck has no metrics backend — each cycle probes live and records a single point, so trend analysis (which needs several points) is effectively flat. The value of a healthcheck source is the down alert and the current state in each digest, not a trend. Each endpoint is probed twice per digest cycle (once for the alert, once for the metric series) and once more per trends cycle, so size your endpoints' own rate limits accordingly.
- Readiness. A down endpoint never marks the pod NotReady:
/readyzreflects the prober itself (always ready), and endpoint health is reported through the digest and theupmetric instead — so one unreachable URL cannot stop the other sources' digests, the UI, or metrics. - Cloud-safe. Unlike the token-based cloud collectors, a healthcheck source carries no ambient credential, so a hosted deployment routes its probes through the same egress (SSRF) gate as a tenant Prometheus URL — a probe that resolves to a private or metadata address is refused at dial time.
Loki (LogQL)
A loki source runs your LogQL metric queries against a
Grafana Loki instance and feeds the resulting
numeric series into the same digest / incident / trends engine as Prometheus —
so logs-derived signals (error rate, per-level counts, anomaly rates) sit next to
your metrics. It is the near-twin of a Prometheus source: same url + queries
shape, only the query language and the endpoint differ.
sources:
- name: app-logs
type: loki
url: http://loki.monitoring.svc:3100 # Loki HTTP API base URL
tenant_id: team-a # optional; X-Scope-OrgID for multi-tenant Loki
queries: # at least one; each is a LogQL METRIC query
- name: error_rate
query: sum(rate({app="api"} |= "error" [5m]))
signal: errors # optional engine hint
unit: /s # optional display suffix in the digest
- name: warn_logs
query: sum by (level) (count_over_time({app="api"} | logfmt | level="warn" [5m]))
log_queries: # optional; raw log lines for LLM context
- name: api_errors
query: '{app="api"} |= "error"' # a raw LogQL LOG selector (returns lines)
limit: 30 # optional per-query line cap (default 20, max 100)
url— the Loki HTTP API base. Each query is issued asGET {url}/loki/api/v1/query_range.queries— LogQL metric queries —rate,count_over_time,sum,bytes_rate,quantile_over_time, etc. — whose result is a matrix of numeric series, exactly like a PromQL range query.name,signal,warn,critandunitbehave exactly as they do for a Prometheus query (see Query hints for the analysis engine). A raw LogQL log query (a bare stream selector, which returns log lines) is rejected here — wrap it in a metric aggregation, or put it inlog_queries.log_queries(optional add-on; needs a metric query) — raw LogQL log selectors (e.g.{app="api"} |= "error") whose recent lines are captured as LLM context on a critical cycle (#246). Because a loki source emits no alerts, a "critical cycle" for it means a critical finding — e.g. an error-rate metric query breaching itscrit:threshold. When that fires, the most recent lines (per-querylimit, default 20, max 100; capped in total; max 10log_queries) are attached to the digest / incident summary so the model can reason over the actual logs. On a normal cycle no log query runs.log_queriescarry nosignal/warn/crit/unit(a line is not a numeric series) and are not citable claim evidence — they are context only. They are an add-on, not a substitute: a loki source still needs at least one metricqueries, because the metric queries are the numeric digest/trends signal — a source with none has nothing to summarize. That holds for a loki source configured purely as another source'slog_sourcetarget too, whoselog_queriesfire on that source's critical cycle.Privacy: captured log lines are sent to your configured LLM provider (for
provider: openai/anthropic, a third-party API) and may be quoted into the stored report. Application logs often contain secrets or PII (tokens, emails, request bodies) — scope yourlog_queriesselectors to streams safe to share, especially on a hosted LLM. Control characters and embedded newlines are neutralized, but secrets are not redacted.tenant_id(optional) — the Grafana Loki tenant to query. A multi-tenant Loki (the common self-hosted setup) requires anX-Scope-OrgIDheader to select the tenant and rejects a tenant-agnostic query withno org id; settenant_idand InfraSigns sends it on every query. InfraSigns does not treat it as a credential (it names a tenant), so it may be a literal or a${VAR}— put real authentication in front of Loki (a reverse proxy, or the bearertokenbelow) rather than relying onX-Scope-OrgIDas access control. It is sent only on the query endpoint, never on Loki's tenant-agnostic/readyreadiness check. The value must be free of control characters (it rides an HTTP header) and at most 150 bytes — a longer or CR/LF-bearing value is rejected at config load.- Authentication. For an authenticated Loki endpoint (e.g. Grafana Cloud
Logs) set an optional bearer
token— sent asAuthorization: Bearer <token>, exactly like a Prometheus source. It is a secret; inject it via${VAR}and keep it out of the URL. - No secrets in the URL. A query string (
?token=…, or a bare trailing?) is rejected at config load, exactly like a Prometheus source — put the bearer intoken, or use Loki's own auth (e.g. a reverse proxy). Userinfo (user:pass@) is allowed and still sent on the wire to connect, but it is stripped from the displayed URL in the web UI and logs, so a credential there never renders. - Readiness.
Healthy//readyzprobe Loki'sGET /ready— a loki source that cannot reach Loki reports unhealthy, but the probe is cheap and unmetered. - Cloud-safe. Like a healthcheck and Prometheus URL, a tenant's loki
urlis dialed through the egress (SSRF) gate in a hosted deployment.
Grounding an incident in another source's logs (log_source)
In a real deployment the alerts live in Prometheus and the logs live in Loki —
two different sources. Log capture (log_queries, above) is per-source, so on its
own it can only ground a loki source's own critical finding: an
alert-driven incident, on a prometheus or healthcheck source, has no logs to
attach because neither of those source types can fetch any.
log_source closes that gap. It names another configured source whose recent
log lines are attached to this source's incident summary:
sources:
- name: prod
url: http://prometheus:9090
log_source: app-logs # a critical alert on prod is summarized WITH app-logs' logs
- name: app-logs
type: loki
url: http://loki.monitoring.svc:3100
queries:
- name: error_rate
query: sum(rate({app="api"} |= "error" [5m]))
log_queries: # required for a log_source target
- name: api_errors
query: '{app="api"} |= "error"'
- The target must exist, be a
lokisource, and carry at least onelog_queries— all three are checked at config load, so a target that would silently produce nothing fails loudly instead of shipping an empty log section. - Naming yourself is rejected. Leaving
log_sourceunset already means "my own logs", so a self-reference is a misunderstanding rather than a shorthand. - Any source type may set it (including another
lokisource), and it changes nothing until a cycle turns critical — a firingcriticalalert, or a critical finding from the analysis engine. On a normal cycle no log query runs. - Unset behaves exactly as before: a loki source with
log_queriesstill grounds its own critical findings in its own logs. - It REPLACES this source's own logs, it does not add to them. Setting
log_sourceon a source that can fetch logs itself — alokisource with its ownlog_queries— means its critical cycles are grounded in the named source's lines and no longer in its own. There is one origin per cycle, which is what keeps the "Recent log lines from source …" line in the prompt honest. Set it on a log-capable source only when the other source's logs are the ones worth reading. - Self-hosted YAML configuration only, today. There is no hosted-UI write path
for
log_source: the add-source wizard and the source edit form do not offer it, so an organization on the hosted service cannot set it. The runtime honors the key wherever it is present. Hosted-UI support is tracked in #346. - The prompt (and the
provider: nonereport) names the origin — "Recent log lines from source app-logs" — so the model is not told another source's logs are this one's. - A missing target degrades, it does not fail. If the named source is not running (it failed to initialize, or a hosted plan's source cap truncated it), the cycle logs a warning and is summarized without log context.
Limitation — the lines are not correlated to the firing alert. What is captured is the log source's standing
log_queriesover the cycle's window. They are not filtered by the firing alert'sinstance/job/podlabels — loki queries are not templated at all today — so on a large, busy log source the context is "what this source was logging while the alert fired", not "what the alerting instance was logging". Scope thelog_queriesselectors to the streams that matter for the alerts you expect. Per-alert label templating is tracked in #347.
The privacy note under log_queries applies unchanged: the
captured lines are sent to your configured LLM provider and may be quoted into
the stored report.
LLM
provider: none runs the deterministic analysis engine with no API key and
no external calls; checks: and deploys.enabled are rejected in that mode.
See LLM Providers for the standalone mode details, model
recommendations, cost estimates, and the daily cost guard
(llm.max_calls_per_day).
Notifications
notify:
telegram:
token: "..." # bot token from @BotFather
chat_id: "..." # group, channel, or user chat ID
slack: # omit to disable; see docs/notifications.md for the api (threaded) transport
mode: webhook # "webhook" | "api"
webhook_url: "https://hooks.slack.com/services/T00/B00/xxxx" # required when mode is "webhook"
email:
smtp_host: "" # SMTP host; empty disables the channel
smtp_port: 587 # optional; defaults to 587 (STARTTLS)
smtp_user: "" # optional; set with smtp_password for authenticated relays
smtp_password: ""
from: "InfraSigns <[email protected]>"
to: [[email protected]]
pagerduty: # incidents only — see note below
routing_key: '${PAGERDUTY_ROUTING_KEY}' # Events API v2 integration key; empty disables the channel
severity: critical # default for unspecified severity: info|warning|error|critical (default info)
Channels are optional and independent — configure any combination. Telegram, Slack, and email receive every notification (digests, trends, and incident alerts). PagerDuty is incident-only: a firing incident — an inbound alert group or a failing health check — triggers a PagerDuty alert and its resolution resolves it (correlated per episode), while digests, trends and deploy verdicts are not paged. See Notifications for setup instructions.
Scheduled reports
Both the daily digest and the trends report run on a cron schedule under reports:
reports:
digest:
schedule: "0 8 * * *" # daily health digest
model: "" # optional llm.model override for this cycle (digest + incident summaries)
stale_episode_after: "24h" # an open incident episode nothing has re-asserted for this
# long stops standing the verdict up; defaults to 24h
trends: # optional resource-utilization + trend report
enabled: true
schedule: "0 9 * * 1" # e.g. Mondays at 09:00
window: "168h" # analysis look-back (Go duration); defaults to 7d
step: "1h" # range-query resolution; defaults to 1h
timezone: "UTC" # IANA timezone for the schedule; defaults to UTC
model: "" # optional llm.model override for this report
retention_days: 90 # prune archived reports after N days; 0/absent = keep forever
The digest analyzes a range window derived from its own schedule: the gap
between two consecutive runs, clamped to [1 hour, 7 days], at a resolution
of about 120 points per series (step >= 30s). A daily digest therefore looks
back 24 hours — anomalies and trends that fired and resolved between cycles
still show up. There is no knob: the window follows reports.digest.schedule.
On sub-hourly schedules consecutive windows overlap (the 1h minimum keeps a
useful anomaly baseline), so a short-lived anomaly may appear in more than one
digest.
Each digest (and incident summary) also carries a "What changed" section derived from the same window (no configuration, no extra state):
- New / removed scrape targets — the target set is
upevaluated at the window's start and end; a target present at the end but not the start is new, one present at the start but not the end was removed. A target merely down (up=0) still emits samples, so it is not reported — only a change in service discovery membership counts. Membership is evaluated over a 15-minute lookback at each boundary, so targets scraped as slowly as every 15 minutes (and brief scrape gaps at a boundary) do not read as spurious changes. Detection needs a populated baseline, so a freshly started Prometheus with less than a window of history reports no new targets rather than flagging the whole fleet. - Newly firing alerts — alerts whose start time falls inside the window, as opposed to recurring ones already active before it. (An alert that both fired and resolved within the window is not shown — the digest sees the currently firing set.)
- Threshold crossings — metrics that went from below a configured
warn/critthreshold at the window's start to at/above it at the end. Only queries with awarn/crithint are eligible; the comparison is between the window's first and last samples.
It is fed to the summary alongside the metric snapshot so the analysis can correlate a change — a new exporter, a freshly crossed threshold — with an anomaly. The section is omitted when nothing changed. Because the analysis window follows the schedule, a sub-hourly cadence (whose windows overlap the 1h minimum) may report the same change in several consecutive digests, and a cadence longer than 7 days can miss a change older than the 7d window cap.
Open episodes on the digest
Everything above is derived from the analysis window, which means it can only describe what the window contains. An incident that opened last Tuesday and was never resolved is older than every window a daily digest looks at, so the digest used to say nothing about it — and could legitimately render "All healthy" over a fire that had been burning for three days.
Each digest now carries one row per open episode, with the alert's name, when it started, and how long ago that was:
An open episode is one the inbound alert receiver opened and nothing has resolved. (An episode nothing has re-asserted recently renders differently and does not stand the verdict up — see the last bullet below.) The rows are facts read from the incident archive, not something the model is asked to mention — but the model is told about them, so its prose can correlate a long-running fire with what the metrics are doing and will not write "no issues observed" above a row that says otherwise.
Details worth knowing:
- The verdict answers for them. The report you are sent — Telegram, Slack,
email, the plain-text floor, PagerDuty — can never read "All healthy" while it
carries an episode a sender is still re-asserting, and no open-episode row ever
draws the green healthy accent. Such an episode at critical or warning tier is
counted like any other offending signal ("1 critical"); one whose sender graded
it
severity: info— the case a count cannot see — renders "N still firing" at the warning tier. The tier the sender chose is never overruled; the digest only declines to call it health. An episode nothing has re-asserted lately is the exception, and it is deliberate — see An episode nothing re-asserts is demoted, not hidden below. - The archive card is not covered yet. The same digest's entry in the web
archive (and the dashboard card, and the MCP
latest_digesttool) can still show its green HEALTHY badge: that badge is derived from the live alert count stored alongside the report, and a stored report carries neither the still-firing rows nor a count of them. Closing it needs a new column, so it is filed separately — read the delivered report, not the archive badge, for this. - Which episodes appear on which source's digest. A digest is per configured source, while episodes arrive by webhook and are matched to a source only best-effort (see Incident attribution). A source's digest therefore carries the episodes attributed to it plus every episode nothing could be attributed to — so no episode is invisible, which is the whole point, while an episode attributed to another source stays on that source's digest. With no attribution rules configured, nothing is attributed and every digest carries every open episode.
- Long lists are cut, oldest kept. Episodes are ranked worst-severity first
and then longest-running, and every cut keeps that prefix — the read itself
asks the database for the 200 longest-running open episodes, the report renders
at most 10 of them, and the prompt sees at most 10. The rendered list is closed
by an "…and N more still open" note ("open", not "firing", because the cut takes
the tail of the list — which is exactly where the demoted rows sort); that note
is a count, not another incident, so it never adds to the verdict's own count. Past 200 the counts
become a floor — a fleet in that state should be reading the incidents page,
not a digest row. Two consequences of ranking by age at the read: at the 200
boundary an old
infoepisode is kept ahead of a newer critical one, and since a demoted episode is an old one by construction, a digest whose read filled that 200 keeps its verdict amber even when every row it managed to read is demoted — the rows it did not reach are the newer ones, and it will not report an open set it could not finish reading as healthy. - It degrades quietly. If the archive cannot be read, the digest ships without the section rather than failing; the failure is logged as a warning.
- An episode nothing re-asserts is demoted, not hidden. A deleted Alertmanager
rule, a sender configured with
send_resolved: false, a curl'd test alert or a decommissioned host leaves an episode open permanently — nothing ever resolves it, and retention prunes only resolved rows. Once nothing has re-asserted such an episode for longer thanreports.digest.stale_episode_after(default 24h), the digest stops standing its verdict on it: the row still appears, reading
but at the info tier, outside the "N still firing" count, and sorted below every episode that is still being re-asserted. A digest whose only open episodes are stale can read "All healthy" again — which is the point: a permanently amber verdict is noise that hides the next real episode. The row never disappears, because nothing resolved it and the report must not claim otherwise.
The clock is the episode's last re-assert, not its start: a repeat firing from the sender refreshes it, so an alert Alertmanager is still repeating never goes stale however long it has been open. Two things have to be true for that refresh to happen, and InfraSigns enforces both rather than assuming them:
- The sender has to repeat at all. Alertmanager and Grafana re-notify an
unchanged group every
repeat_interval. CloudWatch does not — an SNS alarm notification is a state change, so an alarm sitting in ALARM sends nothing more. Itsupdated_atwould freeze at the transition, so CloudWatch episodes are never demoted on this clock, however long they have been open. Nothing can tell an abandoned CloudWatch episode from a burning one, so the remedy for those is the incident page's Mark resolved, not a timer. - The repeat has to survive the deduper. A repeat inside
webhook.dedupe_windowis suppressed and never reaches the archive, so the real refresh period is at least the longer ofdedupe_windowand the sender'srepeat_interval.stale_episode_aftermust therefore be longer thanwebhook.dedupe_window; a config where it is not is rejected at load, with both values in the message.
The 24h default is six missed re-asserts at Alertmanager's own default
repeat_interval of 4h (with the 5m default dedupe window, which is nowhere
near binding), so a receiver outage or a paused sender cannot demote a live
fire. There is deliberately no "off"
value — 0s and negatives are rejected at config load, since a zero would be
indistinguishable from an absent key; to keep the pre-stale_episode_after
behaviour, set something very large ("87600h").
To make the row go away entirely rather than be demoted, close the episode: either make the sender send its resolution, or use the incident page's Mark resolved action. The two halves are deliberately independent — the demotion handles the sender that went quiet without anyone noticing, the manual close handles the alert an operator knows will never resolve. - Cloud: incidents are received process-wide today, so a hosted organization has no incident rows of its own and the section stays empty for it. The read is organization-scoped, so it starts working the moment per-tenant reception does.
Trend detection needs at least 4 data points: for trends,
window / step >= 4 is validated at config load (e.g. window: 1h at the
default 1h step is rejected with a clear error instead of silently reporting
every trend as "stable").
Each report — and the webhook receiver via webhook.model — can override the
global llm.model; see LLM Providers.
Every delivered report lands in the web archive (the reports table).
reports.retention_days prunes rows older than N whole days — the prune runs
at startup and after each persisted report, and deletes the report's feedback
with it. 0 (the default) keeps everything forever; retention is opt-in so an
upgrade never silently deletes history.
Incident episodes from the webhook receiver accumulate in the incidents
table the same way; the sibling incidents.retention_days (top-level) prunes
RESOLVED episodes older than N days, at startup and after each persisted batch
of incidents. Open (still-firing) episodes are never pruned, however old.
The source timeline journal (source_events: fetch failures/recoveries and
check-verdict changes, shown on the web UI's per-source Timeline) has its own
sibling knob, timeline.retention_days — pruned at startup and after each
journaled transition. Only transitions are journaled, so growth is slow; the
default 0 keeps them forever.
The source_health table holds one last-known health row per source. When a
source is removed from config its collector stops, so that row freezes and its
web UI card becomes a neutral "removed" card. source_health.retention_days
prunes those frozen rows once they are older than N days — pruned at startup and
after each journaled transition, the same path as timeline. A live source's
row keeps updating every collection cycle, so only genuinely-removed sources are
reaped; the default 0 keeps them forever. Independently of this knob, the web
UI hides a removed source's card after 30 days so the sources grid stays bounded
even with retention off.
Since #358 the same knob also governs source_unrecognized_severities, the
per-source reading behind the Unmapped severity labels your sources send card on
Settings — same shape and same prune path, because a live
source rewrites its reading every cycle too. The freeze behaviour is nearly the
same, with one difference worth knowing: a health row advances on every collection
attempt, while a severity reading advances only on a successful one. So a
source that keeps failing for longer than the retention window has its reading
reaped while its health row stays fresh — the card then reports it as never having
reported, which its health card contradicts, and correctly: there is no reading
because there was no successful cycle to take one from. Two more consequences:
lowering this knob deletes the evidence behind that card as well as the health
rows, and — as with the sources grid — the card applies its own view-side bound, so
a source you removed from config stops being listed and stops counting toward its
all-clear regardless of whether you ever set retention.
In the hosted (cloud) service these three per-org knobs — reports, timeline,
and source_health — are additionally capped by your subscription tier (Free
7 days / Solo 90 / Team 365). The effective retention is min(configured
retention_days, tier cap), and the 0=keep-forever default is likewise capped to
the tier, so history is never kept longer than your plan allows. A value below the
cap is honored as-is; raising retention_days above the cap has no effect (the
Billing and Settings pages show the effective, tier-clamped value). Self-hosted has
no plan, so the configured value is used verbatim. incidents.retention_days is
not capped per-org because incidents arrive through the process-global webhook
receiver.
Each schedule is a standard 5-field cron expression. Examples:
| Schedule | Meaning |
|---|---|
0 8 * * * |
Every day at 08:00 |
0 8 * * 1-5 |
Weekdays at 08:00 |
0 8,20 * * * |
Twice a day at 08:00 and 20:00 |
Digest schedules are evaluated in UTC. The trends report uses reports.trends.timezone
(defaults to UTC).
Web UI (experimental)
ui:
enabled: true # serves the embedded web UI under /app; off by default
charts:
enabled: true # source-detail live metric charts (default on when the UI is on)
An embedded dashboard over the same store that backs your digests, incidents,
and reports, plus an add-source wizard that probes Prometheus live and generates
config for you. The wizard also generates config blocks for CloudWatch,
DigitalOcean, Hetzner, healthcheck, and Loki sources (region / resource types /
endpoints / LogQL queries / Loki tenant_id + log_queries; a cloud token is
emitted as a ${…_TOKEN} env placeholder) — but it does not live-verify these: that would require a token
(or arbitrary-URL probing) on this unauthenticated endpoint, so they are
added-and-applied and verify on their first collection. It shares
server.port and is unauthenticated — restrict /app at the network layer
until self-hosted auth ships. See the Web UI page for the full tour,
the wizard flow, and the security posture.
Each source's detail page draws live metric charts for its configured
queries, fetched on view from that source's Prometheus. They add per-view range
queries to Prometheus (bounded: at most 24 charts × 8 series × ~120 points per
view, 4 concurrent, one 12s budget) — but because /app is unauthenticated,
the /series endpoint is a live-query path anyone who can reach the port can
trigger, so it is one more reason to restrict /app at the network layer.
Concurrent identical requests (same source and window — a scripted loop or many
viewers on the same page) are coalesced onto a single Prometheus fan-out, so
the cross-request amplification is bounded to one in-flight fetch per
source/window rather than one per caller; there is no rate limit yet, and adding
one is deferred until /app grows real auth (which changes the threat model).
Set ui.charts.enabled: false to turn the charts and their /series endpoint
off while keeping the rest of the UI; absent, the charts are on whenever the UI
is.
Sign-in (hosted)
auth:
base_url: https://app.example.com # public origin; fixed paths are appended (no query/fragment/userinfo)
github:
client_id: "Iv1.abc123" # setting EITHER provider turns ON cloud sign-in
client_secret: ${GITHUB_OAUTH_SECRET} # from the environment, never committed
# enterprise_url: https://ghe.example.com # optional; a GitHub Enterprise Server instance
google: # optional; enable one or both providers
client_id: "1234.apps.googleusercontent.com"
client_secret: ${GOOGLE_OAUTH_SECRET}
# session_ttl: 720h # login lifetime (default 30 days)
Setting either auth.github.client_id or auth.google.client_id switches
the deployment into cloud mode: the web UI stops resolving the single
implicit organization and instead resolves the acting organization per
request from a login session, and mounts the sign-in routes (/login,
/auth/{provider}/login, /auth/{provider}/callback, /auth/logout). The
login page shows a button per configured provider. Leaving auth empty (the
default) keeps InfraSigns self-hosted single-tenant — no login, every request
scoped to the default organization.
- Disabled until a provider's
client_idis set. Cloud mode requiresui.enabled: true(the login flow is served by the web UI) andauth.base_url; each configured provider also requires itsclient_secret(an unconfigured provider is simply omitted, not validated). auth.base_urlmust be https for any non-localhost host: the session cookie is__Host--prefixed and thereforeSecure-only, so a plain-http origin would mint a cookie the browser refuses.http://localhost(in any case, and the root-qualifiedlocalhost.) or a loopback IP literal (including an IPv6 zone such as[::1%25eth0]) is accepted for local development. The name match is ASCII-case-insensitive only, so a look-alike such aslocalhoſt(U+017F) is a different DNS name and is refused; short address literals like127.1are refused too — Go resolves those as DNS names, where a search domain can send the traffic to a remote host.auth.base_urlmust be an origin, optionally with a path prefix. Every consumer appends a fixed path to it — each provider's registered callback is<base_url>/auth/github/callback/<base_url>/auth/google/callback, the members page shows the owner<base_url>/invite/<token>to copy and send to the invited teammate, and Stripe returns to<base_url>/app/billing— so credentials (@), a query string (including a bare trailing?) and a fragment (including a bare trailing#) are rejected byconfig validaterather than silently rewritten. A trailing slash is fine. "Origin" includes a host, andhttps://:8080— a port and nothing else — is rejected for the reason Sources gives for every URL key: every URL built from such a base would have been hostless, so the registered OAuth callback could never match and the invite and Stripe return links named no origin.auth.github.enterprise_urlpoints GitHub sign-in at a GitHub Enterprise Server instance instead of github.com. Leave it unset for github.com. A GHE instance serves the whole flow from one origin, so one base URL is enough: InfraSigns derives the endpoints it requests —<base>/login/oauth/authorize,<base>/login/oauth/access_tokenand the REST API base<base>/api/v3— from it (a trailing slash is fine). Give it the instance origin, not the API base — a value whose path resolves to an/api/v3base is rejected (so/api/v3,/api/v3/and/api/v3/.alike), because/api/v3is appended to derive the REST base. It must be https unless the host is loopback — the same setauth.base_urlaccepts, described above — and it must carry no credentials (@), query string (including a bare trailing?) or fragment (including a bare trailing#) —config validaterejects those rather than silently rewriting the value. When sign-in is enabled, setting it withoutauth.github.client_idis an error: the GitHub provider would never be registered and the key would have no effect. It does not by itself turn cloud mode on — so if no provider client id is set anywhere, the wholeauthblock is off, this key is validated against nothing and is simply unused. What the https rule buys is transit protection and nothing more — the authorize redirect carries the CSRF state, the callback carries the authorization code, and the token exchange carries the client secret and returns the access token, all readable and replayable over plain http on a routed network. It is not a phishing or endpoint-pinning defence: this is an instance-level operator key at the same trust level asauth.github.client_secret,api.tokenanddatabase.dsn— it is read once at process start, is never part of an organization's config, and cannot be set by a tenant, so anyone able to point it at a hostile host could already hand over the client secret. Endpoints are configurable for GitHub Enterprise Server; InfraSigns is not tested against a live GHE instance. There is deliberately no Google equivalent: Google has no on-prem analogue — Workspace, Cloud Identity and GCP all authenticate against the same endpoints regardless of tenant. To confirm the key took effect, check the startup log: thecloud sign-in enabledline reportsgithub_origin, which isgithub.comby default and the resolved instance origin otherwise (a misspelled or mis-nested key is only warned about, so this line is the way to tell it apart from a key that was ignored).- A GHE instance behind a private CA needs its bundle mounted. The token
exchange and both profile requests use Go's default HTTP transport against the
system root pool, and the published image is
gcr.io/distroless/static-debian12— the public Mozilla bundle only. If your instance presents a certificate from an internal CA, mount the CA bundle into the container and point Go at it withSSL_CERT_FILE=/path/to/ca-bundle.crt(orSSL_CERT_DIR); otherwise the first symptom isx509: certificate signed by unknown authorityon the OAuth callback, after the browser round-trip has already succeeded. - GitHub sign-in requires a verified email, on GHE too. InfraSigns takes the
account's primary verified address (falling back to any verified one) and
refuses the login if there is none — an unverified address cannot establish
identity. On github.com that is rarely an obstacle; on a GHE instance it
depends on the instance having outbound email configured, and accounts
provisioned through LDAP or SAML commonly report
verified: false. If every user is refused while the endpoints are demonstrably right, this is the thing to check first. - Sessions are opaque and server-revocable (a row in the database keyed by a
random cookie value), not JWTs — logout and ban take effect immediately.
Expired sessions are reaped in the background; correctness never depends on the
reaper (an expired session is never honored).
session_ttlbounds a login before re-auth (default 30 days). - On a brand-new first sign-in a user is bootstrapped with a personal organization; a user who belongs to several organizations picks the active one on an org-picker, and a returning user who belongs to none (e.g. removed from their only organization) lands on that picker with a Create organization action rather than a silently re-minted org. Deauthorizing a member takes effect on their next request (the acting organization is re-checked against live membership every time).
- Accounts are linked by verified email. Signing in with GitHub and later with Google (or vice versa) using the same verified email resolves to one account, not two — the second provider is attached to the existing identity.
The requested scopes are minimal: GitHub read:user + user:email, Google
openid + email + profile — the verified email (used as the identity and
billing address) plus display name and avatar, no repository, Drive, or Gmail
access. Helm support for the hosted deployment lands with the cloud rollout; the
published chart remains self-hosted single-tenant.
Billing (hosted)
Stripe billing turns the plan limits (Free / Solo / Team) into a paid product on the hosted service. It is cloud-only and requires sign-in (above) — every checkout and webhook is scoped to an organization.
billing:
secret_key: ${STRIPE_SECRET_KEY} # Stripe secret API key (sk_…); its presence turns billing ON
webhook_secret: ${STRIPE_WEBHOOK_SECRET} # verifies the /billing/webhook signature (whsec_…)
price_solo: ${STRIPE_PRICE_SOLO} # Stripe Price ID (price_…) the Solo plan maps to
price_team: ${STRIPE_PRICE_TEAM} # Stripe Price ID the Team plan maps to
Setting billing.secret_key switches on the paid boundary: the /app/billing
page gains Subscribe / Manage actions (owner-only), the Stripe webhook mounts at
POST /billing/webhook, and the source/member/Slack quota gates fail closed on
a plan read error (a read failure must never hand out a higher tier's limits once
money is involved). Leaving billing empty keeps the read-only billing page and
the fail-open gates — every org stays on its 14-day Team trial or Free.
- Requires sign-in and at least one price. Billing validation fails closed
unless
authis enabled (it needs the session/org spine),billing.webhook_secretis set (an unverified webhook could forge plan state), and at least one ofprice_solo/price_teamis configured. - Secrets come from the environment, never the config file:
secret_keyandwebhook_secretare${ENV}references. The price IDs are not secret but are referenced the same way for parity. - The webhook is unauthenticated by design — Stripe calls it, and the only
trust boundary is the HMAC signature over the raw body (verified with
webhook_secret). Point your Stripe webhook endpoint at<base_url>/billing/webhookand subscribe it tocheckout.session.completed,customer.subscription.updated,customer.subscription.deleted, andinvoice.payment_failed. - Downgrades keep the config, freeze premium capabilities. A cancelled subscription drops the plan to Free and a failed payment marks it past-due (access kept through Stripe's dunning). Your configuration is never rewritten, but on the lower plan the worker enforces the plan live: it stops delivering Slack (Free has no Slack) and stops monitoring any sources beyond the plan's source limit (the first N by config order are kept; the rest show as over-limit on the health page, uncollected). Over-cap members are retained (a downgrade never removes a teammate). History retention is also clamped to the lower tier's cap — the one data-destructive downgrade effect: existing report/timeline/source-health rows beyond the new tier's window are reaped on the next prune (re-upgrading widens the window again but cannot restore already-deleted rows). None of this rewrites the blob — re-upgrading resumes delivery and monitoring with no reconfiguration. The same rule fires when a Team trial lapses to Free (there is no Stripe event for that — it is resolved live at delivery time), so a trial that quietly ends stops delivering Slack too.
Like sign-in, billing is not wired into the published Helm chart — it lands with the hosted cloud rollout; the self-hosted chart never sets it.
Q&A bot
bot:
telegram: # getUpdates long-poll
allowed_chat_ids: [123456789] # non-empty enables this transport (fail-closed allowlist)
# token: "" # falls back to notify.telegram.token when empty
slack: # Socket Mode; operators @-mention the bot
allowed_channel_ids: [C0123ABCD]
app_token: "xapp-..." # required
# bot_token: "" # falls back to notify.slack.token when empty
mcp_servers: # external MCP tool servers the agent consumes (self-hosted only)
- name: runbooks # [A-Za-z0-9_-], ≤40; prefixes every tool as <name>__<tool>
url: https://runbooks.internal/mcp # MCP HTTP (streamable) endpoint
# token: "${RUNBOOKS_TOKEN}" # optional bearer; https required off localhost
tools: [list_runbooks, get_runbook] # per-server allowlist of tool names to expose
An LLM agent that answers infrastructure questions in Telegram and/or Slack
using the same read-only tools as the MCP server. Each transport is enabled by
its own fail-closed allowlist. Requires a reasoning llm.provider
(openai/anthropic); one question costs up to 9 calls against the shared
llm.max_calls_per_day. See the Q&A bot page for the security model
and operational notes.
bot.mcp_servers lets the agent consume read-only tools from external MCP
servers (a runbook server, a cloud provider's endpoint), namespaced as
<name>__<tool> and gated by a per-server tools allowlist. Validated whenever
present, but only consumed when a Q&A transport is enabled — and, like the whole
AI surface, suppressed in the hosted service. See
External MCP tool servers.
API authentication
POST /api/digest/trigger runs a digest cycle on demand — a billable LLM call
plus user-visible notifications — so it requires a bearer token and is
disabled until one is configured:
Requests must send Authorization: Bearer <token>. The bundled
.env.example ships a dev-only token so the compose quickstart works out of
the box — replace it for anything reachable beyond localhost. The
infrasigns digest trigger CLI picks the token up from $API_TOKEN (or
--token).
Both surfaces this token gates — the digest trigger and the MCP /mcp endpoint —
run against the process configuration and the default organization, so in the
hosted (cloud sign-in) mode they are suppressed regardless of api.token:
they would act on the operator's data rather than a signed-in organization's, and
each organization's digests already run automatically per organization. The token
therefore only enables these surfaces in self-hosted mode.
The read-only endpoints — /healthz, /readyz, /metrics, /sources —
are deliberately unauthenticated: they expose operational health, not data,
and platform probes (Kubernetes, load balancers) need them open. Restrict
them at the network layer if required.
Upgrading from a version without api.token: the trigger endpoint was
previously unauthenticated and is now disabled until a token is configured.
Add API_TOKEN=<16+ chars> to your existing .env (compose) or secret
(Kubernetes) and, for automation calling the endpoint directly, send
Authorization: Bearer <token>.
HTTP endpoints
| Method | Path | Auth | Purpose |
|---|---|---|---|
| GET | /healthz |
none | liveness: status + uptime, always 200 |
| GET | /readyz |
none | readiness: sources/notify are live probes (dial the source / notifier); subsystems (scheduler, bot-telegram, bot-slack, webhook, mcp) report whether each enabled component started and its worker is running. The bot transports additionally report disconnected — advisory, non-gating — when their live chat connection is lost (see bot readiness; mirrored by the infrasigns_bot_connected gauge). 503 when degraded |
| GET | /metrics |
none | Prometheus scrape (metrics catalog) |
| GET | /sources |
none | latest persisted per-source health |
| POST | /api/digest/trigger |
Bearer api.token |
manual digest run (?source=, ?wait=true); disabled without a token, and route absent entirely in cloud sign-in mode (suppressed regardless of token) |
| POST | /webhook/alerts |
Bearer webhook.token |
inbound alert receiver (details); route absent without a token |
| POST | /webhook/deploys |
Bearer webhook.token |
deploy events for post-deploy verification (details); route absent unless deploys.enabled |
| GET/POST/DELETE | /mcp |
Bearer api.token |
MCP server (details); route absent without a token, and absent entirely in cloud sign-in mode (suppressed regardless of token) |
| GET | /app* |
none | embedded web UI (project-slugged: /app/default/...); present only when ui.enabled: true |
| POST | /app/sources/verify |
none | wizard probe: two fixed read-only GETs against the entered URL; aggregate reply only |
| POST | /app/sources/preview |
none | wizard preview analysis: bounded 24h range fetch + deterministic engine findings; no LLM |
| POST | /app/sources/config |
none | wizard config generation (pure computation, no network) |
| POST | /app/{project}/reports/{id}/feedback |
none | records digest feedback (vote + note) from the web UI — an unauthenticated write; restrict /app at the network layer like the other UI routes |
| POST | /app/{project}/incidents/{id}/resolve |
none (cloud: any member of the organization) | closes one still-firing episode by hand (details) — the same unauthenticated write in self-hosted; a sender that is still repeating the alert re-opens the episode on its next send |
| GET | /assets/* |
none | embedded UI static assets; present only when ui.enabled: true |
The unauthenticated endpoints expose operational health, not data — restrict them at the network layer if required (see API authentication).
Metrics collected
InfraSigns queries these node_exporter metrics — the same golden-signal set
the add-source wizard suggests for node jobs, so preview findings match what
the runtime produces. Each carries warn/crit thresholds for the
deterministic analysis engine:
# cpu_usage_percent — warn 80, crit 95
(1 - avg(rate(node_cpu_seconds_total{mode="idle"}[5m]))) * 100
# memory_usage_percent (per instance) — warn 85, crit 95
(1 - (node_memory_MemAvailable_bytes / node_memory_MemTotal_bytes)) * 100
# disk_usage_percent (worst non-tmpfs/overlay mount per instance) — warn 80, crit 90
max by (instance) ((1 - node_filesystem_avail_bytes{fstype!~"tmpfs|overlay"} / node_filesystem_size_bytes{fstype!~"tmpfs|overlay"}) * 100)
If node_exporter is not present, metric queries return empty results (logged as warnings) and the digest is generated from active alerts only.