observix
Provider-agnostic observability for Python and AI applications. Instrument once. Send the same telemetry to Phoenix, Langfuse, MLflow, Datadog, Arize and any OTLP backend — simultaneously — and change destinations without touching application code.
from observix import observe, configure
configure(service_name="my-app", exporters=["phoenix", "langfuse"])
@observe
def my_function(): ...
That is the whole integration. Adding a backend, removing one, or stripping prompts before they reach a third party are all configuration changes.
Why this exists
Every LLM observability backend now ingests OTLP, so transport is solved. What is not solved is that each backend reads a different attribute vocabulary and renders poorly when handed another's:
| Backend | Reads | Wants |
|---|---|---|
| Phoenix / Arize | OpenInference | openinference.span.kind, input.value, llm.token_count.prompt |
| Langfuse | langfuse.* (highest precedence) |
langfuse.observation.input, usage_details |
| MLflow | mlflow.* |
mlflow.spanInputs, mlflow.llm.model |
| Datadog / Grafana / Honeycomb | OTel GenAI | gen_ai.* |
Emit one vocabulary and you get a first-class experience in exactly one backend. Langfuse #12657 is this problem in miniature: spans using GenAI semconv v1.37+ arrive with null input/output.
Meanwhile the OTel GenAI conventions are still Development-status — they moved to a separate repository in June 2026 — so hard-coding today's names into your application inherits every future rename.
observix records a canonical model once and translates it per-destination at export time. One @observe produces OpenInference for Phoenix, langfuse.* for Langfuse, and gen_ai.* for Datadog, all natively shaped, all from the same recording.
What it is not
It does not reimplement OpenTelemetry. Transport, batching, retries, context propagation and sampling are OTel's. It is not another auto-instrumentation library — OpenLLMetry and OpenInference do that well, and observix adopts their spans rather than competing. It is not a backend; there is no server and no UI.
Install
pip install observix
With the backends you need:
pip install 'observix[all]'
The developer experience
Decorate anything
from observix import observe
@observe
def handle(request): ...
@observe(kind="agent", name="planner")
async def plan(goal: str): ... # coroutines, generators, async generators
@observe(kind="retriever", capture_output=False)
def search(query: str): ...
Record AI-specific detail
from observix import get_current_span
span = get_current_span()
span.record_llm_call(
provider="anthropic",
request_model="claude-opus-4",
input_messages=[{"role": "user", "content": "Hello"}],
output_messages=[{"role": "assistant", "content": "Hi"}],
input_tokens=12,
output_tokens=4,
)
Cost in USD is computed automatically from a built-in, overridable price book.
Stream without losing the metrics that matter
When a model streams, the decorated function returns before anything is generated — so a plain decorator records an empty output and misses time-to-first-token entirely.
from observix import observe, observe_stream
@observe(kind="chat")
def chat(prompt: str):
stream = client.messages.create(..., stream=True)
return observe_stream(stream, provider="anthropic", request_model="claude-opus-4")
Chunks pass through untouched. TTFT, the accumulated response and the chunk count land on the span when the stream ends — including when it's abandoned part-way. observe_astream for async for.
Instrument a block
from observix import observe_block
with observe_block("retrieval", kind="retriever") as span:
docs = vector_db.search(query, k=5)
span.set_retrieval(query=query, documents=docs, top_k=5)
Switching backends is configuration
# observix.toml
[observix]
service_name = "my-app"
exporters = ["phoenix", "langfuse"]
[observix.exporters.langfuse]
sample_ratio = 0.25
redact = "hashed"
Or environment variables, for the same result with no file:
export OBSERVIX_EXPORTERS=phoenix,langfuse
export OBSERVIX_LANGFUSE_SAMPLE_RATIO=0.25
Or in code:
configure(
service_name="my-app",
exporters=[
{"provider": "phoenix"},
{"provider": "langfuse", "sample_ratio": 0.25},
{"provider": "datadog", "redact": "none"}, # no prompts leave the building
],
)
Precedence, lowest to highest: defaults → config file → environment → code.
Per-destination privacy
Privacy is a property of the destination, not of the span. The same recorded prompt can go in full to your self-hosted Langfuse, hashed to staging, and not at all to a third-party SaaS:
configure(
exporters=[
{"provider": "langfuse"}, # full content
{"provider": "phoenix", "redact": "hashed"}, # joinable, unreadable
{"provider": "datadog", "redact": "none"}, # metrics only
]
)
When no destination retains content, observix skips prompt serialisation entirely — you pay nothing for data you were never going to send.
Built-in providers and dialects
Providers — console, memory, otlp, phoenix, langfuse, mlflow, arize, datadog
Dialects — passthrough, otel_genai, openinference, langfuse, mlflow
Add your own without forking. Declare an entry point and it becomes available by name:
[project.entry-points."observix.providers"]
mybackend = "my_pkg:MyProvider"
See docs/extending.md.
Testing
Spans reach the collector after redaction and translation, so tests assert on exactly what a backend would have received:
from observix.testing import collect_spans
def test_renders_in_phoenix():
with collect_spans(dialect="openinference") as spans:
my_function()
assert spans.one().attributes["openinference.span.kind"] == "LLM"
And verified against a real backend. In-memory tests prove observix emits what it intended to; they cannot prove the backend understands it. tests/live/ sends real spans to a running Phoenix and asserts on its typed columns, which only populate for attributes Phoenix actually recognises. That suite caught a real bug — project routing needs a resource attribute, not the header we had been sending.
phoenix serve # separate terminal
pytest tests/live -m live
Overhead
Measured, not asserted — full results and caveats in benchmarks/.
| ns/call | |
|---|---|
| Undecorated baseline | ~200 |
@observe, unconfigured |
~585 |
| Raw OpenTelemetry span | ~56,000 |
@observe, enabled |
~85,400 |
Unconfigured observix costs well under a microsecond. When enabled, roughly two-thirds of a span's cost is the OpenTelemetry SDK underneath it. Fan-out adds ~22 µs per extra destination on the calling thread — translation and network I/O happen on each destination's own worker. When no destination retains content, prompt serialisation is skipped entirely, cutting ~32% off a full LLM span.
Documentation
- Design and competitive analysis
- Quickstart
- Configuration reference
- Providers · Dialects
- Extending observix
- Examples
Licence
Apache-2.0. Contributions welcome — see CONTRIBUTING.md.
Metadata
Release files for observix 0.1.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 | |
|---|---|---|---|
| observix-0.1.0.tar.gz | 133.5 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| observix-0.1.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 228.4 kB
Release files / observix-0.1.0.tar.gz
| Download URL | observix-0.1.0.tar.gz |
|---|---|
| Size | 133.5 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
207292027b8c4008b4789eb1d29bfa6367fd25b927188f56f5651a7e6d3f004d
|
|
BLAKE2b-256 checksum How to use checksums |
c0f277387a1e88789facb8da90fa6fb9c02540fb6ed6b2d52f54561508d76ed4
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Aug 12, 2026.
Transparency logRelease files / observix-0.1.0-py3-none-any.whl
| Download URL | observix-0.1.0-py3-none-any.whl |
|---|---|
| Size | 94.8 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
d3f4180730610b28981457c91f790b95605e2394c4aede48ce476aac3728f948
|
|
BLAKE2b-256 checksum How to use checksums |
c97bbf0310a3eb12b620a6bc6917b279a956daa4842849f562ba2ca4772fefe9
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Aug 12, 2026.
Transparency log