Metadata-Version: 2.4
Name: seren-sinew
Version: 5.1.1
Summary: The connective tissue of the Seren stack: shared runtime plumbing (request logging now; cluster client / discovery / DTOs later).
License: AGPL-3.0-or-later
Project-URL: Homepage, https://github.com/ChadRoesler/SerenSinew
Project-URL: Repository, https://github.com/ChadRoesler/SerenSinew
Project-URL: Bug Tracker, https://github.com/ChadRoesler/SerenSinew/issues
Keywords: seren,fastapi,starlette,logging,middleware
Classifier: Development Status :: 4 - Beta
Classifier: Framework :: FastAPI
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: Topic :: System :: Logging
Classifier: License :: OSI Approved :: GNU Affero General Public License v3 or later (AGPLv3+)
Requires-Python: >=3.10
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: starlette>=0.37
Provides-Extra: client
Requires-Dist: httpx>=0.27; extra == "client"
Provides-Extra: dev
Requires-Dist: pytest>=8; extra == "dev"
Requires-Dist: pytest-asyncio>=0.23; extra == "dev"
Requires-Dist: httpx>=0.27; extra == "dev"
Dynamic: license-file

# SerenSinew

The connective tissue of the Seren stack.

If [SerenMeninges](https://github.com/ChadRoesler/SerenMeninges) is the
**membrane** - the UI shell, auth, config, credentials every service wears -
then Sinew is the **tendon**: the cross-cutting *runtime* plumbing every Seren
web service repeats. One copy, so a fix lands everywhere at once.

## What's in it (today)

**Request logging.** Drop-in middleware that logs every HTTP request as
`<client> <METHOD> <path> -> <status> (<ms>ms) [rid=<id>]` - INFO for 2xx/3xx,
WARNING for 4xx (and slow 2xx), ERROR for 5xx, with the full traceback when an
exception escaped the route. Goes to **both** stderr (so `journalctl` catches
it) **and** a rotating file the running user owns at
`~/seren-logs/<service>-requests.log`, so anyone debugging can `tail` it without
sudo. If there is no home directory to put the file in, it logs to stderr only
and says so, rather than refusing to start.

**Request ids.** Every request gets one: an inbound `X-Request-Id` is kept, so
one string follows a call from Lodestar through Observatory into a service;
otherwise a short random one is minted. It is on the log line and echoed back
as `X-Request-Id` on the response, and routes can read it from
`request.state.request_id` to pass along to the next hop.

It's parameterized, not hardcoded - `service_name` picks the logger name and
log filename, `env_prefix` picks the env-var namespace:

```python
from seren_meninges.auth import bearer_auth_middleware
from seren_sinew.request_log import RequestLoggingMiddleware

# Mount OUTERMOST - before auth - so 401s get logged too.
app.add_middleware(bearer_auth_middleware(token))                # inner
app.add_middleware(                                              # outer
    RequestLoggingMiddleware,
    service_name="seren-observatory",
    env_prefix="SEREN_AGENT",   # -> SEREN_AGENT_LOG_LEVEL / SEREN_AGENT_LOG_QUERY
)
```

Knobs (per `env_prefix`):

| env var | effect |
| --- | --- |
| `<PREFIX>_LOG_LEVEL` | `INFO` (default) `\| DEBUG \| WARNING \| ERROR` |
| `<PREFIX>_LOG_QUERY` | `1` to append `?query` to the logged path (off by default - query strings can carry tokens / PII) |

**Running apps as needed** (`seren_sinew.orchestration`, `seren_sinew.ondemand`).
"I need this up" and "I'm done with it", said the same way by every Seren app:
`ensure` / `release` messages, and a lease book that knows who holds what, for
how long (a lease runs out unless it is renewed), and how long an app has sat
with nobody holding it. A **run policy** per app says which apps run all the
time and which run as needed:

| mode | |
| --- | --- |
| `unmanaged` | the default: a release stops what an ensure started, nothing else |
| `always` | never stopped |
| `on_demand` | started by an ensure, stopped after `idle_minutes` with nobody holding it |
| `scheduled` | kept up inside `hours` (`"22:00-07:00"`), on demand outside them |

`decide()` turns the book, what's running and the policies into "start this,
stop that"; Lodestar runs it on a timer (its `orchestration:` block). For the
caller, `OnDemandTransport` wraps an app's own httpx client: a refused
connection asks Lodestar to start the other app and retries, and the lease is
renewed while the calls keep coming.

```python
from seren_sinew.ondemand import OnDemand, OnDemandTransport

probe = OnDemand(lodestar_url, "probe", holder="seren-workbench", token=lodestar_token)
client = httpx.AsyncClient(base_url=probe_url, transport=OnDemandTransport(probe))
```

## What's coming

The cluster client, discovery and the DTOs that Lodestar and Observatory
currently spell separately. Sinew is where the connective runtime code goes;
Meninges stays the membrane.

## Install

```
pip install seren-sinew
```

Light by design - depends only on `starlette` (already in every leaf via
FastAPI), so it stays FastAPI-agnostic and adds nothing to a real install.

AGPL-3.0-or-later.
