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.
@@ -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
+ ]
@@ -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"]