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
genai_otel/config.py ADDED
@@ -0,0 +1,187 @@
1
+ """Configuration management for the GenAI OpenTelemetry instrumentation library.
2
+
3
+ This module defines the `OTelConfig` dataclass, which encapsulates all configurable
4
+ parameters for the OpenTelemetry setup, including service name, exporter endpoint,
5
+ enablement flags for various features (GPU metrics, cost tracking, MCP instrumentation),
6
+ and error handling behavior. Configuration values are primarily loaded from
7
+ environment variables, with sensible defaults provided.
8
+ """
9
+
10
+ import logging
11
+ import os
12
+ import sys
13
+ from dataclasses import dataclass, field
14
+ from typing import Dict, List, Optional
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+
19
+ # Default list of instrumentors to enable if not specified by the user.
20
+ # This maintains the "instrument everything available" behavior.
21
+ # Note: "mcp" is excluded by default because it requires the 'mcp' library (>= 1.6.0)
22
+ # which is a specialized dependency for Model Context Protocol servers/clients.
23
+ # Users can enable it by setting GENAI_ENABLED_INSTRUMENTORS="...,mcp" if needed.
24
+ #
25
+ # Note: "smolagents" and "litellm" OpenInference instrumentors require Python >= 3.10
26
+ # They are only added to the default list if Python version is compatible.
27
+ DEFAULT_INSTRUMENTORS = [
28
+ "openai",
29
+ "anthropic",
30
+ "google.generativeai",
31
+ "boto3",
32
+ "azure.ai.openai",
33
+ "cohere",
34
+ "mistralai",
35
+ "together",
36
+ "groq",
37
+ "ollama",
38
+ "vertexai",
39
+ "replicate",
40
+ "anyscale",
41
+ "langchain",
42
+ "llama_index",
43
+ "transformers",
44
+ ]
45
+
46
+ # Add OpenInference instrumentors only for Python >= 3.10
47
+ if sys.version_info >= (3, 10):
48
+ DEFAULT_INSTRUMENTORS.extend(["smolagents", "litellm"])
49
+
50
+
51
+ def _get_enabled_instrumentors() -> List[str]:
52
+ """
53
+ Gets the list of enabled instrumentors from the environment variable.
54
+ Defaults to all supported instrumentors if the variable is not set.
55
+ """
56
+ enabled_str = os.getenv("GENAI_ENABLED_INSTRUMENTORS")
57
+ if enabled_str:
58
+ return [s.strip() for s in enabled_str.split(",")]
59
+ return DEFAULT_INSTRUMENTORS
60
+
61
+
62
+ @dataclass
63
+ class OTelConfig:
64
+ """Configuration for OpenTelemetry instrumentation.
65
+
66
+ Loads settings from environment variables with sensible defaults.
67
+ """
68
+
69
+ service_name: str = field(default_factory=lambda: os.getenv("OTEL_SERVICE_NAME", "genai-app"))
70
+ endpoint: str = field(
71
+ default_factory=lambda: os.getenv("OTEL_EXPORTER_OTLP_ENDPOINT", "http://localhost:4318")
72
+ )
73
+ enabled_instrumentors: List[str] = field(default_factory=_get_enabled_instrumentors)
74
+ enable_gpu_metrics: bool = field(
75
+ default_factory=lambda: os.getenv("GENAI_ENABLE_GPU_METRICS", "true").lower() == "true"
76
+ )
77
+ enable_cost_tracking: bool = field(
78
+ default_factory=lambda: os.getenv("GENAI_ENABLE_COST_TRACKING", "true").lower() == "true"
79
+ )
80
+ enable_mcp_instrumentation: bool = field(
81
+ default_factory=lambda: os.getenv("GENAI_ENABLE_MCP_INSTRUMENTATION", "true").lower()
82
+ == "true"
83
+ )
84
+ enable_http_instrumentation: bool = field(
85
+ default_factory=lambda: os.getenv("GENAI_ENABLE_HTTP_INSTRUMENTATION", "false").lower()
86
+ == "true"
87
+ )
88
+ # Add fail_on_error configuration
89
+ fail_on_error: bool = field(
90
+ default_factory=lambda: os.getenv("GENAI_FAIL_ON_ERROR", "false").lower() == "true"
91
+ )
92
+ headers: Optional[Dict[str, str]] = None
93
+
94
+ enable_co2_tracking: bool = field(
95
+ default_factory=lambda: os.getenv("GENAI_ENABLE_CO2_TRACKING", "false").lower() == "true"
96
+ )
97
+ exporter_timeout: float = field(
98
+ default_factory=lambda: float(os.getenv("OTEL_EXPORTER_OTLP_TIMEOUT", "60.0"))
99
+ )
100
+ carbon_intensity: float = field(
101
+ default_factory=lambda: float(os.getenv("GENAI_CARBON_INTENSITY", "475.0"))
102
+ ) # gCO2e/kWh
103
+
104
+ gpu_collection_interval: int = field(
105
+ default_factory=lambda: int(os.getenv("GENAI_GPU_COLLECTION_INTERVAL", "5"))
106
+ ) # seconds - how often to collect GPU metrics and CO2 emissions
107
+
108
+ # OpenTelemetry semantic convention stability opt-in
109
+ # Supports "gen_ai" for new conventions, "gen_ai/dup" for dual emission
110
+ semconv_stability_opt_in: str = field(
111
+ default_factory=lambda: os.getenv("OTEL_SEMCONV_STABILITY_OPT_IN", "gen_ai")
112
+ )
113
+
114
+ # Enable content capture as span events
115
+ # WARNING: May capture sensitive data. Use with caution.
116
+ enable_content_capture: bool = field(
117
+ default_factory=lambda: os.getenv("GENAI_ENABLE_CONTENT_CAPTURE", "false").lower() == "true"
118
+ )
119
+
120
+
121
+ import os
122
+
123
+ from opentelemetry import trace
124
+ from opentelemetry.sdk.resources import ( # noqa: F401
125
+ DEPLOYMENT_ENVIRONMENT,
126
+ SERVICE_NAME,
127
+ TELEMETRY_SDK_NAME,
128
+ Resource,
129
+ )
130
+ from opentelemetry.sdk.trace import TracerProvider
131
+ from opentelemetry.sdk.trace.export import (
132
+ BatchSpanProcessor,
133
+ ConsoleSpanExporter,
134
+ SimpleSpanProcessor,
135
+ )
136
+
137
+ if os.environ.get("OTEL_EXPORTER_OTLP_PROTOCOL") == "grpc":
138
+ from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter
139
+ else:
140
+ from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter
141
+
142
+
143
+ def setup_tracing(
144
+ config: "OTelConfig", # Use OTelConfig from this module
145
+ tracer_name: str,
146
+ disable_batch: bool = False,
147
+ ):
148
+ """
149
+ Sets up tracing with OpenTelemetry.
150
+ Initializes the tracer provider and configures the span processor and exporter.
151
+ """
152
+
153
+ try:
154
+ # Disable Haystack Auto Tracing
155
+ os.environ["HAYSTACK_AUTO_TRACE_ENABLED"] = "false"
156
+
157
+ # Create a resource with the service name attribute.
158
+ resource = Resource.create(
159
+ attributes={
160
+ SERVICE_NAME: config.service_name,
161
+ DEPLOYMENT_ENVIRONMENT: os.getenv("ENVIRONMENT", "dev"),
162
+ TELEMETRY_SDK_NAME: "genai_otel_instrument",
163
+ }
164
+ )
165
+
166
+ # Initialize the TracerProvider with the created resource.
167
+ trace.set_tracer_provider(TracerProvider(resource=resource))
168
+
169
+ # Configure the span exporter and processor based on whether the endpoint is effectively set.
170
+ if config.endpoint:
171
+ span_exporter = OTLPSpanExporter(headers=config.headers)
172
+ span_processor = (
173
+ BatchSpanProcessor(span_exporter)
174
+ if not disable_batch
175
+ else SimpleSpanProcessor(span_exporter)
176
+ )
177
+ else:
178
+ span_exporter = ConsoleSpanExporter()
179
+ span_processor = SimpleSpanProcessor(span_exporter)
180
+
181
+ trace.get_tracer_provider().add_span_processor(span_processor)
182
+
183
+ return trace.get_tracer(tracer_name)
184
+
185
+ except Exception as e:
186
+ logger.error("Failed to initialize OpenTelemetry: %s", e, exc_info=True)
187
+ return None
@@ -0,0 +1,276 @@
1
+ """Module for calculating estimated costs of LLM API calls."""
2
+
3
+ import json
4
+ import logging
5
+ from typing import Any, Dict, Optional
6
+
7
+ logger = logging.getLogger(__name__)
8
+
9
+
10
+ class CostCalculator:
11
+ """Calculate estimated costs for LLM API calls based on loaded pricing data."""
12
+
13
+ DEFAULT_PRICING_FILE = "llm_pricing.json"
14
+
15
+ def __init__(self):
16
+ """Initializes the CostCalculator by loading pricing data from a JSON file."""
17
+ self.pricing_data: Dict[str, Any] = {}
18
+ self._load_pricing()
19
+
20
+ def _load_pricing(self):
21
+ """Load pricing data from the JSON configuration file."""
22
+ try:
23
+ try:
24
+ from importlib.resources import files
25
+
26
+ pricing_file = files("genai_otel").joinpath(self.DEFAULT_PRICING_FILE)
27
+ data = json.loads(pricing_file.read_text(encoding="utf-8"))
28
+ except (ImportError, AttributeError):
29
+ try:
30
+ import importlib_resources
31
+
32
+ pricing_file = importlib_resources.files("genai_otel").joinpath(
33
+ self.DEFAULT_PRICING_FILE
34
+ )
35
+ data = json.loads(pricing_file.read_text(encoding="utf-8"))
36
+ except ImportError:
37
+ import pkg_resources
38
+
39
+ pricing_file_path = pkg_resources.resource_filename(
40
+ "genai_otel", self.DEFAULT_PRICING_FILE
41
+ )
42
+ with open(pricing_file_path, "r", encoding="utf-8") as f:
43
+ data = json.load(f)
44
+
45
+ if isinstance(data, dict):
46
+ self.pricing_data = data
47
+ logger.info("Successfully loaded pricing data.")
48
+ else:
49
+ logger.error("Invalid format in pricing file. Root element is not a dictionary.")
50
+ except FileNotFoundError:
51
+ logger.warning(
52
+ "Pricing file '%s' not found. Cost tracking will be disabled.",
53
+ self.DEFAULT_PRICING_FILE,
54
+ )
55
+ except json.JSONDecodeError as e:
56
+ logger.error(
57
+ "Failed to decode JSON from pricing file: %s. Cost tracking will be disabled.", e
58
+ )
59
+ except Exception as e:
60
+ logger.error("An unexpected error occurred while loading pricing: %s", e, exc_info=True)
61
+
62
+ def calculate_cost(
63
+ self,
64
+ model: str,
65
+ usage: Dict[str, Any],
66
+ call_type: str,
67
+ ) -> float:
68
+ """Calculate cost in USD for a request based on model, usage, and call type.
69
+
70
+ Note: For chat requests, use calculate_granular_cost() to get prompt/completion/reasoning/cache breakdown.
71
+ This method returns total cost for backwards compatibility.
72
+ """
73
+ if not self.pricing_data:
74
+ return 0.0
75
+
76
+ if call_type == "chat":
77
+ return self._calculate_chat_cost(model, usage)
78
+ if call_type == "embedding":
79
+ return self._calculate_embedding_cost(model, usage)
80
+ if call_type == "image":
81
+ return self._calculate_image_cost(model, usage)
82
+ if call_type == "audio":
83
+ return self._calculate_audio_cost(model, usage)
84
+
85
+ logger.warning("Unknown call type '%s' for cost calculation.", call_type)
86
+ return 0.0
87
+
88
+ def calculate_granular_cost(
89
+ self,
90
+ model: str,
91
+ usage: Dict[str, Any],
92
+ call_type: str,
93
+ ) -> Dict[str, float]:
94
+ """Calculate granular cost breakdown for a request.
95
+
96
+ Returns a dictionary with:
97
+ - total: Total cost
98
+ - prompt: Prompt tokens cost
99
+ - completion: Completion tokens cost
100
+ - reasoning: Reasoning tokens cost (OpenAI o1 models)
101
+ - cache_read: Cache read cost (Anthropic)
102
+ - cache_write: Cache write cost (Anthropic)
103
+ """
104
+ if not self.pricing_data:
105
+ return {
106
+ "total": 0.0,
107
+ "prompt": 0.0,
108
+ "completion": 0.0,
109
+ "reasoning": 0.0,
110
+ "cache_read": 0.0,
111
+ "cache_write": 0.0,
112
+ }
113
+
114
+ if call_type == "chat":
115
+ return self._calculate_chat_cost_granular(model, usage)
116
+
117
+ # For non-chat requests, only return total cost
118
+ total_cost = self.calculate_cost(model, usage, call_type)
119
+ return {
120
+ "total": total_cost,
121
+ "prompt": 0.0,
122
+ "completion": 0.0,
123
+ "reasoning": 0.0,
124
+ "cache_read": 0.0,
125
+ "cache_write": 0.0,
126
+ }
127
+
128
+ def _calculate_chat_cost(self, model: str, usage: Dict[str, int]) -> float:
129
+ """Calculate cost for chat models."""
130
+ granular = self._calculate_chat_cost_granular(model, usage)
131
+ return granular["total"]
132
+
133
+ def _calculate_chat_cost_granular(self, model: str, usage: Dict[str, int]) -> Dict[str, float]:
134
+ """Calculate granular cost breakdown for chat models.
135
+
136
+ Returns:
137
+ Dict with keys: total, prompt, completion, reasoning, cache_read, cache_write
138
+ """
139
+ model_key = self._normalize_model_name(model, "chat")
140
+ if not model_key:
141
+ logger.debug("Pricing not found for chat model: %s", model)
142
+ return {
143
+ "total": 0.0,
144
+ "prompt": 0.0,
145
+ "completion": 0.0,
146
+ "reasoning": 0.0,
147
+ "cache_read": 0.0,
148
+ "cache_write": 0.0,
149
+ }
150
+
151
+ pricing = self.pricing_data["chat"][model_key]
152
+
153
+ # Standard prompt and completion tokens
154
+ prompt_tokens = usage.get("prompt_tokens", 0)
155
+ completion_tokens = usage.get("completion_tokens", 0)
156
+
157
+ prompt_cost = (prompt_tokens / 1000) * pricing.get("promptPrice", 0.0)
158
+ completion_cost = (completion_tokens / 1000) * pricing.get("completionPrice", 0.0)
159
+
160
+ # Reasoning tokens (OpenAI o1 models)
161
+ reasoning_tokens = usage.get("completion_tokens_details", {}).get("reasoning_tokens", 0)
162
+ reasoning_cost = 0.0
163
+ if reasoning_tokens > 0 and "reasoningPrice" in pricing:
164
+ reasoning_cost = (reasoning_tokens / 1000) * pricing.get("reasoningPrice", 0.0)
165
+
166
+ # Cache costs (Anthropic models)
167
+ cache_read_tokens = usage.get("cache_read_input_tokens", 0)
168
+ cache_write_tokens = usage.get("cache_creation_input_tokens", 0)
169
+ cache_read_cost = 0.0
170
+ cache_write_cost = 0.0
171
+
172
+ if cache_read_tokens > 0 and "cacheReadPrice" in pricing:
173
+ cache_read_cost = (cache_read_tokens / 1000) * pricing.get("cacheReadPrice", 0.0)
174
+ if cache_write_tokens > 0 and "cacheWritePrice" in pricing:
175
+ cache_write_cost = (cache_write_tokens / 1000) * pricing.get("cacheWritePrice", 0.0)
176
+
177
+ total_cost = (
178
+ prompt_cost + completion_cost + reasoning_cost + cache_read_cost + cache_write_cost
179
+ )
180
+
181
+ return {
182
+ "total": total_cost,
183
+ "prompt": prompt_cost,
184
+ "completion": completion_cost,
185
+ "reasoning": reasoning_cost,
186
+ "cache_read": cache_read_cost,
187
+ "cache_write": cache_write_cost,
188
+ }
189
+
190
+ def _calculate_embedding_cost(self, model: str, usage: Dict[str, int]) -> float:
191
+ """Calculate cost for embedding models."""
192
+ model_key = self._normalize_model_name(model, "embeddings")
193
+ if not model_key:
194
+ logger.debug("Pricing not found for embedding model: %s", model)
195
+ return 0.0
196
+
197
+ price_per_1k_tokens = self.pricing_data["embeddings"][model_key]
198
+ total_tokens = usage.get("prompt_tokens", 0) or usage.get("total_tokens", 0)
199
+ return (total_tokens / 1000) * price_per_1k_tokens
200
+
201
+ def _calculate_image_cost(self, model: str, usage: Dict[str, Any]) -> float:
202
+ """Calculate cost for image generation models."""
203
+ model_key = self._normalize_model_name(model, "images")
204
+ if not model_key:
205
+ logger.debug("Pricing not found for image model: %s", model)
206
+ return 0.0
207
+
208
+ pricing_info = self.pricing_data["images"][model_key]
209
+ quality = usage.get("quality", "standard")
210
+ size = usage.get("size")
211
+ n = usage.get("n", 1)
212
+
213
+ if quality not in pricing_info:
214
+ logger.warning("Quality '%s' not found for image model %s", quality, model_key)
215
+ return 0.0
216
+
217
+ # Handle pricing per million pixels
218
+ if "1000000" in pricing_info[quality]:
219
+ price_per_million_pixels = pricing_info[quality]["1000000"]
220
+ height = usage.get("height", 0)
221
+ width = usage.get("width", 0)
222
+ return (height * width / 1_000_000) * price_per_million_pixels * n
223
+
224
+ if not size:
225
+ logger.warning("Image size not provided for model %s", model_key)
226
+ return 0.0
227
+
228
+ if size not in pricing_info[quality]:
229
+ logger.warning(
230
+ "Size '%s' not found for image model %s with quality '%s'", size, model_key, quality
231
+ )
232
+ return 0.0
233
+
234
+ price_per_image = pricing_info[quality][size]
235
+ return price_per_image * n
236
+
237
+ def _calculate_audio_cost(self, model: str, usage: Dict[str, int]) -> float:
238
+ """Calculate cost for audio models."""
239
+ model_key = self._normalize_model_name(model, "audio")
240
+ if not model_key:
241
+ logger.debug("Pricing not found for audio model: %s", model)
242
+ return 0.0
243
+
244
+ pricing = self.pricing_data["audio"][model_key]
245
+
246
+ if "characters" in usage:
247
+ # Price is per 1000 characters
248
+ return (usage["characters"] / 1000) * pricing
249
+ if "seconds" in usage:
250
+ # Price is per second
251
+ return usage["seconds"] * pricing
252
+
253
+ logger.warning(
254
+ "Could not determine usage unit for audio model %s. Expected 'characters' or 'seconds'.",
255
+ model_key,
256
+ )
257
+ return 0.0
258
+
259
+ def _normalize_model_name(self, model: str, category: str) -> Optional[str]:
260
+ """Normalize model name to match pricing keys for a specific category."""
261
+ if category not in self.pricing_data:
262
+ return None
263
+
264
+ normalized_model = model.lower()
265
+
266
+ # Exact match (case-insensitive)
267
+ for key in self.pricing_data[category]:
268
+ if normalized_model == key.lower():
269
+ return key
270
+
271
+ # Substring match (case-insensitive)
272
+ sorted_keys = sorted(self.pricing_data[category].keys(), key=len, reverse=True)
273
+ for key in sorted_keys:
274
+ if key.lower() in normalized_model:
275
+ return key
276
+ return None
@@ -0,0 +1,17 @@
1
+ """Custom exceptions for better error handling"""
2
+
3
+
4
+ class InstrumentationError(Exception):
5
+ """Base exception for instrumentation errors"""
6
+
7
+
8
+ class ProviderInstrumentationError(InstrumentationError):
9
+ """Error instrumenting a specific provider"""
10
+
11
+
12
+ class TelemetryExportError(InstrumentationError):
13
+ """Error exporting telemetry data"""
14
+
15
+
16
+ class ConfigurationError(InstrumentationError):
17
+ """Error in configuration"""