genai-otel-instrument 0.1.1.dev0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of genai-otel-instrument might be problematic. Click here for more details.
- genai_otel/__init__.py +129 -0
- genai_otel/__version__.py +34 -0
- genai_otel/auto_instrument.py +413 -0
- genai_otel/cli.py +92 -0
- genai_otel/config.py +187 -0
- genai_otel/cost_calculator.py +276 -0
- genai_otel/exceptions.py +17 -0
- genai_otel/gpu_metrics.py +240 -0
- genai_otel/instrumentors/__init__.py +47 -0
- genai_otel/instrumentors/anthropic_instrumentor.py +134 -0
- genai_otel/instrumentors/anyscale_instrumentor.py +27 -0
- genai_otel/instrumentors/aws_bedrock_instrumentor.py +94 -0
- genai_otel/instrumentors/azure_openai_instrumentor.py +69 -0
- genai_otel/instrumentors/base.py +528 -0
- genai_otel/instrumentors/cohere_instrumentor.py +76 -0
- genai_otel/instrumentors/google_ai_instrumentor.py +87 -0
- genai_otel/instrumentors/groq_instrumentor.py +106 -0
- genai_otel/instrumentors/huggingface_instrumentor.py +97 -0
- genai_otel/instrumentors/langchain_instrumentor.py +75 -0
- genai_otel/instrumentors/llamaindex_instrumentor.py +36 -0
- genai_otel/instrumentors/mistralai_instrumentor.py +119 -0
- genai_otel/instrumentors/ollama_instrumentor.py +83 -0
- genai_otel/instrumentors/openai_instrumentor.py +241 -0
- genai_otel/instrumentors/replicate_instrumentor.py +42 -0
- genai_otel/instrumentors/togetherai_instrumentor.py +42 -0
- genai_otel/instrumentors/vertexai_instrumentor.py +42 -0
- genai_otel/llm_pricing.json +589 -0
- genai_otel/logging_config.py +45 -0
- genai_otel/mcp_instrumentors/__init__.py +14 -0
- genai_otel/mcp_instrumentors/api_instrumentor.py +144 -0
- genai_otel/mcp_instrumentors/base.py +105 -0
- genai_otel/mcp_instrumentors/database_instrumentor.py +336 -0
- genai_otel/mcp_instrumentors/kafka_instrumentor.py +31 -0
- genai_otel/mcp_instrumentors/manager.py +139 -0
- genai_otel/mcp_instrumentors/redis_instrumentor.py +31 -0
- genai_otel/mcp_instrumentors/vector_db_instrumentor.py +265 -0
- genai_otel/metrics.py +148 -0
- genai_otel/py.typed +2 -0
- genai_otel_instrument-0.1.1.dev0.dist-info/METADATA +463 -0
- genai_otel_instrument-0.1.1.dev0.dist-info/RECORD +44 -0
- genai_otel_instrument-0.1.1.dev0.dist-info/WHEEL +5 -0
- genai_otel_instrument-0.1.1.dev0.dist-info/entry_points.txt +2 -0
- genai_otel_instrument-0.1.1.dev0.dist-info/licenses/LICENSE +201 -0
- genai_otel_instrument-0.1.1.dev0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
"""Module for collecting GPU metrics using nvidia-ml-py and reporting them via OpenTelemetry.
|
|
2
|
+
|
|
3
|
+
This module provides the `GPUMetricsCollector` class, which periodically collects
|
|
4
|
+
GPU utilization, memory usage, and temperature, and exports these as OpenTelemetry
|
|
5
|
+
metrics. It relies on the `nvidia-ml-py` library for interacting with NVIDIA GPUs.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import logging
|
|
9
|
+
import threading
|
|
10
|
+
import time
|
|
11
|
+
from typing import Optional
|
|
12
|
+
|
|
13
|
+
from opentelemetry.metrics import Meter, ObservableCounter, ObservableGauge, Observation
|
|
14
|
+
|
|
15
|
+
from genai_otel.config import OTelConfig
|
|
16
|
+
|
|
17
|
+
logger = logging.getLogger(__name__)
|
|
18
|
+
|
|
19
|
+
# Try to import nvidia-ml-py (official replacement for pynvml)
|
|
20
|
+
try:
|
|
21
|
+
import pynvml
|
|
22
|
+
|
|
23
|
+
NVML_AVAILABLE = True
|
|
24
|
+
except ImportError:
|
|
25
|
+
NVML_AVAILABLE = False
|
|
26
|
+
logger.debug("nvidia-ml-py not available, GPU metrics will be disabled")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class GPUMetricsCollector:
|
|
30
|
+
"""Collects and reports GPU metrics using nvidia-ml-py."""
|
|
31
|
+
|
|
32
|
+
def __init__(self, meter: Meter, config: OTelConfig, interval: int = 10):
|
|
33
|
+
"""Initializes the GPUMetricsCollector.
|
|
34
|
+
|
|
35
|
+
Args:
|
|
36
|
+
meter (Meter): The OpenTelemetry meter to use for recording metrics.
|
|
37
|
+
"""
|
|
38
|
+
self.meter = meter
|
|
39
|
+
self.running = False
|
|
40
|
+
self.thread: Optional[threading.Thread] = None
|
|
41
|
+
self._thread: Optional[threading.Thread] = None # Initialize _thread
|
|
42
|
+
self._stop_event = threading.Event()
|
|
43
|
+
self.gpu_utilization_counter: Optional[ObservableCounter] = None
|
|
44
|
+
self.gpu_memory_used_gauge: Optional[ObservableGauge] = None
|
|
45
|
+
self.gpu_temperature_gauge: Optional[ObservableGauge] = None
|
|
46
|
+
self.config = config
|
|
47
|
+
self.interval = interval # seconds
|
|
48
|
+
self.gpu_available = False
|
|
49
|
+
|
|
50
|
+
self.device_count = 0
|
|
51
|
+
self.nvml = None
|
|
52
|
+
if NVML_AVAILABLE:
|
|
53
|
+
try:
|
|
54
|
+
pynvml.nvmlInit()
|
|
55
|
+
self.device_count = pynvml.nvmlDeviceGetCount()
|
|
56
|
+
if self.device_count > 0:
|
|
57
|
+
self.gpu_available = True
|
|
58
|
+
self.nvml = pynvml
|
|
59
|
+
except Exception as e:
|
|
60
|
+
logger.error("Failed to initialize NVML to get device count: %s", e)
|
|
61
|
+
|
|
62
|
+
self.cumulative_energy_wh = [0.0] * self.device_count # Per GPU, in Wh
|
|
63
|
+
self.last_timestamp = [time.time()] * self.device_count
|
|
64
|
+
self.co2_counter = meter.create_counter(
|
|
65
|
+
"gen_ai.co2.emissions", # Fixed metric name
|
|
66
|
+
description="Cumulative CO2 equivalent emissions in grams",
|
|
67
|
+
unit="gCO2e",
|
|
68
|
+
)
|
|
69
|
+
if not NVML_AVAILABLE:
|
|
70
|
+
logger.warning(
|
|
71
|
+
"GPU metrics collection not available - nvidia-ml-py not installed. "
|
|
72
|
+
"Install with: pip install genai-otel-instrument[gpu]"
|
|
73
|
+
)
|
|
74
|
+
return
|
|
75
|
+
|
|
76
|
+
try:
|
|
77
|
+
# Use ObservableGauge for all GPU metrics (not Counter!)
|
|
78
|
+
self.gpu_utilization_gauge = self.meter.create_observable_gauge(
|
|
79
|
+
"gen_ai.gpu.utilization", # Fixed metric name
|
|
80
|
+
callbacks=[self._observe_gpu_utilization],
|
|
81
|
+
description="GPU utilization percentage",
|
|
82
|
+
unit="%",
|
|
83
|
+
)
|
|
84
|
+
self.gpu_memory_used_gauge = self.meter.create_observable_gauge(
|
|
85
|
+
"gen_ai.gpu.memory.used", # Fixed metric name
|
|
86
|
+
callbacks=[self._observe_gpu_memory],
|
|
87
|
+
description="GPU memory used in MiB",
|
|
88
|
+
unit="MiB",
|
|
89
|
+
)
|
|
90
|
+
self.gpu_temperature_gauge = self.meter.create_observable_gauge(
|
|
91
|
+
"gen_ai.gpu.temperature", # Fixed metric name
|
|
92
|
+
callbacks=[self._observe_gpu_temperature],
|
|
93
|
+
description="GPU temperature in Celsius",
|
|
94
|
+
unit="Cel",
|
|
95
|
+
)
|
|
96
|
+
except Exception as e:
|
|
97
|
+
logger.error("Failed to create GPU metrics instruments: %s", e, exc_info=True)
|
|
98
|
+
|
|
99
|
+
def _get_device_name(self, handle, index):
|
|
100
|
+
"""Get GPU device name safely."""
|
|
101
|
+
try:
|
|
102
|
+
device_name = pynvml.nvmlDeviceGetName(handle)
|
|
103
|
+
if isinstance(device_name, bytes):
|
|
104
|
+
device_name = device_name.decode("utf-8")
|
|
105
|
+
return device_name
|
|
106
|
+
except Exception as e:
|
|
107
|
+
logger.debug("Failed to get GPU name: %s", e)
|
|
108
|
+
return f"GPU_{index}"
|
|
109
|
+
|
|
110
|
+
def _observe_gpu_utilization(self, options):
|
|
111
|
+
"""Observable callback for GPU utilization."""
|
|
112
|
+
if not NVML_AVAILABLE or not self.gpu_available:
|
|
113
|
+
return
|
|
114
|
+
|
|
115
|
+
try:
|
|
116
|
+
pynvml.nvmlInit()
|
|
117
|
+
device_count = pynvml.nvmlDeviceGetCount()
|
|
118
|
+
|
|
119
|
+
for i in range(device_count):
|
|
120
|
+
handle = pynvml.nvmlDeviceGetHandleByIndex(i)
|
|
121
|
+
device_name = self._get_device_name(handle, i)
|
|
122
|
+
|
|
123
|
+
try:
|
|
124
|
+
utilization = pynvml.nvmlDeviceGetUtilizationRates(handle)
|
|
125
|
+
yield Observation(
|
|
126
|
+
value=utilization.gpu,
|
|
127
|
+
attributes={"gpu_id": str(i), "gpu_name": device_name},
|
|
128
|
+
)
|
|
129
|
+
except Exception as e:
|
|
130
|
+
logger.debug("Failed to get GPU utilization for GPU %d: %s", i, e)
|
|
131
|
+
|
|
132
|
+
pynvml.nvmlShutdown()
|
|
133
|
+
except Exception as e:
|
|
134
|
+
logger.error("Error observing GPU utilization: %s", e)
|
|
135
|
+
|
|
136
|
+
def _observe_gpu_memory(self, options):
|
|
137
|
+
"""Observable callback for GPU memory usage."""
|
|
138
|
+
if not NVML_AVAILABLE or not self.gpu_available:
|
|
139
|
+
return
|
|
140
|
+
|
|
141
|
+
try:
|
|
142
|
+
pynvml.nvmlInit()
|
|
143
|
+
device_count = pynvml.nvmlDeviceGetCount()
|
|
144
|
+
|
|
145
|
+
for i in range(device_count):
|
|
146
|
+
handle = pynvml.nvmlDeviceGetHandleByIndex(i)
|
|
147
|
+
device_name = self._get_device_name(handle, i)
|
|
148
|
+
|
|
149
|
+
try:
|
|
150
|
+
memory_info = pynvml.nvmlDeviceGetMemoryInfo(handle)
|
|
151
|
+
gpu_memory_used = memory_info.used / (1024**2) # Convert to MiB
|
|
152
|
+
yield Observation(
|
|
153
|
+
value=gpu_memory_used,
|
|
154
|
+
attributes={"gpu_id": str(i), "gpu_name": device_name},
|
|
155
|
+
)
|
|
156
|
+
except Exception as e:
|
|
157
|
+
logger.debug("Failed to get GPU memory for GPU %d: %s", i, e)
|
|
158
|
+
|
|
159
|
+
pynvml.nvmlShutdown()
|
|
160
|
+
except Exception as e:
|
|
161
|
+
logger.error("Error observing GPU memory: %s", e)
|
|
162
|
+
|
|
163
|
+
def _observe_gpu_temperature(self, options):
|
|
164
|
+
"""Observable callback for GPU temperature."""
|
|
165
|
+
if not NVML_AVAILABLE or not self.gpu_available:
|
|
166
|
+
return
|
|
167
|
+
|
|
168
|
+
try:
|
|
169
|
+
pynvml.nvmlInit()
|
|
170
|
+
device_count = pynvml.nvmlDeviceGetCount()
|
|
171
|
+
|
|
172
|
+
for i in range(device_count):
|
|
173
|
+
handle = pynvml.nvmlDeviceGetHandleByIndex(i)
|
|
174
|
+
device_name = self._get_device_name(handle, i)
|
|
175
|
+
|
|
176
|
+
try:
|
|
177
|
+
gpu_temp = pynvml.nvmlDeviceGetTemperature(handle, pynvml.NVML_TEMPERATURE_GPU)
|
|
178
|
+
yield Observation(
|
|
179
|
+
value=gpu_temp, attributes={"gpu_id": str(i), "gpu_name": device_name}
|
|
180
|
+
)
|
|
181
|
+
except Exception as e:
|
|
182
|
+
logger.debug("Failed to get GPU temperature for GPU %d: %s", i, e)
|
|
183
|
+
|
|
184
|
+
pynvml.nvmlShutdown()
|
|
185
|
+
except Exception as e:
|
|
186
|
+
logger.error("Error observing GPU temperature: %s", e)
|
|
187
|
+
|
|
188
|
+
def start(self):
|
|
189
|
+
"""Starts the GPU metrics collection.
|
|
190
|
+
|
|
191
|
+
ObservableGauges are automatically collected by the MeterProvider,
|
|
192
|
+
so we only need to start the CO2 collection thread.
|
|
193
|
+
"""
|
|
194
|
+
if not NVML_AVAILABLE:
|
|
195
|
+
logger.warning("Cannot start GPU metrics collection - nvidia-ml-py not available")
|
|
196
|
+
return
|
|
197
|
+
|
|
198
|
+
if not self.gpu_available:
|
|
199
|
+
return
|
|
200
|
+
|
|
201
|
+
logger.info("Starting GPU metrics collection (CO2 tracking)")
|
|
202
|
+
# Only start CO2 collection thread - ObservableGauges are auto-collected
|
|
203
|
+
self._thread = threading.Thread(target=self._collect_loop, daemon=True)
|
|
204
|
+
self._thread.start()
|
|
205
|
+
|
|
206
|
+
def _collect_loop(self):
|
|
207
|
+
while not self._stop_event.wait(self.interval):
|
|
208
|
+
current_time = time.time()
|
|
209
|
+
for i in range(self.device_count):
|
|
210
|
+
try:
|
|
211
|
+
handle = self.nvml.nvmlDeviceGetHandleByIndex(i)
|
|
212
|
+
power_w = self.nvml.nvmlDeviceGetPowerUsage(handle) / 1000.0 # Watts
|
|
213
|
+
delta_time_hours = (current_time - self.last_timestamp[i]) / 3600.0
|
|
214
|
+
delta_energy_wh = (power_w / 1000.0) * (
|
|
215
|
+
delta_time_hours * 3600.0
|
|
216
|
+
) # Wh (power in kW * hours = kWh, but track in Wh for precision)
|
|
217
|
+
self.cumulative_energy_wh[i] += delta_energy_wh
|
|
218
|
+
if self.config.enable_co2_tracking:
|
|
219
|
+
delta_co2_g = (
|
|
220
|
+
delta_energy_wh / 1000.0
|
|
221
|
+
) * self.config.carbon_intensity # gCO2e
|
|
222
|
+
self.co2_counter.add(delta_co2_g, {"gpu_id": str(i)})
|
|
223
|
+
self.last_timestamp[i] = current_time
|
|
224
|
+
except Exception as e:
|
|
225
|
+
logger.error(f"Error collecting GPU {i} metrics: {e}")
|
|
226
|
+
|
|
227
|
+
def stop(self):
|
|
228
|
+
"""Stops the GPU metrics collection thread."""
|
|
229
|
+
# Stop CO2 collection thread
|
|
230
|
+
self._stop_event.set()
|
|
231
|
+
if self._thread and self._thread.is_alive():
|
|
232
|
+
self._thread.join(timeout=5)
|
|
233
|
+
logger.info("GPU CO2 metrics collection thread stopped.")
|
|
234
|
+
|
|
235
|
+
# ObservableGauges will automatically stop when MeterProvider is shutdown
|
|
236
|
+
if self.gpu_available and NVML_AVAILABLE:
|
|
237
|
+
try:
|
|
238
|
+
pynvml.nvmlShutdown()
|
|
239
|
+
except Exception as e:
|
|
240
|
+
logger.debug("Error shutting down NVML: %s", e)
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""Module for OpenTelemetry instrumentors for various LLM providers and frameworks.
|
|
2
|
+
|
|
3
|
+
This package contains individual instrumentor classes for different Generative AI
|
|
4
|
+
libraries and frameworks, allowing for automatic tracing and metric collection
|
|
5
|
+
of their operations.
|
|
6
|
+
|
|
7
|
+
All imports are done lazily to avoid ImportError when optional dependencies
|
|
8
|
+
are not installed.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from .anthropic_instrumentor import AnthropicInstrumentor
|
|
12
|
+
from .anyscale_instrumentor import AnyscaleInstrumentor
|
|
13
|
+
from .aws_bedrock_instrumentor import AWSBedrockInstrumentor
|
|
14
|
+
from .azure_openai_instrumentor import AzureOpenAIInstrumentor
|
|
15
|
+
from .cohere_instrumentor import CohereInstrumentor
|
|
16
|
+
from .google_ai_instrumentor import GoogleAIInstrumentor
|
|
17
|
+
from .groq_instrumentor import GroqInstrumentor
|
|
18
|
+
from .huggingface_instrumentor import HuggingFaceInstrumentor
|
|
19
|
+
from .langchain_instrumentor import LangChainInstrumentor
|
|
20
|
+
from .llamaindex_instrumentor import LlamaIndexInstrumentor
|
|
21
|
+
from .mistralai_instrumentor import MistralAIInstrumentor
|
|
22
|
+
from .ollama_instrumentor import OllamaInstrumentor
|
|
23
|
+
|
|
24
|
+
# Import instrumentors only - they handle their own dependency checking
|
|
25
|
+
from .openai_instrumentor import OpenAIInstrumentor
|
|
26
|
+
from .replicate_instrumentor import ReplicateInstrumentor
|
|
27
|
+
from .togetherai_instrumentor import TogetherAIInstrumentor
|
|
28
|
+
from .vertexai_instrumentor import VertexAIInstrumentor
|
|
29
|
+
|
|
30
|
+
__all__ = [
|
|
31
|
+
"OpenAIInstrumentor",
|
|
32
|
+
"AnthropicInstrumentor",
|
|
33
|
+
"GoogleAIInstrumentor",
|
|
34
|
+
"AWSBedrockInstrumentor",
|
|
35
|
+
"AzureOpenAIInstrumentor",
|
|
36
|
+
"CohereInstrumentor",
|
|
37
|
+
"MistralAIInstrumentor",
|
|
38
|
+
"TogetherAIInstrumentor",
|
|
39
|
+
"GroqInstrumentor",
|
|
40
|
+
"OllamaInstrumentor",
|
|
41
|
+
"VertexAIInstrumentor",
|
|
42
|
+
"ReplicateInstrumentor",
|
|
43
|
+
"AnyscaleInstrumentor",
|
|
44
|
+
"LangChainInstrumentor",
|
|
45
|
+
"LlamaIndexInstrumentor",
|
|
46
|
+
"HuggingFaceInstrumentor",
|
|
47
|
+
]
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
"""OpenTelemetry instrumentor for the Anthropic Claude SDK.
|
|
2
|
+
|
|
3
|
+
This instrumentor automatically traces calls to the Anthropic API, capturing
|
|
4
|
+
relevant attributes such as model name, message count, and token usage.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import logging
|
|
8
|
+
from typing import Any, Dict, Optional
|
|
9
|
+
|
|
10
|
+
from ..config import OTelConfig
|
|
11
|
+
from .base import BaseInstrumentor
|
|
12
|
+
|
|
13
|
+
logger = logging.getLogger(__name__)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class AnthropicInstrumentor(BaseInstrumentor):
|
|
17
|
+
"""Instrumentor for Anthropic Claude SDK"""
|
|
18
|
+
|
|
19
|
+
def __init__(self):
|
|
20
|
+
"""Initialize the instrumentor."""
|
|
21
|
+
super().__init__()
|
|
22
|
+
self._anthropic_available = False
|
|
23
|
+
self._check_availability()
|
|
24
|
+
|
|
25
|
+
def _check_availability(self):
|
|
26
|
+
"""Check if Anthropic library is available."""
|
|
27
|
+
try:
|
|
28
|
+
import anthropic
|
|
29
|
+
|
|
30
|
+
self._anthropic_available = True
|
|
31
|
+
logger.debug("Anthropic library detected and available for instrumentation")
|
|
32
|
+
except ImportError:
|
|
33
|
+
logger.debug("Anthropic library not installed, instrumentation will be skipped")
|
|
34
|
+
self._anthropic_available = False
|
|
35
|
+
|
|
36
|
+
def instrument(self, config: OTelConfig):
|
|
37
|
+
"""Instrument Anthropic SDK if available.
|
|
38
|
+
|
|
39
|
+
Args:
|
|
40
|
+
config (OTelConfig): The OpenTelemetry configuration object.
|
|
41
|
+
"""
|
|
42
|
+
if not self._anthropic_available:
|
|
43
|
+
logger.debug("Skipping Anthropic instrumentation - library not available")
|
|
44
|
+
return
|
|
45
|
+
|
|
46
|
+
self.config = config
|
|
47
|
+
|
|
48
|
+
try:
|
|
49
|
+
import anthropic
|
|
50
|
+
import wrapt
|
|
51
|
+
|
|
52
|
+
if hasattr(anthropic, "Anthropic"):
|
|
53
|
+
original_init = anthropic.Anthropic.__init__
|
|
54
|
+
|
|
55
|
+
def wrapped_init(wrapped, instance, args, kwargs):
|
|
56
|
+
result = wrapped(*args, **kwargs)
|
|
57
|
+
self._instrument_client(instance)
|
|
58
|
+
return result
|
|
59
|
+
|
|
60
|
+
anthropic.Anthropic.__init__ = wrapt.FunctionWrapper(original_init, wrapped_init)
|
|
61
|
+
self._instrumented = True
|
|
62
|
+
logger.info("Anthropic instrumentation enabled")
|
|
63
|
+
|
|
64
|
+
except Exception as e:
|
|
65
|
+
logger.error("Failed to instrument Anthropic: %s", e, exc_info=True)
|
|
66
|
+
if config.fail_on_error:
|
|
67
|
+
raise
|
|
68
|
+
|
|
69
|
+
def _instrument_client(self, client):
|
|
70
|
+
"""Instrument Anthropic client methods.
|
|
71
|
+
|
|
72
|
+
Args:
|
|
73
|
+
client: The Anthropic client instance to instrument.
|
|
74
|
+
"""
|
|
75
|
+
if hasattr(client, "messages") and hasattr(client.messages, "create"):
|
|
76
|
+
original_create = client.messages.create
|
|
77
|
+
instrumented_create_method = self.create_span_wrapper(
|
|
78
|
+
span_name="anthropic.messages.create",
|
|
79
|
+
extract_attributes=self._extract_anthropic_attributes,
|
|
80
|
+
)(original_create)
|
|
81
|
+
client.messages.create = instrumented_create_method
|
|
82
|
+
|
|
83
|
+
def _extract_anthropic_attributes(
|
|
84
|
+
self, instance: Any, args: Any, kwargs: Any
|
|
85
|
+
) -> Dict[str, Any]:
|
|
86
|
+
"""Extract attributes from Anthropic API call.
|
|
87
|
+
|
|
88
|
+
Args:
|
|
89
|
+
instance: The client instance.
|
|
90
|
+
args: Positional arguments.
|
|
91
|
+
kwargs: Keyword arguments.
|
|
92
|
+
|
|
93
|
+
Returns:
|
|
94
|
+
Dict[str, Any]: Dictionary of attributes to set on the span.
|
|
95
|
+
"""
|
|
96
|
+
attrs = {}
|
|
97
|
+
model = kwargs.get("model", "unknown")
|
|
98
|
+
messages = kwargs.get("messages", [])
|
|
99
|
+
|
|
100
|
+
attrs["gen_ai.system"] = "anthropic"
|
|
101
|
+
attrs["gen_ai.request.model"] = model
|
|
102
|
+
attrs["gen_ai.request.message_count"] = len(messages)
|
|
103
|
+
return attrs
|
|
104
|
+
|
|
105
|
+
def _extract_usage(self, result) -> Optional[Dict[str, int]]:
|
|
106
|
+
"""Extract token usage from Anthropic response.
|
|
107
|
+
|
|
108
|
+
Args:
|
|
109
|
+
result: The API response object.
|
|
110
|
+
|
|
111
|
+
Returns:
|
|
112
|
+
Optional[Dict[str, int]]: Dictionary with token counts or None.
|
|
113
|
+
"""
|
|
114
|
+
if hasattr(result, "usage") and result.usage:
|
|
115
|
+
usage = result.usage
|
|
116
|
+
usage_dict = {
|
|
117
|
+
"prompt_tokens": getattr(usage, "input_tokens", 0),
|
|
118
|
+
"completion_tokens": getattr(usage, "output_tokens", 0),
|
|
119
|
+
"total_tokens": getattr(usage, "input_tokens", 0)
|
|
120
|
+
+ getattr(usage, "output_tokens", 0),
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
# Extract cache tokens for Anthropic models (Phase 3.2)
|
|
124
|
+
# cache_read_input_tokens: Tokens that were read from cache
|
|
125
|
+
# cache_creation_input_tokens: Tokens that were written to cache
|
|
126
|
+
if hasattr(usage, "cache_read_input_tokens"):
|
|
127
|
+
usage_dict["cache_read_input_tokens"] = getattr(usage, "cache_read_input_tokens", 0)
|
|
128
|
+
if hasattr(usage, "cache_creation_input_tokens"):
|
|
129
|
+
usage_dict["cache_creation_input_tokens"] = getattr(
|
|
130
|
+
usage, "cache_creation_input_tokens", 0
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
return usage_dict
|
|
134
|
+
return None
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""OpenTelemetry instrumentor for Anyscale Endpoints.
|
|
2
|
+
|
|
3
|
+
This instrumentor integrates with Anyscale Endpoints, which often leverage
|
|
4
|
+
OpenAI-compatible APIs. It ensures that calls made to Anyscale services are
|
|
5
|
+
properly traced and attributed within the OpenTelemetry ecosystem.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from typing import Dict, Optional
|
|
9
|
+
|
|
10
|
+
from ..config import OTelConfig
|
|
11
|
+
from .base import BaseInstrumentor
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class AnyscaleInstrumentor(BaseInstrumentor):
|
|
15
|
+
"""Instrumentor for Anyscale Endpoints"""
|
|
16
|
+
|
|
17
|
+
def instrument(self, config: OTelConfig):
|
|
18
|
+
self.config = config
|
|
19
|
+
try:
|
|
20
|
+
# Anyscale uses OpenAI SDK, already instrumented
|
|
21
|
+
pass
|
|
22
|
+
|
|
23
|
+
except ImportError:
|
|
24
|
+
pass
|
|
25
|
+
|
|
26
|
+
def _extract_usage(self, result) -> Optional[Dict[str, int]]:
|
|
27
|
+
return None
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import logging
|
|
3
|
+
from typing import Any, Dict, Optional
|
|
4
|
+
|
|
5
|
+
from ..config import OTelConfig
|
|
6
|
+
from .base import BaseInstrumentor
|
|
7
|
+
|
|
8
|
+
logger = logging.getLogger(__name__)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class AWSBedrockInstrumentor(BaseInstrumentor):
|
|
12
|
+
"""Instrumentor for AWS Bedrock"""
|
|
13
|
+
|
|
14
|
+
def __init__(self):
|
|
15
|
+
"""Initialize the instrumentor."""
|
|
16
|
+
super().__init__()
|
|
17
|
+
self._boto3_available = False
|
|
18
|
+
self._check_availability()
|
|
19
|
+
|
|
20
|
+
def _check_availability(self):
|
|
21
|
+
"""Check if boto3 library is available."""
|
|
22
|
+
try:
|
|
23
|
+
import boto3 # Moved to top
|
|
24
|
+
|
|
25
|
+
self._boto3_available = True
|
|
26
|
+
logger.debug("boto3 library detected and available for instrumentation")
|
|
27
|
+
except ImportError:
|
|
28
|
+
logger.debug("boto3 library not installed, instrumentation will be skipped")
|
|
29
|
+
self._boto3_available = False
|
|
30
|
+
|
|
31
|
+
def instrument(self, config: OTelConfig):
|
|
32
|
+
self.config = config
|
|
33
|
+
try:
|
|
34
|
+
import boto3 # Moved to top
|
|
35
|
+
|
|
36
|
+
original_client = boto3.client
|
|
37
|
+
|
|
38
|
+
def wrapped_client(*args, **kwargs):
|
|
39
|
+
client = original_client(*args, **kwargs)
|
|
40
|
+
if args and args[0] == "bedrock-runtime":
|
|
41
|
+
self._instrument_bedrock_client(client)
|
|
42
|
+
return client
|
|
43
|
+
|
|
44
|
+
boto3.client = wrapped_client
|
|
45
|
+
|
|
46
|
+
except ImportError:
|
|
47
|
+
pass
|
|
48
|
+
|
|
49
|
+
def _instrument_bedrock_client(self, client):
|
|
50
|
+
if hasattr(client, "invoke_model"):
|
|
51
|
+
instrumented_invoke_method = self.create_span_wrapper(
|
|
52
|
+
span_name="aws.bedrock.invoke_model",
|
|
53
|
+
extract_attributes=self._extract_aws_bedrock_attributes,
|
|
54
|
+
)
|
|
55
|
+
client.invoke_model = instrumented_invoke_method
|
|
56
|
+
|
|
57
|
+
def _extract_aws_bedrock_attributes(
|
|
58
|
+
self, instance: Any, args: Any, kwargs: Any
|
|
59
|
+
) -> Dict[str, Any]: # pylint: disable=W0613
|
|
60
|
+
attrs = {}
|
|
61
|
+
model_id = kwargs.get("modelId", "unknown")
|
|
62
|
+
|
|
63
|
+
attrs["gen_ai.system"] = "aws_bedrock"
|
|
64
|
+
attrs["gen_ai.request.model"] = model_id
|
|
65
|
+
return attrs
|
|
66
|
+
|
|
67
|
+
def _extract_usage(self, result) -> Optional[Dict[str, int]]: # pylint: disable=R1705
|
|
68
|
+
if hasattr(result, "get"):
|
|
69
|
+
content_type = result.get("contentType", "").lower()
|
|
70
|
+
body_str = result.get("body", "")
|
|
71
|
+
|
|
72
|
+
if "application/json" in content_type and body_str:
|
|
73
|
+
try:
|
|
74
|
+
body = json.loads(body_str)
|
|
75
|
+
if "usage" in body and isinstance(body["usage"], dict):
|
|
76
|
+
usage = body["usage"]
|
|
77
|
+
return {
|
|
78
|
+
"prompt_tokens": getattr(usage, "inputTokens", 0),
|
|
79
|
+
"completion_tokens": getattr(usage, "outputTokens", 0),
|
|
80
|
+
"total_tokens": getattr(usage, "inputTokens", 0)
|
|
81
|
+
+ getattr(usage, "outputTokens", 0),
|
|
82
|
+
}
|
|
83
|
+
elif "usageMetadata" in body and isinstance(body["usageMetadata"], dict):
|
|
84
|
+
usage = body["usageMetadata"]
|
|
85
|
+
return {
|
|
86
|
+
"prompt_tokens": getattr(usage, "promptTokenCount", 0),
|
|
87
|
+
"completion_tokens": getattr(usage, "candidatesTokenCount", 0),
|
|
88
|
+
"total_tokens": getattr(usage, "totalTokenCount", 0),
|
|
89
|
+
}
|
|
90
|
+
except json.JSONDecodeError:
|
|
91
|
+
logger.debug("Failed to parse Bedrock response body as JSON.")
|
|
92
|
+
except Exception as e:
|
|
93
|
+
logger.debug("Error extracting usage from Bedrock response: %s", e)
|
|
94
|
+
return None
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""OpenTelemetry instrumentor for Azure OpenAI SDK.
|
|
2
|
+
|
|
3
|
+
This instrumentor automatically traces calls to Azure OpenAI models, capturing
|
|
4
|
+
relevant attributes such as model name and token usage.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import logging
|
|
8
|
+
from typing import Dict, Optional
|
|
9
|
+
|
|
10
|
+
from ..config import OTelConfig
|
|
11
|
+
from .base import BaseInstrumentor
|
|
12
|
+
|
|
13
|
+
logger = logging.getLogger(__name__)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class AzureOpenAIInstrumentor(BaseInstrumentor):
|
|
17
|
+
"""Instrumentor for Azure OpenAI"""
|
|
18
|
+
|
|
19
|
+
def __init__(self):
|
|
20
|
+
"""Initialize the instrumentor."""
|
|
21
|
+
super().__init__()
|
|
22
|
+
self._azure_openai_available = False
|
|
23
|
+
self._check_availability()
|
|
24
|
+
|
|
25
|
+
def _check_availability(self):
|
|
26
|
+
"""Check if Azure AI OpenAI library is available."""
|
|
27
|
+
try:
|
|
28
|
+
import azure.ai.openai # Moved to top
|
|
29
|
+
|
|
30
|
+
self._azure_openai_available = True
|
|
31
|
+
logger.debug("Azure AI OpenAI library detected and available for instrumentation")
|
|
32
|
+
except ImportError:
|
|
33
|
+
logger.debug("Azure AI OpenAI library not installed, instrumentation will be skipped")
|
|
34
|
+
self._azure_openai_available = False
|
|
35
|
+
|
|
36
|
+
def instrument(self, config: OTelConfig):
|
|
37
|
+
self.config = config
|
|
38
|
+
try:
|
|
39
|
+
from azure.ai.openai import OpenAIClient
|
|
40
|
+
|
|
41
|
+
original_complete = OpenAIClient.complete
|
|
42
|
+
|
|
43
|
+
def wrapped_complete(instance, *args, **kwargs):
|
|
44
|
+
with self.tracer.start_as_current_span("azure.openai.complete") as span:
|
|
45
|
+
model = kwargs.get("model", "unknown")
|
|
46
|
+
|
|
47
|
+
span.set_attribute("gen_ai.system", "azure_openai")
|
|
48
|
+
span.set_attribute("gen_ai.request.model", model)
|
|
49
|
+
|
|
50
|
+
if self.request_counter:
|
|
51
|
+
self.request_counter.add(1, {"model": model, "provider": "azure_openai"})
|
|
52
|
+
|
|
53
|
+
result = original_complete(instance, *args, **kwargs)
|
|
54
|
+
self._record_result_metrics(span, result, 0)
|
|
55
|
+
return result
|
|
56
|
+
|
|
57
|
+
OpenAIClient.complete = wrapped_complete
|
|
58
|
+
|
|
59
|
+
except ImportError:
|
|
60
|
+
pass
|
|
61
|
+
|
|
62
|
+
def _extract_usage(self, result) -> Optional[Dict[str, int]]:
|
|
63
|
+
if hasattr(result, "usage") and result.usage:
|
|
64
|
+
return {
|
|
65
|
+
"prompt_tokens": result.usage.prompt_tokens,
|
|
66
|
+
"completion_tokens": result.usage.completion_tokens,
|
|
67
|
+
"total_tokens": result.usage.total_tokens,
|
|
68
|
+
}
|
|
69
|
+
return None
|