genai-otel-instrument 0.1.4.dev0__py3-none-any.whl → 0.1.9.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/__version__.py CHANGED
@@ -28,7 +28,7 @@ version_tuple: VERSION_TUPLE
28
28
  commit_id: COMMIT_ID
29
29
  __commit_id__: COMMIT_ID
30
30
 
31
- __version__ = version = '0.1.4.dev0'
32
- __version_tuple__ = version_tuple = (0, 1, 4, 'dev0')
31
+ __version__ = version = '0.1.9.dev0'
32
+ __version_tuple__ = version_tuple = (0, 1, 9, 'dev0')
33
33
 
34
34
  __commit_id__ = commit_id = None
@@ -19,6 +19,7 @@ from opentelemetry.sdk.trace.export import BatchSpanProcessor, ConsoleSpanExport
19
19
  from .config import OTelConfig
20
20
  from .cost_calculator import CostCalculator
21
21
  from .cost_enrichment_processor import CostEnrichmentSpanProcessor
22
+ from .cost_enriching_exporter import CostEnrichingSpanExporter
22
23
  from .gpu_metrics import GPUMetricsCollector
23
24
  from .mcp_instrumentors import MCPInstrumentorManager
24
25
  from .metrics import (
@@ -169,14 +170,17 @@ def setup_auto_instrumentation(config: OTelConfig):
169
170
 
170
171
  set_global_textmap(TraceContextTextMapPropagator())
171
172
 
172
- # Add cost enrichment processor for OpenInference instrumentors
173
- # This enriches spans from smolagents, litellm, mcp with cost attributes
173
+ # Add cost enrichment processor for custom instrumentors (OpenAI, Ollama, etc.)
174
+ # These instrumentors set cost attributes directly, so processor is mainly for logging
175
+ # Also attempts to enrich OpenInference spans (smolagents, litellm, mcp), though
176
+ # the processor can't modify ReadableSpan - the exporter below handles that
177
+ cost_calculator = None
174
178
  if config.enable_cost_tracking:
175
179
  try:
176
180
  cost_calculator = CostCalculator()
177
181
  cost_processor = CostEnrichmentSpanProcessor(cost_calculator)
178
182
  tracer_provider.add_span_processor(cost_processor)
179
- logger.info("Cost enrichment processor added for OpenInference instrumentors")
183
+ logger.info("Cost enrichment processor added")
180
184
  except Exception as e:
181
185
  logger.warning(f"Failed to add cost enrichment processor: {e}", exc_info=True)
182
186
 
genai_otel/config.py CHANGED
@@ -11,7 +11,7 @@ import logging
11
11
  import os
12
12
  import sys
13
13
  from dataclasses import dataclass, field
14
- from typing import Dict, List, Optional
14
+ from typing import Any, Callable, Dict, List, Optional, Tuple
15
15
 
16
16
  logger = logging.getLogger(__name__)
17
17
 
@@ -104,6 +104,10 @@ class OTelConfig:
104
104
  default_factory=lambda: float(os.getenv("GENAI_CARBON_INTENSITY", "475.0"))
105
105
  ) # gCO2e/kWh
106
106
 
107
+ power_cost_per_kwh: float = field(
108
+ default_factory=lambda: float(os.getenv("GENAI_POWER_COST_PER_KWH", "0.12"))
109
+ ) # USD per kWh - electricity cost for power consumption tracking
110
+
107
111
  gpu_collection_interval: int = field(
108
112
  default_factory=lambda: int(os.getenv("GENAI_GPU_COLLECTION_INTERVAL", "5"))
109
113
  ) # seconds - how often to collect GPU metrics and CO2 emissions
@@ -120,6 +124,20 @@ class OTelConfig:
120
124
  default_factory=lambda: os.getenv("GENAI_ENABLE_CONTENT_CAPTURE", "false").lower() == "true"
121
125
  )
122
126
 
127
+ # Custom pricing configuration for models not in llm_pricing.json
128
+ # Format: JSON string with same structure as llm_pricing.json
129
+ # Example: {"chat": {"custom-model": {"promptPrice": 0.001, "completionPrice": 0.002}}}
130
+ custom_pricing_json: Optional[str] = field(
131
+ default_factory=lambda: os.getenv("GENAI_CUSTOM_PRICING_JSON")
132
+ )
133
+
134
+ # Session and user tracking (Phase 4.1)
135
+ # Optional callable functions to extract session_id and user_id from requests
136
+ # Signature: (instance, args, kwargs) -> Optional[str]
137
+ # Example: lambda instance, args, kwargs: kwargs.get("metadata", {}).get("session_id")
138
+ session_id_extractor: Optional[Callable[[Any, Tuple, Dict], Optional[str]]] = None
139
+ user_id_extractor: Optional[Callable[[Any, Tuple, Dict], Optional[str]]] = None
140
+
123
141
 
124
142
  import os
125
143
 
@@ -13,10 +13,18 @@ class CostCalculator:
13
13
 
14
14
  DEFAULT_PRICING_FILE = "llm_pricing.json"
15
15
 
16
- def __init__(self):
17
- """Initializes the CostCalculator by loading pricing data from a JSON file."""
16
+ def __init__(self, custom_pricing_json: Optional[str] = None):
17
+ """Initializes the CostCalculator by loading pricing data from a JSON file.
18
+
19
+ Args:
20
+ custom_pricing_json: Optional JSON string with custom model pricing.
21
+ Format: {"chat": {"model-name": {"promptPrice": 0.001, "completionPrice": 0.002}}}
22
+ Custom prices will be merged with default pricing, with custom taking precedence.
23
+ """
18
24
  self.pricing_data: Dict[str, Any] = {}
19
25
  self._load_pricing()
26
+ if custom_pricing_json:
27
+ self._merge_custom_pricing(custom_pricing_json)
20
28
 
21
29
  def _load_pricing(self):
22
30
  """Load pricing data from the JSON configuration file."""
@@ -60,6 +68,64 @@ class CostCalculator:
60
68
  except Exception as e:
61
69
  logger.error("An unexpected error occurred while loading pricing: %s", e, exc_info=True)
62
70
 
71
+ def _merge_custom_pricing(self, custom_pricing_json: str):
72
+ """Merge custom pricing from JSON string into existing pricing data.
73
+
74
+ Args:
75
+ custom_pricing_json: JSON string with custom model pricing.
76
+ Format: {"chat": {"model-name": {"promptPrice": 0.001, "completionPrice": 0.002}}}
77
+ """
78
+ try:
79
+ custom_pricing = json.loads(custom_pricing_json)
80
+
81
+ if not isinstance(custom_pricing, dict):
82
+ logger.error(
83
+ "Custom pricing must be a JSON object/dict. Got: %s",
84
+ type(custom_pricing).__name__,
85
+ )
86
+ return
87
+
88
+ # Merge custom pricing into each category (chat, embeddings, images, audio)
89
+ for category, models in custom_pricing.items():
90
+ if category not in ["chat", "embeddings", "images", "audio"]:
91
+ logger.warning(
92
+ "Unknown pricing category '%s' in custom pricing. Valid categories: "
93
+ "chat, embeddings, images, audio",
94
+ category,
95
+ )
96
+ continue
97
+
98
+ if not isinstance(models, dict):
99
+ logger.error(
100
+ "Custom pricing for category '%s' must be a dict. Got: %s",
101
+ category,
102
+ type(models).__name__,
103
+ )
104
+ continue
105
+
106
+ # Initialize category if it doesn't exist
107
+ if category not in self.pricing_data:
108
+ self.pricing_data[category] = {}
109
+
110
+ # Merge models into the category
111
+ for model_name, pricing in models.items():
112
+ self.pricing_data[category][model_name] = pricing
113
+ logger.info(
114
+ "Added custom pricing for %s model '%s': %s",
115
+ category,
116
+ model_name,
117
+ pricing,
118
+ )
119
+
120
+ except json.JSONDecodeError as e:
121
+ logger.error(
122
+ "Failed to decode custom pricing JSON: %s. Custom pricing will be ignored.", e
123
+ )
124
+ except Exception as e:
125
+ logger.error(
126
+ "An unexpected error occurred while merging custom pricing: %s", e, exc_info=True
127
+ )
128
+
63
129
  def calculate_cost(
64
130
  self,
65
131
  model: str,
@@ -150,7 +216,7 @@ class CostCalculator:
150
216
  model,
151
217
  param_count,
152
218
  pricing["promptPrice"],
153
- pricing["completionPrice"]
219
+ pricing["completionPrice"],
154
220
  )
155
221
  else:
156
222
  logger.debug("Pricing not found for chat model: %s", model)
@@ -319,14 +385,14 @@ class CostCalculator:
319
385
 
320
386
  # First try explicit parameter count patterns (e.g., 135m, 7b, 70b)
321
387
  # Matches: digits followed by optional decimal, then 'm' or 'b'
322
- pattern = r'(\d+(?:\.\d+)?)(m|b)(?:\s|:|$|-)'
388
+ pattern = r"(\d+(?:\.\d+)?)(m|b)(?:\s|:|$|-)"
323
389
  match = re.search(pattern, model_lower)
324
390
  if match:
325
391
  value = float(match.group(1))
326
392
  unit = match.group(2)
327
- if unit == 'm':
393
+ if unit == "m":
328
394
  return value / 1000 # Convert millions to billions
329
- elif unit == 'b':
395
+ elif unit == "b":
330
396
  return value
331
397
 
332
398
  # Fallback to common model size indicators for HuggingFace models
@@ -0,0 +1,207 @@
1
+ """Custom SpanExporter that enriches spans with cost attributes before export.
2
+
3
+ This exporter wraps another exporter (like OTLPSpanExporter) and adds cost
4
+ attributes to spans before passing them to the wrapped exporter.
5
+ """
6
+
7
+ import logging
8
+ from typing import Optional, Sequence
9
+
10
+ from opentelemetry.sdk.trace import ReadableSpan
11
+ from opentelemetry.sdk.trace.export import SpanExporter, SpanExportResult
12
+
13
+ from .cost_calculator import CostCalculator
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+
18
+ class CostEnrichingSpanExporter(SpanExporter):
19
+ """Wraps a SpanExporter and enriches spans with cost attributes before export.
20
+
21
+ This exporter:
22
+ 1. Receives ReadableSpan objects from the SDK
23
+ 2. Extracts model name and token usage from span attributes
24
+ 3. Calculates cost using CostCalculator
25
+ 4. Creates enriched span data with cost attributes
26
+ 5. Exports to the wrapped exporter (e.g., OTLP)
27
+ """
28
+
29
+ def __init__(
30
+ self, wrapped_exporter: SpanExporter, cost_calculator: Optional[CostCalculator] = None
31
+ ):
32
+ """Initialize the cost enriching exporter.
33
+
34
+ Args:
35
+ wrapped_exporter: The underlying exporter to send enriched spans to.
36
+ cost_calculator: CostCalculator instance to use for cost calculations.
37
+ If None, creates a new instance.
38
+ """
39
+ self.wrapped_exporter = wrapped_exporter
40
+ self.cost_calculator = cost_calculator or CostCalculator()
41
+ logger.info(
42
+ f"CostEnrichingSpanExporter initialized, wrapping {type(wrapped_exporter).__name__}"
43
+ )
44
+
45
+ def export(self, spans: Sequence[ReadableSpan]) -> SpanExportResult:
46
+ """Export spans after enriching them with cost attributes.
47
+
48
+ Args:
49
+ spans: Sequence of ReadableSpan objects to export.
50
+
51
+ Returns:
52
+ SpanExportResult from the wrapped exporter.
53
+ """
54
+ try:
55
+ # Enrich spans with cost attributes
56
+ enriched_spans = []
57
+ for span in spans:
58
+ enriched_span = self._enrich_span(span)
59
+ enriched_spans.append(enriched_span)
60
+
61
+ # Export to wrapped exporter
62
+ return self.wrapped_exporter.export(enriched_spans)
63
+
64
+ except Exception as e:
65
+ logger.error(f"Failed to export spans: {e}", exc_info=True)
66
+ return SpanExportResult.FAILURE
67
+
68
+ def _enrich_span(self, span: ReadableSpan) -> ReadableSpan:
69
+ """Enrich a span with cost attributes if applicable.
70
+
71
+ Args:
72
+ span: The original ReadableSpan.
73
+
74
+ Returns:
75
+ A new ReadableSpan with cost attributes added (or the original if not applicable).
76
+ """
77
+ try:
78
+ # Check if span has LLM-related attributes
79
+ if not span.attributes:
80
+ return span
81
+
82
+ attributes = dict(span.attributes) # Make a mutable copy
83
+
84
+ # Check for model name - support both GenAI and OpenInference conventions
85
+ model = (
86
+ attributes.get("gen_ai.request.model")
87
+ or attributes.get("llm.model_name")
88
+ or attributes.get("embedding.model_name")
89
+ )
90
+ if not model:
91
+ return span
92
+
93
+ # Skip if cost attributes are already present
94
+ if "gen_ai.usage.cost.total" in attributes:
95
+ logger.debug(f"Span '{span.name}' already has cost attributes, skipping enrichment")
96
+ return span
97
+
98
+ # Extract token usage - support GenAI, OpenInference, and legacy conventions
99
+ prompt_tokens = (
100
+ attributes.get("gen_ai.usage.prompt_tokens")
101
+ or attributes.get("gen_ai.usage.input_tokens")
102
+ or attributes.get("llm.token_count.prompt") # OpenInference
103
+ or 0
104
+ )
105
+ completion_tokens = (
106
+ attributes.get("gen_ai.usage.completion_tokens")
107
+ or attributes.get("gen_ai.usage.output_tokens")
108
+ or attributes.get("llm.token_count.completion") # OpenInference
109
+ or 0
110
+ )
111
+
112
+ # Skip if no tokens recorded
113
+ if prompt_tokens == 0 and completion_tokens == 0:
114
+ return span
115
+
116
+ # Get call type - support both GenAI and OpenInference conventions
117
+ span_kind = attributes.get("openinference.span.kind", "").upper()
118
+ call_type = attributes.get("gen_ai.operation.name") or span_kind.lower() or "chat"
119
+
120
+ # Map operation names to call types
121
+ call_type_mapping = {
122
+ "chat": "chat",
123
+ "completion": "chat",
124
+ "embedding": "embedding",
125
+ "embeddings": "embedding",
126
+ "text_generation": "chat",
127
+ "image_generation": "image",
128
+ "audio": "audio",
129
+ "llm": "chat",
130
+ "chain": "chat",
131
+ "retriever": "embedding",
132
+ "reranker": "embedding",
133
+ "tool": "chat",
134
+ "agent": "chat",
135
+ }
136
+ normalized_call_type = call_type_mapping.get(str(call_type).lower(), "chat")
137
+
138
+ # Calculate cost
139
+ usage = {
140
+ "prompt_tokens": int(prompt_tokens),
141
+ "completion_tokens": int(completion_tokens),
142
+ "total_tokens": int(prompt_tokens) + int(completion_tokens),
143
+ }
144
+
145
+ cost_info = self.cost_calculator.calculate_granular_cost(
146
+ model=str(model),
147
+ usage=usage,
148
+ call_type=normalized_call_type,
149
+ )
150
+
151
+ if cost_info and cost_info.get("total", 0.0) > 0:
152
+ # Add cost attributes to the mutable copy
153
+ attributes["gen_ai.usage.cost.total"] = cost_info["total"]
154
+
155
+ if cost_info.get("prompt", 0.0) > 0:
156
+ attributes["gen_ai.usage.cost.prompt"] = cost_info["prompt"]
157
+ if cost_info.get("completion", 0.0) > 0:
158
+ attributes["gen_ai.usage.cost.completion"] = cost_info["completion"]
159
+
160
+ logger.info(
161
+ f"Enriched span '{span.name}' with cost: {cost_info['total']:.6f} USD "
162
+ f"for model {model} ({usage['total_tokens']} tokens)"
163
+ )
164
+
165
+ # Create a new ReadableSpan with enriched attributes
166
+ # ReadableSpan is a NamedTuple, so we need to replace it
167
+ from opentelemetry.sdk.trace import ReadableSpan as RS
168
+
169
+ enriched_span = RS(
170
+ name=span.name,
171
+ context=span.context,
172
+ kind=span.kind,
173
+ parent=span.parent,
174
+ start_time=span.start_time,
175
+ end_time=span.end_time,
176
+ status=span.status,
177
+ attributes=attributes, # Use enriched attributes
178
+ events=span.events,
179
+ links=span.links,
180
+ resource=span.resource,
181
+ instrumentation_scope=span.instrumentation_scope,
182
+ )
183
+ return enriched_span
184
+
185
+ except Exception as e:
186
+ logger.warning(
187
+ f"Failed to enrich span '{getattr(span, 'name', 'unknown')}' with cost: {e}",
188
+ exc_info=True,
189
+ )
190
+
191
+ return span
192
+
193
+ def shutdown(self) -> None:
194
+ """Shutdown the wrapped exporter."""
195
+ logger.info("CostEnrichingSpanExporter shutting down")
196
+ self.wrapped_exporter.shutdown()
197
+
198
+ def force_flush(self, timeout_millis: int = 30000) -> bool:
199
+ """Force flush the wrapped exporter.
200
+
201
+ Args:
202
+ timeout_millis: Timeout in milliseconds.
203
+
204
+ Returns:
205
+ True if flush succeeded.
206
+ """
207
+ return self.wrapped_exporter.force_flush(timeout_millis)