Skip to content

Commit f92a115

Browse files
Dwij1704dot-agi
andauthored
Lazy Loading of LLM Libraries to Prevent Duplicate Instrumentation (#987)
* Enhance instrumentation module with dynamic library detection and version checks. Introduced PROVIDERS and AGENTIC_LIBRARIES dictionaries for managing instrumentors, and implemented get_active_libraries function to identify currently used libraries. Updated InstrumentorLoader to validate library versions before activation. Refactored instrument_all function to prioritize agentic libraries before standard providers. * Refactor instrumentation module to implement InstrumentationManager for improved package monitoring and dynamic instrumentation. Added methods for managing active instrumentors, handling conflicts between agentic libraries and providers, and monitoring imports. Updated configuration for supported libraries and combined target packages for streamlined instrumentation. * Enhance instrumentation module by introducing TypedDict for instrumentor configurations, improving type safety for PROVIDERS and AGENTIC_LIBRARIES dictionaries. This change facilitates better structure and clarity in managing instrumentor settings. * Refactor instrumentation module to remove InstrumentationManager and implement module-level state management for active instrumentors. Introduced helper functions for package instrumentation, monitoring imports, and uninstrumenting providers, enhancing clarity and maintainability of the code. Updated instrumentation logic to handle conflicts between agentic libraries and providers more effectively. * Refactor instrumentation module to streamline monitoring and uninstrumenting processes. Removed internal helper functions for starting and stopping monitoring, integrating their logic directly into the `instrument_all` and `uninstrument_all` functions for improved clarity and maintainability. * Refactor instrumentation module by integrating the logic for checking existing imports directly into the `instrument_all` function. This change removes the now-unnecessary `_check_existing_imports` helper function, enhancing code clarity and maintainability while preserving the functionality of monitoring already imported packages. --------- Co-authored-by: Pratyush Shukla <ps4534@nyu.edu>
1 parent 8dda585 commit f92a115

File tree

1 file changed

+219
-83
lines changed

1 file changed

+219
-83
lines changed
Lines changed: 219 additions & 83 deletions
Original file line numberDiff line numberDiff line change
@@ -1,140 +1,276 @@
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
216
from types import ModuleType
317
from dataclasses import dataclass
418
import importlib
19+
import sys
20+
from importlib.metadata import version
21+
from packaging.version import Version, parse
22+
import builtins
523

624
from opentelemetry.instrumentation.instrumentor import BaseInstrumentor # type: ignore
725

826
from agentops.logging import logger
927
from agentops.sdk.core import TracingCore
1028

1129

12-
# references to all active instrumentors
30+
# Module-level state variables
1331
_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
1435

1536

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:
1859
"""
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)
20128

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.
25129

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.
29188
"""
30189

31190
module_name: str
32191
class_name: str
33-
provider_import_name: str
192+
min_version: str
34193

35194
@property
36195
def module(self) -> ModuleType:
37-
"""Reference to the instrumentor module."""
196+
"""Get the instrumentor module."""
38197
return importlib.import_module(self.module_name)
39198

40199
@property
41200
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."""
43202
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)
46206
except ImportError:
47207
return False
48208

49209
def get_instance(self) -> BaseInstrumentor:
50-
"""Return a new instance of the instrumentor."""
210+
"""Create and return a new instance of the instrumentor."""
51211
return getattr(self.module, self.class_name)()
52212

53213

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-
93214
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+
"""
95219
if not loader.should_activate:
96-
# this package is not in the environment; skip
97220
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"
99222
)
100223
return None
101224

102225
instrumentor = loader.get_instance()
103226
instrumentor.instrument(tracer_provider=TracingCore.get_instance()._provider)
104227
logger.debug(f"Instrumented {loader.class_name}")
105-
106228
return instrumentor
107229

108230

109231
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
115241

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
125245

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)
129254

130255

131256
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
137260
for instrumentor in _active_instrumentors:
138261
instrumentor.uninstrument()
139262
logger.debug(f"Uninstrumented {instrumentor.__class__.__name__}")
140263
_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

Comments
 (0)