mainwave 0.1.0rc1__py3-none-any.whl

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.
mainwave/__init__.py ADDED
@@ -0,0 +1,133 @@
1
+ """mainwave — Python SDK for the Mainwave AI observability platform.
2
+
3
+ Public surface (stable):
4
+ init, instrument, register_framework
5
+ run, arun, start_run, span, flush, aflush, Run
6
+ langchain.CallbackHandler (lazy via `from mainwave import langchain`)
7
+ testing (test helpers)
8
+ """
9
+
10
+ import logging as _logging
11
+
12
+ from . import _config, _instrumentors
13
+ from . import testing as testing # noqa: F401 (re-export module for users)
14
+ from ._config import init
15
+ from ._errors import MainwaveConfigError, MainwaveError, MainwaveReinitConflict
16
+ from ._flush import aflush, flush
17
+ from ._runtime import Run, arun, run, span, start_run
18
+
19
+ __version__ = _config._SDK_VERSION
20
+
21
+ _log = _logging.getLogger("mainwave")
22
+
23
+
24
+ def _active_injector():
25
+ """The attribute-injector processor that stamps framework names, or None if absent."""
26
+ from ._processors import MainwaveAttrInjector
27
+
28
+ procs = _config._state.mainwave_owned_processors
29
+ if procs and isinstance(procs[0], MainwaveAttrInjector):
30
+ return procs[0]
31
+ return None
32
+
33
+
34
+ def _stamp_frameworks(injector, names: list[str]) -> None:
35
+ """Record framework names (+ detected versions) on the injector, deduped."""
36
+ if injector is None:
37
+ return
38
+ injector.record_frameworks(names)
39
+
40
+
41
+ def register_framework(name: str) -> None:
42
+ """Stamp a framework name on spans without wiring an OpenInference instrumentor.
43
+
44
+ Use when the instrumentor for a framework is unavailable or broken but you
45
+ still want the framework to appear in telemetry (e.g. crewai>=1.x while
46
+ openinference-instrumentation-crewai is incompatible).
47
+ """
48
+ if not _config.is_initialized() or _config.is_disabled():
49
+ return
50
+ _stamp_frameworks(_active_injector(), [name])
51
+
52
+
53
+ def instrument(name: str | list[str] | None = None) -> list[str]:
54
+ """Wire OpenInference instrumentors after `init()`.
55
+
56
+ Use after a late import (lazy import, Click subcommand, FastAPI lifespan)
57
+ where the target library wasn't loaded at init() time.
58
+
59
+ - `name=None`: re-walk the full registry, instrumenting any newly-importable
60
+ packages. Returns the names that were newly wired this call.
61
+ - `name="openai"`: instrument exactly that platform. Raises KeyError if
62
+ the name isn't registered.
63
+ - `name=["crewai", "openai"]`: instrument each named platform (handy when a
64
+ framework needs more than one, e.g. CrewAI's structure instrumentor plus
65
+ its LLM-provider instrumentor). Raises KeyError before wiring anything if
66
+ any name is unregistered. Returns the subset that actually wired.
67
+
68
+ Returns the names that wired — a name whose package is missing or whose
69
+ installed version conflicts with the instrumentor is silently dropped from
70
+ the result (with a WARN from the wiring layer), not raised.
71
+
72
+ No-op when the SDK is disabled or not yet initialized (logs at WARN
73
+ if not initialized).
74
+ """
75
+ if not _config.is_initialized():
76
+ _log.warning("mainwave.instrument() called before init(); ignoring")
77
+ return []
78
+ if _config.is_disabled():
79
+ return []
80
+ provider = _config.get_provider()
81
+ if provider is None:
82
+ return []
83
+
84
+ injector = _active_injector()
85
+
86
+ if name is None:
87
+ newly_wired = _instrumentors.wire_instrumentors(provider)
88
+ _stamp_frameworks(injector, newly_wired)
89
+ if newly_wired:
90
+ _log.info("mainwave: instrumented %s", ", ".join(newly_wired))
91
+ return newly_wired
92
+
93
+ raw = [name] if isinstance(name, str) else list(name)
94
+ # Accept hyphen or underscore spellings ("llama-index" == "llama_index"):
95
+ # the install extra is hyphenated, so users naturally type hyphens.
96
+ names = [_instrumentors.normalize_name(n) for n in raw]
97
+ # Validate the whole list up front so a typo in one name doesn't leave the
98
+ # crew half-instrumented (KeyError after some have already wired).
99
+ unknown = [n for n in names if n not in _instrumentors.known_names()]
100
+ if unknown:
101
+ raise KeyError(unknown[0] if len(unknown) == 1 else unknown)
102
+
103
+ wired = [n for n in names if _instrumentors.wire_one(n, provider)]
104
+ _stamp_frameworks(injector, wired)
105
+ if wired:
106
+ _log.info("mainwave: instrumented %s", ", ".join(wired))
107
+ not_wired = [n for n in names if n not in wired]
108
+ if not_wired:
109
+ _log.warning(
110
+ "mainwave: requested instrumentor(s) not wired (package not importable "
111
+ "or version conflict): %s",
112
+ ", ".join(not_wired),
113
+ )
114
+ return wired
115
+
116
+
117
+ __all__ = [
118
+ "init",
119
+ "instrument",
120
+ "register_framework",
121
+ "run",
122
+ "arun",
123
+ "start_run",
124
+ "span",
125
+ "flush",
126
+ "aflush",
127
+ "Run",
128
+ "testing",
129
+ "MainwaveError",
130
+ "MainwaveConfigError",
131
+ "MainwaveReinitConflict",
132
+ "__version__",
133
+ ]
@@ -0,0 +1,164 @@
1
+ """Recover Anthropic extended-thinking content the OpenInference instrumentor drops.
2
+
3
+ `openinference-instrumentation-anthropic` enumerates a response's content blocks
4
+ into OpenInference message attributes, but does not currently emit the `thinking`
5
+ and `redacted_thinking` blocks — so an agent that calls Claude directly (or via
6
+ `AnthropicBedrock`) emits no chain-of-thought, unlike the native LangChain
7
+ handler (`mainwave.langchain`).
8
+
9
+ This module monkeypatches the two extractor seams in the installed instrumentor
10
+ so they additionally emit the model's reasoning as OpenInference reasoning
11
+ content parts, using the same `message_content.type = "reasoning"` shape — so
12
+ direct-Claude runs capture reasoning with no opt-in.
13
+
14
+ The patch is coupled to instrumentor internals, so it is defensive: if the seams
15
+ are missing (upstream renamed them) it logs and skips rather than crashing
16
+ instrumentation. `tests/test_anthropic_thinking.py` asserts capture works against
17
+ those seams, so a silent upstream shape change fails CI loudly."""
18
+
19
+ from __future__ import annotations
20
+
21
+ import logging
22
+ import os
23
+ from collections.abc import Iterator
24
+ from typing import Any
25
+
26
+ log = logging.getLogger("mainwave")
27
+
28
+ # A `redacted_thinking` block's payload is encrypted; we surface that the model
29
+ # reasoned but redacted it, never the opaque `data`.
30
+ _REDACTED_MARKER = "[redacted thinking]"
31
+
32
+ # Mirrors the langchain handler's content-capture knobs (own env var so the two
33
+ # paths tune independently).
34
+ _CONTENT_MAX_ENV = "MAINWAVE_ANTHROPIC_CONTENT_MAX"
35
+ _DEFAULT_CONTENT_MAX = 8192
36
+
37
+ # Sentinel marking a wrapped callable so re-wiring (instrument() called again)
38
+ # never double-wraps and double-emits.
39
+ _PATCHED = "_mainwave_thinking_patched"
40
+
41
+
42
+ def _max_len() -> int:
43
+ try:
44
+ return max(0, int(os.environ.get(_CONTENT_MAX_ENV, _DEFAULT_CONTENT_MAX)))
45
+ except ValueError:
46
+ return _DEFAULT_CONTENT_MAX
47
+
48
+
49
+ def _truncate(value: str) -> str:
50
+ limit = _max_len()
51
+ return value if len(value) <= limit else value[:limit] + "…[truncated]"
52
+
53
+
54
+ def _block_attr(block: Any, key: str) -> Any:
55
+ """Read a content-block field whether the block is a pydantic object
56
+ (the SDK response shape) or a plain dict (defensive / test shape)."""
57
+ if isinstance(block, dict):
58
+ return block.get(key)
59
+ return getattr(block, key, None)
60
+
61
+
62
+ def _reasoning_parts(content: Any) -> list[dict[str, str]]:
63
+ """Reasoning parts from a response's `content` list, in order, each
64
+ `{text, signature}`. `thinking` carries its text + Anthropic signature;
65
+ `redacted_thinking` carries a marker (never the encrypted payload). Anything
66
+ else (text, tool_use, …) is skipped. Returns [] when there is no reasoning."""
67
+ if not isinstance(content, (list, tuple)):
68
+ return []
69
+ parts: list[dict[str, str]] = []
70
+ for block in content:
71
+ block_type = _block_attr(block, "type")
72
+ if block_type == "thinking":
73
+ text = _block_attr(block, "thinking")
74
+ if not isinstance(text, str) or not text:
75
+ continue
76
+ sig = _block_attr(block, "signature")
77
+ parts.append({"text": text, "signature": sig if isinstance(sig, str) else ""})
78
+ elif block_type == "redacted_thinking":
79
+ parts.append({"text": _REDACTED_MARKER, "signature": ""})
80
+ return parts
81
+
82
+
83
+ def reasoning_attributes(content: Any) -> Iterator[tuple[str, Any]]:
84
+ """OpenInference reasoning content-part attributes for a response's content.
85
+
86
+ Emits `llm.output_messages.0.message.contents.{j}.message_content.{type,text}`
87
+ (plus `.signature` when present), per the OpenInference reasoning content-part
88
+ shape. The j index runs over reasoning parts only; the instrumentor's own text
89
+ branch emits the flat `message.content`, so the two never collide."""
90
+ for j, part in enumerate(_reasoning_parts(content)):
91
+ prefix = f"llm.output_messages.0.message.contents.{j}.message_content"
92
+ yield f"{prefix}.type", "reasoning"
93
+ yield f"{prefix}.text", _truncate(part["text"])
94
+ if part["signature"]:
95
+ yield f"{prefix}.signature", part["signature"]
96
+
97
+
98
+ def patch_anthropic_thinking() -> bool:
99
+ """Patch the installed Anthropic instrumentor to emit reasoning content parts.
100
+
101
+ Wraps the non-streaming output extractor and the streaming snapshot extractor
102
+ so both append reasoning parts after the upstream attributes. Idempotent and
103
+ defensive: returns False (and logs) if the package or a seam is missing, or if
104
+ already patched — never raises into the instrumentation path."""
105
+ try:
106
+ from openinference.instrumentation.anthropic import _stream, _wrappers
107
+ except ImportError:
108
+ return False
109
+
110
+ ok = True
111
+ ok &= _wrap_output_messages(_wrappers)
112
+ ok &= _wrap_stream_extractor(_stream)
113
+ return ok
114
+
115
+
116
+ def _wrap_output_messages(wrappers: Any) -> bool:
117
+ """Wrap `_wrappers._get_output_messages` (the non-streaming seam). Both the
118
+ sync and async message wrappers resolve it as a module global at call time,
119
+ so one patch covers both."""
120
+ orig = getattr(wrappers, "_get_output_messages", None)
121
+ if orig is None:
122
+ log.error(
123
+ "mainwave: openinference anthropic instrumentor has no "
124
+ "_get_output_messages; cannot capture thinking. Update the patch."
125
+ )
126
+ return False
127
+ if getattr(orig, _PATCHED, False):
128
+ return True
129
+
130
+ def patched(response: Any) -> Iterator[tuple[str, Any]]:
131
+ yield from orig(response)
132
+ yield from reasoning_attributes(getattr(response, "content", None))
133
+
134
+ setattr(patched, _PATCHED, True)
135
+ patched._mainwave_orig = orig # type: ignore[attr-defined]
136
+ wrappers._get_output_messages = patched
137
+ return True
138
+
139
+
140
+ def _wrap_stream_extractor(stream: Any) -> bool:
141
+ """Wrap `_stream._MessageExtractor.get_attributes` (the streaming seam),
142
+ which only handles text/tool_use blocks on the accumulated snapshot."""
143
+ extractor = getattr(stream, "_MessageExtractor", None)
144
+ orig = getattr(extractor, "get_attributes", None)
145
+ if extractor is None or orig is None:
146
+ log.error(
147
+ "mainwave: openinference anthropic instrumentor has no "
148
+ "_MessageExtractor.get_attributes; cannot capture streamed thinking. "
149
+ "Update the patch."
150
+ )
151
+ return False
152
+ if getattr(orig, _PATCHED, False):
153
+ return True
154
+
155
+ def patched(self: Any) -> Iterator[tuple[str, Any]]:
156
+ yield from orig(self)
157
+ snapshot = getattr(self, "_snapshot", None)
158
+ if snapshot is not None:
159
+ yield from reasoning_attributes(getattr(snapshot, "content", None))
160
+
161
+ setattr(patched, _PATCHED, True)
162
+ patched._mainwave_orig = orig # type: ignore[attr-defined]
163
+ extractor.get_attributes = patched
164
+ return True
mainwave/_config.py ADDED
@@ -0,0 +1,302 @@
1
+ """init() — read config, build TracerProvider (or coexist), wire processors.
2
+
3
+ Config-equality contract for idempotent re-init lives here too."""
4
+
5
+ import importlib.metadata
6
+ import logging
7
+ import os
8
+ import threading
9
+ import uuid
10
+ from dataclasses import dataclass
11
+ from typing import Any
12
+ from urllib.parse import urlsplit, urlunsplit
13
+
14
+ from opentelemetry import trace
15
+ from opentelemetry.sdk.resources import Resource
16
+ from opentelemetry.sdk.trace import TracerProvider
17
+
18
+ from ._errors import MainwaveConfigError, MainwaveReinitConflict
19
+
20
+ log = logging.getLogger("mainwave")
21
+
22
+ _DEFAULT_COLLECTOR = "https://collect.mainwave.ai"
23
+ _DEFAULT_ENV = "production"
24
+ _UCID_NAMESPACE = uuid.UUID("a1b2c3d4-e5f6-7890-abcd-ef1234567890")
25
+ _OTLP_TRACES_PATH = "/v1/traces"
26
+
27
+ try:
28
+ _SDK_VERSION = importlib.metadata.version("mainwave")
29
+ except importlib.metadata.PackageNotFoundError:
30
+ _SDK_VERSION = "0.0.0+unknown"
31
+
32
+
33
+ @dataclass(frozen=True)
34
+ class _Config:
35
+ api_key: str
36
+ use_case_id: str
37
+ ucid_source: str # "explicit" | "derived" | "" (disabled)
38
+ collector_url: str
39
+ service_name: str
40
+ deployment_environment: str
41
+ disabled: bool
42
+
43
+
44
+ @dataclass
45
+ class _State:
46
+ config: _Config | None = None
47
+ provider: TracerProvider | None = None
48
+ coexist_mode: bool = False
49
+ mainwave_owned_processors: list[Any] | None = None
50
+ # The MainwaveExporter inside the BatchSpanProcessor, kept so flush() can
51
+ # drain its trace-end buffers (the BSP's force_flush doesn't reach it).
52
+ mainwave_exporter: Any = None
53
+ init_lock: threading.Lock = None # type: ignore[assignment]
54
+
55
+ def __post_init__(self) -> None:
56
+ if self.init_lock is None:
57
+ self.init_lock = threading.Lock()
58
+
59
+
60
+ _state = _State()
61
+
62
+
63
+ def _normalize_collector_url(url: str) -> str:
64
+ """Lowercase host, strip trailing slash on path, drop default ports."""
65
+ parts = urlsplit(url)
66
+ if not parts.scheme or not parts.netloc:
67
+ raise MainwaveConfigError(f"collector_url is unparseable: {url!r}")
68
+ host = parts.hostname or ""
69
+ port = parts.port
70
+ if (parts.scheme == "http" and port == 80) or (parts.scheme == "https" and port == 443):
71
+ port = None
72
+ netloc = host.lower()
73
+ if port is not None:
74
+ netloc = f"{netloc}:{port}"
75
+ if parts.username:
76
+ userinfo = parts.username
77
+ if parts.password:
78
+ userinfo = f"{userinfo}:{parts.password}"
79
+ netloc = f"{userinfo}@{netloc}"
80
+ path = parts.path.rstrip("/")
81
+ return urlunsplit((parts.scheme.lower(), netloc, path, parts.query, parts.fragment))
82
+
83
+
84
+ def _coerce_bool(raw: str | None, *, name: str) -> bool:
85
+ if raw is None:
86
+ return False
87
+ if raw.lower() in {"1", "true", "yes", "on"}:
88
+ return True
89
+ if raw.lower() in {"0", "false", "no", "off", ""}:
90
+ return False
91
+ raise MainwaveConfigError(f"{name}={raw!r} is not a valid boolean")
92
+
93
+
94
+ def _build_config(
95
+ *,
96
+ api_key: str | None,
97
+ use_case_id: str | None,
98
+ collector_url: str | None,
99
+ service_name: str | None,
100
+ deployment_environment: str | None,
101
+ disabled: bool | None,
102
+ ) -> _Config:
103
+ api_key = api_key if api_key is not None else os.environ.get("MAINWAVE_API_KEY")
104
+ use_case_id = use_case_id if use_case_id is not None else os.environ.get("MAINWAVE_USE_CASE_ID")
105
+ collector_url = (
106
+ collector_url
107
+ if collector_url is not None
108
+ else os.environ.get("MAINWAVE_COLLECTOR_URL", _DEFAULT_COLLECTOR)
109
+ )
110
+ service_name = (
111
+ service_name
112
+ if service_name is not None
113
+ else os.environ.get("OTEL_SERVICE_NAME", "mainwave-app")
114
+ )
115
+ deployment_environment = (
116
+ deployment_environment
117
+ if deployment_environment is not None
118
+ else os.environ.get("MAINWAVE_ENVIRONMENT", _DEFAULT_ENV)
119
+ )
120
+ if disabled is None:
121
+ disabled = _coerce_bool(os.environ.get("MAINWAVE_DISABLED"), name="MAINWAVE_DISABLED")
122
+
123
+ ucid_source = ""
124
+ if not disabled:
125
+ if not api_key:
126
+ raise MainwaveConfigError("api_key is required (pass api_key= or set MAINWAVE_API_KEY)")
127
+ if not use_case_id:
128
+ use_case_id = str(uuid.uuid5(_UCID_NAMESPACE, f"{api_key}:{service_name}"))
129
+ ucid_source = "derived"
130
+ log.info(
131
+ "mainwave: use_case_id not set — derived stable ucid %s from api_key + service_name", # noqa: E501
132
+ use_case_id,
133
+ )
134
+ else:
135
+ ucid_source = "explicit"
136
+
137
+ normalized_url = _normalize_collector_url(collector_url)
138
+
139
+ return _Config(
140
+ api_key=api_key or "",
141
+ use_case_id=use_case_id or "",
142
+ ucid_source=ucid_source,
143
+ collector_url=normalized_url,
144
+ service_name=service_name,
145
+ deployment_environment=deployment_environment,
146
+ disabled=disabled,
147
+ )
148
+
149
+
150
+ def _build_resource(cfg: _Config) -> Resource:
151
+ return Resource(
152
+ attributes={
153
+ "service.name": cfg.service_name,
154
+ "deployment.environment": cfg.deployment_environment,
155
+ "mainwave.ucid": cfg.use_case_id,
156
+ "mainwave.ucid_source": cfg.ucid_source,
157
+ "mainwave.sdk.version": _SDK_VERSION,
158
+ "mainwave.sdk.language": "python",
159
+ }
160
+ )
161
+
162
+
163
+ def _is_real_provider(p: trace.TracerProvider) -> bool:
164
+ """True if the global provider is a real SDK TracerProvider (not the API
165
+ no-op default)."""
166
+ return isinstance(p, TracerProvider)
167
+
168
+
169
+ def init(
170
+ *,
171
+ api_key: str | None = None,
172
+ use_case_id: str | None = None,
173
+ collector_url: str | None = None,
174
+ service_name: str | None = None,
175
+ deployment_environment: str | None = None,
176
+ disabled: bool | None = None,
177
+ skip_instrumentors: list[str] | None = None,
178
+ ) -> None:
179
+ """Initialize the SDK. Idempotent on identical config; raises
180
+ MainwaveReinitConflict on diverging re-init.
181
+
182
+ Bad config (missing api_key, unparseable URL, malformed env bool) raises
183
+ MainwaveConfigError. Once initialized, runtime errors are swallowed
184
+ per the 'cannot break the host' guarantee.
185
+
186
+ `skip_instrumentors` names OpenInference instrumentors to leave unwired even
187
+ when their package is installed — for a framework you drive through an
188
+ explicit handler instead (e.g. `["langchain"]` when using
189
+ `mainwave.langchain.CallbackHandler`), so the two paths don't double-count.
190
+ It only affects the one-time wiring on first init, so it is not part of the
191
+ re-init equality contract.
192
+ """
193
+ cfg = _build_config(
194
+ api_key=api_key,
195
+ use_case_id=use_case_id,
196
+ collector_url=collector_url,
197
+ service_name=service_name,
198
+ deployment_environment=deployment_environment,
199
+ disabled=disabled,
200
+ )
201
+
202
+ with _state.init_lock:
203
+ if _state.config is not None:
204
+ if _state.config == cfg:
205
+ return
206
+ raise MainwaveReinitConflict(
207
+ "mainwave.init() was already called with a different config"
208
+ )
209
+
210
+ if cfg.disabled:
211
+ _state.config = cfg
212
+ log.info("mainwave: disabled; no spans will be exported")
213
+ return
214
+
215
+ from ._exporter import build_exporter
216
+ from ._instrumentors import wire_instrumentors
217
+ from ._processors import MainwaveAttrInjector
218
+
219
+ existing = trace.get_tracer_provider()
220
+ coexist = _is_real_provider(existing)
221
+
222
+ injector = MainwaveAttrInjector(
223
+ use_case_id=cfg.use_case_id, ucid_source=cfg.ucid_source, coexist=coexist
224
+ )
225
+ exporter_processor, mainwave_exporter = build_exporter(cfg)
226
+
227
+ if coexist:
228
+ log.info("mainwave: detected existing TracerProvider; running in coexist mode")
229
+ existing.add_span_processor(injector) # type: ignore[attr-defined]
230
+ existing.add_span_processor(exporter_processor)
231
+ _state.provider = existing # type: ignore[assignment]
232
+ _state.coexist_mode = True
233
+ else:
234
+ resource = _build_resource(cfg)
235
+ provider = TracerProvider(resource=resource)
236
+ provider.add_span_processor(injector)
237
+ provider.add_span_processor(exporter_processor)
238
+ trace.set_tracer_provider(provider)
239
+ _state.provider = provider
240
+ _state.coexist_mode = False
241
+
242
+ _state.mainwave_owned_processors = [injector, exporter_processor]
243
+ _state.mainwave_exporter = mainwave_exporter
244
+ _state.config = cfg
245
+
246
+ wired = wire_instrumentors(_state.provider, skip=skip_instrumentors)
247
+ if wired:
248
+ injector.record_frameworks(wired)
249
+ log.info("mainwave: instrumented %s", ", ".join(wired))
250
+
251
+ from ._flush import register_atexit
252
+
253
+ register_atexit()
254
+
255
+
256
+ def get_config() -> _Config | None:
257
+ return _state.config
258
+
259
+
260
+ def get_provider() -> TracerProvider | None:
261
+ return _state.provider
262
+
263
+
264
+ def get_mainwave_exporter() -> Any:
265
+ """The MainwaveExporter for the active init, or None (uninitialized,
266
+ disabled, or an in-memory test provider). flush() drains its buffers."""
267
+ return _state.mainwave_exporter
268
+
269
+
270
+ def is_initialized() -> bool:
271
+ return _state.config is not None
272
+
273
+
274
+ def is_disabled() -> bool:
275
+ return _state.config is not None and _state.config.disabled
276
+
277
+
278
+ def is_coexist_mode() -> bool:
279
+ return _state.coexist_mode
280
+
281
+
282
+ def reset_for_testing() -> None:
283
+ """Tear down state. For test fixtures only."""
284
+ with _state.init_lock:
285
+ if _state.provider is not None and not _state.coexist_mode:
286
+ try:
287
+ _state.provider.shutdown()
288
+ except Exception:
289
+ pass
290
+ _state.config = None
291
+ _state.provider = None
292
+ _state.coexist_mode = False
293
+ _state.mainwave_owned_processors = None
294
+ _state.mainwave_exporter = None
295
+ trace._TRACER_PROVIDER = None # type: ignore[attr-defined]
296
+ trace._PROXY_TRACER_PROVIDER._real_tracer_provider = None # type: ignore[attr-defined]
297
+ # Clear a possibly-leaked run contextvar: a test that intentionally leaks
298
+ # a start_run (no .end()) would otherwise leave _current_run set, making
299
+ # the next test's run()/start_run() look nested and skip its wrapper span.
300
+ from ._runtime import _current_run
301
+
302
+ _current_run.set(None)
mainwave/_errors.py ADDED
@@ -0,0 +1,15 @@
1
+ """Public exception types raised by the SDK."""
2
+
3
+
4
+ class MainwaveError(Exception):
5
+ """Base class for all SDK-raised exceptions."""
6
+
7
+
8
+ class MainwaveConfigError(MainwaveError):
9
+ """Raised at init() when configuration is invalid (missing api key,
10
+ unparseable collector URL, malformed env value)."""
11
+
12
+
13
+ class MainwaveReinitConflict(MainwaveError):
14
+ """Raised when init() is called a second time with a config that diverges
15
+ from the first call. Re-init with identical config is a no-op."""