stapel-alerts
An alert store for a fleet that has no Sentry, and the same capture() call whether one is connected or not. One row per BUG (fingerprint, status, count, first/last seen, the release that claimed the fix, whether it came back) rather than a search over log text — because the question a fix wave is reconciled against is 'which bugs are open, which are fixed, and which regressed', and that needs a status column. Every input is wired by installing the app: a WARNING+ log handler, the DRF 5xx hook, Celery task failures, core's DLQ parks, and the comm handler exceptions that are RETURNED rather than raised and which no except clause anywhere would otherwise see. Monoliths write in process; microservices POST with a per-service key, buffered and retried, and fall through to Telegram when the owner is unreachable — never over the bus, because the bus is the thing whose failures this records. A Prometheus blind-spot watchdog files the monitoring stack's own gaps as issues, on the principle that 'no alerts firing' is the observable state of both a healthy fleet and a blind one.
Part of the Stapel framework — composable Django apps that deploy as a monolith or as microservices without changing module code.
Install
Not published on PyPI yet. Install from source:
pip install git+https://github.com/usestapel/stapel-alerts
At a glance
| Fact | Value |
|---|---|
| Version | 0.3.2 |
| Python | >=3.11 (3.11, 3.12, 3.13, 3.14) |
| HTTP operations | 5 |
| Config axes | 6 |
| Usage surface | 18 |
| Extension points | 7 |
| Error codes | 49 |
| Fleet dependencies | celery (optional) · sentry-sdk (optional) · stapel-core · stapel-notifications (optional) |
Documentation
Errors: English · Español · Русский · OpenAPI · capabilities.json · llms.txt (for agents)
Why it exists
A day spent reading a production incident out of docker logs is the reason
this exists. The signals were all there — a foreign key violation repeating on
three accounts, twenty-two events parked in a dead-letter queue, a JWT refusal
that turned out to be correct behaviour with a misleading log line — and every
one of them had to be found by a human grepping containers, because nothing
collected them.
This library collects them. Same interface with Sentry or without it:
from stapel_alerts import capture
capture(exc)
capture("STT provider exhausted", level="error", context={"provider": "x"})
Two-line mount
A monolith — this process owns the store:
INSTALLED_APPS = [..., "stapel_alerts"]
urlpatterns = [path("alerts/", include("stapel_alerts.urls"))]
That is all. STAPEL_ALERTS["SERVICE"] names the service in the tracker, and a
system check tells you at boot if you forgot it.
A microservice — this process reports to the owner:
INSTALLED_APPS = [..., "stapel_alerts"] # no urlconf mount
STAPEL_ALERTS = {
"SERVICE": "svc-billing",
"OWNER_URL": "https://api.example.com",
"SERVICE_KEY": "<from `manage.py alerts_service svc-billing` on the owner>",
"FALLBACK": {"TELEGRAM_BOT_TOKEN": "...", "TELEGRAM_CHAT_ID": "..."},
}
Nothing else changes. Every input below is wired by AppConfig.ready().
What it captures, without you instrumenting anything
| input | what it catches |
|---|---|
AlertsLogHandler |
every log record at WARNING+ , rate-limited per fingerprint |
alerts_exception_handler |
anything the fleet exception handler answers with a 5xx |
Celery task_failure |
a task that failed after its retries |
bus_event_parked |
a DLQ park or a task-ledger unprocessable — work dropped |
deliver_to_subscribers |
a comm Action handler that raised |
capture(...) |
whatever a library decides is worth saying |
| the watchdog | a blind spot in the monitoring itself — see below |
Two models, because they answer two questions
Issue is the tracker: one row per bug, with status, count,
first_seen/last_seen, and the version that fixed it. It is
machine-addressable on purpose — an agent lists it, fixes the code, and closes
the issue over the API.
ErrorEvent is one occurrence: the full trace, the context (redacted
through core's redaction seam), the request path, the trace id.
Grouping is by similarity, not by exact match
Two tracebacks that differ only in a uuid, an id, a timestamp, a memory address or a checkout path are one issue. The normaliser strips those, the fingerprint is taken over the normalised top frame plus the escaping exception's message, and a near-duplicate scoring ≥ 0.9 over normalised frames joins the existing issue.
A short trace (fewer than four frames) groups only on an exact match after normalisation. Two four-line traces hit 0.9 by coincidence, and a wrong merge is worse than a duplicate issue: it hides a live bug behind one somebody has already marked fixed.
The rule is tested on real production traces, including the case that must not group — a different constraint on the same table, from the same frames.
Statuses, and where they come from
new ──▶ fixed ──▶ regressed
│ ▲ │
└── muted┘◀──────────┘
fixed is set by an API call or by CI on a commit referencing
alerts:<issue-id>, with the version and sha that claim the fix.
regressed is never set by a caller — the store sets it when a fixed issue
receives a new event. A caller who could assert it could also decline to.
API
GET /alerts/api/v1/issues ?status= &level= &service= &since= &open= &offset= &limit=
GET /alerts/api/v1/issues/{id} + the last 20 events
PATCH /alerts/api/v1/issues/{id} {status, note, muted_until}
POST /alerts/api/v1/issues/{id}/fix {version, sha}
POST /alerts/api/v1/report {events: [...]} X-Service-Key
The list is a page, {count, offset, limit, results} (IssuePage in the
schema). limit is 1..200, default 50; an out-of-range value is clamped and
the envelope echoes the limit that was applied. A PATCH carrying only
muted_until is a mute (null = no deadline); leaving muted by any route
clears the deadline.
Staff session for the tracker, service key for /report, and not the other
way round: a reporter's key lives in every container in the fleet, so the
blast radius of one leaking must not include everything the store has ever
recorded. JSON only, stable uuid ids, ETag on the reads.
When the owner is unreachable
Buffer, retry with backoff, and after FALLBACK_AFTER_MINUTES send a digest
to the NOTIFY seam — Telegram by default, through stapel-notifications'
channel if that is configured, otherwise a direct Bot API call. A dead alert
store must be loud, not silent. The buffer is bounded and drops oldest first:
a reporter that ran out of memory holding alerts about an outage would be a
second outage.
The monitoring watchdog
A Prometheus blind spot is itself an issue in this tracker, because "nothing is firing" is the observable state of a healthy fleet and of a monitoring stack that has stopped looking. Something outside the stack has to ask.
STAPEL_ALERTS = {
"MONITORING": {
"PROMETHEUS_URL": "http://prometheus:9090",
"TARGETS": ["svc-billing", "svc-api"], # up == 0, and no `up` at all
"METRICS": ["alerts_open_total"], # absent()
"HEARTBEAT_ALERT": "Watchdog", # a dead-man's switch
"ALERTMANAGER_URL": "http://alertmanager:9093",
},
}
# and the schedule, shipped by the library:
from stapel_alerts.beat import get_alerts_beat_schedule
CELERY_BEAT_SCHEDULE = {**get_alerts_beat_schedule(), ...} # every 5 minutes
No Celery? manage.py alerts_watch_monitoring is the same pass, for cron.
--dry-run prints what it would say without filing or notifying anything.
| check | what it means |
|---|---|
exporter_down |
a scrape target reporting up == 0 |
scrape_missing |
a target with no up series — dropped from the config or renamed, so every alert over its metrics silently stopped firing |
metric_absent |
absent(metric) returned a series: the metric stopped existing |
heartbeat_missing |
the always-firing alert stopped firing — the whole chain is broken |
alertmanager_silenced |
an active silence, keyed by its matchers so re-silencing the same alert raises one count instead of opening a new issue |
alertmanager_unreachable / prometheus_unreachable |
the stack itself did not answer |
Each finding becomes a kind="monitoring" issue and a message on the
NOTIFY seam, on every run that has one. That is deliberate: the notification
thresholds (new / regressed / spike) would be silent on a blind spot's ninth
consecutive run, which is not less urgent than its first. A check that stops
failing closes its issue with fixed_in = recovered <ts>; one that comes
back regresses it. A check that is no longer configured is not closed —
nothing recovered, nobody looked.
Two facts on the comm bus
from stapel_core.comm import on_action
@on_action("alerts.issue.opened")
def page_somebody(event): ...
@on_action("alerts.issue.regressed")
def reopen_the_ticket(event): ...
Schemas are in schemas/emits/, registered by core's autoloader and validated
on every emit. Only these two — an event per occurrence would put a failing
loop's whole traffic on the bus and make every subscriber re-derive the
grouping this module already did.
alerts.issue.regressed carries the release that claimed the fix next to the
release that is running, which is the pair that separates "the fix is wrong"
from "the fix is not deployed".
The emit happens after the issue has committed, in a transaction of its
own — not in the ingest's atomic block, where the rest of the fleet emits.
emit marks its transaction rollback-only when it fails, so emitting inside
the ingest would mean a broken outbox deletes the alert. The bus is the
thing whose failures this store records; it has to record them on the day the
bus is what is broken.
Sentry
Set SENTRY_DSN (or STAPEL_ALERTS["SENTRY_DSN"]) and install the extra
(pip install 'stapel-alerts[sentry]'): each event is forwarded and its
Sentry id stored back on the row. Storing locally is never conditional on it.
Retention and GDPR
manage.py alerts_sweep drops events past their level's retention and closed
issues with nothing left. An open issue is never swept, however old:
deleting it would turn "unresolved" into "never happened". erase_subject
(wired into the GDPR provider registry) drops the user_id link and scrubs
context that repeats it, and keeps the failure — an alert store's rows are the
record of a system failing, not a record about a person.
Metrics
alerts_new_total{level,service} (counter) and alerts_open_total{level,service}
(gauge, declared at zero for every known pair, because a series that has never
existed cannot be alerted on).
License
MIT — see LICENSE.
This page is assembled by stapel-readme from docs/readme.md plus the contract artifacts in docs/. Edit the prose in docs/readme.md; the badges, facts and links above and below it are generated — do not hand-edit README.md.
Metadata
Release files for stapel-alerts 0.3.2
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| stapel_alerts-0.3.2.tar.gz | 143.5 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| stapel_alerts-0.3.2-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 270.3 kB
Release files / stapel_alerts-0.3.2.tar.gz
| Download URL | stapel_alerts-0.3.2.tar.gz |
|---|---|
| Size | 143.5 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
d00cc8b61f54e06221fb3ea6d617a7203d70878cb8105de79ce8e10949e3eecb
|
|
BLAKE2b-256 checksum How to use checksums |
04aa8f752bd71874a6357136ad2fd9aa96181e989c88716b16bd867c7e8f94c5
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 13, 2026.
Transparency logRelease files / stapel_alerts-0.3.2-py3-none-any.whl
| Download URL | stapel_alerts-0.3.2-py3-none-any.whl |
|---|---|
| Size | 126.8 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
97d536bc02c156da238751b7a36dffe33c5e1657fa783ca33a9f21f9e9022d59
|
|
BLAKE2b-256 checksum How to use checksums |
518e9d5c8d23608db14ed5688a4a971578894897febae5da1f4d4e0fa965ab00
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 13, 2026.
Transparency log