Adding Alert Rules
This guide explains how to create custom alert rules for the alerter. Custom rules extend the built-in rule set with monitoring requirements specific to your organization.
Prerequisites
Before creating a custom rule, ensure the following conditions are met:
- The metric you want to monitor is collected by the collector.
- You understand the metric's normal value range.
- You have determined an appropriate threshold and severity.
Creating a Rule
Alert rules are stored in the alert_rules table in the
datastore. You can create rules using SQL or through the API.
Using SQL
In the following example, a custom rule monitors temporary file usage:
INSERT INTO alert_rules (
name,
description,
category,
metric_name,
default_operator,
default_threshold,
default_severity,
default_enabled,
is_built_in
) VALUES (
'High Temporary File Usage',
'Alerts when temporary files exceed 100 per hour',
'performance',
'pg_stat_database.temp_files_delta',
'>',
100.0,
'warning',
true,
false
);
Rule Fields
Each rule requires the following fields:
| Field | Type | Description |
|---|---|---|
name |
text | A unique, descriptive name |
description |
text | An explanation of what the rule detects |
category |
text | The category for grouping rules |
metric_name |
text | The metric identifier to evaluate |
default_operator |
text | The comparison operator |
default_threshold |
numeric | The threshold value |
default_severity |
text | The alert severity level |
default_enabled |
boolean | Whether the rule is enabled by default |
required_extension |
text | An optional PostgreSQL extension the rule needs |
is_built_in |
boolean | Set to false for custom rules |
Required Extensions
When a rule sets required_extension, the alerter checks the
newest metrics.pg_extension snapshot for each connection before
evaluating the rule, and skips any connection whose snapshot does
not list the extension in any database. The pg_extension probe
only writes a new snapshot when the installed set changes, so the
newest snapshot may be older than the metric values being
evaluated; no time window is applied to it. A connection with no
snapshot at all is treated as lacking the extension.
The same check applies when the alerter decides whether an existing alert has resolved: an alert on a connection that lacks the extension is left active rather than resolved, so uninstalling an extension never reports a condition as cleared. Such an alert stays active until an operator acknowledges or clears it.
Metric Names
The metric name must match a metric that the alerter can query. The alerter supports the following metric name patterns.
Connection Metrics
pg_settings.max_connections- The max_connections setting value.connection_utilization_percent- Connection usage percentage.pg_stat_activity.count- Client backend session count.pg_stat_activity.blocked_count- Number of blocked sessions.pg_stat_activity.idle_in_transaction_seconds- Idle transaction time.pg_stat_activity.max_query_duration_seconds- Longest running query.pg_stat_activity.max_xact_duration_seconds- Longest transaction.pg_stat_activity.max_lock_wait_seconds- Longest lock wait time.
Replication Metrics
pg_stat_replication.replay_lag_seconds- Replication lag in seconds.pg_stat_replication.lag_bytes- Replication lag in bytes.pg_stat_replication.standby_disconnected- Standby with no WAL receiver process.pg_replication_slots.inactive- Inactive replication slots.pg_replication_slots.inactive_count- Number of inactive replication slots.pg_replication_slots.retained_bytes- Largest retained WAL size, taken from the newest sample of each slot.pg_replication_slots.max_retained_bytes- Largest retained WAL size, taken from the connection's newest sample.pg_node_role.subscription_worker_down- Subscription with an apply worker that is not running.
Spock Metrics
The Spock metrics count rows that the collector captures from a rolling 15-minute window on the monitored server:
spock_exception_log.recent_count- Spock exceptions logged in the window.spock_resolutions.recent_count- Spock conflict resolutions recorded in the window.
Database Metrics
pg_stat_database.cache_hit_ratio- Buffer cache hit ratio.pg_stat_database.deadlocks_delta- Deadlocks in the last hour.pg_stat_database.temp_files_delta- Temporary files created in the last hour.
Table Metrics
pg_stat_all_tables.dead_tuple_percent- Dead tuple percentage.table_last_autovacuum_hours- Hours since last autovacuum.age_percent- Transaction ID age percentage.
System Metrics
pg_sys_cpu_usage_info.processor_time_percent- CPU usage percentage.pg_sys_memory_info.used_percent- Memory usage percentage.pg_sys_disk_info.used_percent- Disk usage percentage.pg_sys_load_avg_info.load_avg_fifteen_minutes- 15-minute load average.
Other Metrics
pg_stat_archiver.failed_count_delta- Failed archive attempts.pg_stat_checkpointer.checkpoints_req_delta- Requested checkpoints.pg_stat_statements.slow_query_count- Slow queries per interval.
Adding Support for New Metrics
If you need a metric that is not currently supported, you must add support in the alerter code before creating a rule.
Step 1: Add the Metric Query
Edit internal/database/metric_registry.go and add an entry to
the metricRegistry map. The latestSQL field holds the query
that GetLatestMetricValues runs to fetch the current value for
each connection, and the scan field names the column layout the
query returns: scanBasic for (connection_id, value,
collected_at), scanWithDB when a database name column follows
connection_id, and scanWithDBObject when an object name column
follows the database name.
In the following example, a new registry entry retrieves values from a custom table:
"your_new_metric_name": {
latestSQL: `
SELECT connection_id, your_value::float,
collected_at
FROM metrics.your_table
WHERE collected_at > NOW()
- INTERVAL '5 minutes'
`,
scan: scanBasic,
},
Step 2: Add the Historical Query
Set the historicalSQL field on the same registry entry if the
metric should support anomaly detection. The baseline calculator
builds the all, hourly and daily baselines from this query,
and the historicalScan field names its column layout:
historicalScanBasic when the database name column is always
NULL, historicalScanWithDB when the column carries a database
name, and historicalScanWithDBAndSamples when the query returns
pre-aggregated rows with a trailing sample_count column.
A metric without a historical query is skipped by both baseline calculation and anomaly detection; the alerter does not build a baseline from the current value in its place. Threshold rules for the metric still work. See the Anomaly Detection page for the list of built-in metrics that are excluded on this basis.
The historical query must join connections as shown below.
Metric rows can outlive the connection that produced them, and
metric_baselines.connection_id references connections(id), so
a query that reads metrics.* alone would have the calculator
write a baseline for a deleted connection and fail on the foreign
key.
In the following example, a historical query retrieves data for baseline calculations:
"your_new_metric_name": {
latestSQL: `...`,
historicalSQL: `
SELECT m.connection_id, NULL::text AS database_name,
m.your_value::float, m.collected_at
FROM metrics.your_table m
JOIN connections c ON c.id = m.connection_id
WHERE m.collected_at > NOW()
- INTERVAL '1 day' * $1
ORDER BY m.connection_id, m.collected_at
`,
scan: scanBasic,
historicalScan: historicalScanBasic,
},
Step 3: Test the Metric
Verify the metric query returns expected values.
In the following example, the queries check current metric values and verify the alerter can access the data:
-- Check current values
SELECT * FROM metrics.your_table
WHERE collected_at > NOW() - INTERVAL '5 minutes';
-- Verify the alerter can query the metric
SELECT connection_id, your_value
FROM metrics.your_table
WHERE collected_at > NOW() - INTERVAL '5 minutes'
GROUP BY connection_id;
Registry Requirements
Every metric in the alerter's registry
(internal/database/metric_registry.go) must satisfy three further
requirements that govern how the alert cleaner reads its results.
Freshness Cutoffs
Each latestSQL query must bound collected_at with a
NOW() - INTERVAL cutoff of at least three probe intervals. Three
intervals leave room for two consecutive samples inside the window,
so that a delta metric still has a predecessor, whilst one late
collection cannot empty the window. A metric whose probe runs every
300 seconds therefore uses a 15 minute cutoff.
For a metric that clears on absent data the three intervals are a
hard requirement rather than a guideline, and
TestMetricRegistryAbsenceWindowCoversProbeInterval enforces it
against the intervals the collector seeds into probe_configs. A
window of one interval holds a single sample, so one collection
arriving later than the probe's own runtime empties the query, and
the cleaner reads that as a recovery.
Without a cutoff, the query returns the newest row the table still holds, however old that row is. An alert then goes on firing on data that is days old after the collector stops, or after the connection stops being monitored, until retention purges the partition.
In the following example, the cutoff restricts a latest query to the last three samples of a 300 second probe:
SELECT connection_id, your_value::float, collected_at
FROM metrics.your_table
WHERE collected_at > NOW() - INTERVAL '15 minutes';
A metric may omit the cutoff only where freshness is not meaningful.
The pg_settings.max_connections metric is the one current
exception, because the settings probe is change-tracked and stores
nothing whilst the configuration is unchanged, so any maximum age
predicate would silence the metric on a stable server. The
TestMetricRegistryLatestSQLFreshnessCutoff test in
internal/database/audit_defects_test.go enforces the requirement
and holds the allowlist of exceptions, so a metric without a cutoff
must be added to that allowlist with a reason.
Clearing on Missing Data
The clearWhenAbsent field on metricQueryConfig tells the alert
cleaner what a missing row means. The cleaner consults the field
through Datastore.MetricClearsWhenAbsent whenever the latest query
returns no row for an active alert's connection and database, or no
rows at all.
Choose the value from the shape of the query:
- Leave the field at its default of
falsewhen the query emits a row for every healthy connection, as a CPU percentage or a cache hit ratio does. A connection that disappears from the result set has stopped reporting rather than recovered, so the alert stays active until fresh data shows the condition has ended, and themetric_stalenessrule reports the stalled probe. - Set the field to
truewhen the query emits a row only whilst the condition holds, as a blocked backend count does, or when the query describes an object that an operator can legitimately drop, such as a replication slot or a standby. A missing row is then the recovery signal, so the cleaner clears the alert.
Every entry that sets clearWhenAbsent to true carries a comment
on the registry entry explaining why absence means recovery for that
metric; add one alongside the field. A metric name that the registry
does not know returns false, so a metric evaluated outside the
registry never clears on absent data.
Declaring the Window
Every entry that sets clearWhenAbsent to true also sets
absenceWindow to the interval its latest query looks back over,
written as a time.Duration: a query carrying a 15 minute cutoff
declares 15 * time.Minute. Where a query has more than one cutoff,
the window is the shortest of them, since that is the bound which
decides whether the result is empty.
The cleaner reads the field through
Datastore.MetricAbsenceWindow, and
TestMetricRegistryAbsenceWindowMatchesSQL checks the declared value
against the SQL literal, so the two cannot drift apart. Entries that
do not clear on absent data leave the field unset.
Naming the Collector Probe
Every registry entry also sets probeName to the collector probe
that fills the metrics table its latest query reads, such as
pg_stat_activity, pg_replication_slots or pg_stat_database. The
TestMetricRegistryProbeName test requires the name to match a table
the query selects from, so an entry whose query reads a table other
than its metric prefix suggests names that table's probe: the
pg_stat_archiver.failed_count_delta metric reads
metrics.pg_stat_wal, for example.
The cleaner uses the probe name to tell a condition that ended from
data that stopped arriving. Because every query bounds collected_at,
a stopped collector empties the result set exactly as a recovered
condition does, so an alert on a clearWhenAbsent metric clears only
when that probe last collected for the alert's connection inside the
metric's absenceWindow. A probe that has stalled, that an operator
has disabled, or that belongs to a connection which is no longer
monitored leaves the alert active until somebody clears or
acknowledges it, which is the safe direction: the alternative is
reporting a resolution that nobody observed.
The window is the yardstick here, and not the probe's configured
collection interval, because the two move independently: an operator
may raise probe_configs.collection_interval_seconds, for every
connection or for one, whilst the window stays where the query's SQL
puts it. A check counted in intervals would then widen along with the
interval and stay quiet exactly where ordinary collection starts
emptying the query. Comparing the probe's last collection against the
window holds at any interval, and needs to know nothing about how the
probe is configured. The metric_staleness rule still works in
ratios, because what counts as a late probe does depend on how often
it is meant to run.
Rules Evaluated Outside the Registry
Two built-in rules are evaluated by bespoke code in the engine
rather than through metricRegistry, because neither metric comes
from a metrics table. Both rules are probe-scoped: each alert
names the probe it was raised for.
The following table shows the two rules and the engine functions that evaluate them:
| Rule | Metric | Evaluator |
|---|---|---|
metric_staleness |
probe_staleness_ratio |
evaluateMetricStaleness |
probe_unavailable |
probe_available |
evaluateProbeUnavailable |
evaluateMetricStaleness raises an alert when a probe that is
still available has not collected for more than the configured
number of collection intervals, and evaluateProbeUnavailable
raises one when a probe that had collected at least once is no
longer available.
Both evaluators read GetProbeStalenessByConnection, and the view
behind that call filters on a non-NULL last_collected, so an
entry whose availability flag is false belongs to a probe that
collected before and has stopped. A probe whose extension was never
installed does not appear in the view at all, which is why
evaluateProbeUnavailable needs no stored record of the previous
availability; do not add one.
The two rules also need bespoke resolution checks, and the alert
cleaner tells them apart by metric name. Every alert either
evaluator raises carries a probe name, so checkAlertResolved
routes an alert whose metric name is probe_available to
checkProbeUnavailableAlertResolved and every other probe-scoped
alert to checkStalenessAlertResolved. The two checks resolve on
different conditions: a probe_unavailable alert clears only when
the probe collects again or when an operator retires the probe,
whereas a staleness alert is held open whilst its probe is
unavailable. Routing one through the other's check would judge the
alert on a condition it was never raised on.
Because neither metric has a registry entry, neither clears on
absent data, and neither supports anomaly detection. A rule added
in this style therefore needs its own evaluator, its own resolution
check, and a branch in checkAlertResolved that reaches the new
check by metric name.
Choosing Thresholds
Select thresholds based on your operational requirements.
Critical Thresholds
Set critical thresholds for conditions that require immediate action:
- System running out of resources (disk, connections).
- Replication failures or significant lag.
- Security-related events.
Warning Thresholds
Set warning thresholds for conditions that should be investigated:
- Resources approaching limits.
- Performance degradation.
- Unusual patterns that may indicate problems.
Information Thresholds
Set informational thresholds for awareness:
- Routine events that should be logged.
- Conditions that may need future attention.
Per-Connection Overrides
After creating a rule, you can customize thresholds for specific connections.
In the following example, an override sets a higher threshold for a specific connection:
INSERT INTO alert_thresholds (
rule_id,
connection_id,
operator,
threshold,
severity,
enabled
) VALUES (
(SELECT id FROM alert_rules
WHERE name = 'Your Rule Name'),
5, -- connection_id
'>',
150.0, -- higher threshold for this connection
'warning',
true
);
Disabling Rules
Disable a rule globally by updating the rule.
In the following example, the UPDATE statement disables a rule
for all connections:
UPDATE alert_rules
SET default_enabled = false
WHERE name = 'Rule Name';
In the following example, an override disables a rule for a specific connection:
INSERT INTO alert_thresholds (rule_id, connection_id, enabled)
VALUES (
(SELECT id FROM alert_rules
WHERE name = 'Rule Name'),
5,
false
);
Testing Rules
After creating a rule, verify the rule evaluates correctly:
- Check the alerter debug logs for rule evaluation.
- Temporarily lower the threshold to trigger an alert.
- Verify the alert appears in the
alertstable. - Restore the threshold to the intended value.
- Verify the alert clears when the condition resolves.
Rule Best Practices
Follow these guidelines when creating rules:
- Use descriptive names that explain what the rule detects.
- Write clear descriptions that help operators understand alerts.
- Choose appropriate categories for organization.
- Set thresholds based on historical data and operational experience.
- Start with warning severity and escalate to critical after validation.
- Document any dependencies or special considerations.
- Test rules thoroughly before enabling rules in production.
Removing Rules
Delete a custom rule by removing the rule from the database.
In the following example, the DELETE statements remove a custom
rule and any associated overrides:
-- First remove any overrides
DELETE FROM alert_thresholds
WHERE rule_id = (
SELECT id FROM alert_rules
WHERE name = 'Rule Name'
);
-- Then remove the rule
DELETE FROM alert_rules WHERE name = 'Rule Name';
Built-in rules should not be deleted. Disable built-in rules instead if you do not need the rules.