genai-otel-instrument 0.1.24__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.
Files changed (69) hide show
  1. genai_otel/__init__.py +132 -0
  2. genai_otel/__version__.py +34 -0
  3. genai_otel/auto_instrument.py +602 -0
  4. genai_otel/cli.py +92 -0
  5. genai_otel/config.py +333 -0
  6. genai_otel/cost_calculator.py +467 -0
  7. genai_otel/cost_enriching_exporter.py +207 -0
  8. genai_otel/cost_enrichment_processor.py +174 -0
  9. genai_otel/evaluation/__init__.py +76 -0
  10. genai_otel/evaluation/bias_detector.py +364 -0
  11. genai_otel/evaluation/config.py +261 -0
  12. genai_otel/evaluation/hallucination_detector.py +525 -0
  13. genai_otel/evaluation/pii_detector.py +356 -0
  14. genai_otel/evaluation/prompt_injection_detector.py +262 -0
  15. genai_otel/evaluation/restricted_topics_detector.py +316 -0
  16. genai_otel/evaluation/span_processor.py +962 -0
  17. genai_otel/evaluation/toxicity_detector.py +406 -0
  18. genai_otel/exceptions.py +17 -0
  19. genai_otel/gpu_metrics.py +516 -0
  20. genai_otel/instrumentors/__init__.py +71 -0
  21. genai_otel/instrumentors/anthropic_instrumentor.py +134 -0
  22. genai_otel/instrumentors/anyscale_instrumentor.py +27 -0
  23. genai_otel/instrumentors/autogen_instrumentor.py +394 -0
  24. genai_otel/instrumentors/aws_bedrock_instrumentor.py +94 -0
  25. genai_otel/instrumentors/azure_openai_instrumentor.py +69 -0
  26. genai_otel/instrumentors/base.py +919 -0
  27. genai_otel/instrumentors/bedrock_agents_instrumentor.py +398 -0
  28. genai_otel/instrumentors/cohere_instrumentor.py +140 -0
  29. genai_otel/instrumentors/crewai_instrumentor.py +311 -0
  30. genai_otel/instrumentors/dspy_instrumentor.py +661 -0
  31. genai_otel/instrumentors/google_ai_instrumentor.py +310 -0
  32. genai_otel/instrumentors/groq_instrumentor.py +106 -0
  33. genai_otel/instrumentors/guardrails_ai_instrumentor.py +510 -0
  34. genai_otel/instrumentors/haystack_instrumentor.py +503 -0
  35. genai_otel/instrumentors/huggingface_instrumentor.py +399 -0
  36. genai_otel/instrumentors/hyperbolic_instrumentor.py +236 -0
  37. genai_otel/instrumentors/instructor_instrumentor.py +425 -0
  38. genai_otel/instrumentors/langchain_instrumentor.py +340 -0
  39. genai_otel/instrumentors/langgraph_instrumentor.py +328 -0
  40. genai_otel/instrumentors/llamaindex_instrumentor.py +36 -0
  41. genai_otel/instrumentors/mistralai_instrumentor.py +315 -0
  42. genai_otel/instrumentors/ollama_instrumentor.py +197 -0
  43. genai_otel/instrumentors/ollama_server_metrics_poller.py +336 -0
  44. genai_otel/instrumentors/openai_agents_instrumentor.py +291 -0
  45. genai_otel/instrumentors/openai_instrumentor.py +260 -0
  46. genai_otel/instrumentors/pydantic_ai_instrumentor.py +362 -0
  47. genai_otel/instrumentors/replicate_instrumentor.py +87 -0
  48. genai_otel/instrumentors/sambanova_instrumentor.py +196 -0
  49. genai_otel/instrumentors/togetherai_instrumentor.py +146 -0
  50. genai_otel/instrumentors/vertexai_instrumentor.py +106 -0
  51. genai_otel/llm_pricing.json +1676 -0
  52. genai_otel/logging_config.py +45 -0
  53. genai_otel/mcp_instrumentors/__init__.py +14 -0
  54. genai_otel/mcp_instrumentors/api_instrumentor.py +144 -0
  55. genai_otel/mcp_instrumentors/base.py +105 -0
  56. genai_otel/mcp_instrumentors/database_instrumentor.py +336 -0
  57. genai_otel/mcp_instrumentors/kafka_instrumentor.py +31 -0
  58. genai_otel/mcp_instrumentors/manager.py +139 -0
  59. genai_otel/mcp_instrumentors/redis_instrumentor.py +31 -0
  60. genai_otel/mcp_instrumentors/vector_db_instrumentor.py +265 -0
  61. genai_otel/metrics.py +148 -0
  62. genai_otel/py.typed +2 -0
  63. genai_otel/server_metrics.py +197 -0
  64. genai_otel_instrument-0.1.24.dist-info/METADATA +1404 -0
  65. genai_otel_instrument-0.1.24.dist-info/RECORD +69 -0
  66. genai_otel_instrument-0.1.24.dist-info/WHEEL +5 -0
  67. genai_otel_instrument-0.1.24.dist-info/entry_points.txt +2 -0
  68. genai_otel_instrument-0.1.24.dist-info/licenses/LICENSE +680 -0
  69. genai_otel_instrument-0.1.24.dist-info/top_level.txt +1 -0
@@ -0,0 +1,962 @@
1
+ """OpenTelemetry Span Processor for evaluation and safety features.
2
+
3
+ This module provides a span processor that adds evaluation metrics and safety
4
+ checks to GenAI spans, including PII detection, toxicity detection, bias detection,
5
+ prompt injection detection, restricted topics, and hallucination detection.
6
+ """
7
+
8
+ import logging
9
+ from typing import Optional
10
+
11
+ from opentelemetry import metrics
12
+ from opentelemetry.sdk.trace import ReadableSpan, Span, SpanProcessor
13
+ from opentelemetry.trace import Status, StatusCode
14
+
15
+ from .bias_detector import BiasDetector
16
+ from .config import (
17
+ BiasConfig,
18
+ HallucinationConfig,
19
+ PIIConfig,
20
+ PromptInjectionConfig,
21
+ RestrictedTopicsConfig,
22
+ ToxicityConfig,
23
+ )
24
+ from .hallucination_detector import HallucinationDetector
25
+ from .pii_detector import PIIDetector
26
+ from .prompt_injection_detector import PromptInjectionDetector
27
+ from .restricted_topics_detector import RestrictedTopicsDetector
28
+ from .toxicity_detector import ToxicityDetector
29
+
30
+ logger = logging.getLogger(__name__)
31
+
32
+
33
+ class EvaluationSpanProcessor(SpanProcessor):
34
+ """Span processor for evaluation and safety features.
35
+
36
+ This processor analyzes GenAI spans and adds evaluation metrics and safety
37
+ attributes. It runs checks on prompts and responses based on enabled features.
38
+
39
+ Features:
40
+ - PII Detection: Detect and redact personally identifiable information
41
+ - Toxicity Detection: Monitor toxic or harmful content
42
+ - Bias Detection: Detect demographic and other biases
43
+ - Prompt Injection Detection: Protect against prompt injection attacks
44
+ - Restricted Topics: Block sensitive or inappropriate topics
45
+ - Hallucination Detection: Track factual accuracy and groundedness
46
+
47
+ All features are opt-in and configured independently.
48
+ """
49
+
50
+ def __init__(
51
+ self,
52
+ pii_config: Optional[PIIConfig] = None,
53
+ toxicity_config: Optional[ToxicityConfig] = None,
54
+ bias_config: Optional[BiasConfig] = None,
55
+ prompt_injection_config: Optional[PromptInjectionConfig] = None,
56
+ restricted_topics_config: Optional[RestrictedTopicsConfig] = None,
57
+ hallucination_config: Optional[HallucinationConfig] = None,
58
+ ):
59
+ """Initialize evaluation span processor.
60
+
61
+ Args:
62
+ pii_config: PII detection configuration
63
+ toxicity_config: Toxicity detection configuration
64
+ bias_config: Bias detection configuration
65
+ prompt_injection_config: Prompt injection detection configuration
66
+ restricted_topics_config: Restricted topics configuration
67
+ hallucination_config: Hallucination detection configuration
68
+ """
69
+ super().__init__()
70
+
71
+ # Store configurations
72
+ self.pii_config = pii_config or PIIConfig()
73
+ self.toxicity_config = toxicity_config or ToxicityConfig()
74
+ self.bias_config = bias_config or BiasConfig()
75
+ self.prompt_injection_config = prompt_injection_config or PromptInjectionConfig()
76
+ self.restricted_topics_config = restricted_topics_config or RestrictedTopicsConfig()
77
+ self.hallucination_config = hallucination_config or HallucinationConfig()
78
+
79
+ # Initialize detectors
80
+ self.pii_detector = None
81
+ if self.pii_config.enabled:
82
+ self.pii_detector = PIIDetector(self.pii_config)
83
+ if not self.pii_detector.is_available():
84
+ logger.warning(
85
+ "PII detector not available, PII detection will use fallback patterns"
86
+ )
87
+
88
+ self.toxicity_detector = None
89
+ if self.toxicity_config.enabled:
90
+ self.toxicity_detector = ToxicityDetector(self.toxicity_config)
91
+ if not self.toxicity_detector.is_available():
92
+ logger.warning(
93
+ "Toxicity detector not available, please install either:\n"
94
+ " - Perspective API: pip install google-api-python-client\n"
95
+ " - Detoxify: pip install detoxify"
96
+ )
97
+
98
+ self.bias_detector = None
99
+ if self.bias_config.enabled:
100
+ self.bias_detector = BiasDetector(self.bias_config)
101
+ if not self.bias_detector.is_available():
102
+ logger.warning(
103
+ "Bias detector not available (pattern-based detection always available)"
104
+ )
105
+
106
+ self.prompt_injection_detector = None
107
+ if self.prompt_injection_config.enabled:
108
+ self.prompt_injection_detector = PromptInjectionDetector(self.prompt_injection_config)
109
+
110
+ self.restricted_topics_detector = None
111
+ if self.restricted_topics_config.enabled:
112
+ self.restricted_topics_detector = RestrictedTopicsDetector(
113
+ self.restricted_topics_config
114
+ )
115
+
116
+ self.hallucination_detector = None
117
+ if self.hallucination_config.enabled:
118
+ self.hallucination_detector = HallucinationDetector(self.hallucination_config)
119
+
120
+ # Initialize metrics
121
+ meter = metrics.get_meter(__name__)
122
+
123
+ # PII Detection Metrics
124
+ self.pii_detection_counter = meter.create_counter(
125
+ name="genai.evaluation.pii.detections",
126
+ description="Number of PII detections in prompts and responses",
127
+ unit="1",
128
+ )
129
+
130
+ self.pii_entity_counter = meter.create_counter(
131
+ name="genai.evaluation.pii.entities",
132
+ description="Number of PII entities detected by type",
133
+ unit="1",
134
+ )
135
+
136
+ self.pii_blocked_counter = meter.create_counter(
137
+ name="genai.evaluation.pii.blocked",
138
+ description="Number of requests/responses blocked due to PII",
139
+ unit="1",
140
+ )
141
+
142
+ # Toxicity Detection Metrics
143
+ self.toxicity_detection_counter = meter.create_counter(
144
+ name="genai.evaluation.toxicity.detections",
145
+ description="Number of toxicity detections in prompts and responses",
146
+ unit="1",
147
+ )
148
+
149
+ self.toxicity_category_counter = meter.create_counter(
150
+ name="genai.evaluation.toxicity.categories",
151
+ description="Toxicity detections by category",
152
+ unit="1",
153
+ )
154
+
155
+ self.toxicity_blocked_counter = meter.create_counter(
156
+ name="genai.evaluation.toxicity.blocked",
157
+ description="Number of requests/responses blocked due to toxicity",
158
+ unit="1",
159
+ )
160
+
161
+ self.toxicity_score_histogram = meter.create_histogram(
162
+ name="genai.evaluation.toxicity.score",
163
+ description="Toxicity score distribution",
164
+ unit="1",
165
+ )
166
+
167
+ # Bias Detection Metrics
168
+ self.bias_detection_counter = meter.create_counter(
169
+ name="genai.evaluation.bias.detections",
170
+ description="Number of bias detections in prompts and responses",
171
+ unit="1",
172
+ )
173
+
174
+ self.bias_type_counter = meter.create_counter(
175
+ name="genai.evaluation.bias.types",
176
+ description="Bias detections by type",
177
+ unit="1",
178
+ )
179
+
180
+ self.bias_blocked_counter = meter.create_counter(
181
+ name="genai.evaluation.bias.blocked",
182
+ description="Number of requests/responses blocked due to bias",
183
+ unit="1",
184
+ )
185
+
186
+ self.bias_score_histogram = meter.create_histogram(
187
+ name="genai.evaluation.bias.score",
188
+ description="Bias score distribution",
189
+ unit="1",
190
+ )
191
+
192
+ # Prompt Injection Detection Metrics
193
+ self.prompt_injection_counter = meter.create_counter(
194
+ name="genai.evaluation.prompt_injection.detections",
195
+ description="Number of prompt injection attempts detected",
196
+ unit="1",
197
+ )
198
+
199
+ self.prompt_injection_type_counter = meter.create_counter(
200
+ name="genai.evaluation.prompt_injection.types",
201
+ description="Prompt injection detections by type",
202
+ unit="1",
203
+ )
204
+
205
+ self.prompt_injection_blocked_counter = meter.create_counter(
206
+ name="genai.evaluation.prompt_injection.blocked",
207
+ description="Number of requests blocked due to prompt injection",
208
+ unit="1",
209
+ )
210
+
211
+ self.prompt_injection_score_histogram = meter.create_histogram(
212
+ name="genai.evaluation.prompt_injection.score",
213
+ description="Prompt injection score distribution",
214
+ unit="1",
215
+ )
216
+
217
+ # Restricted Topics Metrics
218
+ self.restricted_topics_counter = meter.create_counter(
219
+ name="genai.evaluation.restricted_topics.detections",
220
+ description="Number of restricted topics detected",
221
+ unit="1",
222
+ )
223
+
224
+ self.restricted_topics_type_counter = meter.create_counter(
225
+ name="genai.evaluation.restricted_topics.types",
226
+ description="Restricted topics by type",
227
+ unit="1",
228
+ )
229
+
230
+ self.restricted_topics_blocked_counter = meter.create_counter(
231
+ name="genai.evaluation.restricted_topics.blocked",
232
+ description="Number of requests/responses blocked due to restricted topics",
233
+ unit="1",
234
+ )
235
+
236
+ self.restricted_topics_score_histogram = meter.create_histogram(
237
+ name="genai.evaluation.restricted_topics.score",
238
+ description="Restricted topics score distribution",
239
+ unit="1",
240
+ )
241
+
242
+ # Hallucination Detection Metrics
243
+ self.hallucination_counter = meter.create_counter(
244
+ name="genai.evaluation.hallucination.detections",
245
+ description="Number of potential hallucinations detected",
246
+ unit="1",
247
+ )
248
+
249
+ self.hallucination_indicator_counter = meter.create_counter(
250
+ name="genai.evaluation.hallucination.indicators",
251
+ description="Hallucination detections by indicator type",
252
+ unit="1",
253
+ )
254
+
255
+ self.hallucination_score_histogram = meter.create_histogram(
256
+ name="genai.evaluation.hallucination.score",
257
+ description="Hallucination score distribution",
258
+ unit="1",
259
+ )
260
+
261
+ logger.info("EvaluationSpanProcessor initialized with features:")
262
+ logger.info(" - PII Detection: %s", self.pii_config.enabled)
263
+ logger.info(" - Toxicity Detection: %s", self.toxicity_config.enabled)
264
+ logger.info(" - Bias Detection: %s", self.bias_config.enabled)
265
+ logger.info(" - Prompt Injection Detection: %s", self.prompt_injection_config.enabled)
266
+ logger.info(" - Restricted Topics: %s", self.restricted_topics_config.enabled)
267
+ logger.info(" - Hallucination Detection: %s", self.hallucination_config.enabled)
268
+
269
+ def on_start(self, span: Span, parent_context=None) -> None:
270
+ """Called when a span is started.
271
+
272
+ For evaluation features, we primarily process on_end when we have the full
273
+ prompt and response data.
274
+
275
+ Args:
276
+ span: The span that was started
277
+ parent_context: Parent context (optional)
278
+ """
279
+ # Most evaluation happens on_end, but we can do prompt analysis here if needed
280
+ pass
281
+
282
+ def on_end(self, span: ReadableSpan) -> None:
283
+ """Called when a span is ended.
284
+
285
+ This is where we perform evaluation and safety checks on the span's
286
+ prompt and response data.
287
+
288
+ Args:
289
+ span: The span that ended
290
+ """
291
+ if not isinstance(span, Span):
292
+ return
293
+
294
+ try:
295
+ # Extract prompt and response from span attributes
296
+ attributes = dict(span.attributes) if span.attributes else {}
297
+
298
+ prompt = self._extract_prompt(attributes)
299
+ response = self._extract_response(attributes)
300
+
301
+ # Run PII detection
302
+ if self.pii_config.enabled and self.pii_detector:
303
+ self._check_pii(span, prompt, response)
304
+
305
+ # Run toxicity detection
306
+ if self.toxicity_config.enabled and self.toxicity_detector:
307
+ self._check_toxicity(span, prompt, response)
308
+
309
+ # Run bias detection
310
+ if self.bias_config.enabled and self.bias_detector:
311
+ self._check_bias(span, prompt, response)
312
+
313
+ # Run prompt injection detection (prompts only)
314
+ if self.prompt_injection_config.enabled and self.prompt_injection_detector:
315
+ self._check_prompt_injection(span, prompt)
316
+
317
+ # Run restricted topics detection
318
+ if self.restricted_topics_config.enabled and self.restricted_topics_detector:
319
+ self._check_restricted_topics(span, prompt, response)
320
+
321
+ # Run hallucination detection (responses only)
322
+ if self.hallucination_config.enabled and self.hallucination_detector:
323
+ self._check_hallucination(span, prompt, response, attributes)
324
+
325
+ except Exception as e:
326
+ logger.error("Error in evaluation span processor: %s", e, exc_info=True)
327
+
328
+ def _extract_prompt(self, attributes: dict) -> Optional[str]:
329
+ """Extract prompt text from span attributes.
330
+
331
+ Args:
332
+ attributes: Span attributes
333
+
334
+ Returns:
335
+ Optional[str]: Prompt text if found
336
+ """
337
+ # Try different attribute names used by various instrumentors
338
+ prompt_keys = [
339
+ "gen_ai.prompt",
340
+ "gen_ai.prompt.0.content",
341
+ "gen_ai.request.prompt",
342
+ "llm.prompts",
343
+ "gen_ai.content.prompt",
344
+ ]
345
+
346
+ for key in prompt_keys:
347
+ if key in attributes:
348
+ value = attributes[key]
349
+ if isinstance(value, str):
350
+ return value
351
+ elif isinstance(value, list) and value:
352
+ # Handle list of messages
353
+ if isinstance(value[0], dict) and "content" in value[0]:
354
+ return value[0]["content"]
355
+ elif isinstance(value[0], str):
356
+ return value[0]
357
+
358
+ return None
359
+
360
+ def _extract_response(self, attributes: dict) -> Optional[str]:
361
+ """Extract response text from span attributes.
362
+
363
+ Args:
364
+ attributes: Span attributes
365
+
366
+ Returns:
367
+ Optional[str]: Response text if found
368
+ """
369
+ # Try different attribute names used by various instrumentors
370
+ response_keys = [
371
+ "gen_ai.response",
372
+ "gen_ai.completion",
373
+ "gen_ai.response.0.content",
374
+ "llm.responses",
375
+ "gen_ai.content.completion",
376
+ "gen_ai.response.message.content",
377
+ ]
378
+
379
+ for key in response_keys:
380
+ if key in attributes:
381
+ value = attributes[key]
382
+ if isinstance(value, str):
383
+ return value
384
+ elif isinstance(value, list) and value:
385
+ # Handle list of messages
386
+ if isinstance(value[0], dict) and "content" in value[0]:
387
+ return value[0]["content"]
388
+ elif isinstance(value[0], str):
389
+ return value[0]
390
+
391
+ return None
392
+
393
+ def _check_pii(self, span: Span, prompt: Optional[str], response: Optional[str]) -> None:
394
+ """Check for PII in prompt and response.
395
+
396
+ Args:
397
+ span: The span to add PII attributes to
398
+ prompt: Prompt text (optional)
399
+ response: Response text (optional)
400
+ """
401
+ if not self.pii_detector:
402
+ return
403
+
404
+ try:
405
+ # Check prompt for PII
406
+ if prompt:
407
+ result = self.pii_detector.detect(prompt)
408
+ if result.has_pii:
409
+ span.set_attribute("evaluation.pii.prompt.detected", True)
410
+ span.set_attribute("evaluation.pii.prompt.entity_count", len(result.entities))
411
+ span.set_attribute(
412
+ "evaluation.pii.prompt.entity_types",
413
+ list(result.entity_counts.keys()),
414
+ )
415
+ span.set_attribute("evaluation.pii.prompt.score", result.score)
416
+
417
+ # Record metrics
418
+ self.pii_detection_counter.add(
419
+ 1, {"location": "prompt", "mode": self.pii_config.mode.value}
420
+ )
421
+
422
+ # Add entity counts by type
423
+ for entity_type, count in result.entity_counts.items():
424
+ span.set_attribute(
425
+ f"evaluation.pii.prompt.{entity_type.lower()}_count", count
426
+ )
427
+ # Record entity metrics
428
+ self.pii_entity_counter.add(
429
+ count,
430
+ {
431
+ "entity_type": entity_type,
432
+ "location": "prompt",
433
+ },
434
+ )
435
+
436
+ # If blocking, set error status
437
+ if result.blocked:
438
+ span.set_status(
439
+ Status(
440
+ StatusCode.ERROR,
441
+ "Request blocked due to PII detection",
442
+ )
443
+ )
444
+ span.set_attribute("evaluation.pii.prompt.blocked", True)
445
+ # Record blocked metric
446
+ self.pii_blocked_counter.add(1, {"location": "prompt"})
447
+
448
+ # Add redacted text if available
449
+ if result.redacted_text:
450
+ span.set_attribute("evaluation.pii.prompt.redacted", result.redacted_text)
451
+ else:
452
+ span.set_attribute("evaluation.pii.prompt.detected", False)
453
+
454
+ # Check response for PII
455
+ if response:
456
+ result = self.pii_detector.detect(response)
457
+ if result.has_pii:
458
+ span.set_attribute("evaluation.pii.response.detected", True)
459
+ span.set_attribute("evaluation.pii.response.entity_count", len(result.entities))
460
+ span.set_attribute(
461
+ "evaluation.pii.response.entity_types",
462
+ list(result.entity_counts.keys()),
463
+ )
464
+ span.set_attribute("evaluation.pii.response.score", result.score)
465
+
466
+ # Record metrics
467
+ self.pii_detection_counter.add(
468
+ 1, {"location": "response", "mode": self.pii_config.mode.value}
469
+ )
470
+
471
+ # Add entity counts by type
472
+ for entity_type, count in result.entity_counts.items():
473
+ span.set_attribute(
474
+ f"evaluation.pii.response.{entity_type.lower()}_count",
475
+ count,
476
+ )
477
+ # Record entity metrics
478
+ self.pii_entity_counter.add(
479
+ count,
480
+ {
481
+ "entity_type": entity_type,
482
+ "location": "response",
483
+ },
484
+ )
485
+
486
+ # If blocking, set error status
487
+ if result.blocked:
488
+ span.set_status(
489
+ Status(
490
+ StatusCode.ERROR,
491
+ "Response blocked due to PII detection",
492
+ )
493
+ )
494
+ span.set_attribute("evaluation.pii.response.blocked", True)
495
+ # Record blocked metric
496
+ self.pii_blocked_counter.add(1, {"location": "response"})
497
+
498
+ # Add redacted text if available
499
+ if result.redacted_text:
500
+ span.set_attribute("evaluation.pii.response.redacted", result.redacted_text)
501
+ else:
502
+ span.set_attribute("evaluation.pii.response.detected", False)
503
+
504
+ except Exception as e:
505
+ logger.error("Error checking PII: %s", e, exc_info=True)
506
+ span.set_attribute("evaluation.pii.error", str(e))
507
+
508
+ def _check_toxicity(self, span: Span, prompt: Optional[str], response: Optional[str]) -> None:
509
+ """Check for toxicity in prompt and response.
510
+
511
+ Args:
512
+ span: The span to add toxicity attributes to
513
+ prompt: Prompt text (optional)
514
+ response: Response text (optional)
515
+ """
516
+ if not self.toxicity_detector:
517
+ return
518
+
519
+ try:
520
+ # Check prompt for toxicity
521
+ if prompt:
522
+ result = self.toxicity_detector.detect(prompt)
523
+ if result.is_toxic:
524
+ span.set_attribute("evaluation.toxicity.prompt.detected", True)
525
+ span.set_attribute("evaluation.toxicity.prompt.max_score", result.max_score)
526
+ span.set_attribute(
527
+ "evaluation.toxicity.prompt.categories",
528
+ result.toxic_categories,
529
+ )
530
+
531
+ # Add individual category scores
532
+ for category, score in result.scores.items():
533
+ span.set_attribute(f"evaluation.toxicity.prompt.{category}_score", score)
534
+
535
+ # Record metrics
536
+ self.toxicity_detection_counter.add(1, {"location": "prompt"})
537
+ self.toxicity_score_histogram.record(result.max_score, {"location": "prompt"})
538
+
539
+ # Record category metrics
540
+ for category in result.toxic_categories:
541
+ self.toxicity_category_counter.add(
542
+ 1, {"category": category, "location": "prompt"}
543
+ )
544
+
545
+ # If blocking, set error status
546
+ if result.blocked:
547
+ span.set_status(
548
+ Status(
549
+ StatusCode.ERROR,
550
+ "Request blocked due to toxic content",
551
+ )
552
+ )
553
+ span.set_attribute("evaluation.toxicity.prompt.blocked", True)
554
+ self.toxicity_blocked_counter.add(1, {"location": "prompt"})
555
+ else:
556
+ span.set_attribute("evaluation.toxicity.prompt.detected", False)
557
+
558
+ # Check response for toxicity
559
+ if response:
560
+ result = self.toxicity_detector.detect(response)
561
+ if result.is_toxic:
562
+ span.set_attribute("evaluation.toxicity.response.detected", True)
563
+ span.set_attribute("evaluation.toxicity.response.max_score", result.max_score)
564
+ span.set_attribute(
565
+ "evaluation.toxicity.response.categories",
566
+ result.toxic_categories,
567
+ )
568
+
569
+ # Add individual category scores
570
+ for category, score in result.scores.items():
571
+ span.set_attribute(f"evaluation.toxicity.response.{category}_score", score)
572
+
573
+ # Record metrics
574
+ self.toxicity_detection_counter.add(1, {"location": "response"})
575
+ self.toxicity_score_histogram.record(result.max_score, {"location": "response"})
576
+
577
+ # Record category metrics
578
+ for category in result.toxic_categories:
579
+ self.toxicity_category_counter.add(
580
+ 1, {"category": category, "location": "response"}
581
+ )
582
+
583
+ # If blocking, set error status
584
+ if result.blocked:
585
+ span.set_status(
586
+ Status(
587
+ StatusCode.ERROR,
588
+ "Response blocked due to toxic content",
589
+ )
590
+ )
591
+ span.set_attribute("evaluation.toxicity.response.blocked", True)
592
+ self.toxicity_blocked_counter.add(1, {"location": "response"})
593
+ else:
594
+ span.set_attribute("evaluation.toxicity.response.detected", False)
595
+
596
+ except Exception as e:
597
+ logger.error("Error checking toxicity: %s", e, exc_info=True)
598
+ span.set_attribute("evaluation.toxicity.error", str(e))
599
+
600
+ def _check_bias(self, span: Span, prompt: Optional[str], response: Optional[str]) -> None:
601
+ """Check for bias in prompt and response.
602
+
603
+ Args:
604
+ span: The span to add bias attributes to
605
+ prompt: Prompt text (optional)
606
+ response: Response text (optional)
607
+ """
608
+ if not self.bias_detector:
609
+ return
610
+
611
+ try:
612
+ # Check prompt for bias
613
+ if prompt:
614
+ result = self.bias_detector.detect(prompt)
615
+ if result.has_bias:
616
+ span.set_attribute("evaluation.bias.prompt.detected", True)
617
+ span.set_attribute("evaluation.bias.prompt.max_score", result.max_score)
618
+ span.set_attribute(
619
+ "evaluation.bias.prompt.detected_biases",
620
+ result.detected_biases,
621
+ )
622
+
623
+ # Add individual bias type scores
624
+ for bias_type, score in result.bias_scores.items():
625
+ if score > 0:
626
+ span.set_attribute(f"evaluation.bias.prompt.{bias_type}_score", score)
627
+
628
+ # Add patterns matched
629
+ for bias_type, patterns in result.patterns_matched.items():
630
+ span.set_attribute(
631
+ f"evaluation.bias.prompt.{bias_type}_patterns",
632
+ patterns[:5], # Limit to first 5 patterns
633
+ )
634
+
635
+ # Record metrics
636
+ self.bias_detection_counter.add(1, {"location": "prompt"})
637
+ self.bias_score_histogram.record(result.max_score, {"location": "prompt"})
638
+
639
+ # Record bias type metrics
640
+ for bias_type in result.detected_biases:
641
+ self.bias_type_counter.add(
642
+ 1, {"bias_type": bias_type, "location": "prompt"}
643
+ )
644
+
645
+ # If blocking mode and threshold exceeded, set error status
646
+ if self.bias_config.block_on_detection and result.has_bias:
647
+ span.set_status(
648
+ Status(
649
+ StatusCode.ERROR,
650
+ "Request blocked due to bias detection",
651
+ )
652
+ )
653
+ span.set_attribute("evaluation.bias.prompt.blocked", True)
654
+ self.bias_blocked_counter.add(1, {"location": "prompt"})
655
+ else:
656
+ span.set_attribute("evaluation.bias.prompt.detected", False)
657
+
658
+ # Check response for bias
659
+ if response:
660
+ result = self.bias_detector.detect(response)
661
+ if result.has_bias:
662
+ span.set_attribute("evaluation.bias.response.detected", True)
663
+ span.set_attribute("evaluation.bias.response.max_score", result.max_score)
664
+ span.set_attribute(
665
+ "evaluation.bias.response.detected_biases",
666
+ result.detected_biases,
667
+ )
668
+
669
+ # Add individual bias type scores
670
+ for bias_type, score in result.bias_scores.items():
671
+ if score > 0:
672
+ span.set_attribute(f"evaluation.bias.response.{bias_type}_score", score)
673
+
674
+ # Add patterns matched
675
+ for bias_type, patterns in result.patterns_matched.items():
676
+ span.set_attribute(
677
+ f"evaluation.bias.response.{bias_type}_patterns",
678
+ patterns[:5], # Limit to first 5 patterns
679
+ )
680
+
681
+ # Record metrics
682
+ self.bias_detection_counter.add(1, {"location": "response"})
683
+ self.bias_score_histogram.record(result.max_score, {"location": "response"})
684
+
685
+ # Record bias type metrics
686
+ for bias_type in result.detected_biases:
687
+ self.bias_type_counter.add(
688
+ 1, {"bias_type": bias_type, "location": "response"}
689
+ )
690
+
691
+ # If blocking mode and threshold exceeded, set error status
692
+ if self.bias_config.block_on_detection and result.has_bias:
693
+ span.set_status(
694
+ Status(
695
+ StatusCode.ERROR,
696
+ "Response blocked due to bias detection",
697
+ )
698
+ )
699
+ span.set_attribute("evaluation.bias.response.blocked", True)
700
+ self.bias_blocked_counter.add(1, {"location": "response"})
701
+ else:
702
+ span.set_attribute("evaluation.bias.response.detected", False)
703
+
704
+ except Exception as e:
705
+ logger.error("Error checking bias: %s", e, exc_info=True)
706
+ span.set_attribute("evaluation.bias.error", str(e))
707
+
708
+ def _check_prompt_injection(self, span: Span, prompt: Optional[str]) -> None:
709
+ """Check for prompt injection attempts in prompt.
710
+
711
+ Args:
712
+ span: The span to add prompt injection attributes to
713
+ prompt: Prompt text (optional)
714
+ """
715
+ if not self.prompt_injection_detector or not prompt:
716
+ return
717
+
718
+ try:
719
+ result = self.prompt_injection_detector.detect(prompt)
720
+ if result.is_injection:
721
+ span.set_attribute("evaluation.prompt_injection.detected", True)
722
+ span.set_attribute("evaluation.prompt_injection.score", result.injection_score)
723
+ span.set_attribute("evaluation.prompt_injection.types", result.injection_types)
724
+
725
+ # Add patterns matched
726
+ for inj_type, patterns in result.patterns_matched.items():
727
+ span.set_attribute(
728
+ f"evaluation.prompt_injection.{inj_type}_patterns",
729
+ patterns[:5], # Limit to first 5 patterns
730
+ )
731
+
732
+ # Record metrics
733
+ self.prompt_injection_counter.add(1, {"location": "prompt"})
734
+ self.prompt_injection_score_histogram.record(
735
+ result.injection_score, {"location": "prompt"}
736
+ )
737
+
738
+ # Record injection type metrics
739
+ for inj_type in result.injection_types:
740
+ self.prompt_injection_type_counter.add(1, {"injection_type": inj_type})
741
+
742
+ # If blocking, set error status
743
+ if result.blocked:
744
+ span.set_status(
745
+ Status(
746
+ StatusCode.ERROR,
747
+ "Request blocked due to prompt injection attempt",
748
+ )
749
+ )
750
+ span.set_attribute("evaluation.prompt_injection.blocked", True)
751
+ self.prompt_injection_blocked_counter.add(1, {})
752
+ else:
753
+ span.set_attribute("evaluation.prompt_injection.detected", False)
754
+
755
+ except Exception as e:
756
+ logger.error("Error checking prompt injection: %s", e, exc_info=True)
757
+ span.set_attribute("evaluation.prompt_injection.error", str(e))
758
+
759
+ def _check_restricted_topics(
760
+ self, span: Span, prompt: Optional[str], response: Optional[str]
761
+ ) -> None:
762
+ """Check for restricted topics in prompt and response.
763
+
764
+ Args:
765
+ span: The span to add restricted topics attributes to
766
+ prompt: Prompt text (optional)
767
+ response: Response text (optional)
768
+ """
769
+ if not self.restricted_topics_detector:
770
+ return
771
+
772
+ try:
773
+ # Check prompt for restricted topics
774
+ if prompt:
775
+ result = self.restricted_topics_detector.detect(prompt)
776
+ if result.has_restricted_topic:
777
+ span.set_attribute("evaluation.restricted_topics.prompt.detected", True)
778
+ span.set_attribute(
779
+ "evaluation.restricted_topics.prompt.max_score", result.max_score
780
+ )
781
+ span.set_attribute(
782
+ "evaluation.restricted_topics.prompt.topics",
783
+ result.detected_topics,
784
+ )
785
+
786
+ # Add individual topic scores
787
+ for topic, score in result.topic_scores.items():
788
+ if score > 0:
789
+ span.set_attribute(
790
+ f"evaluation.restricted_topics.prompt.{topic}_score", score
791
+ )
792
+
793
+ # Record metrics
794
+ self.restricted_topics_counter.add(1, {"location": "prompt"})
795
+ self.restricted_topics_score_histogram.record(
796
+ result.max_score, {"location": "prompt"}
797
+ )
798
+
799
+ # Record topic metrics
800
+ for topic in result.detected_topics:
801
+ self.restricted_topics_type_counter.add(
802
+ 1, {"topic": topic, "location": "prompt"}
803
+ )
804
+
805
+ # If blocking, set error status
806
+ if result.blocked:
807
+ span.set_status(
808
+ Status(
809
+ StatusCode.ERROR,
810
+ "Request blocked due to restricted topic",
811
+ )
812
+ )
813
+ span.set_attribute("evaluation.restricted_topics.prompt.blocked", True)
814
+ self.restricted_topics_blocked_counter.add(1, {"location": "prompt"})
815
+ else:
816
+ span.set_attribute("evaluation.restricted_topics.prompt.detected", False)
817
+
818
+ # Check response for restricted topics
819
+ if response:
820
+ result = self.restricted_topics_detector.detect(response)
821
+ if result.has_restricted_topic:
822
+ span.set_attribute("evaluation.restricted_topics.response.detected", True)
823
+ span.set_attribute(
824
+ "evaluation.restricted_topics.response.max_score", result.max_score
825
+ )
826
+ span.set_attribute(
827
+ "evaluation.restricted_topics.response.topics",
828
+ result.detected_topics,
829
+ )
830
+
831
+ # Add individual topic scores
832
+ for topic, score in result.topic_scores.items():
833
+ if score > 0:
834
+ span.set_attribute(
835
+ f"evaluation.restricted_topics.response.{topic}_score", score
836
+ )
837
+
838
+ # Record metrics
839
+ self.restricted_topics_counter.add(1, {"location": "response"})
840
+ self.restricted_topics_score_histogram.record(
841
+ result.max_score, {"location": "response"}
842
+ )
843
+
844
+ # Record topic metrics
845
+ for topic in result.detected_topics:
846
+ self.restricted_topics_type_counter.add(
847
+ 1, {"topic": topic, "location": "response"}
848
+ )
849
+
850
+ # If blocking, set error status
851
+ if result.blocked:
852
+ span.set_status(
853
+ Status(
854
+ StatusCode.ERROR,
855
+ "Response blocked due to restricted topic",
856
+ )
857
+ )
858
+ span.set_attribute("evaluation.restricted_topics.response.blocked", True)
859
+ self.restricted_topics_blocked_counter.add(1, {"location": "response"})
860
+ else:
861
+ span.set_attribute("evaluation.restricted_topics.response.detected", False)
862
+
863
+ except Exception as e:
864
+ logger.error("Error checking restricted topics: %s", e, exc_info=True)
865
+ span.set_attribute("evaluation.restricted_topics.error", str(e))
866
+
867
+ def _check_hallucination(
868
+ self,
869
+ span: Span,
870
+ prompt: Optional[str],
871
+ response: Optional[str],
872
+ attributes: dict,
873
+ ) -> None:
874
+ """Check for potential hallucinations in response.
875
+
876
+ Args:
877
+ span: The span to add hallucination attributes to
878
+ prompt: Prompt text (optional, used as context)
879
+ response: Response text (optional)
880
+ attributes: Span attributes (for extracting context)
881
+ """
882
+ if not self.hallucination_detector or not response:
883
+ return
884
+
885
+ try:
886
+ # Use prompt as context if available
887
+ context = prompt
888
+
889
+ # Try to extract additional context from attributes
890
+ context_keys = [
891
+ "gen_ai.context",
892
+ "gen_ai.retrieval.documents",
893
+ "gen_ai.rag.context",
894
+ ]
895
+ for key in context_keys:
896
+ if key in attributes:
897
+ value = attributes[key]
898
+ if isinstance(value, str):
899
+ context = f"{context}\n{value}" if context else value
900
+ break
901
+
902
+ result = self.hallucination_detector.detect(response, context)
903
+
904
+ # Always set basic attributes
905
+ span.set_attribute(
906
+ "evaluation.hallucination.response.detected", result.has_hallucination
907
+ )
908
+ span.set_attribute(
909
+ "evaluation.hallucination.response.score", result.hallucination_score
910
+ )
911
+ span.set_attribute("evaluation.hallucination.response.citations", result.citation_count)
912
+ span.set_attribute(
913
+ "evaluation.hallucination.response.hedge_words", result.hedge_words_count
914
+ )
915
+ span.set_attribute(
916
+ "evaluation.hallucination.response.claims", result.factual_claim_count
917
+ )
918
+
919
+ if result.has_hallucination:
920
+ span.set_attribute(
921
+ "evaluation.hallucination.response.indicators", result.hallucination_indicators
922
+ )
923
+
924
+ # Add unsupported claims
925
+ if result.unsupported_claims:
926
+ span.set_attribute(
927
+ "evaluation.hallucination.response.unsupported_claims",
928
+ result.unsupported_claims[:3], # Limit to first 3
929
+ )
930
+
931
+ # Record metrics
932
+ self.hallucination_counter.add(1, {"location": "response"})
933
+ self.hallucination_score_histogram.record(
934
+ result.hallucination_score, {"location": "response"}
935
+ )
936
+
937
+ # Record indicator metrics
938
+ for indicator in result.hallucination_indicators:
939
+ self.hallucination_indicator_counter.add(1, {"indicator": indicator})
940
+
941
+ except Exception as e:
942
+ logger.error("Error checking hallucination: %s", e, exc_info=True)
943
+ span.set_attribute("evaluation.hallucination.error", str(e))
944
+
945
+ def shutdown(self) -> None:
946
+ """Shutdown the span processor.
947
+
948
+ Called when the tracer provider is shut down.
949
+ """
950
+ logger.info("EvaluationSpanProcessor shutting down")
951
+
952
+ def force_flush(self, timeout_millis: int = 30000) -> bool:
953
+ """Force flush any buffered spans.
954
+
955
+ Args:
956
+ timeout_millis: Timeout in milliseconds
957
+
958
+ Returns:
959
+ bool: True if successful
960
+ """
961
+ # No buffering in this processor
962
+ return True