Metadata-Version: 2.5
Name: neva-asgi
Version: 0.3.0
Summary: ASGI middleware for the Neva framework.
Requires-Python: >=3.12
Requires-Dist: anyio>=4.0
Requires-Dist: pyinstrument>=5.1.1
Requires-Dist: python-neva>=5.3.0
Requires-Dist: starlette>=0.41
Requires-Dist: structlog>=25.5.0
Provides-Extra: otel
Requires-Dist: opentelemetry-api>=1.41.0; extra == 'otel'
Requires-Dist: opentelemetry-instrumentation-asgi>=0.62b0; extra == 'otel'
Provides-Extra: testing
Requires-Dist: httpx>=0.27; extra == 'testing'
Requires-Dist: pytest>=9.0.2; extra == 'testing'
Description-Content-Type: text/markdown

# neva-asgi

ASGI middleware for the [Neva](https://pypi.org/project/python-neva/) framework.

`python-neva` is the framework-agnostic core: it must not import an ASGI
framework. This package holds the middleware that used to live in
`neva.obs.middleware`, so that boundary holds.

It sits below the protocol integrations rather than inside one — `neva-fastapi`
and `neva-faststream` (in its ASGI mode) can both consume it without depending
on each other.

## Install

```bash
uv add neva-asgi
```

## Middleware

### `CorrelationMiddleware`

Assigns a correlation ID to every HTTP and WebSocket request, reusing an
inbound `X-Request-ID` or `X-Correlation-ID` when it is a valid UUID and
generating one otherwise. The ID is published on `scope["state"]["correlation_id"]`
and echoed on the response. It is also bound into structlog's context, so every
log line emitted while handling the request carries `correlation_id` without the
caller threading it through — structlog's default processor chain merges
contextvars, so this needs no logging configuration.

```python
from neva.asgi import CorrelationMiddleware

app.add_middleware(CorrelationMiddleware, header_name="X-Request-ID")
```

`generator` and `validator` are both injectable, so a service that uses ULIDs or
a non-UUID scheme can swap them.

### `ProfilerMiddleware`

Profiles HTTP requests with [pyinstrument](https://pyinstrument.readthedocs.io)
and writes one report per profiled request, named
`profile_<duration>s_<METHOD>_<route>_<id>` so that a directory listing is a
slowest-first ordering. The trailing ID is the correlation ID when
`CorrelationMiddleware` runs ahead of it, and a timestamp when it does not.

```python
from neva.asgi import ProfilerMiddleware

app.add_middleware(ProfilerMiddleware, path="./profiles", interval=0.001)
```

Intended for local and staging use: by default it profiles every request and
writes a file for each. Four knobs narrow that — `should_profile` (a predicate
over the ASGI scope), `sample_rate`, `max_reports`, and `min_duration`, which
keeps a report only when the request turned out to be slow:

```python
app.add_middleware(
    ProfilerMiddleware,
    path="./profiles",
    min_duration=1.0,   # only requests over a second leave a file
    max_reports=200,    # and never more than 200 of them
)
```

To profile one request in a running deployment without a restart, give it a
`trigger_header` and a `trigger_secret` — the switch then travels with the
request rather than living in one worker process. A `manifest.jsonl` beside the
reports makes the collection greppable, `renderer` selects any pyinstrument
renderer (Speedscope included), and a profiled request emits a `profile` span
carrying the report name so a slow trace can reach its flame graph.

Because pyinstrument's sampler is process-global, only one request is profiled
at a time; a request that overlaps a profiled one is served unprofiled rather
than queued. The report is rendered and written on a worker thread, after the
response has gone out, so it does not delay what the worker serves next.

Profiling never alters the response, and a profile that cannot be taken or
written is logged, not raised.

### `TracingMiddleware`

Requires the `otel` extra:

```bash
uv add "neva-asgi[otel]"
```

Emits an OpenTelemetry HTTP server span per request, carrying the matched
route's **template** rather than the resolved path — `/users/{id}`, not
`/users/42`. Without that, every request is its own operation name and no
endpoint can be aggregated, so a trace backend has nothing to graph.

```python
from neva.asgi import CorrelationMiddleware
from neva.asgi.tracing import TracingMiddleware
from neva.fastapi import route_template

app.add_middleware(TracingMiddleware, route_resolver=route_template)
app.add_middleware(CorrelationMiddleware)
```

The span is not written here: `opentelemetry-instrumentation-asgi` already
carries the semantic conventions, the server metrics, header capture, context
propagation and WebSockets. This middleware composes it and adds the one thing
it cannot know — the route — because it runs outside the application, where
nothing has matched yet. `opentelemetry-instrumentation-fastapi` is not the
answer either: it re-walks `app.routes` to resolve the route, and since FastAPI
0.137 made `include_router` lazy that walk no longer reaches the prefixed
template.

- **`route_resolver`** is injected rather than imported, so this package
  depends on no web framework and `neva-fastapi` depends on no OpenTelemetry.
  `neva.fastapi.route_template` is the FastAPI implementation; any
  `(scope) -> str | None` will do.
- **The correlation ID** is stamped on the span as `correlation_id` when
  `CorrelationMiddleware` has run, which is what pivots a client-reported
  request ID to the trace that served it. Either middleware order works — the
  span is stamped once routing is done, by which point both have run.
- **Unmatched requests** keep a bare method as the span name (`GET`, or `HTTP`
  for a method the client invented) and carry no `http.route`. Upstream's
  default appends the request path, which lets a bot sweep mint one operation
  name per URL it probes.
- **Unknown keyword arguments** are forwarded to the instrumentor, so
  `excluded_urls`, the request hooks, header capture and an explicit
  `tracer_provider` all still work. Two upstream defaults are overridden and
  can be set back: `default_span_details` and `exclude_spans`, the latter
  because upstream emits a child span per ASGI `receive` and `send` event.

Importing `neva.asgi` does **not** import OpenTelemetry — `neva.asgi.tracing`
is the only module that does, and nothing re-exports it. A test asserts that in
a subprocess, because a single convenience re-export would quietly make the
extra mandatory for every `CorrelationMiddleware` user.

## Develop

```bash
uv sync --all-extras
poe lint && poe fmt && poe tc && poe test
```

## Agent guidelines

`neva/asgi/guidelines/fragments/` is what this package contributes to
[`neva-boost`](https://pypi.org/project/neva-boost/). Those files are the source and
the thing to edit; `poe guidelines` holds them to the frontmatter contract and runs in
CI.

Renders are **not** committed — `requires` is evaluated against installed versions, so
one is only correct for the environment that produced it. Compose your own:

```bash
poe guidelines-render                        # .claude/skills/, gitignored
uv run neva-boost install --target agents    # AGENTS.md instead
```

Commits follow Conventional Commits with gitmoji via `cz commit`; releases are
cut with `cz bump`.
