|
1 | | -from typing import Optional |
| 1 | +""" |
| 2 | +AgentOps Instrumentation Module |
| 3 | +
|
| 4 | +This module provides automatic instrumentation for various LLM providers and agentic libraries. |
| 5 | +It works by monitoring Python imports and automatically instrumenting packages as they are imported. |
| 6 | +
|
| 7 | +Key Features: |
| 8 | +- Automatic detection and instrumentation of LLM providers (OpenAI, Anthropic, etc.) |
| 9 | +- Support for agentic libraries (CrewAI, AutoGen, etc.) |
| 10 | +- Version-aware instrumentation (only activates for supported versions) |
| 11 | +- Smart handling of provider vs agentic library conflicts |
| 12 | +- Non-intrusive monitoring using Python's import system |
| 13 | +""" |
| 14 | + |
| 15 | +from typing import Optional, Set, TypedDict |
2 | 16 | from types import ModuleType |
3 | 17 | from dataclasses import dataclass |
4 | 18 | import importlib |
| 19 | +import sys |
| 20 | +from importlib.metadata import version |
| 21 | +from packaging.version import Version, parse |
| 22 | +import builtins |
5 | 23 |
|
6 | 24 | from opentelemetry.instrumentation.instrumentor import BaseInstrumentor # type: ignore |
7 | 25 |
|
8 | 26 | from agentops.logging import logger |
9 | 27 | from agentops.sdk.core import TracingCore |
10 | 28 |
|
11 | 29 |
|
12 | | -# references to all active instrumentors |
| 30 | +# Module-level state variables |
13 | 31 | _active_instrumentors: list[BaseInstrumentor] = [] |
| 32 | +_original_builtins_import = builtins.__import__ # Store original import |
| 33 | +_instrumenting_packages: Set[str] = set() |
| 34 | +_has_agentic_library: bool = False |
14 | 35 |
|
15 | 36 |
|
16 | | -@dataclass |
17 | | -class InstrumentorLoader: |
| 37 | +def _is_package_instrumented(package_name: str) -> bool: |
| 38 | + """Check if a package is already instrumented by looking at active instrumentors.""" |
| 39 | + return any( |
| 40 | + instrumentor.__class__.__name__.lower().startswith(package_name.lower()) |
| 41 | + for instrumentor in _active_instrumentors |
| 42 | + ) |
| 43 | + |
| 44 | + |
| 45 | +def _uninstrument_providers(): |
| 46 | + """Uninstrument all provider instrumentors while keeping agentic libraries active.""" |
| 47 | + global _active_instrumentors |
| 48 | + providers_to_remove = [] |
| 49 | + for instrumentor in _active_instrumentors: |
| 50 | + if any(instrumentor.__class__.__name__.lower().startswith(provider.lower()) for provider in PROVIDERS.keys()): |
| 51 | + instrumentor.uninstrument() |
| 52 | + logger.debug(f"Uninstrumented provider {instrumentor.__class__.__name__}") |
| 53 | + providers_to_remove.append(instrumentor) |
| 54 | + |
| 55 | + _active_instrumentors = [i for i in _active_instrumentors if i not in providers_to_remove] |
| 56 | + |
| 57 | + |
| 58 | +def _should_instrument_package(package_name: str) -> bool: |
18 | 59 | """ |
19 | | - Represents a dynamically-loadable instrumentor. |
| 60 | + Determine if a package should be instrumented based on current state. |
| 61 | + Handles special cases for agentic libraries and providers. |
| 62 | + """ |
| 63 | + global _has_agentic_library |
| 64 | + # If this is an agentic library, uninstrument all providers first |
| 65 | + if package_name in AGENTIC_LIBRARIES: |
| 66 | + _uninstrument_providers() |
| 67 | + _has_agentic_library = True |
| 68 | + logger.debug(f"Uninstrumented all providers due to agentic library {package_name} detection") |
| 69 | + return True |
| 70 | + |
| 71 | + # Skip providers if an agentic library is already instrumented |
| 72 | + if package_name in PROVIDERS and _has_agentic_library: |
| 73 | + logger.debug(f"Skipping provider {package_name} instrumentation as an agentic library is already instrumented") |
| 74 | + return False |
| 75 | + |
| 76 | + # Skip if already instrumented |
| 77 | + if _is_package_instrumented(package_name): |
| 78 | + logger.debug(f"Package {package_name} is already instrumented") |
| 79 | + return False |
| 80 | + |
| 81 | + return True |
| 82 | + |
| 83 | + |
| 84 | +def _perform_instrumentation(package_name: str): |
| 85 | + """Helper function to perform instrumentation for a given package.""" |
| 86 | + global _instrumenting_packages, _active_instrumentors |
| 87 | + if not _should_instrument_package(package_name): |
| 88 | + return |
| 89 | + |
| 90 | + # Get the appropriate configuration for the package |
| 91 | + config = PROVIDERS.get(package_name) or AGENTIC_LIBRARIES[package_name] |
| 92 | + loader = InstrumentorLoader(**config) |
| 93 | + |
| 94 | + if loader.should_activate: |
| 95 | + instrumentor = instrument_one(loader) # instrument_one is already a module function |
| 96 | + if instrumentor is not None: |
| 97 | + _active_instrumentors.append(instrumentor) |
| 98 | + |
| 99 | + |
| 100 | +def _import_monitor(name: str, globals_dict=None, locals_dict=None, fromlist=(), level=0): |
| 101 | + """ |
| 102 | + Monitor imports and instrument packages as they are imported. |
| 103 | + This replaces the built-in import function to intercept package imports. |
| 104 | + """ |
| 105 | + global _instrumenting_packages |
| 106 | + root = name.split(".", 1)[0] |
| 107 | + |
| 108 | + # Skip providers if an agentic library is already instrumented |
| 109 | + if _has_agentic_library and root in PROVIDERS: |
| 110 | + return _original_builtins_import(name, globals_dict, locals_dict, fromlist, level) |
| 111 | + |
| 112 | + # Check if this is a package we should instrument |
| 113 | + if ( |
| 114 | + root in TARGET_PACKAGES |
| 115 | + and root not in _instrumenting_packages |
| 116 | + and not _is_package_instrumented(root) # Check if already instrumented before adding |
| 117 | + ): |
| 118 | + logger.debug(f"Detected import of {root}") |
| 119 | + _instrumenting_packages.add(root) |
| 120 | + try: |
| 121 | + _perform_instrumentation(root) |
| 122 | + except Exception as e: |
| 123 | + logger.error(f"Error instrumenting {root}: {str(e)}") |
| 124 | + finally: |
| 125 | + _instrumenting_packages.discard(root) |
| 126 | + |
| 127 | + return _original_builtins_import(name, globals_dict, locals_dict, fromlist, level) |
20 | 128 |
|
21 | | - This class is used to load and activate instrumentors based on their module |
22 | | - and class names. |
23 | | - We use the `provider_import_name` to determine if the library is installed i |
24 | | - n the environment. |
25 | 129 |
|
26 | | - `module_name` is the name of the module to import from. |
27 | | - `class_name` is the name of the class to instantiate from the module. |
28 | | - `provider_import_name` is the name of the package to check for availability. |
| 130 | +# Define the structure for instrumentor configurations |
| 131 | +class InstrumentorConfig(TypedDict): |
| 132 | + module_name: str |
| 133 | + class_name: str |
| 134 | + min_version: str |
| 135 | + |
| 136 | + |
| 137 | +# Configuration for supported LLM providers |
| 138 | +PROVIDERS: dict[str, InstrumentorConfig] = { |
| 139 | + "openai": { |
| 140 | + "module_name": "agentops.instrumentation.openai", |
| 141 | + "class_name": "OpenAIInstrumentor", |
| 142 | + "min_version": "1.0.0", |
| 143 | + }, |
| 144 | + "anthropic": { |
| 145 | + "module_name": "agentops.instrumentation.anthropic", |
| 146 | + "class_name": "AnthropicInstrumentor", |
| 147 | + "min_version": "0.32.0", |
| 148 | + }, |
| 149 | + "google.genai": { |
| 150 | + "module_name": "agentops.instrumentation.google_generativeai", |
| 151 | + "class_name": "GoogleGenerativeAIInstrumentor", |
| 152 | + "min_version": "0.1.0", |
| 153 | + }, |
| 154 | + "ibm_watsonx_ai": { |
| 155 | + "module_name": "agentops.instrumentation.ibm_watsonx_ai", |
| 156 | + "class_name": "IBMWatsonXInstrumentor", |
| 157 | + "min_version": "0.1.0", |
| 158 | + }, |
| 159 | +} |
| 160 | + |
| 161 | +# Configuration for supported agentic libraries |
| 162 | +AGENTIC_LIBRARIES: dict[str, InstrumentorConfig] = { |
| 163 | + "crewai": { |
| 164 | + "module_name": "agentops.instrumentation.crewai", |
| 165 | + "class_name": "CrewAIInstrumentor", |
| 166 | + "min_version": "0.56.0", |
| 167 | + }, |
| 168 | + "autogen": {"module_name": "agentops.instrumentation.ag2", "class_name": "AG2Instrumentor", "min_version": "0.1.0"}, |
| 169 | + "agents": { |
| 170 | + "module_name": "agentops.instrumentation.openai_agents", |
| 171 | + "class_name": "OpenAIAgentsInstrumentor", |
| 172 | + "min_version": "0.1.0", |
| 173 | + }, |
| 174 | +} |
| 175 | + |
| 176 | +# Combine all target packages for monitoring |
| 177 | +TARGET_PACKAGES = set(PROVIDERS.keys()) | set(AGENTIC_LIBRARIES.keys()) |
| 178 | + |
| 179 | +# Create a single instance of the manager |
| 180 | +# _manager = InstrumentationManager() # Removed |
| 181 | + |
| 182 | + |
| 183 | +@dataclass |
| 184 | +class InstrumentorLoader: |
| 185 | + """ |
| 186 | + Represents a dynamically-loadable instrumentor. |
| 187 | + Handles version checking and instantiation of instrumentors. |
29 | 188 | """ |
30 | 189 |
|
31 | 190 | module_name: str |
32 | 191 | class_name: str |
33 | | - provider_import_name: str |
| 192 | + min_version: str |
34 | 193 |
|
35 | 194 | @property |
36 | 195 | def module(self) -> ModuleType: |
37 | | - """Reference to the instrumentor module.""" |
| 196 | + """Get the instrumentor module.""" |
38 | 197 | return importlib.import_module(self.module_name) |
39 | 198 |
|
40 | 199 | @property |
41 | 200 | def should_activate(self) -> bool: |
42 | | - """Is the provider import available in the environment?""" |
| 201 | + """Check if the package is available and meets version requirements.""" |
43 | 202 | try: |
44 | | - importlib.import_module(self.provider_import_name) |
45 | | - return True |
| 203 | + provider_name = self.module_name.split(".")[-1] |
| 204 | + module_version = version(provider_name) |
| 205 | + return module_version is not None and Version(module_version) >= parse(self.min_version) |
46 | 206 | except ImportError: |
47 | 207 | return False |
48 | 208 |
|
49 | 209 | def get_instance(self) -> BaseInstrumentor: |
50 | | - """Return a new instance of the instrumentor.""" |
| 210 | + """Create and return a new instance of the instrumentor.""" |
51 | 211 | return getattr(self.module, self.class_name)() |
52 | 212 |
|
53 | 213 |
|
54 | | -available_instrumentors: list[InstrumentorLoader] = [ |
55 | | - InstrumentorLoader( |
56 | | - module_name="agentops.instrumentation.openai", |
57 | | - class_name="OpenAIInstrumentor", |
58 | | - provider_import_name="openai", |
59 | | - ), |
60 | | - InstrumentorLoader( |
61 | | - module_name="agentops.instrumentation.anthropic", |
62 | | - class_name="AnthropicInstrumentor", |
63 | | - provider_import_name="anthropic", |
64 | | - ), |
65 | | - InstrumentorLoader( |
66 | | - module_name="agentops.instrumentation.crewai", |
67 | | - class_name="CrewAIInstrumentor", |
68 | | - provider_import_name="crewai", |
69 | | - ), |
70 | | - InstrumentorLoader( |
71 | | - module_name="agentops.instrumentation.openai_agents", |
72 | | - class_name="OpenAIAgentsInstrumentor", |
73 | | - provider_import_name="agents", |
74 | | - ), |
75 | | - InstrumentorLoader( |
76 | | - module_name="agentops.instrumentation.google_generativeai", |
77 | | - class_name="GoogleGenerativeAIInstrumentor", |
78 | | - provider_import_name="google.genai", |
79 | | - ), |
80 | | - InstrumentorLoader( |
81 | | - module_name="agentops.instrumentation.ibm_watsonx_ai", |
82 | | - class_name="IBMWatsonXInstrumentor", |
83 | | - provider_import_name="ibm_watsonx_ai", |
84 | | - ), |
85 | | - InstrumentorLoader( |
86 | | - module_name="agentops.instrumentation.ag2", |
87 | | - class_name="AG2Instrumentor", |
88 | | - provider_import_name="autogen", |
89 | | - ), |
90 | | -] |
91 | | - |
92 | | - |
93 | 214 | def instrument_one(loader: InstrumentorLoader) -> Optional[BaseInstrumentor]: |
94 | | - """Instrument a single instrumentor.""" |
| 215 | + """ |
| 216 | + Instrument a single package using the provided loader. |
| 217 | + Returns the instrumentor instance if successful, None otherwise. |
| 218 | + """ |
95 | 219 | if not loader.should_activate: |
96 | | - # this package is not in the environment; skip |
97 | 220 | logger.debug( |
98 | | - f"Package {loader.provider_import_name} not found; skipping instrumentation of {loader.class_name}" |
| 221 | + f"Package {loader.module_name} not found or version < {loader.min_version}; skipping instrumentation" |
99 | 222 | ) |
100 | 223 | return None |
101 | 224 |
|
102 | 225 | instrumentor = loader.get_instance() |
103 | 226 | instrumentor.instrument(tracer_provider=TracingCore.get_instance()._provider) |
104 | 227 | logger.debug(f"Instrumented {loader.class_name}") |
105 | | - |
106 | 228 | return instrumentor |
107 | 229 |
|
108 | 230 |
|
109 | 231 | def instrument_all(): |
110 | | - """ |
111 | | - Instrument all available instrumentors. |
112 | | - This function is called when `instrument_llm_calls` is enabled. |
113 | | - """ |
114 | | - global _active_instrumentors |
| 232 | + """Start monitoring and instrumenting packages if not already started.""" |
| 233 | + # Check if active_instrumentors is empty, as a proxy for not started. |
| 234 | + if not _active_instrumentors: |
| 235 | + builtins.__import__ = _import_monitor |
| 236 | + global _instrumenting_packages |
| 237 | + for name in list(sys.modules.keys()): |
| 238 | + module = sys.modules.get(name) |
| 239 | + if not isinstance(module, ModuleType): |
| 240 | + continue |
115 | 241 |
|
116 | | - if len(_active_instrumentors): |
117 | | - logger.debug("Instrumentors have already been populated.") |
118 | | - return |
119 | | - |
120 | | - for loader in available_instrumentors: |
121 | | - if loader.class_name in _active_instrumentors: |
122 | | - # already instrumented |
123 | | - logger.debug(f"Instrumentor {loader.class_name} has already been instrumented.") |
124 | | - return None |
| 242 | + root = name.split(".", 1)[0] |
| 243 | + if _has_agentic_library and root in PROVIDERS: |
| 244 | + continue |
125 | 245 |
|
126 | | - instrumentor = instrument_one(loader) |
127 | | - if instrumentor is not None: |
128 | | - _active_instrumentors.append(instrumentor) |
| 246 | + if root in TARGET_PACKAGES and root not in _instrumenting_packages and not _is_package_instrumented(root): |
| 247 | + _instrumenting_packages.add(root) |
| 248 | + try: |
| 249 | + _perform_instrumentation(root) |
| 250 | + except Exception as e: |
| 251 | + logger.error(f"Error instrumenting {root}: {str(e)}") |
| 252 | + finally: |
| 253 | + _instrumenting_packages.discard(root) |
129 | 254 |
|
130 | 255 |
|
131 | 256 | def uninstrument_all(): |
132 | | - """ |
133 | | - Uninstrument all available instrumentors. |
134 | | - This can be called to disable instrumentation. |
135 | | - """ |
136 | | - global _active_instrumentors |
| 257 | + """Stop monitoring and uninstrument all packages.""" |
| 258 | + global _active_instrumentors, _has_agentic_library |
| 259 | + builtins.__import__ = _original_builtins_import |
137 | 260 | for instrumentor in _active_instrumentors: |
138 | 261 | instrumentor.uninstrument() |
139 | 262 | logger.debug(f"Uninstrumented {instrumentor.__class__.__name__}") |
140 | 263 | _active_instrumentors = [] |
| 264 | + _has_agentic_library = False |
| 265 | + |
| 266 | + |
| 267 | +def get_active_libraries() -> set[str]: |
| 268 | + """ |
| 269 | + Get all actively used libraries in the current execution context. |
| 270 | + Returns a set of package names that are currently imported and being monitored. |
| 271 | + """ |
| 272 | + return { |
| 273 | + name.split(".")[0] |
| 274 | + for name, module in sys.modules.items() |
| 275 | + if isinstance(module, ModuleType) and name.split(".")[0] in TARGET_PACKAGES |
| 276 | + } |
0 commit comments