|
| 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) |
0 commit comments