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.

Files changed (44) hide show
  1. genai_otel/__init__.py +129 -0
  2. genai_otel/__version__.py +34 -0
  3. genai_otel/auto_instrument.py +413 -0
  4. genai_otel/cli.py +92 -0
  5. genai_otel/config.py +187 -0
  6. genai_otel/cost_calculator.py +276 -0
  7. genai_otel/exceptions.py +17 -0
  8. genai_otel/gpu_metrics.py +240 -0
  9. genai_otel/instrumentors/__init__.py +47 -0
  10. genai_otel/instrumentors/anthropic_instrumentor.py +134 -0
  11. genai_otel/instrumentors/anyscale_instrumentor.py +27 -0
  12. genai_otel/instrumentors/aws_bedrock_instrumentor.py +94 -0
  13. genai_otel/instrumentors/azure_openai_instrumentor.py +69 -0
  14. genai_otel/instrumentors/base.py +528 -0
  15. genai_otel/instrumentors/cohere_instrumentor.py +76 -0
  16. genai_otel/instrumentors/google_ai_instrumentor.py +87 -0
  17. genai_otel/instrumentors/groq_instrumentor.py +106 -0
  18. genai_otel/instrumentors/huggingface_instrumentor.py +97 -0
  19. genai_otel/instrumentors/langchain_instrumentor.py +75 -0
  20. genai_otel/instrumentors/llamaindex_instrumentor.py +36 -0
  21. genai_otel/instrumentors/mistralai_instrumentor.py +119 -0
  22. genai_otel/instrumentors/ollama_instrumentor.py +83 -0
  23. genai_otel/instrumentors/openai_instrumentor.py +241 -0
  24. genai_otel/instrumentors/replicate_instrumentor.py +42 -0
  25. genai_otel/instrumentors/togetherai_instrumentor.py +42 -0
  26. genai_otel/instrumentors/vertexai_instrumentor.py +42 -0
  27. genai_otel/llm_pricing.json +589 -0
  28. genai_otel/logging_config.py +45 -0
  29. genai_otel/mcp_instrumentors/__init__.py +14 -0
  30. genai_otel/mcp_instrumentors/api_instrumentor.py +144 -0
  31. genai_otel/mcp_instrumentors/base.py +105 -0
  32. genai_otel/mcp_instrumentors/database_instrumentor.py +336 -0
  33. genai_otel/mcp_instrumentors/kafka_instrumentor.py +31 -0
  34. genai_otel/mcp_instrumentors/manager.py +139 -0
  35. genai_otel/mcp_instrumentors/redis_instrumentor.py +31 -0
  36. genai_otel/mcp_instrumentors/vector_db_instrumentor.py +265 -0
  37. genai_otel/metrics.py +148 -0
  38. genai_otel/py.typed +2 -0
  39. genai_otel_instrument-0.1.1.dev0.dist-info/METADATA +463 -0
  40. genai_otel_instrument-0.1.1.dev0.dist-info/RECORD +44 -0
  41. genai_otel_instrument-0.1.1.dev0.dist-info/WHEEL +5 -0
  42. genai_otel_instrument-0.1.1.dev0.dist-info/entry_points.txt +2 -0
  43. genai_otel_instrument-0.1.1.dev0.dist-info/licenses/LICENSE +201 -0
  44. 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