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.
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file neva_otel-0.2.0.tar.gz.
File metadata
- Download URL: neva_otel-0.2.0.tar.gz
- Upload date:
- Size: 141.0 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via:
uv/0.8.22
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
d3a17647bbd63d2c3c9a2ec3f48968af232828a675ab701d2ca9213bc2735a49
|
|
| MD5 |
0b9d84c08e1b562b2672e59c68dc4b06
|
|
| BLAKE2b-256 |
a97a1ebad5859035ec2d05ebbb1bc7584015230970227bd363a112972f19d2d9
|
File details
Details for the file neva_otel-0.2.0-py3-none-any.whl.
File metadata
- Download URL: neva_otel-0.2.0-py3-none-any.whl
- Upload date:
- Size: 36.8 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via:
uv/0.8.22
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
5dddc87d8d57130be7c0ca0358439ec56f2f08a1983261f18855b3c78f6e747a
|
|
| MD5 |
28e2ff0b909153eee6150788ed3db2fa
|
|
| BLAKE2b-256 |
1827c5b52fb2ca1f5a159cc6307174c11b3d07f1fb3928f6b057e0e25a7982f7
|