netra-sdk 0.1.7__tar.gz → 0.1.9__tar.gz
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 netra-sdk might be problematic. Click here for more details.
- {netra_sdk-0.1.7 → netra_sdk-0.1.9}/PKG-INFO +53 -1
- {netra_sdk-0.1.7 → netra_sdk-0.1.9}/README.md +52 -0
- {netra_sdk-0.1.7 → netra_sdk-0.1.9}/netra/__init__.py +2 -2
- {netra_sdk-0.1.7 → netra_sdk-0.1.9}/netra/config.py +2 -2
- {netra_sdk-0.1.7 → netra_sdk-0.1.9}/netra/instrumentation/__init__.py +26 -1
- netra_sdk-0.1.9/netra/instrumentation/fastapi/__init__.py +363 -0
- netra_sdk-0.1.9/netra/instrumentation/fastapi/version.py +1 -0
- {netra_sdk-0.1.7 → netra_sdk-0.1.9}/netra/instrumentation/instruments.py +1 -0
- netra_sdk-0.1.9/netra/instrumentation/openai/__init__.py +153 -0
- netra_sdk-0.1.9/netra/instrumentation/openai/version.py +1 -0
- netra_sdk-0.1.9/netra/instrumentation/openai/wrappers.py +554 -0
- netra_sdk-0.1.9/netra/processors/__init__.py +3 -0
- {netra_sdk-0.1.7 → netra_sdk-0.1.9}/netra/span_wrapper.py +15 -0
- {netra_sdk-0.1.7 → netra_sdk-0.1.9}/netra/tracer.py +1 -2
- netra_sdk-0.1.9/netra/version.py +2 -0
- {netra_sdk-0.1.7 → netra_sdk-0.1.9}/pyproject.toml +1 -1
- netra_sdk-0.1.7/netra/processors/__init__.py +0 -4
- netra_sdk-0.1.7/netra/processors/error_detection_processor.py +0 -84
- netra_sdk-0.1.7/netra/version.py +0 -1
- {netra_sdk-0.1.7 → netra_sdk-0.1.9}/LICENCE +0 -0
- {netra_sdk-0.1.7 → netra_sdk-0.1.9}/netra/anonymizer/__init__.py +0 -0
- {netra_sdk-0.1.7 → netra_sdk-0.1.9}/netra/anonymizer/anonymizer.py +0 -0
- {netra_sdk-0.1.7 → netra_sdk-0.1.9}/netra/anonymizer/base.py +0 -0
- {netra_sdk-0.1.7 → netra_sdk-0.1.9}/netra/anonymizer/fp_anonymizer.py +0 -0
- {netra_sdk-0.1.7 → netra_sdk-0.1.9}/netra/decorators.py +0 -0
- {netra_sdk-0.1.7 → netra_sdk-0.1.9}/netra/exceptions/__init__.py +0 -0
- {netra_sdk-0.1.7 → netra_sdk-0.1.9}/netra/exceptions/injection.py +0 -0
- {netra_sdk-0.1.7 → netra_sdk-0.1.9}/netra/exceptions/pii.py +0 -0
- {netra_sdk-0.1.7 → netra_sdk-0.1.9}/netra/input_scanner.py +0 -0
- {netra_sdk-0.1.7 → netra_sdk-0.1.9}/netra/instrumentation/aiohttp/__init__.py +0 -0
- {netra_sdk-0.1.7 → netra_sdk-0.1.9}/netra/instrumentation/aiohttp/version.py +0 -0
- {netra_sdk-0.1.7 → netra_sdk-0.1.9}/netra/instrumentation/cohere/__init__.py +0 -0
- {netra_sdk-0.1.7 → netra_sdk-0.1.9}/netra/instrumentation/cohere/version.py +0 -0
- {netra_sdk-0.1.7 → netra_sdk-0.1.9}/netra/instrumentation/google_genai/__init__.py +0 -0
- {netra_sdk-0.1.7 → netra_sdk-0.1.9}/netra/instrumentation/google_genai/config.py +0 -0
- {netra_sdk-0.1.7 → netra_sdk-0.1.9}/netra/instrumentation/google_genai/utils.py +0 -0
- {netra_sdk-0.1.7 → netra_sdk-0.1.9}/netra/instrumentation/google_genai/version.py +0 -0
- {netra_sdk-0.1.7 → netra_sdk-0.1.9}/netra/instrumentation/httpx/__init__.py +0 -0
- {netra_sdk-0.1.7 → netra_sdk-0.1.9}/netra/instrumentation/httpx/version.py +0 -0
- {netra_sdk-0.1.7 → netra_sdk-0.1.9}/netra/instrumentation/mistralai/__init__.py +0 -0
- {netra_sdk-0.1.7 → netra_sdk-0.1.9}/netra/instrumentation/mistralai/config.py +0 -0
- {netra_sdk-0.1.7 → netra_sdk-0.1.9}/netra/instrumentation/mistralai/utils.py +0 -0
- {netra_sdk-0.1.7 → netra_sdk-0.1.9}/netra/instrumentation/mistralai/version.py +0 -0
- {netra_sdk-0.1.7 → netra_sdk-0.1.9}/netra/instrumentation/weaviate/__init__.py +0 -0
- {netra_sdk-0.1.7 → netra_sdk-0.1.9}/netra/instrumentation/weaviate/version.py +0 -0
- {netra_sdk-0.1.7 → netra_sdk-0.1.9}/netra/pii.py +0 -0
- {netra_sdk-0.1.7 → netra_sdk-0.1.9}/netra/processors/session_span_processor.py +0 -0
- {netra_sdk-0.1.7 → netra_sdk-0.1.9}/netra/scanner.py +0 -0
- {netra_sdk-0.1.7 → netra_sdk-0.1.9}/netra/session_manager.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.3
|
|
2
2
|
Name: netra-sdk
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.9
|
|
4
4
|
Summary: A Python SDK for AI application observability that provides OpenTelemetry-based monitoring, tracing, and PII protection for LLM and vector database applications. Enables easy instrumentation, session tracking, and privacy-focused data collection for AI systems in production environments.
|
|
5
5
|
License: Apache-2.0
|
|
6
6
|
Keywords: netra,tracing,observability,sdk,ai,llm,vector,database
|
|
@@ -440,6 +440,58 @@ with Netra.start_span("image_generation") as span:
|
|
|
440
440
|
|
|
441
441
|
# Get the current active open telemetry span
|
|
442
442
|
current_span = span.get_current_span()
|
|
443
|
+
|
|
444
|
+
# Track database operations and other actions
|
|
445
|
+
action = ActionModel(
|
|
446
|
+
action="DB",
|
|
447
|
+
action_type="INSERT",
|
|
448
|
+
affected_records=[
|
|
449
|
+
{"record_id": "user_123", "record_type": "user"},
|
|
450
|
+
{"record_id": "profile_456", "record_type": "profile"}
|
|
451
|
+
],
|
|
452
|
+
metadata={
|
|
453
|
+
"table": "users",
|
|
454
|
+
"operation_id": "tx_789",
|
|
455
|
+
"duration_ms": "45"
|
|
456
|
+
},
|
|
457
|
+
success=True
|
|
458
|
+
)
|
|
459
|
+
span.set_action([action])
|
|
460
|
+
|
|
461
|
+
# Record API calls
|
|
462
|
+
api_action = ActionModel(
|
|
463
|
+
action="API",
|
|
464
|
+
action_type="CALL",
|
|
465
|
+
metadata={
|
|
466
|
+
"endpoint": "/api/v1/process",
|
|
467
|
+
"method": "POST",
|
|
468
|
+
"status_code": 200,
|
|
469
|
+
"duration_ms": "120"
|
|
470
|
+
},
|
|
471
|
+
success=True
|
|
472
|
+
)
|
|
473
|
+
span.set_action([api_action])
|
|
474
|
+
```
|
|
475
|
+
|
|
476
|
+
### Action Tracking Schema
|
|
477
|
+
|
|
478
|
+
Action tracking follows this schema:
|
|
479
|
+
|
|
480
|
+
```python
|
|
481
|
+
[
|
|
482
|
+
{
|
|
483
|
+
"action": str, # Type of action (e.g., "DB", "API", "CACHE")
|
|
484
|
+
"action_type": str, # Action subtype (e.g., "INSERT", "SELECT", "CALL")
|
|
485
|
+
"affected_records": [ # Optional: List of records affected
|
|
486
|
+
{
|
|
487
|
+
"record_id": str, # ID of the affected record
|
|
488
|
+
"record_type": str # Type of the record
|
|
489
|
+
}
|
|
490
|
+
],
|
|
491
|
+
"metadata": Dict[str, str], # Additional metadata as key-value pairs
|
|
492
|
+
"success": bool # Whether the action succeeded
|
|
493
|
+
}
|
|
494
|
+
]
|
|
443
495
|
```
|
|
444
496
|
|
|
445
497
|
## 🔧 Advanced Configuration
|
|
@@ -364,6 +364,58 @@ with Netra.start_span("image_generation") as span:
|
|
|
364
364
|
|
|
365
365
|
# Get the current active open telemetry span
|
|
366
366
|
current_span = span.get_current_span()
|
|
367
|
+
|
|
368
|
+
# Track database operations and other actions
|
|
369
|
+
action = ActionModel(
|
|
370
|
+
action="DB",
|
|
371
|
+
action_type="INSERT",
|
|
372
|
+
affected_records=[
|
|
373
|
+
{"record_id": "user_123", "record_type": "user"},
|
|
374
|
+
{"record_id": "profile_456", "record_type": "profile"}
|
|
375
|
+
],
|
|
376
|
+
metadata={
|
|
377
|
+
"table": "users",
|
|
378
|
+
"operation_id": "tx_789",
|
|
379
|
+
"duration_ms": "45"
|
|
380
|
+
},
|
|
381
|
+
success=True
|
|
382
|
+
)
|
|
383
|
+
span.set_action([action])
|
|
384
|
+
|
|
385
|
+
# Record API calls
|
|
386
|
+
api_action = ActionModel(
|
|
387
|
+
action="API",
|
|
388
|
+
action_type="CALL",
|
|
389
|
+
metadata={
|
|
390
|
+
"endpoint": "/api/v1/process",
|
|
391
|
+
"method": "POST",
|
|
392
|
+
"status_code": 200,
|
|
393
|
+
"duration_ms": "120"
|
|
394
|
+
},
|
|
395
|
+
success=True
|
|
396
|
+
)
|
|
397
|
+
span.set_action([api_action])
|
|
398
|
+
```
|
|
399
|
+
|
|
400
|
+
### Action Tracking Schema
|
|
401
|
+
|
|
402
|
+
Action tracking follows this schema:
|
|
403
|
+
|
|
404
|
+
```python
|
|
405
|
+
[
|
|
406
|
+
{
|
|
407
|
+
"action": str, # Type of action (e.g., "DB", "API", "CACHE")
|
|
408
|
+
"action_type": str, # Action subtype (e.g., "INSERT", "SELECT", "CALL")
|
|
409
|
+
"affected_records": [ # Optional: List of records affected
|
|
410
|
+
{
|
|
411
|
+
"record_id": str, # ID of the affected record
|
|
412
|
+
"record_type": str # Type of the record
|
|
413
|
+
}
|
|
414
|
+
],
|
|
415
|
+
"metadata": Dict[str, str], # Additional metadata as key-value pairs
|
|
416
|
+
"success": bool # Whether the action succeeded
|
|
417
|
+
}
|
|
418
|
+
]
|
|
367
419
|
```
|
|
368
420
|
|
|
369
421
|
## 🔧 Advanced Configuration
|
|
@@ -9,7 +9,7 @@ from .config import Config
|
|
|
9
9
|
# Instrumentor functions
|
|
10
10
|
from .instrumentation import init_instrumentations
|
|
11
11
|
from .session_manager import SessionManager
|
|
12
|
-
from .span_wrapper import SpanWrapper, UsageModel
|
|
12
|
+
from .span_wrapper import ActionModel, SpanWrapper, UsageModel
|
|
13
13
|
from .tracer import Tracer
|
|
14
14
|
|
|
15
15
|
logger = logging.getLogger(__name__)
|
|
@@ -145,4 +145,4 @@ class Netra:
|
|
|
145
145
|
return SpanWrapper(name, attributes, module_name)
|
|
146
146
|
|
|
147
147
|
|
|
148
|
-
__all__ = ["Netra", "UsageModel"]
|
|
148
|
+
__all__ = ["Netra", "UsageModel", "ActionModel"]
|
|
@@ -51,7 +51,7 @@ class Config:
|
|
|
51
51
|
if isinstance(headers, str):
|
|
52
52
|
self.headers = parse_env_headers(headers)
|
|
53
53
|
|
|
54
|
-
if self.otlp_endpoint
|
|
54
|
+
if self.otlp_endpoint in ["https://api.dev.getcombat.ai", "https://api.eu.getnetra.ai"] and not self.api_key:
|
|
55
55
|
print("Error: Missing Netra API key, go to https://app.dev.getcombat.ai/api-key to create one")
|
|
56
56
|
print("Set the NETRA_API_KEY environment variable to the key")
|
|
57
57
|
return
|
|
@@ -59,7 +59,7 @@ class Config:
|
|
|
59
59
|
# Handle API key authentication based on OTLP endpoint
|
|
60
60
|
if self.api_key and self.otlp_endpoint:
|
|
61
61
|
# For Netra endpoints, use x-api-key header
|
|
62
|
-
if "getcombat" in self.otlp_endpoint.lower():
|
|
62
|
+
if "getcombat" in self.otlp_endpoint.lower() or "getnetra" in self.otlp_endpoint.lower():
|
|
63
63
|
if not self.headers:
|
|
64
64
|
self.headers = {"x-api-key": self.api_key}
|
|
65
65
|
elif "x-api-key" not in self.headers:
|
|
@@ -37,6 +37,7 @@ def init_instrumentations(
|
|
|
37
37
|
Instruments.QDRANT,
|
|
38
38
|
Instruments.GOOGLE_GENERATIVEAI,
|
|
39
39
|
Instruments.MISTRAL,
|
|
40
|
+
Instruments.OPENAI,
|
|
40
41
|
}
|
|
41
42
|
)
|
|
42
43
|
if instruments is not None and traceloop_instruments is None and traceloop_block_instruments is None:
|
|
@@ -82,6 +83,10 @@ def init_instrumentations(
|
|
|
82
83
|
if CustomInstruments.MISTRALAI in netra_custom_instruments:
|
|
83
84
|
init_mistral_instrumentor()
|
|
84
85
|
|
|
86
|
+
# Initialize OpenAI instrumentation.
|
|
87
|
+
if CustomInstruments.OPENAI in netra_custom_instruments:
|
|
88
|
+
init_openai_instrumentation()
|
|
89
|
+
|
|
85
90
|
# Initialize aio_pika instrumentation.
|
|
86
91
|
if CustomInstruments.AIO_PIKA in netra_custom_instruments:
|
|
87
92
|
init_aio_pika_instrumentation()
|
|
@@ -282,7 +287,7 @@ def init_fastapi_instrumentation() -> bool:
|
|
|
282
287
|
"""
|
|
283
288
|
try:
|
|
284
289
|
if is_package_installed("fastapi"):
|
|
285
|
-
from
|
|
290
|
+
from netra.instrumentation.fastapi import FastAPIInstrumentor
|
|
286
291
|
|
|
287
292
|
instrumentor = FastAPIInstrumentor()
|
|
288
293
|
if not instrumentor.is_instrumented_by_opentelemetry:
|
|
@@ -416,6 +421,26 @@ def init_mistral_instrumentor() -> bool:
|
|
|
416
421
|
return False
|
|
417
422
|
|
|
418
423
|
|
|
424
|
+
def init_openai_instrumentation() -> bool:
|
|
425
|
+
"""Initialize OpenAI instrumentation.
|
|
426
|
+
|
|
427
|
+
Returns:
|
|
428
|
+
bool: True if initialization was successful, False otherwise.
|
|
429
|
+
"""
|
|
430
|
+
try:
|
|
431
|
+
if is_package_installed("openai"):
|
|
432
|
+
from netra.instrumentation.openai import NetraOpenAIInstrumentor
|
|
433
|
+
|
|
434
|
+
instrumentor = NetraOpenAIInstrumentor()
|
|
435
|
+
if not instrumentor.is_instrumented_by_opentelemetry:
|
|
436
|
+
instrumentor.instrument()
|
|
437
|
+
return True
|
|
438
|
+
except Exception as e:
|
|
439
|
+
logging.error(f"Error initializing OpenAI instrumentor: {e}")
|
|
440
|
+
Telemetry().log_exception(e)
|
|
441
|
+
return False
|
|
442
|
+
|
|
443
|
+
|
|
419
444
|
def init_aio_pika_instrumentation() -> bool:
|
|
420
445
|
"""Initialize aio_pika instrumentation."""
|
|
421
446
|
try:
|
|
@@ -0,0 +1,363 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import functools
|
|
4
|
+
import logging
|
|
5
|
+
import types
|
|
6
|
+
from typing import Any, Collection, Dict, Iterable, Optional, Union
|
|
7
|
+
|
|
8
|
+
import fastapi
|
|
9
|
+
import httpx
|
|
10
|
+
from fastapi import HTTPException
|
|
11
|
+
from opentelemetry.instrumentation.asgi import OpenTelemetryMiddleware
|
|
12
|
+
from opentelemetry.instrumentation.asgi.types import (
|
|
13
|
+
ClientRequestHook,
|
|
14
|
+
ClientResponseHook,
|
|
15
|
+
ServerRequestHook,
|
|
16
|
+
)
|
|
17
|
+
from opentelemetry.instrumentation.instrumentor import BaseInstrumentor
|
|
18
|
+
from opentelemetry.metrics import MeterProvider, get_meter
|
|
19
|
+
from opentelemetry.semconv.attributes.http_attributes import HTTP_ROUTE
|
|
20
|
+
from opentelemetry.trace import Span, Status, StatusCode, TracerProvider, get_tracer
|
|
21
|
+
from opentelemetry.util.http import (
|
|
22
|
+
get_excluded_urls,
|
|
23
|
+
parse_excluded_urls,
|
|
24
|
+
sanitize_method,
|
|
25
|
+
)
|
|
26
|
+
from starlette.applications import Starlette
|
|
27
|
+
from starlette.middleware.errors import ServerErrorMiddleware
|
|
28
|
+
from starlette.routing import Match
|
|
29
|
+
from starlette.types import ASGIApp
|
|
30
|
+
|
|
31
|
+
_excluded_urls_from_env = get_excluded_urls("FASTAPI")
|
|
32
|
+
_logger = logging.getLogger(__name__)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class SpanAttributeMonitor:
|
|
36
|
+
"""Monitor to track span attribute changes and detect HTTP status codes."""
|
|
37
|
+
|
|
38
|
+
def __init__(self, span: Span, error_status_codes: set[int], error_messages: dict[Union[int, range], str]):
|
|
39
|
+
self.span = span
|
|
40
|
+
self.error_status_codes = error_status_codes
|
|
41
|
+
self.error_messages = error_messages
|
|
42
|
+
self.original_set_attribute = span.set_attribute
|
|
43
|
+
self.span.set_attribute = self._monitored_set_attribute
|
|
44
|
+
|
|
45
|
+
def _monitored_set_attribute(self, key: str, value: Any) -> None:
|
|
46
|
+
"""Monitor set_attribute calls to detect HTTP status codes."""
|
|
47
|
+
# Call the original set_attribute method
|
|
48
|
+
self.original_set_attribute(key, value)
|
|
49
|
+
|
|
50
|
+
# Check if this is an HTTP status code attribute
|
|
51
|
+
if key == "http.status_code" and isinstance(value, int):
|
|
52
|
+
if value in self.error_status_codes:
|
|
53
|
+
self._record_error_for_span(value)
|
|
54
|
+
|
|
55
|
+
def _record_error_for_span(self, status_code: int) -> None:
|
|
56
|
+
"""Record an HTTPException for the given span based on the status code."""
|
|
57
|
+
if not self.span or not self.span.is_recording():
|
|
58
|
+
return
|
|
59
|
+
|
|
60
|
+
# Get custom error message if available
|
|
61
|
+
error_message = self._get_error_message(status_code)
|
|
62
|
+
|
|
63
|
+
# Create and record the HTTPException
|
|
64
|
+
exception = HTTPException(status_code=status_code, detail=error_message)
|
|
65
|
+
self.span.record_exception(exception)
|
|
66
|
+
|
|
67
|
+
# Set span status to error for 5xx errors
|
|
68
|
+
if httpx.codes.is_error(status_code):
|
|
69
|
+
self.span.set_status(Status(StatusCode.ERROR, error_message))
|
|
70
|
+
|
|
71
|
+
_logger.debug(f"Recorded HTTPException for HTTP {status_code}: {error_message}")
|
|
72
|
+
|
|
73
|
+
def _get_error_message(self, status_code: int) -> str:
|
|
74
|
+
"""Get the appropriate error message for a status code."""
|
|
75
|
+
# Check for exact status code match
|
|
76
|
+
if status_code in self.error_messages:
|
|
77
|
+
return self.error_messages[status_code]
|
|
78
|
+
|
|
79
|
+
# Check for range matches
|
|
80
|
+
for key, message in self.error_messages.items():
|
|
81
|
+
if isinstance(key, range) and status_code in key:
|
|
82
|
+
return message
|
|
83
|
+
|
|
84
|
+
# Default messages based on status code ranges
|
|
85
|
+
if 400 <= status_code < 500:
|
|
86
|
+
return f"Client Error: HTTP {status_code}"
|
|
87
|
+
elif 500 <= status_code < 600:
|
|
88
|
+
return f"Server Error: HTTP {status_code}"
|
|
89
|
+
else:
|
|
90
|
+
return f"HTTP {status_code} Error"
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class StatusCodeMonitoringMiddleware:
|
|
94
|
+
"""Middleware that monitors spans for HTTP status codes and records exceptions."""
|
|
95
|
+
|
|
96
|
+
def __init__(
|
|
97
|
+
self,
|
|
98
|
+
app: ASGIApp,
|
|
99
|
+
error_status_codes: Optional[Iterable[int]] = None,
|
|
100
|
+
error_messages: Optional[Dict[Union[int, range], str]] = None,
|
|
101
|
+
):
|
|
102
|
+
self.app = app
|
|
103
|
+
self.error_status_codes = set(error_status_codes or range(400, 600))
|
|
104
|
+
self.error_messages = error_messages or {}
|
|
105
|
+
|
|
106
|
+
async def __call__(self, scope: dict[str, Any], receive: Any, send: Any) -> None:
|
|
107
|
+
if scope["type"] not in ("http", "websocket"):
|
|
108
|
+
await self.app(scope, receive, send)
|
|
109
|
+
return
|
|
110
|
+
|
|
111
|
+
# Get the current span
|
|
112
|
+
from opentelemetry.trace import get_current_span
|
|
113
|
+
|
|
114
|
+
span = get_current_span()
|
|
115
|
+
|
|
116
|
+
# Set up span monitoring if we have a valid span
|
|
117
|
+
monitor = None
|
|
118
|
+
if span and span.is_recording():
|
|
119
|
+
monitor = SpanAttributeMonitor(span, self.error_status_codes, self.error_messages)
|
|
120
|
+
|
|
121
|
+
try:
|
|
122
|
+
await self.app(scope, receive, send)
|
|
123
|
+
finally:
|
|
124
|
+
# Restore original set_attribute method
|
|
125
|
+
if monitor:
|
|
126
|
+
span.set_attribute = monitor.original_set_attribute
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
class FastAPIInstrumentor(BaseInstrumentor): # type: ignore[misc]
|
|
130
|
+
"""An instrumentor for FastAPI that records HTTPExceptions for HTTP error status codes.
|
|
131
|
+
|
|
132
|
+
This instrumentor follows the OpenTelemetry FastAPI instrumentation pattern while adding
|
|
133
|
+
the capability to monitor http.status_code attributes and automatically record HTTPExceptions
|
|
134
|
+
for error status codes.
|
|
135
|
+
"""
|
|
136
|
+
|
|
137
|
+
_original_fastapi: Optional[type[fastapi.FastAPI]] = None
|
|
138
|
+
|
|
139
|
+
@staticmethod
|
|
140
|
+
def instrument_app(
|
|
141
|
+
app: fastapi.FastAPI,
|
|
142
|
+
server_request_hook: Optional[ServerRequestHook] = None,
|
|
143
|
+
client_request_hook: Optional[ClientRequestHook] = None,
|
|
144
|
+
client_response_hook: Optional[ClientResponseHook] = None,
|
|
145
|
+
tracer_provider: Optional[TracerProvider] = None,
|
|
146
|
+
meter_provider: Optional[MeterProvider] = None,
|
|
147
|
+
excluded_urls: Optional[str] = None,
|
|
148
|
+
http_capture_headers_server_request: Optional[list[str]] = None,
|
|
149
|
+
http_capture_headers_server_response: Optional[list[str]] = None,
|
|
150
|
+
http_capture_headers_sanitize_fields: Optional[list[str]] = None,
|
|
151
|
+
exclude_spans: Optional[list[str]] = None,
|
|
152
|
+
error_status_codes: Optional[Iterable[int]] = None,
|
|
153
|
+
error_messages: Optional[Dict[Union[int, range], str]] = None,
|
|
154
|
+
) -> None:
|
|
155
|
+
"""Instrument an uninstrumented FastAPI application with status code monitoring.
|
|
156
|
+
|
|
157
|
+
Args:
|
|
158
|
+
app: The fastapi ASGI application callable to forward requests to.
|
|
159
|
+
server_request_hook: Optional callback which is called with the server span and ASGI
|
|
160
|
+
scope object for every incoming request.
|
|
161
|
+
client_request_hook: Optional callback which is called with the internal span, and ASGI
|
|
162
|
+
scope and event which are sent as dictionaries for when the method receive is called.
|
|
163
|
+
client_response_hook: Optional callback which is called with the internal span, and ASGI
|
|
164
|
+
scope and event which are sent as dictionaries for when the method send is called.
|
|
165
|
+
tracer_provider: The optional tracer provider to use. If omitted
|
|
166
|
+
the current globally configured one is used.
|
|
167
|
+
meter_provider: The optional meter provider to use. If omitted
|
|
168
|
+
the current globally configured one is used.
|
|
169
|
+
excluded_urls: Optional comma delimited string of regexes to match URLs that should not be traced.
|
|
170
|
+
http_capture_headers_server_request: Optional list of HTTP headers to capture from the request.
|
|
171
|
+
http_capture_headers_server_response: Optional list of HTTP headers to capture from the response.
|
|
172
|
+
http_capture_headers_sanitize_fields: Optional list of HTTP headers to sanitize.
|
|
173
|
+
exclude_spans: Optionally exclude HTTP spans from the trace.
|
|
174
|
+
error_status_codes: Optional iterable of status codes to consider as errors. Defaults to range(400, 600).
|
|
175
|
+
error_messages: Optional dictionary mapping status codes or ranges to custom error messages.
|
|
176
|
+
"""
|
|
177
|
+
if not hasattr(app, "_is_instrumented_by_opentelemetry"):
|
|
178
|
+
app._is_instrumented_by_opentelemetry = False
|
|
179
|
+
|
|
180
|
+
if not getattr(app, "_is_instrumented_by_opentelemetry", False):
|
|
181
|
+
if excluded_urls is None:
|
|
182
|
+
excluded_urls = _excluded_urls_from_env
|
|
183
|
+
else:
|
|
184
|
+
excluded_urls = parse_excluded_urls(excluded_urls)
|
|
185
|
+
|
|
186
|
+
tracer = get_tracer(__name__, "1.0.0", tracer_provider)
|
|
187
|
+
meter = get_meter(__name__, "1.0.0", meter_provider)
|
|
188
|
+
|
|
189
|
+
# Instead of using `app.add_middleware` we monkey patch `build_middleware_stack` to insert our middleware
|
|
190
|
+
# as the outermost middleware.
|
|
191
|
+
# This follows the OpenTelemetry FastAPI instrumentation pattern.
|
|
192
|
+
def build_middleware_stack(self: Starlette) -> ASGIApp:
|
|
193
|
+
inner_server_error_middleware: ASGIApp = self._original_build_middleware_stack()
|
|
194
|
+
|
|
195
|
+
# Add our status code monitoring middleware
|
|
196
|
+
status_code_middleware = StatusCodeMonitoringMiddleware(
|
|
197
|
+
inner_server_error_middleware,
|
|
198
|
+
error_status_codes=error_status_codes,
|
|
199
|
+
error_messages=error_messages,
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
# Add OpenTelemetry middleware
|
|
203
|
+
otel_middleware = OpenTelemetryMiddleware(
|
|
204
|
+
status_code_middleware,
|
|
205
|
+
excluded_urls=excluded_urls,
|
|
206
|
+
default_span_details=_get_default_span_details,
|
|
207
|
+
server_request_hook=server_request_hook,
|
|
208
|
+
client_request_hook=client_request_hook,
|
|
209
|
+
client_response_hook=client_response_hook,
|
|
210
|
+
tracer=tracer,
|
|
211
|
+
meter=meter,
|
|
212
|
+
http_capture_headers_server_request=http_capture_headers_server_request,
|
|
213
|
+
http_capture_headers_server_response=http_capture_headers_server_response,
|
|
214
|
+
http_capture_headers_sanitize_fields=http_capture_headers_sanitize_fields,
|
|
215
|
+
exclude_spans=exclude_spans,
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
# Wrap in an outer layer of ServerErrorMiddleware
|
|
219
|
+
if isinstance(inner_server_error_middleware, ServerErrorMiddleware):
|
|
220
|
+
outer_server_error_middleware = ServerErrorMiddleware(
|
|
221
|
+
app=otel_middleware,
|
|
222
|
+
)
|
|
223
|
+
else:
|
|
224
|
+
outer_server_error_middleware = ServerErrorMiddleware(app=otel_middleware)
|
|
225
|
+
return outer_server_error_middleware
|
|
226
|
+
|
|
227
|
+
app._original_build_middleware_stack = app.build_middleware_stack
|
|
228
|
+
app.build_middleware_stack = types.MethodType(
|
|
229
|
+
functools.wraps(app.build_middleware_stack)(build_middleware_stack),
|
|
230
|
+
app,
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
app._is_instrumented_by_opentelemetry = True
|
|
234
|
+
if app not in _InstrumentedFastAPI._instrumented_fastapi_apps:
|
|
235
|
+
_InstrumentedFastAPI._instrumented_fastapi_apps.add(app)
|
|
236
|
+
else:
|
|
237
|
+
_logger.warning("Attempting to instrument FastAPI app while already instrumented")
|
|
238
|
+
|
|
239
|
+
@staticmethod
|
|
240
|
+
def uninstrument_app(app: fastapi.FastAPI) -> None:
|
|
241
|
+
"""Remove instrumentation from a FastAPI app."""
|
|
242
|
+
original_build_middleware_stack = getattr(app, "_original_build_middleware_stack", None)
|
|
243
|
+
if original_build_middleware_stack:
|
|
244
|
+
app.build_middleware_stack = original_build_middleware_stack
|
|
245
|
+
del app._original_build_middleware_stack
|
|
246
|
+
app.middleware_stack = app.build_middleware_stack()
|
|
247
|
+
app._is_instrumented_by_opentelemetry = False
|
|
248
|
+
|
|
249
|
+
def instrumentation_dependencies(self) -> Collection[str]:
|
|
250
|
+
return ["fastapi"]
|
|
251
|
+
|
|
252
|
+
def _instrument(self, **kwargs: Any) -> None:
|
|
253
|
+
"""Instrument all FastAPI applications with status code monitoring."""
|
|
254
|
+
self._original_fastapi = fastapi.FastAPI
|
|
255
|
+
_InstrumentedFastAPI._tracer_provider = kwargs.get("tracer_provider")
|
|
256
|
+
_InstrumentedFastAPI._meter_provider = kwargs.get("meter_provider")
|
|
257
|
+
_InstrumentedFastAPI._excluded_urls = kwargs.get("excluded_urls")
|
|
258
|
+
_InstrumentedFastAPI._server_request_hook = kwargs.get("server_request_hook")
|
|
259
|
+
_InstrumentedFastAPI._client_request_hook = kwargs.get("client_request_hook")
|
|
260
|
+
_InstrumentedFastAPI._client_response_hook = kwargs.get("client_response_hook")
|
|
261
|
+
_InstrumentedFastAPI._http_capture_headers_server_request = kwargs.get("http_capture_headers_server_request")
|
|
262
|
+
_InstrumentedFastAPI._http_capture_headers_server_response = kwargs.get("http_capture_headers_server_response")
|
|
263
|
+
_InstrumentedFastAPI._http_capture_headers_sanitize_fields = kwargs.get("http_capture_headers_sanitize_fields")
|
|
264
|
+
_InstrumentedFastAPI._exclude_spans = kwargs.get("exclude_spans")
|
|
265
|
+
_InstrumentedFastAPI._error_status_codes = kwargs.get("error_status_codes")
|
|
266
|
+
_InstrumentedFastAPI._error_messages = kwargs.get("error_messages")
|
|
267
|
+
fastapi.FastAPI = _InstrumentedFastAPI
|
|
268
|
+
|
|
269
|
+
def _uninstrument(self, **kwargs: Any) -> None:
|
|
270
|
+
"""Remove instrumentation from all FastAPI applications."""
|
|
271
|
+
for instance in _InstrumentedFastAPI._instrumented_fastapi_apps:
|
|
272
|
+
self.uninstrument_app(instance)
|
|
273
|
+
_InstrumentedFastAPI._instrumented_fastapi_apps.clear()
|
|
274
|
+
fastapi.FastAPI = self._original_fastapi
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
class _InstrumentedFastAPI(fastapi.FastAPI): # type: ignore[misc]
|
|
278
|
+
"""FastAPI class with automatic status code monitoring instrumentation."""
|
|
279
|
+
|
|
280
|
+
_tracer_provider: Optional[TracerProvider] = None
|
|
281
|
+
_meter_provider: Optional[MeterProvider] = None
|
|
282
|
+
_excluded_urls: Optional[str] = None
|
|
283
|
+
_server_request_hook: Optional[ServerRequestHook] = None
|
|
284
|
+
_client_request_hook: Optional[ClientRequestHook] = None
|
|
285
|
+
_client_response_hook: Optional[ClientResponseHook] = None
|
|
286
|
+
_http_capture_headers_server_request: Optional[list[str]] = None
|
|
287
|
+
_http_capture_headers_server_response: Optional[list[str]] = None
|
|
288
|
+
_http_capture_headers_sanitize_fields: Optional[list[str]] = None
|
|
289
|
+
_exclude_spans: Optional[list[str]] = None
|
|
290
|
+
_error_status_codes: Optional[Iterable[int]] = None
|
|
291
|
+
_error_messages: Optional[Dict[Union[int, range], str]] = None
|
|
292
|
+
|
|
293
|
+
_instrumented_fastapi_apps: set[fastapi.FastAPI] = set()
|
|
294
|
+
|
|
295
|
+
def __init__(self, *args: Any, **kwargs: Any) -> None:
|
|
296
|
+
super().__init__(*args, **kwargs)
|
|
297
|
+
FastAPIInstrumentor.instrument_app(
|
|
298
|
+
self,
|
|
299
|
+
server_request_hook=self._server_request_hook,
|
|
300
|
+
client_request_hook=self._client_request_hook,
|
|
301
|
+
client_response_hook=self._client_response_hook,
|
|
302
|
+
tracer_provider=self._tracer_provider,
|
|
303
|
+
meter_provider=self._meter_provider,
|
|
304
|
+
excluded_urls=self._excluded_urls,
|
|
305
|
+
http_capture_headers_server_request=self._http_capture_headers_server_request,
|
|
306
|
+
http_capture_headers_server_response=self._http_capture_headers_server_response,
|
|
307
|
+
http_capture_headers_sanitize_fields=self._http_capture_headers_sanitize_fields,
|
|
308
|
+
exclude_spans=self._exclude_spans,
|
|
309
|
+
error_status_codes=self._error_status_codes,
|
|
310
|
+
error_messages=self._error_messages,
|
|
311
|
+
)
|
|
312
|
+
_InstrumentedFastAPI._instrumented_fastapi_apps.add(self)
|
|
313
|
+
|
|
314
|
+
def __del__(self) -> None:
|
|
315
|
+
if self in _InstrumentedFastAPI._instrumented_fastapi_apps:
|
|
316
|
+
_InstrumentedFastAPI._instrumented_fastapi_apps.remove(self)
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def _get_route_details(scope: dict[str, Any]) -> Optional[str]:
|
|
320
|
+
"""
|
|
321
|
+
Function to retrieve Starlette route from scope.
|
|
322
|
+
|
|
323
|
+
Args:
|
|
324
|
+
scope: A Starlette scope
|
|
325
|
+
Returns:
|
|
326
|
+
A string containing the route or None
|
|
327
|
+
"""
|
|
328
|
+
app = scope["app"]
|
|
329
|
+
route = None
|
|
330
|
+
|
|
331
|
+
for starlette_route in app.routes:
|
|
332
|
+
match, _ = starlette_route.matches(scope)
|
|
333
|
+
if match == Match.FULL:
|
|
334
|
+
route = starlette_route.path
|
|
335
|
+
break
|
|
336
|
+
if match == Match.PARTIAL:
|
|
337
|
+
route = starlette_route.path
|
|
338
|
+
return route
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def _get_default_span_details(scope: dict[str, Any]) -> tuple[str, dict[str, Any]]:
|
|
342
|
+
"""
|
|
343
|
+
Callback to retrieve span name and attributes from scope.
|
|
344
|
+
|
|
345
|
+
Args:
|
|
346
|
+
scope: A Starlette scope
|
|
347
|
+
Returns:
|
|
348
|
+
A tuple of span name and attributes
|
|
349
|
+
"""
|
|
350
|
+
route = _get_route_details(scope)
|
|
351
|
+
method = sanitize_method(scope.get("method", "").strip())
|
|
352
|
+
attributes: dict[str, Any] = {}
|
|
353
|
+
if method == "_OTHER":
|
|
354
|
+
method = "HTTP"
|
|
355
|
+
if route:
|
|
356
|
+
attributes[HTTP_ROUTE] = route
|
|
357
|
+
if method and route: # http
|
|
358
|
+
span_name = f"{method} {route}"
|
|
359
|
+
elif route: # websocket
|
|
360
|
+
span_name = route
|
|
361
|
+
else: # fallback
|
|
362
|
+
span_name = method
|
|
363
|
+
return span_name, attributes
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.116.1"
|