Actions
Response plugins — the Action trait, ActionKind and the sentry-action-* crates
Actions
Every response (block, challenge, webhook, log) is a plugin behind the
Action
trait. When the pipeline reaches a
Decision,
the decider dispatches to the registered actions whose applies_to accepts
the verdict. Errors are logged inside the implementation — one failing
webhook does not kill the daemon.
/// A plugin that executes a response when a decision is reached.
#[async_trait]
pub trait Action: Send + Sync {
fn name(&self) -> &'static str;
async fn execute(&self, evt: &Event, decision: &Decision) -> Result<()>;
async fn execute_with_context(&self, evt: &Event, decision: &Decision, ctx: &ActionContext) -> Result<()>;
fn applies_to(&self, decision: &Decision) -> bool;
fn dispatch(&self) -> ActionDispatch;
}Type-safe via ActionKind
The action type in config is the
ActionKind
enum, not a string — typos in type = "..." in TOML fail at load time.
Each [[action]] (the
ActionConfig
struct with type, provider, dispatch and options) becomes a match
arm in
daemon::build_registry.
| TOML value | Implementation | Effect |
|---|---|---|
challenge | any ChallengeProvider (via provider = "...") | Provider-agnostic edge action |
cloudflare | sentry-action-cloudflare | Legacy alias of challenge + provider = "cloudflare" |
webhook | sentry-action-webhook | JSON alert POST |
blocklist | sentry-action-blocklist | In-memory local blocklist |
report | sentry-action-report | Reports to community abuse databases |
log (default) | LogAction (built-in) | Tracing line per decision |
Two lanes: inline vs deferred (F5)
The ingest hot loop never waits on external I/O. The
ActionDispatch
enum classifies every action:
inline— runs in the ingest hot loop. Reserved for fast, local containment (blocklist, log, kernel firewall).deferred(default) — runs on the post-processing workers (API providers, webhooks, abuse reports).
The natural defaults (resolved in
daemon::resolve_dispatch):
blocklist and log run inline; challenge runs inline only with
provider = "firewall"; cloudflare, nginx, opnsense, webhook and report
defer. Any action can override with dispatch = "inline" | "deferred":
[[action]]
type = "webhook"
dispatch = "inline" # rare: moves the POST into the hot path
[action.options]
url_env = "SENTRY_WEBHOOK_URL"Under overload the deferred action queue fills first — the system sheds
actions, never events (sentry_action_queue_drops_total).
ActionContext
Between pipeline and dispatch the daemon populates the
ActionContext
with incident_id (when the event escalated to an incident in Postgres).
Actions that care override execute_with_context; the webhook, for
instance, includes incident_id and ack_url in the payload for
ack/resolve round-trips.
sentry-action-blocklist
On a Block verdict, records the IP in the shared
BlockTable
for the configured TTL
(BlocklistAction).
This is the action that gives the verdict teeth in inline mode: the edge
fast-path denies table IPs before the pipeline. The daemon mirrors the
same verdicts to Postgres (ip_state) and notifies sentry_blocks_changed
via LISTEN/NOTIFY — a block on one node denies at the edge of all.
[[action]]
type = "blocklist"
[action.options]
ttl_secs = 86400 # default 24 h (shared with the ip_state mirror)sentry-action-webhook
POSTs a JSON payload to the configured URL — Discord, Slack, Telegram or a
custom endpoint
(WebhookAction).
Discord URLs are automatically wrapped in Discord's embed format (the
platform rejects arbitrary JSON); every other endpoint receives the raw
payload.
- HMAC signing: with a secret configured, every request carries
X-Sentry-Signature: sha256=<hex hmac(secret, body)>. The secret comes from the env var named bysecret_env(defaultSENTRY_WEBHOOK_SECRET). - Bidirectional alerts (F4.5): with an associated incident, the payload
includes
incident_idandack_url— the receiver can call backPOST /api/incidents/{id}/ackor/resolve. - Level filter:
on_levels(default["high", "critical"]). - The URL is a credential: prefer
url_envover a plaintexturl. Default timeout of 10 s.
[[action]]
type = "webhook"
[action.options]
url_env = "SENTRY_WEBHOOK_URL"
# on_levels = ["high", "critical"]
# secret_env = "SENTRY_WEBHOOK_SECRET"sentry-action-cloudflare
Edge provider: converts Block/Challenge/RateLimit verdicts into
Cloudflare rules (IP Access Rules and, for IPv6, IP Lists). type = "cloudflare" is the legacy alias of the canonical type = "challenge" +
provider = "cloudflare" form — operational details (reconcile, reaper,
circuit breaker, IPv6 /64) live on the
Challenge providers page.
sentry-action-firewall
Kernel bans (F7.3): enforces Block/Challenge/RateLimit verdicts on
the host itself via the
FirewallProvider,
with three backends auto-detected in this order:
- nftables (recommended) — dedicated table with
flags timeoutsets (the ban expires in-kernel, no reaper) and a DROP rule on the input hook. - ipset —
hash:ip timeoutsets + an iptables-m setrule. - firewalld — runtime ipsets via
firewall-cmd(portable where the nftables backend hides legacy ipset).
The database (ip_state) is the source of truth: sync at startup (bans
survive restarts) and a 60 s reconcile converges manual unblocks. The
never-ban guard ([real_ip] trusted_ips) refuses the IP before any
command. Linux-only — on other OSes the action is skipped with a
warning; requires root or CAP_NET_ADMIN.
[[action]]
type = "challenge"
provider = "firewall"
[action.options]
backend = "auto" # auto | nftables | ipset | firewalld
table = "sentry" # nftables table
set_prefix = "sentry_blocks"
ttl_secs = 86400sentry-action-opnsense
Bans on a firewall appliance (F6.1): the
OpnsenseProvider
speaks provider = "opnsense" (REST alias_util —
/api/firewall/alias_util/add|delete/<alias>, alias provisioned at
startup) or provider = "pfsense" (pfctl -t <table> -T add|delete,
executed without a shell). The platform only understands drop:
Challenge/RateLimit verdicts are logged as unenforced instead of
silently degrading to a ban. TTL via an in-memory expiry map + a 30 s
reaper (the platform has no per-entry TTL); never-ban guard before any
API call.
[[action]]
type = "challenge"
provider = "opnsense" # or "pfsense"
[action.options]
base_url = "https://192.0.2.10" # required for opnsense
api_key_env = "SENTRY_OPN_API_KEY"
api_secret_env = "SENTRY_OPN_API_SECRET"
table = "sentry_blocks"
# pfctl_path = "pfctl"
# accept_invalid_certs = truesentry-action-nginx
Generates config includes for a co-located nginx (F7.8) and reloads it —
the passive-deployment counterpart of the edge's native JS challenge
(NginxProvider).
Two enforcement planes driven from one in-memory map:
- Deny list (
sentry-deny.conf):deny <ip>;lines forBlockverdicts (andRateLimitwithrate_limit_deny = true), included in thehttpcontext. - Challenge (
sentry-challenge.conf+sentry-challenge-if.conf): ageoblock mappingChallenge-verdict IPs to$sentry_challenge_ip, consumed by aserver {}include that turns on the getpagespeedjs_challengemodule.provision_bots(default true) also emits the bot-verification snippet (sentry-bots.conf).
Files are rewritten atomically (tmp + rename) and stamped
# sentry:<created_unix>:<ttl_secs> — expiry survives restarts. Reloads
are debounced (≥ 1/s) and gated on nginx -t when validate = true (a
broken reload is skipped, never applied). Denies reconcile against
ip_state every 60 s; challenge entries are ephemeral by design.
ipv6_prefix writes CIDRs (e.g. /64) instead of exact IPs.
[[action]]
type = "challenge"
provider = "nginx"
[action.options]
conf_dir = "/etc/nginx/conf.d/sentry" # inside nginx's include path
reload_cmd = "nginx -s reload"
validate = true
# rate_limit_deny = false
# provision_bots = true
# geo_var = "$sentry_challenge_ip"sentry-action-report
Community reporting (F7.4): the
ReportAction
reports IPs that earned an enforcing verdict to abuse databases —
provider = "abuseipdb" (default; POST /api/v2/report) or
provider = "reportedip". Sentry's SignalKinds map onto each provider's
category IDs (a SQLi report carries the SQLi category, a port scan the
Port Scan category, and so on).
- Dedupe per IP with a default 24 h TTL (daily quotas).
- Backoff of 600 s after HTTP 429; a circuit breaker soft-disables the provider after 5 consecutive failures (until restart).
min_verdict(defaultblock) filters what gets reported. Never-ban IPs never reach an enforcing verdict, so they are never reported.- The key comes from the env var named by
key_env(defaultsSENTRY_ABUSEIPDB_KEY/SENTRY_REPORTEDIP_KEY); without a key the action is skipped with a warning.
[[action]]
type = "report"
provider = "abuseipdb"
[action.options]
key_env = "SENTRY_ABUSEIPDB_KEY"
# min_verdict = "block"
# endpoint = "https://api.abuseipdb.com/api/v2/report"The log action (built-in)
There is no sentry-action-log crate — the
LogAction
is built into the binary: it emits a tracing line per non-Allow
decision, runs inline, and is registered whenever type = "log" appears
in the config (or when no action is configured at all).
Adding a new action (non-edge)
- Add a variant to
ActionKind. - Create the
sentry-action-<name>crate implementingAction. - Add it to
sentry-cli/Cargo.tomland a match arm todaemon::build_registry.
For edge providers the path is shorter — no new variant is needed; see Challenge providers.