Metadata-Version: 2.5
Name: sereno-sdk
Version: 0.2.0
Summary: Official Python SDK for Sereno: sends logs and exceptions to your Sereno projects.
Project-URL: Homepage, https://github.com/ascariodev/sereno-python
Project-URL: Repository, https://github.com/ascariodev/sereno-python
Project-URL: Issues, https://github.com/ascariodev/sereno-python/issues
License-Expression: MIT
License-File: LICENSE
Keywords: django,errors,fastapi,logging,monitoring,sereno
Classifier: Development Status :: 4 - Beta
Classifier: Framework :: Django
Classifier: Framework :: FastAPI
Classifier: Intended Audience :: Developers
Classifier: Operating System :: OS Independent
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 :: Logging
Requires-Python: >=3.10
Provides-Extra: django
Requires-Dist: django>=4.2; extra == 'django'
Provides-Extra: fastapi
Requires-Dist: fastapi>=0.100; extra == 'fastapi'
Provides-Extra: test
Requires-Dist: anyio; extra == 'test'
Requires-Dist: django>=4.2; extra == 'test'
Requires-Dist: fastapi>=0.100; extra == 'test'
Requires-Dist: httpx; extra == 'test'
Requires-Dist: pytest; extra == 'test'
Requires-Dist: uvicorn; extra == 'test'
Description-Content-Type: text/markdown

# Sereno for Python

Sends logs and errors from Python, Django, FastAPI and any ASGI app to a Sereno project channel. It plugs into the
standard `logging` module and posts events to the Sereno ingest endpoint (`POST {SERENO_URL}/api/ingest/events`) with a
source API key. It also captures uncaught exceptions in the process and in threads, and ships a `LOGGING` helper for
Django and a middleware for FastAPI and other ASGI apps. No runtime dependencies (it uses `urllib`).

## Requirements

- Python 3.10 or newer
- Optional, only for the integration you use: `django` >= 4.2, `fastapi` >= 0.100 (the extras only declare the
  dependency; the middleware works with any ASGI app)

## Installation

Create a project in Sereno, open its Integrations page and choose "Create log source". The dialog shows the source
key (`wsk_...`) only once; if you lose it, rotate the key. Then, in your application:

```bash
pip install sereno-sdk
pip install "sereno-sdk[django]"    # or [fastapi]
```

The package is installed as `sereno-sdk` and imported as `sereno`.

Set `SERENO_URL` (the base URL of the Sereno API, without a path) and `SERENO_KEY` in the environment, as described in
Configuration, and check them with `python -m sereno test` (see Testing the connection).

## Plain Python and `logging`

Call `sereno.init()` once, as early as possible in your entry point:

```python
import logging
import sereno

sereno.init()  # reads SERENO_URL and SERENO_KEY from the environment

logger = logging.getLogger(__name__)
logger.warning("Payment is slow", extra={"order_id": 42})

try:
    charge_card()
except Exception as error:
    sereno.capture_exception(error, order_id=42)
```

You can also pass the settings: `sereno.init(key="wsk_your_key", url="https://your-sereno-api.example.com",
level="warning")`. Arguments passed in code win over the environment. `init` returns the client, and calling it again
replaces the previous setup (the buffered events of the old one are sent first).

Arguments (all optional): `key`, `url`, `level` (minimum level sent, default `debug`), `timeout` (seconds per request,
see Configuration) and `handlers` (`False` to not install the logging handler and the exception hooks described below).
Any other keyword argument goes to `sereno.Client` (`retry=False` makes a single attempt per batch; `notifier`,
`sender`, `headers` and `flush_delay` are also accepted).

Without a URL or a key, `init` still returns a client, but it is inert: nothing is buffered, nothing is installed and no
request is made. The functions below do nothing before `init`.

Functions:

- `capture_exception(exc=None, **context)`: buffers an exception at level `error`. Without `exc` it takes the
  exception being handled (inside an `except` block); outside one it does nothing. Keyword arguments go to the
  context. An exception is sent with its class, message, location and traceback; any other value is sent as `str(value)`.
- `flush(timeout=None)`: sends what is buffered and returns `True`, or `False` if `timeout` seconds pass first. It
  never raises. Call it before a serverless handler returns.
- `close(timeout=None)`: removes the handler and hooks, sends what is buffered and leaves the SDK uninitialized.
- `get_client()`: the active `Client`, or `None` before `init`.

### Standard logging

`init` adds a `SerenoHandler` to the root logger, so every record that reaches the root is sent:

- The level of the record is mapped to the closest Sereno level at or below it (`DEBUG` to `debug`, `INFO` to `info`,
  `WARNING` to `warning`, `ERROR` to `error`, `CRITICAL` to `critical`; a custom level such as 25 is `info`). Sereno
  also has `notice`, `alert` and `emergency`, which `logging` cannot produce.
- **The level of the root logger is not changed.** Python's default is `WARNING`, so `logger.info(...)` never reaches
  the handler until you configure it, for example with `logging.basicConfig(level=logging.INFO)`. The `level` of `init`
  (or `SERENO_LEVEL`) filters further; it does not lower the root's.
- The fields you pass in `extra={...}` go to the context, and the logger name goes to the context as `logger`.
- A record with `exc_info` (`logger.exception(...)`, `logger.error(..., exc_info=True)`) is sent with the exception
  and grouped by its class, file and line.
- Records of the `sereno` logger (the SDK's own) are ignored, so a delivery failure can never loop back.
- A logger with `propagate = False` does not reach the root; attach `sereno.SerenoHandler(sereno.get_client())` to it
  if you need that.

To group repeated events pass `extra={"fingerprint": "..."}`.

### What it captures in the process

Unless you pass `handlers=False`, `init` chains these hooks and always calls the previous ones, so the traceback and the
exit code do not change:

- **Uncaught exceptions** (`sys.excepthook`) and **exceptions that end a thread** (`threading.excepthook`): sent at
  level `error`. Only `Exception` subclasses are captured; `KeyboardInterrupt`, `SystemExit` and other `BaseException`
  pass untouched.

Independently of `handlers`, importing `sereno` registers an `atexit` function that flushes the buffer on a normal exit,
waiting up to 10 seconds.

- **`SIGTERM`**: `init` installs a handler that flushes the buffer for up to 5 seconds and then lets the signal act
  as it would without Sereno. If you already had a `SIGTERM` handler it is chained (called after the flush);
  otherwise the default action is restored and the signal is sent again, so the exit status stays the same. It is
  installed only when `init` runs in the main thread, never on Windows, and not if `SIGTERM` is ignored (`SIG_IGN`)
  or its handler was installed from C (Python cannot read or chain it, so it is left alone). A handler you set after
  `init` replaces it: call `sereno.flush()` in it. Pass `sigterm=False` to `init` to skip it.

Things to know:

- `os._exit()` and `SIGKILL` end the process without running `atexit`, so what is still buffered is lost. Call
  `sereno.flush()` first where you can. The same goes for an out-of-memory kill and for `SIGTERM` when the handler is
  not installed.
- After `os.fork()` the child starts with an empty buffer and its own background thread; events buffered by the
  parent are sent by the parent.
- An exception is sent **once per client**: if you capture it with `capture_exception`, log it with `exc_info` and
  let it reach the excepthook, Sereno receives one event. The mark lives on the exception object, so an instance you
  reuse (a constant or a cached exception) is sent only the first time.

## Django

```python
# settings.py
import sereno
from sereno.django import logging_config

sereno.init()  # SERENO_URL and SERENO_KEY

LOGGING = logging_config()
```

To add Sereno to an existing configuration, pass it: `LOGGING = logging_config(LOGGING)`. It returns a copy with a
`sereno` handler (`sereno.django.DjangoHandler`) on the `django.request` and `django.security` loggers, keeping the
handlers you already list for them. Django's defaults (console and `mail_admins`) keep working.

Notes:

- Django logs every unhandled view exception on `django.request` at level `error`. Responses with a status below 500
  (404, 403, 400...) are not sent: `DjangoHandler` drops those records, also for the root handler of `init()`.
- From the request, the context carries only `method` and `route` (the URL pattern, or the concrete path without the
  query string when nothing matched). It also carries `logger` and the other fields of the log record (on
  `django.request`, `status_code`). Headers, cookies, body and query are never sent.
- `init()` and `LOGGING` can be in any order; the handler looks up the client of `init()` on each record.
- A `LOGGING` with a `root` key replaces the root handlers, including the one `init()` added. Add
  `sereno.SerenoHandler` to that root, or call `init()` after Django configures logging.
- A record is sent once per client even if it passes through the Django handler and the root handler. A
  `DjangoHandler(client=other_client)` different from the one in `init()` does send twice.

## FastAPI and ASGI

```python
import sereno
from fastapi import FastAPI
from sereno.asgi import SerenoMiddleware

sereno.init()

app = FastAPI()
app.add_middleware(SerenoMiddleware)


@app.get("/boom")
def boom():
    raise RuntimeError("boom")
```

Run it with `uvicorn main:app`. The middleware is plain ASGI, so it also works with Starlette or any ASGI app
(`app = SerenoMiddleware(app)`). Call `init()` before the first request.

- It sends the exception that ends in a 500 and raises it again unchanged, so the response and the server's own log
  are the same as without Sereno. `HTTPException`, 404s and validation errors never reach it, and exceptions with a
  `status_code` below 500 are not sent.
- The context carries only `method` and `route` (the route pattern such as `/users/{id}`, with the prefix of a
  `Mount`), or the path without the query string when nothing matched; never headers, body or query. It applies to
  `http` and `websocket` requests.
- Uvicorn logs the same traceback on `uvicorn.error`. With uvicorn's default logging configuration that logger does
  not propagate to the root, so it is not sent from there; if you make it propagate, the exception is still sent
  once, because the middleware already marked it.

## Configuration

| Variable | Default | Description |
|---|---|---|
| `SERENO_URL` | none | Base URL of the Sereno API, without a path (for example `https://api.example.com`). |
| `SERENO_KEY` | none | The source API key, sent as a Bearer token. |
| `SERENO_TIMEOUT` | `2` | Seconds per request (a batch makes at most two, see Delivery behavior). Capped at `10`; a missing, non numeric or non positive value falls back to `2`. |
| `SERENO_LEVEL` | `debug` | Minimum level sent. An invalid value falls back to `debug`. |

`init` reads the four variables and the arguments passed in code win over them. The `test` command reads `SERENO_URL`
and `SERENO_KEY`. Python does not read `.env` files by itself: export the variables or load them with your own tool.

## Testing the connection

```bash
python -m sereno test
sereno test --key=wsk_your_key --url=https://your-sereno-api.example.com
```

Both forms are equivalent (`sereno` is the script that `pip` installs). The `test` subcommand is required. It sends one
test event (level `info`, message "Sereno connection test") to `{url}/api/ingest/events` with a 5 second timeout and no
retry. Without `--key` and `--url` (also accepted as `--key value`) it uses `SERENO_KEY` and `SERENO_URL`. The outcome is
one of:

- Connected: Sereno answered `202`. Exit code 0.
- Unauthorized: `401`, the key is invalid or has been revoked.
- Missing URL or key: nothing is sent.
- Failed: network failure, timeout or any other HTTP status (a redirect hints at a wrong URL scheme).

Every outcome other than "Connected" exits with code 1. The key never appears in the output.

## Events

Each record becomes an event with `id`, `level`, `message`, `occurred_at` (UTC), optional `context` and optional
`fingerprint`. The fingerprint groups repeated events: pass `fingerprint` in the context, or log an exception, which
uses its class, file and line (`Class|file:line`), or its class and message (`Class|message`) when it has no
traceback. Fingerprints over 255 characters are replaced by their SHA-1. Limits
keep the payload valid for the API: messages are cut at 8192 characters, context at 32 KB, 8 levels of depth and 50
items per list or dict, strings in the context at 2048 characters and tracebacks at 2000. When anything is cut, the context
carries `_truncated: true`. An empty message is sent as `-`.

The `id` is a UUID generated once when the record is formatted, so it stays the same when a batch is retried. In the API
the `id` is optional (1 to 64 characters, unique per log source): an event whose `id` was already received from the same
source is accepted but discarded, so resending a batch never duplicates events or inflates the group counters.

Python values in the context are converted so they always serialize:

- `Enum` by its `value`; `Decimal` as a string (`NaN` and infinities as `NAN`/`INF` strings); `bytes` decoded as UTF-8 (invalid bytes become `?`).
- `set`, `frozenset`, `tuple` and `deque` as lists.
- `date` as midnight UTC (`T00:00:00+00:00`); `datetime` keeps its own offset (a naive one is written as `+00:00`).
- Integers outside the signed 64 bit range as strings.
- Functions as `[function]` and any other object as `[object module.Class]`; the SDK never calls your `__str__` or
  `__repr__` on them.

Privacy: the SDK sends only what you put in the context (including `extra`), plus the exception itself. The
integrations add only the method and the route pattern, never the query string, headers, cookies or body. Do not put
secrets in messages or contexts.

## Delivery behavior

- **Background thread, buffered and batched.** Logging a record never blocks on the network: it is formatted and
  buffered in memory, and a daemon thread sends batches of at most 100 events per request. Reaching 100 buffered events
  sends immediately, and otherwise the buffer is sent 1 second after the first record. If the thread cannot be created,
  `flush` and `close` send synchronously.
- **One retry, only on transient failures.** If a request fails with a network error, a timeout or a 5xx response, the
  same batch, with the same event ids, is sent once more; the server discards the events it already stored. Any 4xx
  response (for example `401`, `413`, `422` or `429`) is not retried. Both attempts share a budget of 10 seconds: the
  retry uses the configured timeout or what is left of the budget, whichever is lower, and is skipped when less than
  half a second is left. Redirects are not followed.
- **Timeout.** It is the `urllib` timeout of each socket operation (connect, send, read), not a total deadline: it does
  not cover DNS resolution, and a server that sends bytes slowly can take longer.
- **Buffer cap.** At most 1000 events are buffered between sends. Beyond that, new records are dropped and one notice with
  the number of dropped events is written to `stderr` (through the notifier) when the next batch is sent.
- **Never raises at runtime.** Once initialized, no function of the SDK raises into your code (`init` itself raises
  `TypeError` for an unknown keyword argument). If a batch is not delivered, or an event cannot be
  formatted, a message with the reason is written to `stderr` and the events of that batch are dropped. The API key and
  the credentials in the URL are removed from those messages. While Sereno is sending or reporting a failure, records
  logged from inside the SDK are ignored, so a failure can never loop.
- **Flush points.** A full buffer, the 1 second delay, `flush()`, `close()` and a normal exit. Events still buffered when
  the process is killed are lost.

## License

MIT. See [LICENSE](LICENSE).
