opexia-trace 0.1.0a1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- opexia_trace-0.1.0a1/PKG-INFO +178 -0
- opexia_trace-0.1.0a1/README.md +142 -0
- opexia_trace-0.1.0a1/opexia/__init__.py +0 -0
- opexia_trace-0.1.0a1/opexia/trace/__init__.py +112 -0
- opexia_trace-0.1.0a1/opexia/trace/_internal/__init__.py +0 -0
- opexia_trace-0.1.0a1/opexia/trace/_internal/config.py +33 -0
- opexia_trace-0.1.0a1/opexia/trace/_internal/queue.py +110 -0
- opexia_trace-0.1.0a1/opexia/trace/adapters/__init__.py +12 -0
- opexia_trace-0.1.0a1/opexia/trace/adapters/microsoft.py +155 -0
- opexia_trace-0.1.0a1/opexia/trace/adapters/reasonix.py +83 -0
- opexia_trace-0.1.0a1/opexia/trace/auto_instrument.py +262 -0
- opexia_trace-0.1.0a1/opexia/trace/context_manager.py +188 -0
- opexia_trace-0.1.0a1/opexia/trace/cost.py +33 -0
- opexia_trace-0.1.0a1/opexia/trace/decorator.py +83 -0
- opexia_trace-0.1.0a1/opexia/trace/exporter.py +28 -0
- opexia_trace-0.1.0a1/opexia/trace/schemas.py +70 -0
- opexia_trace-0.1.0a1/opexia_trace.egg-info/PKG-INFO +178 -0
- opexia_trace-0.1.0a1/opexia_trace.egg-info/SOURCES.txt +21 -0
- opexia_trace-0.1.0a1/opexia_trace.egg-info/dependency_links.txt +1 -0
- opexia_trace-0.1.0a1/opexia_trace.egg-info/requires.txt +17 -0
- opexia_trace-0.1.0a1/opexia_trace.egg-info/top_level.txt +1 -0
- opexia_trace-0.1.0a1/pyproject.toml +56 -0
- opexia_trace-0.1.0a1/setup.cfg +4 -0
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: opexia-trace
|
|
3
|
+
Version: 0.1.0a1
|
|
4
|
+
Summary: Pluggable reasoning-trace observability for LLM applications
|
|
5
|
+
Author: OpexIA
|
|
6
|
+
License: Apache-2.0
|
|
7
|
+
Project-URL: Homepage, https://github.com/star-56/deep_reasoning/tree/main/opexia_trace
|
|
8
|
+
Project-URL: Repository, https://github.com/star-56/deep_reasoning
|
|
9
|
+
Project-URL: Issues, https://github.com/star-56/deep_reasoning/issues
|
|
10
|
+
Keywords: observability,opentelemetry,llm,reasoning,tracing,otel
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: License :: OSI Approved :: Apache Software License
|
|
14
|
+
Classifier: Operating System :: OS Independent
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
20
|
+
Classifier: Topic :: System :: Monitoring
|
|
21
|
+
Requires-Python: >=3.10
|
|
22
|
+
Description-Content-Type: text/markdown
|
|
23
|
+
Requires-Dist: opentelemetry-api>=1.24
|
|
24
|
+
Requires-Dist: opentelemetry-sdk>=1.24
|
|
25
|
+
Requires-Dist: opentelemetry-exporter-otlp>=1.24
|
|
26
|
+
Requires-Dist: pydantic>=2.6
|
|
27
|
+
Requires-Dist: tiktoken>=0.6
|
|
28
|
+
Requires-Dist: httpx>=0.27
|
|
29
|
+
Provides-Extra: openllmetry
|
|
30
|
+
Requires-Dist: traceloop-sdk>=0.18; extra == "openllmetry"
|
|
31
|
+
Provides-Extra: litellm
|
|
32
|
+
Requires-Dist: litellm>=1.40; extra == "litellm"
|
|
33
|
+
Provides-Extra: adapters-microsoft
|
|
34
|
+
Provides-Extra: adapters-langchain
|
|
35
|
+
Requires-Dist: langchain-core>=0.2; extra == "adapters-langchain"
|
|
36
|
+
|
|
37
|
+
# opexia-trace
|
|
38
|
+
|
|
39
|
+
Pluggable reasoning-trace observability for LLM applications.
|
|
40
|
+
|
|
41
|
+
`opexia-trace` is the client SDK for the **OpexIA Observability Layer**. It
|
|
42
|
+
captures *reasoning-altitude* spans — the decisions an agent made, the sources
|
|
43
|
+
it used, what each step cost, and the inputs to a reliability score — and ships
|
|
44
|
+
them to an OpexIA backend over OTLP. Unlike raw LLM logging, the unit of
|
|
45
|
+
observation is the *reasoning step*, not the HTTP call.
|
|
46
|
+
|
|
47
|
+
## Install
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
pip install opexia-trace
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
## Quickstart
|
|
54
|
+
|
|
55
|
+
Call `init()` once at process startup:
|
|
56
|
+
|
|
57
|
+
```python
|
|
58
|
+
from opexia.trace import init
|
|
59
|
+
|
|
60
|
+
init(
|
|
61
|
+
org_id="pwc",
|
|
62
|
+
workspace_id="chatpwc",
|
|
63
|
+
project_id="due-diligence",
|
|
64
|
+
backend_url="https://opexia.internal.example.com",
|
|
65
|
+
api_key="opx_live_...", # your workspace API key
|
|
66
|
+
)
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
With `auto_instrument=True` (the default), every LLM call made through
|
|
70
|
+
litellm / anthropic / openai is now traced. No other code changes required.
|
|
71
|
+
|
|
72
|
+
## The three integration patterns
|
|
73
|
+
|
|
74
|
+
### Pattern A — Auto-instrument (zero code changes)
|
|
75
|
+
|
|
76
|
+
`init(auto_instrument=True)` monkey-patches the litellm / anthropic / openai
|
|
77
|
+
client methods at startup. Every completion call emits a `gen_ai.*` span
|
|
78
|
+
carrying the model, token usage, and computed USD cost. This is the default;
|
|
79
|
+
you get it just by calling `init()`.
|
|
80
|
+
|
|
81
|
+
### Pattern B — the `@observe` decorator
|
|
82
|
+
|
|
83
|
+
Wrap any function — sync or async — to emit a reasoning span for it:
|
|
84
|
+
|
|
85
|
+
```python
|
|
86
|
+
from opexia.trace import observe
|
|
87
|
+
|
|
88
|
+
@observe(reasoning_role="decomposer", node_type="decomposer", name="plan.decompose")
|
|
89
|
+
async def decompose(query: str) -> list[str]:
|
|
90
|
+
...
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
Nested `@observe` calls auto-parent via OpenTelemetry context — a decorated
|
|
94
|
+
function called inside another decorated function becomes its child span, so
|
|
95
|
+
the reasoning tree falls out of normal call structure.
|
|
96
|
+
|
|
97
|
+
### Pattern C — the `ReasoningTrace` context manager
|
|
98
|
+
|
|
99
|
+
For explicit, structured traces — when you want to record decisions, sources,
|
|
100
|
+
and costs by hand:
|
|
101
|
+
|
|
102
|
+
```python
|
|
103
|
+
from opexia.trace import ReasoningTrace
|
|
104
|
+
|
|
105
|
+
with ReasoningTrace(name="answer.query", reasoning_role="synthesis") as trace:
|
|
106
|
+
trace.record_cost(model="claude-opus-4-6", input_tokens=1200, output_tokens=400)
|
|
107
|
+
trace.record_sources(used=[...], consulted=[...], dropped=[...])
|
|
108
|
+
trace.record_decision(selected="opt_a", rules_fired=[...], scores={...})
|
|
109
|
+
with trace.subnode(name="retrieve", node_type="retriever") as node:
|
|
110
|
+
node.record_sources(...)
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
### Framework adapters
|
|
114
|
+
|
|
115
|
+
For agent frameworks, one line instruments a whole crew:
|
|
116
|
+
|
|
117
|
+
```python
|
|
118
|
+
from opexia.trace.adapters.microsoft import instrument_microsoft_agents
|
|
119
|
+
instrument_microsoft_agents(crew) # Microsoft Agent Framework
|
|
120
|
+
|
|
121
|
+
from opexia.trace.adapters.reasonix import instrument_reasonix
|
|
122
|
+
instrument_reasonix(orchestrator) # Reasonix orchestrator
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
Adapters are duck-typed — they import nothing from the framework, so they
|
|
126
|
+
never pin you to a version.
|
|
127
|
+
|
|
128
|
+
## Configuration — `init()` parameters
|
|
129
|
+
|
|
130
|
+
| Parameter | Default | Meaning |
|
|
131
|
+
|-----------|---------|---------|
|
|
132
|
+
| `org_id` / `workspace_id` / `project_id` | required | Tenancy identity stamped on every span. |
|
|
133
|
+
| `backend_url` | required | The OpexIA backend base URL. |
|
|
134
|
+
| `api_key` | required | Workspace API key (`opx_live_…` / `opx_test_…`). |
|
|
135
|
+
| `collector_endpoint` | `http://localhost:4317` | OTLP collector endpoint. |
|
|
136
|
+
| `wal_path` | `.opexia-wal/spans.jsonl` | Write-ahead-log path (see Durability). |
|
|
137
|
+
| `auto_instrument` | `True` | Patch litellm/anthropic/openai at startup. |
|
|
138
|
+
| `fail_open` | `False` | See "fail_open scope" in the FAQ. |
|
|
139
|
+
| `sampler_rate` | `1.0` | Fraction of traces sampled. |
|
|
140
|
+
|
|
141
|
+
## Durability — the write-ahead log
|
|
142
|
+
|
|
143
|
+
Spans are written to a local WAL (`wal_path`) before they are exported, so a
|
|
144
|
+
process crash or an unreachable collector does not lose spans — the next
|
|
145
|
+
`init()` replays any WAL left by a previous run. The WAL is why a dropped span
|
|
146
|
+
is treated as a *bug*, not an expected failure mode.
|
|
147
|
+
|
|
148
|
+
## Troubleshooting
|
|
149
|
+
|
|
150
|
+
- **No spans appear in the backend.** Check `backend_url` / `collector_endpoint`
|
|
151
|
+
reachability and that `api_key` is a live (not revoked) workspace key.
|
|
152
|
+
- **`init() has not been called`.** A `@observe` / `ReasoningTrace` ran before
|
|
153
|
+
`init()`. Call `init()` once at process startup, before any traced code.
|
|
154
|
+
- **Auto-instrument patched 0 clients.** None of litellm/anthropic/openai are
|
|
155
|
+
importable in the process — Pattern A has nothing to patch. Use Pattern B/C.
|
|
156
|
+
|
|
157
|
+
## FAQ
|
|
158
|
+
|
|
159
|
+
**Does `opexia-trace` import my agent framework?** No. The adapters are
|
|
160
|
+
duck-typed and import nothing from the framework or from the OpexIA backend.
|
|
161
|
+
|
|
162
|
+
**What does `fail_open` actually cover?** `fail_open=True` only suppresses a
|
|
163
|
+
failure of *auto-instrument registration* during `init()` — it does NOT wrap
|
|
164
|
+
the whole `init()` body. A bad `collector_endpoint` or a WAL-path I/O error
|
|
165
|
+
still raises. Treat `fail_open` as "don't let auto-instrument break my
|
|
166
|
+
startup," not "make `init()` never raise." (Tracked as deferred note B2-N5;
|
|
167
|
+
a future release may widen the scope.)
|
|
168
|
+
|
|
169
|
+
**Can I emit spans during process shutdown?** No. Do not start new spans after
|
|
170
|
+
your shutdown hook runs — a span started concurrently with the exporter
|
|
171
|
+
shutting down can be dropped (this race exists in upstream OpenTelemetry's
|
|
172
|
+
`BatchSpanProcessor` too). Finish traced work before tearing the process down.
|
|
173
|
+
(Tracked as deferred note B2-N7.)
|
|
174
|
+
|
|
175
|
+
**Is the SDK safe in production?** Yes — span export is buffered and
|
|
176
|
+
WAL-backed, and a traced function never fails because tracing failed:
|
|
177
|
+
attribute-extraction errors are caught and recorded on the span, and the
|
|
178
|
+
original return value passes through untouched.
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
# opexia-trace
|
|
2
|
+
|
|
3
|
+
Pluggable reasoning-trace observability for LLM applications.
|
|
4
|
+
|
|
5
|
+
`opexia-trace` is the client SDK for the **OpexIA Observability Layer**. It
|
|
6
|
+
captures *reasoning-altitude* spans — the decisions an agent made, the sources
|
|
7
|
+
it used, what each step cost, and the inputs to a reliability score — and ships
|
|
8
|
+
them to an OpexIA backend over OTLP. Unlike raw LLM logging, the unit of
|
|
9
|
+
observation is the *reasoning step*, not the HTTP call.
|
|
10
|
+
|
|
11
|
+
## Install
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
pip install opexia-trace
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## Quickstart
|
|
18
|
+
|
|
19
|
+
Call `init()` once at process startup:
|
|
20
|
+
|
|
21
|
+
```python
|
|
22
|
+
from opexia.trace import init
|
|
23
|
+
|
|
24
|
+
init(
|
|
25
|
+
org_id="pwc",
|
|
26
|
+
workspace_id="chatpwc",
|
|
27
|
+
project_id="due-diligence",
|
|
28
|
+
backend_url="https://opexia.internal.example.com",
|
|
29
|
+
api_key="opx_live_...", # your workspace API key
|
|
30
|
+
)
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
With `auto_instrument=True` (the default), every LLM call made through
|
|
34
|
+
litellm / anthropic / openai is now traced. No other code changes required.
|
|
35
|
+
|
|
36
|
+
## The three integration patterns
|
|
37
|
+
|
|
38
|
+
### Pattern A — Auto-instrument (zero code changes)
|
|
39
|
+
|
|
40
|
+
`init(auto_instrument=True)` monkey-patches the litellm / anthropic / openai
|
|
41
|
+
client methods at startup. Every completion call emits a `gen_ai.*` span
|
|
42
|
+
carrying the model, token usage, and computed USD cost. This is the default;
|
|
43
|
+
you get it just by calling `init()`.
|
|
44
|
+
|
|
45
|
+
### Pattern B — the `@observe` decorator
|
|
46
|
+
|
|
47
|
+
Wrap any function — sync or async — to emit a reasoning span for it:
|
|
48
|
+
|
|
49
|
+
```python
|
|
50
|
+
from opexia.trace import observe
|
|
51
|
+
|
|
52
|
+
@observe(reasoning_role="decomposer", node_type="decomposer", name="plan.decompose")
|
|
53
|
+
async def decompose(query: str) -> list[str]:
|
|
54
|
+
...
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
Nested `@observe` calls auto-parent via OpenTelemetry context — a decorated
|
|
58
|
+
function called inside another decorated function becomes its child span, so
|
|
59
|
+
the reasoning tree falls out of normal call structure.
|
|
60
|
+
|
|
61
|
+
### Pattern C — the `ReasoningTrace` context manager
|
|
62
|
+
|
|
63
|
+
For explicit, structured traces — when you want to record decisions, sources,
|
|
64
|
+
and costs by hand:
|
|
65
|
+
|
|
66
|
+
```python
|
|
67
|
+
from opexia.trace import ReasoningTrace
|
|
68
|
+
|
|
69
|
+
with ReasoningTrace(name="answer.query", reasoning_role="synthesis") as trace:
|
|
70
|
+
trace.record_cost(model="claude-opus-4-6", input_tokens=1200, output_tokens=400)
|
|
71
|
+
trace.record_sources(used=[...], consulted=[...], dropped=[...])
|
|
72
|
+
trace.record_decision(selected="opt_a", rules_fired=[...], scores={...})
|
|
73
|
+
with trace.subnode(name="retrieve", node_type="retriever") as node:
|
|
74
|
+
node.record_sources(...)
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
### Framework adapters
|
|
78
|
+
|
|
79
|
+
For agent frameworks, one line instruments a whole crew:
|
|
80
|
+
|
|
81
|
+
```python
|
|
82
|
+
from opexia.trace.adapters.microsoft import instrument_microsoft_agents
|
|
83
|
+
instrument_microsoft_agents(crew) # Microsoft Agent Framework
|
|
84
|
+
|
|
85
|
+
from opexia.trace.adapters.reasonix import instrument_reasonix
|
|
86
|
+
instrument_reasonix(orchestrator) # Reasonix orchestrator
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
Adapters are duck-typed — they import nothing from the framework, so they
|
|
90
|
+
never pin you to a version.
|
|
91
|
+
|
|
92
|
+
## Configuration — `init()` parameters
|
|
93
|
+
|
|
94
|
+
| Parameter | Default | Meaning |
|
|
95
|
+
|-----------|---------|---------|
|
|
96
|
+
| `org_id` / `workspace_id` / `project_id` | required | Tenancy identity stamped on every span. |
|
|
97
|
+
| `backend_url` | required | The OpexIA backend base URL. |
|
|
98
|
+
| `api_key` | required | Workspace API key (`opx_live_…` / `opx_test_…`). |
|
|
99
|
+
| `collector_endpoint` | `http://localhost:4317` | OTLP collector endpoint. |
|
|
100
|
+
| `wal_path` | `.opexia-wal/spans.jsonl` | Write-ahead-log path (see Durability). |
|
|
101
|
+
| `auto_instrument` | `True` | Patch litellm/anthropic/openai at startup. |
|
|
102
|
+
| `fail_open` | `False` | See "fail_open scope" in the FAQ. |
|
|
103
|
+
| `sampler_rate` | `1.0` | Fraction of traces sampled. |
|
|
104
|
+
|
|
105
|
+
## Durability — the write-ahead log
|
|
106
|
+
|
|
107
|
+
Spans are written to a local WAL (`wal_path`) before they are exported, so a
|
|
108
|
+
process crash or an unreachable collector does not lose spans — the next
|
|
109
|
+
`init()` replays any WAL left by a previous run. The WAL is why a dropped span
|
|
110
|
+
is treated as a *bug*, not an expected failure mode.
|
|
111
|
+
|
|
112
|
+
## Troubleshooting
|
|
113
|
+
|
|
114
|
+
- **No spans appear in the backend.** Check `backend_url` / `collector_endpoint`
|
|
115
|
+
reachability and that `api_key` is a live (not revoked) workspace key.
|
|
116
|
+
- **`init() has not been called`.** A `@observe` / `ReasoningTrace` ran before
|
|
117
|
+
`init()`. Call `init()` once at process startup, before any traced code.
|
|
118
|
+
- **Auto-instrument patched 0 clients.** None of litellm/anthropic/openai are
|
|
119
|
+
importable in the process — Pattern A has nothing to patch. Use Pattern B/C.
|
|
120
|
+
|
|
121
|
+
## FAQ
|
|
122
|
+
|
|
123
|
+
**Does `opexia-trace` import my agent framework?** No. The adapters are
|
|
124
|
+
duck-typed and import nothing from the framework or from the OpexIA backend.
|
|
125
|
+
|
|
126
|
+
**What does `fail_open` actually cover?** `fail_open=True` only suppresses a
|
|
127
|
+
failure of *auto-instrument registration* during `init()` — it does NOT wrap
|
|
128
|
+
the whole `init()` body. A bad `collector_endpoint` or a WAL-path I/O error
|
|
129
|
+
still raises. Treat `fail_open` as "don't let auto-instrument break my
|
|
130
|
+
startup," not "make `init()` never raise." (Tracked as deferred note B2-N5;
|
|
131
|
+
a future release may widen the scope.)
|
|
132
|
+
|
|
133
|
+
**Can I emit spans during process shutdown?** No. Do not start new spans after
|
|
134
|
+
your shutdown hook runs — a span started concurrently with the exporter
|
|
135
|
+
shutting down can be dropped (this race exists in upstream OpenTelemetry's
|
|
136
|
+
`BatchSpanProcessor` too). Finish traced work before tearing the process down.
|
|
137
|
+
(Tracked as deferred note B2-N7.)
|
|
138
|
+
|
|
139
|
+
**Is the SDK safe in production?** Yes — span export is buffered and
|
|
140
|
+
WAL-backed, and a traced function never fails because tracing failed:
|
|
141
|
+
attribute-extraction errors are caught and recorded on the span, and the
|
|
142
|
+
original return value passes through untouched.
|
|
File without changes
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
"""opexia-trace — pluggable reasoning-trace observability."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
from opentelemetry import trace as ot_trace
|
|
4
|
+
from opentelemetry.sdk.resources import Resource
|
|
5
|
+
from opentelemetry.sdk.trace import TracerProvider
|
|
6
|
+
from opexia.trace.exporter import OpexiaSpanExporter
|
|
7
|
+
from opexia.trace._internal.queue import (
|
|
8
|
+
DurableBatchSpanProcessor,
|
|
9
|
+
replay_wal_if_exists,
|
|
10
|
+
)
|
|
11
|
+
from opexia.trace._internal.config import OpexiaConfig, set_config, get_config
|
|
12
|
+
from opexia.trace.schemas import (
|
|
13
|
+
OpexiaSpanAttributes, DecisionBasis, ReliabilitySignal,
|
|
14
|
+
SourceAssessment, CostBreakdown,
|
|
15
|
+
)
|
|
16
|
+
from opexia.trace.decorator import observe
|
|
17
|
+
from opexia.trace.context_manager import ReasoningTrace
|
|
18
|
+
from opexia.trace.cost import estimate_cost_usd, pricing_version
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def init(
|
|
22
|
+
*, org_id: str, workspace_id: str, project_id: str,
|
|
23
|
+
backend_url: str, api_key: str,
|
|
24
|
+
collector_endpoint: str = "http://localhost:4317",
|
|
25
|
+
wal_path: str = ".opexia-wal/spans.jsonl",
|
|
26
|
+
auto_instrument: bool = True, fail_open: bool = False,
|
|
27
|
+
sampler_rate: float = 1.0,
|
|
28
|
+
) -> None:
|
|
29
|
+
cfg = OpexiaConfig(
|
|
30
|
+
org_id=org_id, workspace_id=workspace_id, project_id=project_id,
|
|
31
|
+
backend_url=backend_url, api_key=api_key,
|
|
32
|
+
collector_endpoint=collector_endpoint, wal_path=wal_path,
|
|
33
|
+
auto_instrument=auto_instrument, fail_open=fail_open,
|
|
34
|
+
sampler_rate=sampler_rate,
|
|
35
|
+
)
|
|
36
|
+
set_config(cfg)
|
|
37
|
+
|
|
38
|
+
# Fetch the workspace capture_text opt-in from the backend (fail-closed).
|
|
39
|
+
try:
|
|
40
|
+
import httpx
|
|
41
|
+
_r = httpx.get(
|
|
42
|
+
f"{cfg.backend_url.rstrip('/')}/v1/workspaces/{cfg.workspace_id}/sdk-config",
|
|
43
|
+
headers={"x-opexia-api-key": cfg.api_key}, timeout=5.0)
|
|
44
|
+
if _r.status_code == 200:
|
|
45
|
+
cfg.capture_text = bool(_r.json().get("capture_text", False))
|
|
46
|
+
except Exception:
|
|
47
|
+
cfg.capture_text = False
|
|
48
|
+
|
|
49
|
+
# B2-N5: fail_open wraps the WHOLE init body below — exporter construction,
|
|
50
|
+
# provider setup, WAL replay, and auto-instrument — not just auto-instrument
|
|
51
|
+
# registration. With fail_open=True a bad collector endpoint, a WAL I/O
|
|
52
|
+
# error, or an exporter-constructor failure degrades gracefully instead of
|
|
53
|
+
# taking down customer startup. set_config() above runs FIRST and outside
|
|
54
|
+
# this guard, so get_config() still works in the degraded state.
|
|
55
|
+
try:
|
|
56
|
+
resource = Resource.create({
|
|
57
|
+
"service.name": f"opexia.{org_id}.{workspace_id}.{project_id}",
|
|
58
|
+
"opexia.org_id": org_id,
|
|
59
|
+
"opexia.workspace_id": workspace_id,
|
|
60
|
+
"opexia.project_id": project_id,
|
|
61
|
+
})
|
|
62
|
+
provider = TracerProvider(resource=resource)
|
|
63
|
+
exporter = OpexiaSpanExporter(
|
|
64
|
+
endpoint=collector_endpoint,
|
|
65
|
+
headers={"x-opexia-api-key": api_key},
|
|
66
|
+
insecure=collector_endpoint.startswith("http://"),
|
|
67
|
+
)
|
|
68
|
+
processor = DurableBatchSpanProcessor(exporter=exporter, wal_path=wal_path)
|
|
69
|
+
provider.add_span_processor(processor)
|
|
70
|
+
ot_trace.set_tracer_provider(provider)
|
|
71
|
+
|
|
72
|
+
# Replay any WAL from a previous crashed process
|
|
73
|
+
replayed = replay_wal_if_exists(wal_path, exporter)
|
|
74
|
+
if replayed:
|
|
75
|
+
import logging
|
|
76
|
+
logging.getLogger(__name__).warning(
|
|
77
|
+
"opexia.trace: replayed %d WAL entries from previous run", replayed
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
if auto_instrument:
|
|
81
|
+
# ImportError on the auto_instrument MODULE is benign (the feature
|
|
82
|
+
# is simply unavailable) — log debug and continue. A runtime error
|
|
83
|
+
# from register_auto_instrument() itself propagates to the
|
|
84
|
+
# fail_open guard below (the B5-N5 distinction).
|
|
85
|
+
try:
|
|
86
|
+
from opexia.trace.auto_instrument import register_auto_instrument
|
|
87
|
+
except ImportError:
|
|
88
|
+
import logging
|
|
89
|
+
logging.getLogger(__name__).debug(
|
|
90
|
+
"opexia.trace.auto_instrument module not available"
|
|
91
|
+
)
|
|
92
|
+
else:
|
|
93
|
+
count = register_auto_instrument()
|
|
94
|
+
import logging
|
|
95
|
+
logging.getLogger(__name__).info(
|
|
96
|
+
"opexia.trace: auto-instrumented %d LLM client methods", count
|
|
97
|
+
)
|
|
98
|
+
except Exception:
|
|
99
|
+
import logging
|
|
100
|
+
logging.getLogger(__name__).exception("opexia.trace: init failed")
|
|
101
|
+
if not fail_open:
|
|
102
|
+
raise
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
__all__ = [
|
|
106
|
+
"init", "get_config",
|
|
107
|
+
"OpexiaSpanExporter",
|
|
108
|
+
"OpexiaSpanAttributes", "DecisionBasis", "ReliabilitySignal",
|
|
109
|
+
"SourceAssessment", "CostBreakdown",
|
|
110
|
+
"observe", "ReasoningTrace",
|
|
111
|
+
"estimate_cost_usd", "pricing_version",
|
|
112
|
+
]
|
|
File without changes
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# opexia_trace/opexia/trace/_internal/config.py
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import Optional
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass
|
|
8
|
+
class OpexiaConfig:
|
|
9
|
+
org_id: str
|
|
10
|
+
workspace_id: str
|
|
11
|
+
project_id: str
|
|
12
|
+
backend_url: str
|
|
13
|
+
api_key: str
|
|
14
|
+
collector_endpoint: str = "http://localhost:4317"
|
|
15
|
+
wal_path: str = ".opexia-wal/spans.jsonl"
|
|
16
|
+
auto_instrument: bool = True
|
|
17
|
+
fail_open: bool = False
|
|
18
|
+
sampler_rate: float = 1.0
|
|
19
|
+
capture_text: bool = False # set from the backend sdk-config fetch (Task 6)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
_GLOBAL: Optional[OpexiaConfig] = None
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def set_config(cfg: OpexiaConfig) -> None:
|
|
26
|
+
global _GLOBAL
|
|
27
|
+
_GLOBAL = cfg
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def get_config() -> OpexiaConfig:
|
|
31
|
+
if _GLOBAL is None:
|
|
32
|
+
raise RuntimeError("opexia.trace.init() has not been called")
|
|
33
|
+
return _GLOBAL
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
# opexia_trace/opexia/trace/_internal/queue.py
|
|
2
|
+
"""Disk-backed durability wrapper for span export.
|
|
3
|
+
|
|
4
|
+
Writes a JSON line per span to a WAL file BEFORE handing the span to the
|
|
5
|
+
BatchSpanProcessor. On clean shutdown, truncates entries that exported
|
|
6
|
+
successfully. On crash/restart, init() replays unfinished WAL entries
|
|
7
|
+
through the exporter.
|
|
8
|
+
|
|
9
|
+
Invariant (spec §5.1): the SDK never loses a span on process restart.
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
import os, threading, time
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from opentelemetry.sdk.trace import ReadableSpan
|
|
15
|
+
from opentelemetry.sdk.trace.export import BatchSpanProcessor, SpanExporter
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class DurableBatchSpanProcessor(BatchSpanProcessor):
|
|
19
|
+
def __init__(self, exporter: SpanExporter, wal_path: str,
|
|
20
|
+
max_queue_size: int = 2048, max_export_batch_size: int = 512,
|
|
21
|
+
max_wal_bytes: int = 10_000_000):
|
|
22
|
+
super().__init__(
|
|
23
|
+
span_exporter=exporter,
|
|
24
|
+
max_queue_size=max_queue_size,
|
|
25
|
+
max_export_batch_size=max_export_batch_size,
|
|
26
|
+
)
|
|
27
|
+
self._wal_path = Path(wal_path)
|
|
28
|
+
self._wal_path.parent.mkdir(parents=True, exist_ok=True)
|
|
29
|
+
self._wal_lock = threading.Lock()
|
|
30
|
+
self._wal_fp = open(self._wal_path, "a", encoding="utf-8")
|
|
31
|
+
# B2-N6: disk-backed WAL size cap (~10MB default). Past the cap the WAL
|
|
32
|
+
# write is REFUSED — the span still goes to the in-memory BSP queue for
|
|
33
|
+
# export, only its crash-durability is lost — and a drop counter
|
|
34
|
+
# advances. Refuse-new beats rewrite-to-drop-oldest: the latter would
|
|
35
|
+
# rewrite the whole file under the lock on the hot path.
|
|
36
|
+
self._max_wal_bytes = max_wal_bytes
|
|
37
|
+
self._wal_bytes = self._wal_fp.tell() # current on-disk size
|
|
38
|
+
self._wal_dropped = 0
|
|
39
|
+
self._wal_full_warned = False
|
|
40
|
+
# B2-N7: set True once shutdown() begins; on_end() gates on it under
|
|
41
|
+
# the same lock so no span is written/enqueued after shutdown started.
|
|
42
|
+
self._is_shutdown = False
|
|
43
|
+
|
|
44
|
+
def on_end(self, span: ReadableSpan) -> None:
|
|
45
|
+
# B2-N7: the WAL write AND super().on_end() (the in-memory enqueue)
|
|
46
|
+
# both run under _wal_lock. A concurrent shutdown() blocks on the lock
|
|
47
|
+
# until this completes; a span arriving once shutdown has begun sees
|
|
48
|
+
# _is_shutdown and is dropped cleanly here — never enqueued into an
|
|
49
|
+
# already-shut-down processor, and never left on a WAL line that the
|
|
50
|
+
# shutdown unlink() then deletes.
|
|
51
|
+
with self._wal_lock:
|
|
52
|
+
if self._is_shutdown:
|
|
53
|
+
return
|
|
54
|
+
line = span.to_json(indent=None) + "\n"
|
|
55
|
+
encoded_len = len(line.encode("utf-8"))
|
|
56
|
+
# B2-N6: refuse the WAL write past the size cap. The span is still
|
|
57
|
+
# exported (super().on_end below) — only crash-durability is lost.
|
|
58
|
+
if self._wal_bytes + encoded_len > self._max_wal_bytes:
|
|
59
|
+
self._wal_dropped += 1
|
|
60
|
+
if not self._wal_full_warned:
|
|
61
|
+
import logging
|
|
62
|
+
logging.getLogger(__name__).warning(
|
|
63
|
+
"opexia.trace: WAL size cap (%d bytes) reached — new "
|
|
64
|
+
"spans are still exported but no longer crash-durable "
|
|
65
|
+
"until the WAL drains.", self._max_wal_bytes,
|
|
66
|
+
)
|
|
67
|
+
self._wal_full_warned = True
|
|
68
|
+
else:
|
|
69
|
+
try:
|
|
70
|
+
self._wal_fp.write(line)
|
|
71
|
+
self._wal_fp.flush()
|
|
72
|
+
os.fsync(self._wal_fp.fileno())
|
|
73
|
+
self._wal_bytes += encoded_len
|
|
74
|
+
except Exception:
|
|
75
|
+
# WAL failure must not crash the customer process.
|
|
76
|
+
pass
|
|
77
|
+
super().on_end(span)
|
|
78
|
+
|
|
79
|
+
def shutdown(self) -> None:
|
|
80
|
+
# B2-N7: the whole shutdown sequence runs under _wal_lock, and
|
|
81
|
+
# _is_shutdown is set FIRST. A racing on_end() either already finished
|
|
82
|
+
# (its span is in the WAL + the BSP queue) or blocks on the lock and
|
|
83
|
+
# then drops cleanly — closing the write-after-shutdown race.
|
|
84
|
+
with self._wal_lock:
|
|
85
|
+
self._is_shutdown = True
|
|
86
|
+
super().shutdown()
|
|
87
|
+
try:
|
|
88
|
+
self._wal_fp.close()
|
|
89
|
+
# On clean shutdown, drop the WAL — all spans exported.
|
|
90
|
+
self._wal_path.unlink(missing_ok=True)
|
|
91
|
+
except Exception:
|
|
92
|
+
pass
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def replay_wal_if_exists(wal_path: str, _exporter: SpanExporter) -> int:
|
|
96
|
+
"""Called on init(): re-export WAL entries from a prior crash.
|
|
97
|
+
Returns the number of spans replayed.
|
|
98
|
+
"""
|
|
99
|
+
p = Path(wal_path)
|
|
100
|
+
if not p.exists():
|
|
101
|
+
return 0
|
|
102
|
+
count = 0
|
|
103
|
+
# MVP: log-only replay; full OTLP serialization round-trip is a Batch 22 hardening item.
|
|
104
|
+
# For v1 we record the line count and rename the file to <wal>.replayed-<ts> so
|
|
105
|
+
# operators can manually re-emit if needed.
|
|
106
|
+
with open(p, "r", encoding="utf-8") as f:
|
|
107
|
+
for _ in f:
|
|
108
|
+
count += 1
|
|
109
|
+
p.rename(str(p) + f".replayed-{int(time.time())}")
|
|
110
|
+
return count
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""Framework adapters for opexia-trace.
|
|
2
|
+
|
|
3
|
+
Each adapter instruments one agent framework's crew/orchestrator to emit
|
|
4
|
+
opexia reasoning spans, importing nothing from the framework itself (duck-typed).
|
|
5
|
+
|
|
6
|
+
B16-N3: the adapters are re-exported here so callers can use the short form
|
|
7
|
+
``from opexia.trace.adapters import instrument_microsoft_agents``.
|
|
8
|
+
"""
|
|
9
|
+
from opexia.trace.adapters.microsoft import instrument_microsoft_agents
|
|
10
|
+
from opexia.trace.adapters.reasonix import instrument_reasonix
|
|
11
|
+
|
|
12
|
+
__all__ = ["instrument_microsoft_agents", "instrument_reasonix"]
|