neva-otel
OpenTelemetry tracing and metrics for the 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
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:
from neva.otel import OtelServiceProvider
config = {"providers": [OtelServiceProvider]}
…and write config/otel.py:
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
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
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
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(), notlifespan(). 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 whoseinstrument()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
# conftest.py
pytest_plugins = ["neva.testing.fixtures", "neva.otel.testing"]
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
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.
Metadata
Release files for neva-otel 0.3.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| neva_otel-0.3.0.tar.gz | 153.8 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| neva_otel-0.3.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 197.3 kB
Release files / neva_otel-0.3.0.tar.gz
| Download URL | neva_otel-0.3.0.tar.gz |
|---|---|
| Size | 153.8 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
49c57e6482d04a35b000ab2c64b8ec77fa7251f565a3b33eb91aa30e0e8e8cc2
|
|
BLAKE2b-256 checksum How to use checksums |
fe3e9e1d7e482be79fb527bba002d38cc3e5da2968eac0f6a6618152d5e9c165
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
uv/0.8.22
|
Release files / neva_otel-0.3.0-py3-none-any.whl
| Download URL | neva_otel-0.3.0-py3-none-any.whl |
|---|---|
| Size | 43.5 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
e0a8eac5b0fae0f4e3570d1872da85a7613dd38efab661a6040c4c190f29c9da
|
|
BLAKE2b-256 checksum How to use checksums |
d424f3685f1f68d4716d580c0e91433ea4e288f2f511d0e48d516179774607e1
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
uv/0.8.22
|