Metadata-Version: 2.5
Name: neva-otel
Version: 0.1.0
Summary: OpenTelemetry tracing and metrics for the Neva framework.
Requires-Python: >=3.12
Requires-Dist: opentelemetry-instrumentation-sqlalchemy>=0.65b0
Requires-Dist: opentelemetry-sdk>=1.44.0
Requires-Dist: python-neva>=5.0.0
Requires-Dist: structlog>=25.5.0
Provides-Extra: otlp
Requires-Dist: opentelemetry-exporter-otlp-proto-grpc>=1.44.0; extra == 'otlp'
Provides-Extra: otlp-http
Requires-Dist: opentelemetry-exporter-otlp-proto-http>=1.44.0; extra == 'otlp-http'
Provides-Extra: testing
Requires-Dist: pytest>=9.0.2; extra == 'testing'
Description-Content-Type: text/markdown

# neva-otel

OpenTelemetry tracing and metrics for the [Neva](https://pypi.org/project/python-neva/)
framework.

`python-neva` ships **logging only** and has no OpenTelemetry dependency at all — not
even `opentelemetry-api`. This package is the backend: the SDK, the tracer and meter
providers, the exporters, the samplers, the resource, and the instrumentors. Everything
that commits an application to a destination lives here.

The split follows the line OpenTelemetry itself draws, and it mirrors Laravel, where the
framework ships `Log` and every APM is a separate package. The consequence worth
preserving: **a team that prefers something else installs nothing from here and pays
nothing.**

## Install

```bash
uv add neva-otel            # console exporters
uv add 'neva-otel[otlp]'    # + OTLP over gRPC      (:4317)
uv add 'neva-otel[otlp-http]'  # + OTLP over HTTP   (:4318)
```

## Enable it

Add the provider to `config/providers.py`:

```python
from neva.otel import OtelServiceProvider

config = {"providers": [OtelServiceProvider]}
```

…and write `config/otel.py`:

```python
from neva.otel import OtelConfig

config: OtelConfig = {
    "enabled": True,
    "service": {"name": "billing", "version": "2.1.0"},
    "traces": {
        "exporter": "otlp",
        "sampler": "parent_ratio",
        "ratio": 0.1,
        "otlp": {"endpoint": "http://collector:4317", "insecure": True},
    },
    "metrics": {"exporter": "otlp", "interval": 60_000},
}
```

**`enabled` defaults to false.** Installing this package and adding no config changes
nothing — no provider, no exporter, and no instrumentation that would cost engine setup
and export nowhere.

`service.name` falls back to the core's `app.name`, and `environment` to
`app.environment`, so `{"enabled": True}` on its own already yields a correct resource.

### Configuration

| Key | Values | Default |
| --- | --- | --- |
| `enabled` | master switch | `false` |
| `service` | `name`, `namespace`, `version`, `instance_id` | from `app.name` |
| `environment` | `deployment.environment.name` | from `app.environment` |
| `resource` | extra attributes, merged last | — |
| `traces.enabled` | overrides `enabled` for traces | `enabled` |
| `traces.exporter` | `console`, `otlp`, `otlp_http`, `memory`, `none` | `console` |
| `traces.sampler` | `always_on`, `always_off`, `parent_ratio` | `parent_ratio` |
| `traces.ratio` | head-sampling ratio, clamped to `[0, 1]` | `1.0` |
| `traces.otlp` | `endpoint`, `headers`, `timeout`, `insecure` | from `OTEL_EXPORTER_OTLP_*` |
| `metrics.enabled` | overrides `enabled` for metrics | `enabled` |
| `metrics.exporter` | as for traces; selects the reader carrying it | `console` |
| `metrics.interval` / `timeout` | milliseconds | `60000` / `30000` |
| `propagators` | `tracecontext`, `baggage` | both |
| `instrumentation.sqlalchemy` | patch engine creation | tracing is on |
| `logging.enabled` | span ids on log records | tracing is on |

Unset OTLP keys are omitted rather than passed as `None`, so the exporters' own
`OTEL_EXPORTER_OTLP_*` environment handling still applies.

An unknown sampler, exporter or propagator raises at boot with a message naming it.
`Application.register_providers` discards the `Result` a provider's `register()` returns,
so an error there would boot an application with no telemetry and nothing saying why.

## Tracing

```python
from neva.otel import Trace

with Trace.span("checkout", order_id=42) as span:
    span.set_attribute("currency", "EUR")
```

With tracing off the same call records nothing, so call sites need no guard.

## Metrics

```python
from neva.otel import Metric

Metric.counter("orders.placed").add(1)
Metric.histogram("checkout.duration", unit="ms").record(elapsed)
Metric.up_down_counter("queue.depth").add(-1)
Metric.gauge("pool.size").set(7)
```

Instruments are cached by name and kind, so asking for one at the call site that records
it is correct.

## Log correlation

`trace_id`, `span_id` and `trace_sampled` land on every log record emitted inside a
recording span, on **every** channel, via the core's `LogManager.processor` seam.
Records emitted outside a span carry none, rather than zeroed ids that would match a
search for a trace that does not exist.

## Propagation

```python
from neva.otel import continued, inject

headers = inject()          # producer
with continued(headers):    # consumer — attached, so spans parent onto it
    ...
```

The carrier is any `str` → `str` mapping. W3C Trace Context is not web-specific: the spec
defines the *format* of two fields and HTTP headers are one binding, so a message
envelope's header bag carries a trace across a queue just as well.

`extract` alone returns a `Context` that still has to be attached — use `continued`, or
`activated(extract(carrier))`.

## SQLAlchemy

Instrumentation is installed from the provider's `register()`, globally, with no
`engine=` argument. That patches engine *creation*, so every connection in
`database.connections` is traced, along with any engine registered later.

Three constraints make that the only correct shape, and each was measured:

- **`register()`, not `lifespan()`.** The database provider's lifespan builds every
  configured engine, and base providers' lifespans enter first — a plugin lifespan is
  already too late to patch creation.
- **No `engine=`.** Instrumenting one engine leaves every other connection silent.
- **Guarded on `is_instrumented_by_opentelemetry`.** The instrumentor is a process-wide
  singleton whose `instrument()` is not idempotent, so a second application in one
  interpreter is a no-op rather than a warning.

There is one hazard this package cannot fix from here: it works because
`neva/database/manager.py` reaches `create_async_engine` through the `asyncio` module at
call time. Rewritten as `from sqlalchemy.ext.asyncio import create_async_engine`, the
local name would hold the unpatched original and every engine would be created untraced,
silently. `tests/test_sqlalchemy.py::TestTheCoreImportHazard` fails if that ever changes.

## Testing

```python
# conftest.py
pytest_plugins = ["neva.testing.fixtures", "neva.otel.testing"]
```

```python
from neva.otel.testing import clear_spans, counter_value, span_named

clear_spans(self.app)
with Trace.span("checkout", order_id=42):
    pass

assert span_named(self.app, "checkout").attributes["order_id"] == 42
assert counter_value(self.app, "orders.placed") == 1
```

Configure `traces.exporter: "memory"` with `sampler: "always_on"` — `neva.otel.testing.IN_MEMORY`
is exactly that configuration, and `write_config(config_dir)` writes it out. The memory
exporter is driven by a `SimpleSpanProcessor`, so a span is readable the moment it ends,
with no flush and no race against a batch worker.

`captured_spans` and `captured_metrics` are function-scoped fixtures over the core's
`application` fixture.

## Develop

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

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