SDK
Python SDK
On this page
Installation
pip install opexia-trace
Optional extras
| Extra | Install | Pulls | Use when |
|---|---|---|---|
live | pip install 'opexia-trace[live]' | rich | You want the opexia live terminal dashboard |
shipcheck | pip install 'opexia-trace[shipcheck]' | pyyaml | Your Ship Check policy is YAML (JSON needs no extra) |
litellm | pip install 'opexia-trace[litellm]' | litellm | You route models through LiteLLM |
openllmetry | pip install 'opexia-trace[openllmetry]' | traceloop-sdk | You already use OpenLLMetry |
adapters-langchain | pip install 'opexia-trace[adapters-langchain]' | langchain-core | You use LangChain |
adapters-microsoft | pip install 'opexia-trace[adapters-microsoft]' | (none) | You use Microsoft agent frameworks |
Core dependencies installed always: opentelemetry-api/-sdk/-exporter-otlp
(1.x), pydantic 2.x, tiktoken, httpx.
init()
Call once at process startup, before anything you want traced.
import opexia.trace
opexia.trace.init(
org_id="acme",
workspace_id="3f2b1c8e-9a4d-4f11-b7c2-1e5a8d3f6b90",
project_id="checkout-agent",
backend_url="https://ingest.opexia.dev",
api_key="opx_live_xxxxxxxxxxxxxxxxxxxx",
transport="direct",
)
Parameters
All parameters are keyword-only.
| Name | Type | Required | Default | Description |
|---|---|---|---|---|
org_id | str | ✅ | — | Organization identifier |
workspace_id | str | ✅ | — | Workspace UUID the dashboard reads |
project_id | str | ✅ | — | Logical project/service name; groups traces |
backend_url | str | ✅ | — | Ingest base URL — https://ingest.opexia.dev |
api_key | str | ✅ | — | Workspace API key (opx_live_…) |
collector_endpoint | str | ➖ | http://localhost:4317 | OTLP/gRPC collector. Used only when transport="collector" |
wal_path | str | ➖ | .opexia-wal/spans.jsonl | Crash-recovery write-ahead log |
auto_instrument | bool | ➖ | True | Patch known LLM client methods automatically |
fail_open | bool | ➖ | False | If True, init failures log instead of raising |
sampler_rate | float | ➖ | 1.0 | Fraction of traces sampled, 0.0–1.0 |
transport | str | None | ➖ | "collector" | "collector" or "direct" — see below |
Choosing a transport
transport="direct" | transport="collector" (default) | |
|---|---|---|
| Path | Your process → ingest.opexia.dev over HTTP/JSON | Your process → local collector (gRPC) → Evigauge |
| Needs a collector | No | Yes |
Uses collector_endpoint | No — ignored | Yes |
| Best for | Serverless, containers, quick starts | Existing OTel infrastructure, local buffering, fan-out |
Override without a code change:
export OPEXIA_TRANSPORT=direct
The explicit transport= argument always wins over the environment variable.
Collector users: your
otlphttpexporter must setencoding: json. The protobuf default returns415from Evigauge ingest, and it fails silently from the collector's point of view — the single most common cause of "no spans arriving".YAMLexporters: otlphttp: endpoint: https://ingest.opexia.dev encoding: json # REQUIRED headers: x-opexia-api-key: opx_live_xxxxxxxxxxxxxxxxxxxx
What init() does
- Stores config, reachable via
get_config(). - Fetches the workspace
capture_textflag fromGET /v1/workspaces/{ws}/sdk-config. Fail-closed — if the lookup fails,capture_textisFalseand no text bodies are sent. - Builds the exporter for the chosen transport.
- Replays any WAL from a previous crashed process, then installs
DurableBatchSpanProcessor. - Registers auto-instrumentation when
auto_instrument=True.
Steps 3–5 are wrapped by fail_open. set_config() runs first and outside
that guard, so get_config() works even in a degraded state.
fail_open
| Value | Behaviour on init failure |
|---|---|
False (default) | Raises. Your process will not start mis-instrumented. |
True | Logs the exception and continues, untraced. |
Use fail_open=True in production when telemetry must never take down the
service; keep False in development so misconfiguration is loud.
Environment-driven setup
import os, opexia.trace
opexia.trace.init(
org_id=os.environ["OPEXIA_ORG_ID"],
workspace_id=os.environ["OPEXIA_WORKSPACE_ID"],
project_id=os.environ.get("OPEXIA_PROJECT_ID", "default"),
backend_url=os.environ.get("OPEXIA_INGEST_URL", "https://ingest.opexia.dev"),
api_key=os.environ["OPEXIA_API_KEY"],
transport=os.environ.get("OPEXIA_TRANSPORT", "direct"),
fail_open=os.environ.get("ENV") == "production",
)
# .env
OPEXIA_ORG_ID=acme
OPEXIA_WORKSPACE_ID=3f2b1c8e-9a4d-4f11-b7c2-1e5a8d3f6b90
OPEXIA_PROJECT_ID=checkout-agent
OPEXIA_INGEST_URL=https://ingest.opexia.dev
OPEXIA_API_KEY=opx_live_xxxxxxxxxxxxxxxxxxxx
OPEXIA_TRANSPORT=direct
@observe
Decorate a function to emit one span per call. Works on both sync and async functions — the decorator detects which and wraps accordingly.
from opexia.trace import observe
@observe(reasoning_role="retrieval", node_type="retrieval")
def fetch_order(order_id: str) -> dict:
return db.get(order_id)
@observe(reasoning_role="synthesis", node_type="agent", name="compose_answer")
async def compose(ctx: dict) -> str:
return await llm.complete(ctx)
Parameters
Keyword-only.
| Name | Type | Required | Default | Description |
|---|---|---|---|---|
reasoning_role | str | None | ➖ | None | What this step is for — see enums |
node_type | str | None | ➖ | None | What kind of component it is — see enums |
end_user | str | None | ➖ | None | End-user identifier for per-user attribution |
name | str | None | ➖ | fn.__qualname__ | Span name override |
Error handling
An exception inside the wrapped function is recorded on the span
(record_exception), the span status is set to ERROR, and the exception is
re-raised unchanged. Instrumentation never swallows your errors.
ReasoningTrace
A context manager for a logical unit of reasoning, giving you a node object to attach structured evidence to. This is what makes reconstruction possible — plain spans record what happened; these records capture why.
from opexia.trace import ReasoningTrace
with ReasoningTrace("answer_customer", reasoning_role="synthesis",
node_type="agent", end_user="user_8412") as node:
node.record_decision(
selected="lookup_order",
scores={"lookup_order": 0.88, "search_kb": 0.31},
alternatives=[{"name": "search_kb", "why_not": "no order id in message"}],
rules_fired=["order_id_present"],
)
node.record_sources(
consulted=["https://docs.internal/returns", "https://docs.internal/shipping"],
used=["https://docs.internal/returns"],
dropped=["https://docs.internal/shipping"],
scores={"https://docs.internal/returns": 0.93},
)
node.record_cost(
model="claude-opus-4-6",
input_tokens=2_140,
output_tokens=310,
)
Constructor
| Name | Type | Required | Default | Description |
|---|---|---|---|---|
name | str | ✅ | — | Span name (positional) |
domain | str | None | ➖ | None | Free-form label. Not indexed — not queryable server-side. |
reasoning_role | str | None | ➖ | None | See enums |
node_type | str | None | ➖ | None | See enums |
end_user | str | None | ➖ | None | End-user identifier |
Node methods
record_decision(*, selected, scores=None, alternatives=None, rules_fired=None)
| Param | Type | Required | Description |
|---|---|---|---|
selected | str | ✅ | The option chosen |
scores | dict[str, float] | ➖ | Score per option |
alternatives | list[dict] | ➖ | Options not taken, with rationale |
rules_fired | list[str] | ➖ | Deterministic rules that applied |
Feeds the decision-trace engine.
record_sources(*, consulted, used=None, dropped=None, scores=None)
| Param | Type | Required | Description |
|---|---|---|---|
consulted | list[str] | ✅ | Every source retrieved — string IDs or URLs, not objects |
used | list[str] | ➖ | The subset actually cited. Drives source_authority. |
dropped | list[str] | ➖ | Retrieved and deliberately discarded |
scores | dict[str, float] | ➖ | Relevance per source, 0–1 |
Feeds the sources matrix. The
consulted vs used split is what surfaces "retrieved a good source and ignored
it".
record_cost(*, model, input_tokens, output_tokens, ...)
| Param | Type | Required | Default | Description |
|---|---|---|---|---|
model | str | ✅ | — | Model identifier |
input_tokens | int | ✅ | — | Prompt tokens |
output_tokens | int | ✅ | — | Completion tokens |
reasoning_tokens | int | ➖ | 0 | Extended-thinking tokens |
tool_call_count | int | ➖ | 0 | Tool invocations |
injected_context_tokens | int | ➖ | 0 | Tokens placed into context |
used_context_tokens | int | ➖ | 0 | Tokens the model actually drew on |
USD and the pricing version are computed for you. The
injected vs used context split powers context-optimization recommendations.
record_reliability_inputs(**kwargs)
Free-form signals for the reliability scorer.
record_plan(plan)
Records the agent's plan; feeds the decomposition engine.
record_end_user(end_user)
Sets opexia.end_user after construction.
subnode(...)
Creates a nested node under the current one.
Cost estimation
from opexia.trace import estimate_cost_usd, pricing_version
usd = estimate_cost_usd(model="claude-opus-4-6", input_tokens=2140, output_tokens=310)
print(f"${usd:.4f} (pricing {pricing_version()})")
Use this for local pre-flight estimates. Server-side cost on stored traces is computed independently, so a stored figure never shifts because your SDK version changed.
Enumerations
Both are server-validated — any other value fails validation and the span is dead-lettered.
reasoning_role — what the step is for:
decomposer · research · analysis · critique · synthesis · arbiter ·
retrieval · guardrail · post_process
node_type — what kind of component it is:
decomposer · classifier · agent · guardrail · post_process · retrieval
Span attribute reference
The wire contract. Violations are silently dead-lettered at ingest — the span disappears rather than erroring, so get these right.
Required envelope
| Attribute | Type | Description |
|---|---|---|
opexia.schema_version | string | Currently "1.0" |
opexia.org_id | string | Organization identifier |
opexia.workspace_id | string | Workspace UUID |
opexia.project_id | string | Project/service name |
opexia.trace_id | string | Must equal the OTel trace ID |
Optional attributes
| Attribute | Type | Description |
|---|---|---|
opexia.user_id | string | Internal user identifier |
opexia.end_user | string | End-user identifier for per-user attribution |
opexia.reasoning_role | enum | See above |
opexia.node_type | enum | See above |
opexia.parent_reasoning_id | string | Parent reasoning node |
opexia.decision | JSON string | {rules_fired, scores, selected, alternatives} |
opexia.sources | JSON string | {consulted, used, dropped, scores} |
opexia.query_text | plain string | The user query / the prompt. ~16 KB cap. |
opexia.outcome_text | plain string | The final answer. ~16 KB cap. |
opexia.prompt_id | string | Pins prompt identity for Ship Check |
opexia.prompt_label | string | Display name |
opexia.prompt_version | string | Client-computed version |
opexia.cost.usd | float | Flat scalar |
opexia.cost.model_pricing_version | string | Pricing table version |
Standard OTel GenAI semantic conventions are also read: gen_ai.system,
gen_ai.request.model, gen_ai.operation.name, gen_ai.usage.input_tokens,
gen_ai.usage.output_tokens, gen_ai.response.finish_reason.
The four rules
- Compound fields are JSON strings.
opexia.decisionandopexia.sourcesmust bejson.dumps(...). A nested object becomes an OTLPkvlistValueand kills the whole span. consulted/used/droppedarestring[]— URLs or stable IDs, never{id, title}objects.query_text/outcome_textare plain strings. Neverjson.dumpsthem.- Only documented
opexia.*keys exist (the schema isextra="forbid"). Any otheropexia.*key kills the span. Note the keys are flat:opexia.prompt_id, notopexia.prompt.id.
Where to put text. Engines take the first non-empty
query_textand the last non-emptyoutcome_textin a trace. Put the query on the earliest span and the outcome on the latest.
On an LLM span,
query_textis the prompt — it is what Evigauge versions, evaluates, and measures a cacheable prefix from. When rendering messages into it, keep role boundaries:Python# ✅ Correct — role boundaries preserved query_text = "\n\n".join(f"{m['role']}:\n{m['content']}" for m in messages) # ❌ Wrong — no boundary, so every call hashes as its own "prompt" and the # Prompts page fills with thousands of one-call rows. query_text = " ".join(m["content"] for m in messages)
Durability
Write-ahead log
Spans are written to wal_path (default .opexia-wal/spans.jsonl) before export.
If the process crashes, the next init() replays the WAL and logs how many
entries it recovered.
Replay happens before the span processor is constructed — required on
Windows, where renaming a file with an open handle raises PermissionError
(WinError 32).
Mount .opexia-wal/ on a writable volume in containers. On a fully read-only
filesystem, point wal_path at a writable temp directory.
Sampling
opexia.trace.init(..., sampler_rate=0.1) # 10% of traces
Sampling is per trace, not per span — a sampled trace keeps all its spans, so reconstruction still works. Sampling a fraction of spans would produce broken traces.
Complete Python example
"""Minimal end-to-end instrumented agent."""
import os
import opexia.trace
from opexia.trace import ReasoningTrace, observe
opexia.trace.init(
org_id=os.environ["OPEXIA_ORG_ID"],
workspace_id=os.environ["OPEXIA_WORKSPACE_ID"],
project_id="checkout-agent",
backend_url="https://ingest.opexia.dev",
api_key=os.environ["OPEXIA_API_KEY"],
transport="direct",
fail_open=os.environ.get("ENV") == "production",
)
@observe(reasoning_role="retrieval", node_type="retrieval")
def retrieve(query: str) -> list[str]:
return ["https://docs.internal/returns", "https://docs.internal/shipping"]
def handle(query: str, user_id: str) -> str:
# One ReasoningTrace per logical request = one trace.
with ReasoningTrace("handle_request", reasoning_role="synthesis",
node_type="agent", end_user=user_id) as node:
# Put the query on the EARLIEST span of the trace.
node._span.set_attribute("opexia.query_text", query)
docs = retrieve(query)
node.record_decision(
selected="answer_from_kb",
scores={"answer_from_kb": 0.91, "escalate": 0.12},
rules_fired=["kb_hit"],
)
node.record_sources(consulted=docs, used=docs[:1], dropped=docs[1:])
answer = f"Returns are accepted within 30 days. ({len(docs)} sources)"
node.record_cost(model="claude-opus-4-6", input_tokens=2140, output_tokens=310)
# Put the outcome on the LATEST span of the trace.
node._span.set_attribute("opexia.outcome_text", answer)
return answer
if __name__ == "__main__":
print(handle("Can I return this?", user_id="user_8412"))
Verifying a span landed
Run this after wiring up instrumentation. It emits one span and confirms it arrived — the definitive check that the whole path works.
"""verify_span.py — emit one span and confirm it landed."""
import os, time, uuid, httpx
import opexia.trace
from opexia.trace import ReasoningTrace
ORG = os.environ["OPEXIA_ORG"] # slug, for the read API
WS = os.environ["OPEXIA_WORKSPACE"] # slug, for the read API
WS_ID = os.environ["OPEXIA_WORKSPACE_ID"] # UUID, for the SDK
KEY = os.environ["OPEXIA_API_KEY"]
opexia.trace.init(
org_id=ORG, workspace_id=WS_ID, project_id="verify",
backend_url="https://ingest.opexia.dev", api_key=KEY, transport="direct",
)
marker = f"verify-{uuid.uuid4().hex[:8]}"
with ReasoningTrace("verify_span", reasoning_role="analysis", node_type="agent") as n:
n._span.set_attribute("opexia.query_text", marker)
n.record_cost(model="claude-opus-4-6", input_tokens=10, output_tokens=5)
# Force the batch out, then let ingestion settle.
from opentelemetry import trace as ot
ot.get_tracer_provider().force_flush()
print(f"emitted {marker}; waiting for ingestion…")
time.sleep(20)
# 1. Did anything dead-letter?
health = httpx.get(
f"https://api.opexia.dev/v1/observ/orgs/{ORG}/workspaces/{WS}/health/ingestion",
headers={"x-opexia-api-key": KEY}, timeout=30,
).json()
print("dead-lettered last hour:", health.get("dead_letter_count_last_hour"))
# 2. Is the trace queryable?
traces = httpx.get(
f"https://api.opexia.dev/v1/observ/orgs/{ORG}/workspaces/{WS}/traces",
headers={"x-opexia-api-key": KEY},
params={"page_size": 20, "include_empty": True}, timeout=30,
).json()
# Payload is under "data", not "items".
print("recent traces:", len(traces["data"]))
assert traces["data"], "No traces — check key, workspace UUID, and ingest URL."
print("✅ span landed")
If nothing lands, work through this in order:
| Check | How |
|---|---|
| Key valid and pointed at the right workspace? | GET https://ingest.opexia.dev/v1/auth/whoami |
| Spans arriving at all? | spans_last_hour on ingestion health |
| Arriving but rejected? | dead_letter_count_last_hour > 0 → an attribute breaks the four rules |
| Using a collector? | Its otlphttp exporter must set encoding: json — protobuf returns 415 |
| Process exiting immediately? | Call force_flush() before exit, or spans die in the batch queue |