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.
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 observix-0.1.0.tar.gz.
File metadata
- Download URL: observix-0.1.0.tar.gz
- Upload date:
- Size: 133.5 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
207292027b8c4008b4789eb1d29bfa6367fd25b927188f56f5651a7e6d3f004d
|
|
| MD5 |
df7a5ad598f573e9a1d4f6bd53fe737c
|
|
| BLAKE2b-256 |
c0f277387a1e88789facb8da90fa6fb9c02540fb6ed6b2d52f54561508d76ed4
|
Provenance
The following attestation bundles were made for observix-0.1.0.tar.gz:
Publisher:
release.yml on Pranav-PA/observix
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
observix-0.1.0.tar.gz -
Subject digest:
207292027b8c4008b4789eb1d29bfa6367fd25b927188f56f5651a7e6d3f004d - Sigstore transparency entry: 2438897809
- Sigstore integration time:
-
Permalink:
Pranav-PA/observix@ceb11e42507550492bb674728b452fa7d4e34634 -
Branch / Tag:
refs/tags/v0.1.0 - Owner: https://github.com/Pranav-PA
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@ceb11e42507550492bb674728b452fa7d4e34634 -
Trigger Event:
push
-
Statement type:
File details
Details for the file observix-0.1.0-py3-none-any.whl.
File metadata
- Download URL: observix-0.1.0-py3-none-any.whl
- Upload date:
- Size: 94.8 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
d3f4180730610b28981457c91f790b95605e2394c4aede48ce476aac3728f948
|
|
| MD5 |
3d82f0f2632e0340a73ec62d224109cc
|
|
| BLAKE2b-256 |
c97bbf0310a3eb12b620a6bc6917b279a956daa4842849f562ba2ca4772fefe9
|
Provenance
The following attestation bundles were made for observix-0.1.0-py3-none-any.whl:
Publisher:
release.yml on Pranav-PA/observix
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
observix-0.1.0-py3-none-any.whl -
Subject digest:
d3f4180730610b28981457c91f790b95605e2394c4aede48ce476aac3728f948 - Sigstore transparency entry: 2438897948
- Sigstore integration time:
-
Permalink:
Pranav-PA/observix@ceb11e42507550492bb674728b452fa7d4e34634 -
Branch / Tag:
refs/tags/v0.1.0 - Owner: https://github.com/Pranav-PA
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@ceb11e42507550492bb674728b452fa7d4e34634 -
Trigger Event:
push
-
Statement type: