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 +133 -0
- mainwave/_anthropic_thinking.py +164 -0
- mainwave/_config.py +302 -0
- mainwave/_errors.py +15 -0
- mainwave/_exporter.py +318 -0
- mainwave/_flush.py +67 -0
- mainwave/_instrumentors.py +130 -0
- mainwave/_processors.py +107 -0
- mainwave/_runtime.py +274 -0
- mainwave/langchain.py +632 -0
- mainwave/py.typed +0 -0
- mainwave/testing.py +70 -0
- mainwave-0.1.0rc1.dist-info/METADATA +147 -0
- mainwave-0.1.0rc1.dist-info/RECORD +16 -0
- mainwave-0.1.0rc1.dist-info/WHEEL +4 -0
- mainwave-0.1.0rc1.dist-info/licenses/LICENSE +202 -0
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."""
|