Skip to content

Commit 96fc8ee

Browse files
feat(closes OPEN-10364): make tracers init-based
1 parent 52a313b commit 96fc8ee

12 files changed

Lines changed: 481 additions & 3 deletions

src/openlayer/lib/__init__.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44
"init",
55
"configure",
66
"get_tracer_config",
7+
"auto_instrument",
8+
"unpatch_all",
79
"trace",
810
"trace_anthropic",
911
"trace_openai",
@@ -49,6 +51,9 @@
4951
configure = tracer.configure
5052
get_tracer_config = tracer.get_tracer_config
5153
trace = tracer.trace
54+
55+
# Auto-instrumentation entry points
56+
from .integrations._auto import auto_instrument, unpatch_all # noqa: E402
5257
trace_async = tracer.trace_async
5358
update_current_trace = tracer.update_current_trace
5459
update_current_step = tracer.update_current_step
Lines changed: 258 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,258 @@
1+
"""Auto-instrumentation registry and orchestrator.
2+
3+
This module powers ``openlayer.lib.init(auto_instrument=True)`` — when called,
4+
it walks a registry of supported LLM SDK integrations, detects which ones are
5+
installed (via ``importlib.util.find_spec``), and applies their patch function
6+
so that every newly-constructed client is auto-traced.
7+
8+
The two helper functions ``_patch_class_init`` and ``_unpatch_class_init`` are
9+
the shared patch/unpatch primitives used by each ``<x>_tracer.py`` module to
10+
wrap an SDK's client class ``__init__``. They are idempotent and reversible.
11+
"""
12+
13+
from __future__ import annotations
14+
15+
import functools
16+
import importlib
17+
import importlib.util
18+
import logging
19+
from dataclasses import dataclass
20+
from typing import Any, Callable, Dict, List, Optional, Tuple, Type, Union
21+
22+
logger = logging.getLogger(__name__)
23+
24+
25+
# Attribute marker placed on a client *class* once its __init__ has been
26+
# wrapped. Distinct from `_openlayer_patched` which marks individual *instances*
27+
# after a per-client wrap. Both are needed: the class marker prevents re-wrapping
28+
# __init__, the instance marker prevents re-wrapping methods if the user mixes
29+
# auto-instrument with an explicit trace_<x>(client) call on the same instance.
30+
_CLASS_PATCHED_ATTR = "_openlayer_class_patched"
31+
32+
33+
def _patch_class_init(
34+
cls: Type[Any],
35+
wrap_fn: Callable[[Any], Any],
36+
) -> None:
37+
"""Wrap ``cls.__init__`` so every newly-constructed instance is auto-traced.
38+
39+
Idempotent: re-applying on an already-patched class is a no-op. The wrap
40+
failure is caught and logged so a broken integration doesn't break client
41+
construction.
42+
"""
43+
if getattr(cls, _CLASS_PATCHED_ATTR, False):
44+
return
45+
46+
original_init = cls.__init__
47+
48+
@functools.wraps(original_init)
49+
def wrapped_init(self, *args, **kwargs): # type: ignore[no-untyped-def]
50+
original_init(self, *args, **kwargs)
51+
try:
52+
wrap_fn(self)
53+
except Exception as e: # pylint: disable=broad-except
54+
logger.warning(
55+
"Openlayer: failed to auto-trace %s instance: %s",
56+
cls.__name__,
57+
e,
58+
)
59+
60+
cls.__init__ = wrapped_init # type: ignore[method-assign]
61+
setattr(cls, _CLASS_PATCHED_ATTR, True)
62+
63+
64+
def _unpatch_class_init(cls: Type[Any]) -> None:
65+
"""Restore the original ``cls.__init__``. No-op if not patched."""
66+
if not getattr(cls, _CLASS_PATCHED_ATTR, False):
67+
return
68+
wrapped = cls.__init__
69+
original = getattr(wrapped, "__wrapped__", None)
70+
if original is not None:
71+
cls.__init__ = original # type: ignore[method-assign]
72+
try:
73+
delattr(cls, _CLASS_PATCHED_ATTR)
74+
except AttributeError:
75+
pass
76+
77+
78+
@dataclass(frozen=True)
79+
class IntegrationSpec:
80+
"""Declarative entry in the auto-instrumentation registry."""
81+
82+
name: str
83+
"""Public name users pass in ``auto_instrument=[...]``."""
84+
85+
probe: str
86+
"""Top-level package name used with ``importlib.util.find_spec`` to detect
87+
whether the SDK is installed. Only the first dotted segment is probed."""
88+
89+
patch: Callable[[], None]
90+
"""Idempotent function that patches the SDK's client class(es)."""
91+
92+
unpatch: Optional[Callable[[], None]] = None
93+
"""Optional restorer. Some integrations (litellm, portkey) don't have one
94+
today; they're still registered so the patch path works."""
95+
96+
97+
# ----------------------------- Lazy import helpers ----------------------------- #
98+
#
99+
# The registry uses lambdas that import the tracer module on first call. This
100+
# keeps the import-time cost of `openlayer.lib` low — we don't load every
101+
# LLM SDK's tracer module just because the user did `from openlayer.lib import init`.
102+
103+
104+
def _patch_via(module_name: str, attr: str) -> Callable[[], None]:
105+
"""Return a lazy patcher: imports ``.<module_name>`` and calls its ``attr``."""
106+
107+
def _do_patch() -> None:
108+
mod = importlib.import_module(f".{module_name}", package=__package__)
109+
getattr(mod, attr)()
110+
111+
return _do_patch
112+
113+
114+
# ----------------------------- Registry ----------------------------- #
115+
116+
REGISTRY: Tuple[IntegrationSpec, ...] = (
117+
IntegrationSpec(
118+
"openai",
119+
"openai",
120+
_patch_via("openai_tracer", "_patch_openai"),
121+
_patch_via("openai_tracer", "_unpatch_openai"),
122+
),
123+
IntegrationSpec(
124+
"anthropic",
125+
"anthropic",
126+
_patch_via("anthropic_tracer", "_patch_anthropic"),
127+
_patch_via("anthropic_tracer", "_unpatch_anthropic"),
128+
),
129+
IntegrationSpec(
130+
"mistral",
131+
"mistralai",
132+
_patch_via("mistral_tracer", "_patch_mistral"),
133+
_patch_via("mistral_tracer", "_unpatch_mistral"),
134+
),
135+
IntegrationSpec(
136+
"groq",
137+
"groq",
138+
_patch_via("groq_tracer", "_patch_groq"),
139+
_patch_via("groq_tracer", "_unpatch_groq"),
140+
),
141+
IntegrationSpec(
142+
"gemini",
143+
"google.generativeai",
144+
_patch_via("gemini_tracer", "_patch_gemini"),
145+
_patch_via("gemini_tracer", "_unpatch_gemini"),
146+
),
147+
IntegrationSpec(
148+
"oci",
149+
"oci",
150+
_patch_via("oci_tracer", "_patch_oci"),
151+
_patch_via("oci_tracer", "_unpatch_oci"),
152+
),
153+
IntegrationSpec(
154+
"azure_content_understanding",
155+
"azure.ai.contentunderstanding",
156+
_patch_via("azure_content_understanding_tracer", "_patch_acu"),
157+
_patch_via("azure_content_understanding_tracer", "_unpatch_acu"),
158+
),
159+
IntegrationSpec(
160+
"litellm",
161+
"litellm",
162+
_patch_via("litellm_tracer", "trace_litellm"),
163+
None,
164+
),
165+
IntegrationSpec(
166+
"portkey",
167+
"portkey_ai",
168+
_patch_via("portkey_tracer", "trace_portkey"),
169+
None,
170+
),
171+
IntegrationSpec(
172+
"google_adk",
173+
"google.adk",
174+
_patch_via("google_adk_tracer", "trace_google_adk"),
175+
_patch_via("google_adk_tracer", "unpatch_google_adk"),
176+
),
177+
)
178+
179+
180+
_REGISTRY_BY_NAME: Dict[str, IntegrationSpec] = {s.name: s for s in REGISTRY}
181+
182+
183+
def _is_installed(probe: str) -> bool:
184+
"""Return True if the full dotted ``probe`` path is importable.
185+
186+
Uses ``find_spec`` so the SDK itself is not imported at probe time — only
187+
the patch function imports it, and only when we've committed to patching.
188+
The full path matters for namespace-package collisions: e.g. ``google``
189+
can be installed via ``google.generativeai`` without ``google.adk`` being
190+
available, so we have to probe ``google.adk`` not just ``google``.
191+
"""
192+
try:
193+
return importlib.util.find_spec(probe) is not None
194+
except (ImportError, ValueError, ModuleNotFoundError):
195+
return False
196+
197+
198+
def auto_instrument(
199+
targets: Union[bool, List[str]] = True,
200+
) -> Dict[str, bool]:
201+
"""Detect and patch installed LLM SDKs so new client instances are auto-traced.
202+
203+
Args:
204+
targets: ``True`` (default) patches every installed supported SDK.
205+
``False`` is a no-op. A list of names patches only that subset
206+
(e.g. ``["openai", "anthropic"]``).
207+
208+
Returns:
209+
Dict mapping integration name to a boolean: ``True`` if patched
210+
successfully, ``False`` if skipped (not installed, or patch raised).
211+
"""
212+
if targets is False:
213+
return {}
214+
215+
if targets is True:
216+
enabled = {s.name for s in REGISTRY}
217+
else:
218+
enabled = set(targets)
219+
unknown = enabled - set(_REGISTRY_BY_NAME)
220+
if unknown:
221+
logger.warning(
222+
"Openlayer: unknown auto_instrument targets: %s",
223+
sorted(unknown),
224+
)
225+
enabled &= set(_REGISTRY_BY_NAME)
226+
227+
results: Dict[str, bool] = {}
228+
for spec in REGISTRY:
229+
if spec.name not in enabled:
230+
continue
231+
if not _is_installed(spec.probe):
232+
results[spec.name] = False
233+
logger.debug("Openlayer: skipped %s (not installed)", spec.name)
234+
continue
235+
try:
236+
spec.patch()
237+
results[spec.name] = True
238+
logger.info("Openlayer: auto-instrumented %s", spec.name)
239+
except Exception as e: # pylint: disable=broad-except
240+
logger.warning("Openlayer: failed to instrument %s: %s", spec.name, e)
241+
results[spec.name] = False
242+
return results
243+
244+
245+
def unpatch_all() -> None:
246+
"""Restore the original ``__init__`` for every patched integration.
247+
248+
Integrations without an unpatch function are skipped. After this call,
249+
previously-constructed (and already-traced) client instances are unaffected
250+
— only future construction goes through the restored ``__init__``.
251+
"""
252+
for spec in REGISTRY:
253+
if spec.unpatch is None:
254+
continue
255+
try:
256+
spec.unpatch()
257+
except Exception as e: # pylint: disable=broad-except
258+
logger.warning("Openlayer: failed to unpatch %s: %s", spec.name, e)

src/openlayer/lib/integrations/anthropic_tracer.py

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,10 @@ def trace_anthropic(
5353
raise ImportError(
5454
"Anthropic library is not installed. Please install it with: pip install anthropic"
5555
)
56-
56+
57+
if getattr(client, "_openlayer_patched", False) is True:
58+
return client
59+
5760
create_func = client.messages.create
5861

5962
@wraps(create_func)
@@ -76,9 +79,37 @@ def traced_create_func(*args, **kwargs):
7679
)
7780

7881
client.messages.create = traced_create_func
82+
client._openlayer_patched = True
7983
return client
8084

8185

86+
def _patch_anthropic() -> None:
87+
"""Patch ``anthropic.Anthropic`` (and async variant if available) ``__init__``
88+
so every newly-constructed instance is auto-traced. Idempotent."""
89+
if not HAVE_ANTHROPIC:
90+
return
91+
# pylint: disable=import-outside-toplevel
92+
from ._auto import _patch_class_init
93+
94+
_patch_class_init(anthropic.Anthropic, trace_anthropic)
95+
# Async variant: patch only if it exists in this anthropic version.
96+
async_cls = getattr(anthropic, "AsyncAnthropic", None)
97+
if async_cls is not None:
98+
_patch_class_init(async_cls, trace_anthropic)
99+
100+
101+
def _unpatch_anthropic() -> None:
102+
if not HAVE_ANTHROPIC:
103+
return
104+
# pylint: disable=import-outside-toplevel
105+
from ._auto import _unpatch_class_init
106+
107+
_unpatch_class_init(anthropic.Anthropic)
108+
async_cls = getattr(anthropic, "AsyncAnthropic", None)
109+
if async_cls is not None:
110+
_unpatch_class_init(async_cls)
111+
112+
82113
def handle_streaming_create(
83114
create_func: callable,
84115
*args,

src/openlayer/lib/integrations/async_openai_tracer.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,9 @@ def trace_async_openai(
7171
if not HAVE_OPENAI:
7272
raise ImportError("OpenAI library is not installed. Please install it with: pip install openai")
7373

74+
if getattr(client, "_openlayer_patched", False) is True:
75+
return client
76+
7477
is_azure_openai = isinstance(client, openai.AsyncAzureOpenAI)
7578

7679
# Patch Chat Completions API
@@ -174,6 +177,7 @@ async def traced_embeddings_create_func(*args, **kwargs):
174177

175178
client.embeddings.create = traced_embeddings_create_func
176179

180+
client._openlayer_patched = True
177181
return client
178182

179183

src/openlayer/lib/integrations/azure_content_understanding_tracer.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,9 @@ def trace_azure_content_understanding(
5757
"Please install it with: pip install azure-ai-contentunderstanding"
5858
)
5959

60+
if getattr(client, "_openlayer_patched", False) is True:
61+
return client
62+
6063
begin_analyze_func = client.begin_analyze
6164

6265
@wraps(begin_analyze_func)
@@ -109,9 +112,30 @@ def traced_result(*result_args, **result_kwargs):
109112
return poller
110113

111114
client.begin_analyze = traced_begin_analyze
115+
client._openlayer_patched = True
112116
return client
113117

114118

119+
def _patch_acu() -> None:
120+
"""Patch ``azure.ai.contentunderstanding.ContentUnderstandingClient.__init__``
121+
so every newly-constructed client is auto-traced. Idempotent."""
122+
if not HAVE_AZURE_CONTENT_UNDERSTANDING:
123+
return
124+
# pylint: disable=import-outside-toplevel
125+
from ._auto import _patch_class_init
126+
127+
_patch_class_init(ContentUnderstandingClient, trace_azure_content_understanding)
128+
129+
130+
def _unpatch_acu() -> None:
131+
if not HAVE_AZURE_CONTENT_UNDERSTANDING:
132+
return
133+
# pylint: disable=import-outside-toplevel
134+
from ._auto import _unpatch_class_init
135+
136+
_unpatch_class_init(ContentUnderstandingClient)
137+
138+
115139
def _extract_usage_from_poller(poller: Any) -> Dict[str, Any]:
116140
"""Extract UsageDetails from the LRO poller's final pipeline response.
117141

0 commit comments

Comments
 (0)