Metadata-Version: 2.4
Name: dgranel-alerts
Version: 0.5.0
Summary: Fire standardised alerts to an SNS topic without letting observability take down the service it observes
Author: vanderson.torres
License-Expression: MIT
Keywords: alerts,sns,observability,ecs
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Developers
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: System :: Monitoring
Classifier: Operating System :: OS Independent
Classifier: Typing :: Typed
Requires-Python: >=3.10
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: boto3>=1.26
Provides-Extra: dev
Requires-Dist: pytest>=7.4; extra == "dev"
Requires-Dist: mypy>=1.10; extra == "dev"
Dynamic: license-file

# dgranel-alerts

Fire standardised alerts to an SNS topic from any Python service, with one rule
above all others: **the call never raises.** Observability must not take down
the service it observes.

```python
from dgranel_alerts import alerts

alerts.notify(
    title='Partner X integration failed',
    detail='Timed out after 3 attempts',
    severity='critical',
    dedup_key='partner-x-timeout',
    context={'order_id': 12345},
)
```

The package builds a small JSON contract, publishes it to a topic you configure,
and gets out of the way. Everything downstream — deduplication, rate limiting,
routing, formatting for a chat tool — is the consumer's job, not this library's.
That keeps the client thin, and a thin client is one that rarely needs updating.

## Install

```bash
pip install dgranel-alerts
# or
poetry add dgranel-alerts
poetry install
```

## Configure

One environment variable is required:

```
PLATFORM_ALERTS_TOPIC_ARN=arn:aws:sns:<region>:<account>:<topic>
```

The region is taken from the ARN, never from the caller's default region, so a
service in one region can publish to a topic in another without extra setup.
When the variable is absent the package logs a warning and publishes nothing.

Credentials come from the standard boto3 chain — an ECS task role, an instance
profile, or a local profile. Nothing else to set.

## Who is alerting, and from where

The service name and the environment are detected automatically. Inside ECS the
package reads the task metadata endpoint and uses the task family: a family
named `order-api-dev` becomes service `order-api` in environment `dev`. A family
with no recognised suffix (`dev`, `qa`, `stg`, `staging`, `homolog`, `prod`) is
treated as production.

Outside ECS, or to override the detection:

```
SERVICE_NAME=order-api
ALERTS_ENV=prod
```

## Only production publishes

Alerts from `dev`, `qa` or any non-production environment are **built, logged
at INFO and not published**. A test environment reaching the real alert channel
is noise that erodes trust in it, and relying on every deployment to remember a
flag is not a guarantee — so the default is silence.

The `[staging]` prefix is added to the title outside production, so that when
you *do* want a non-production alert delivered it is never mistaken for a real
incident.

`ALERTS_DRY_RUN` is a three-way switch that overrides the decision:

| Value | Effect |
|---|---|
| unset | the package decides from the detected environment |
| `true` | never publish, even in production |
| `false` | always publish, even outside production |

An unrecognised value (`y`, `off-ish` typos) is **not** treated as `false`: it logs a
warning and falls back to the detected environment.

Outside ECS there is nothing to detect, so the environment defaults to `prod` and the
package **does** publish. If you run on a laptop, a VM cron or CI, set `ALERTS_ENV`
explicitly.

## API

### `notify(...)`

| Parameter | Required | Notes |
|---|---|---|
| `title` | yes | The headline. Clipped at 200 characters. |
| `detail` | no | The body. Clipped at 4,000 characters. |
| `severity` | no | `critical`, `warning` or `info`. Anything else becomes `warning`. |
| `dedup_key` | no | Key of the **condition**, not the occurrence. Enables suppression downstream. |
| `context` | no | Free-form dict of internal identifiers. Rendered as key/value pairs. |
| `chat_id` | no | Opaque routing hint, forwarded unchanged in the payload. The library does not interpret it. |
| `block` | no | `False` (default) enqueues and returns. `True` publishes on the caller's thread. |
| `dry_run` | no | Build and log, never publish. |

Returns nothing. Any failure becomes a `warning` in your log.

### `dedup_key` — use it

Without it every call is an alert, and an `except` inside a loop becomes a
hundred messages. Choose a key that names the **condition**:
`'partner-x-timeout'` is good; `f'order-{id}-failed'` is not, because it changes
with every occurrence.

### Blocking or not

By default `notify()` hands the alert to a single background worker and returns
immediately. The queue is bounded, and it is drained when the process exits, so
short-lived jobs and CLI commands work without doing anything special.

Use `block=True` for background jobs whose process may be shut down as a
consequence of the very failure being reported — there the few hundred
milliseconds of a synchronous publish are worth it.

### Dry run

```python
import logging
logging.basicConfig(level=logging.INFO)  # the library installs no handler

alerts.notify(title='rehearsal', dry_run=True)
```

Logs `dgranel-alerts [dry-run] would publish (env=...): {...}` with the full
contract, so you can check service, environment and severity before it counts.

## What not to put in an alert

Personal data, financial amounts, credentials, or stack traces that carry any
of those. The alert leaves your service and may land in an external channel.
Identify records by internal id: `context={'order_id': 12345}`.

## The contract

If a consumer needs to parse what this library publishes, or a non-Python
service wants to publish the same shape directly to the topic:

```json
{
  "version": "1",
  "service": "order-api",
  "severity": "critical",
  "title": "Partner X integration failed",
  "detail": "Timed out after 3 attempts",
  "context": { "order_id": 12345 },
  "dedup_key": "partner-x-timeout"
}
```

`version`, `service`, `severity` and `title` are always present; the rest only
when provided. `chat_id`, when given, is forwarded unchanged.
