Skip to content

Commit 220ef34

Browse files
committed
Merge branch 'crewai-tags-fix' of https://github.com/AgentOps-AI/agentops into crewai-tags-fix
2 parents d2c00d9 + 7c03016 commit 220ef34

File tree

2 files changed

+266
-164
lines changed

2 files changed

+266
-164
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)