Alerts and notification channels¶
What it does¶
Alert rules notify you the moment a matching article is ingested: they look at the title and feed summary right after the feed is stored, before text extraction, clustering, briefs or the publish gate (so an unpublished story can alert; the story link is added only once the story is published).
How it works¶
- Manage them at Alerts (
/p/<slug>/alerts, admin like Settings): rules, channels with Send test, a 7-day Preview that sends nothing, and the notification log (filter by rule/status, Retry failed). - Feed cards show a bell badge with the rules that alerted; Settings > Pipeline shows rules, alerts fired in 24 h, queued, held and failed counts and each channel's last error.
- Rules/channels in YAML are seeded into the DB once per profile; afterwards the DB (UI/API) is the source of truth.
profiles:
market-monitor:
alert_rules: # seeded into the DB once; edit them in the UI afterwards
- name: Big tech tickers
include: [$AAPL, $NVDA] # $AAPL = case-sensitive "AAPL" (also matches "$AAPL")
exclude: [proposed rule] # excludes win over includes
match: any # or all
match_in: [title, summary] # add `text` to match the extracted text too (checked
# again after extraction)
sources: [CNBC Markets] # optional; default all the profile's sources
priority: high # low | default | high
channels: [phone, email]
quiet_hours: {start: "22:00", end: "07:00"} # in alerts.timezone; held -> digest
max_per_hour: 10 # overflow is held and sent as one digest
notify_on_update: false # true: one update (at most hourly) when a new source joins
alerts:
timezone: Europe/Berlin
public_url: http://nas.local:8000 # story links in notifications
alert_max_age_hours: 6 # older articles never alert (no backfill floods)
notification_retention_days: 30
max_attempts: 5 # exponential backoff between attempts
channels:
- {id: phone, type: ntfy, server: https://ntfy.sh, topic: my-markets, token: ${NTFY_TOKEN}}
- {id: hook, type: webhook, url: https://example.com/hook, secret: ${ALERT_WEBHOOK_SECRET}}
- id: email
type: email
host: smtp.example.com
port: 587
tls: starttls # starttls | ssl (port 465) | none
username: ${SMTP_USER}
password: ${SMTP_PASSWORD}
sender: alerts@example.com
to: [me@example.com]
All keys: alerts block, alert rules, channels.
Secrets¶
Channel secrets (token, secret, username, password) must be
${ENV_VAR} references: only the variable name is stored and the value is read
when sending; it never appears in the DB, payloads, logs or errors. Each
channel has timeout_seconds (default 10).
Channels¶
- Webhook: JSON POST with
X-Discover-Event,X-Discover-Delivery(notification id) andX-Discover-Signature: sha256=<hex>= HMAC-SHA256 of the raw body with the secret. - ntfy: JSON publish to the server root (topic, title, message, priority 2/3/5, tags, click = story page if published, else the article), optional bearer token.
- Email: plain text + simple HTML,
Message-IDfrom the notification id.
Exactly once¶
A rule fires at most once per story lineage. Merged stories keep their history: an article joining a story (or a story redirected into another) that already alerted for the rule does not alert again, and an article that the clusterer would put into an alerted story is suppressed even before clustering runs. A unique constraint on (rule, lineage, kind, slot) makes concurrent evaluations safe. Sent notifications are never re-sent after a restart; queued ones resume (receivers can dedupe on the delivery id / Message-ID).
Demo sink¶
make demo / demo.cmd start a local fake webhook + ntfy + SMTP server, point
the market-monitor channels at it and ingest a few "live" market items, so
alerts fire (sent, held in quiet hours, and failed via a demo channel that
always fails). See what was delivered at http://localhost:8000/demo/sink.
Standalone: python -m app.alerts.sink --port 8099 --smtp-port 8025
(PowerShell: same command with .venv\Scripts\python).
Troubleshooting¶
| Symptom | Likely cause | Fix |
|---|---|---|
| Nothing alerts on old articles | alert_max_age_hours (6) |
By design; only fresh articles fire |
| Alert arrives late | quiet_hours held it or max_per_hour digest |
Check the log: status held → digest |
| Channel failed | Wrong ${ENV} name or unreachable target |
Alerts page lists missing names; Send test |
| Duplicate after restart | Should not happen — dedupe on delivery id | Report with alert_firings rows |