Changed code to support older Python versions

This commit is contained in:
Malasaur 2025-12-01 23:27:09 +01:00
parent eb92d2d36f
commit 582458cdd0
5027 changed files with 794942 additions and 4 deletions

View file

@ -0,0 +1,7 @@
from .utils import (
set_data_normalized,
GEN_AI_MESSAGE_ROLE_MAPPING,
GEN_AI_MESSAGE_ROLE_REVERSE_MAPPING,
normalize_message_role,
normalize_message_roles,
) # noqa: F401

View file

@ -0,0 +1,137 @@
import inspect
from functools import wraps
from sentry_sdk.consts import SPANDATA
import sentry_sdk.utils
from sentry_sdk import start_span
from sentry_sdk.tracing import Span
from sentry_sdk.utils import ContextVar
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from typing import Optional, Callable, Awaitable, Any, Union, TypeVar
F = TypeVar("F", bound=Union[Callable[..., Any], Callable[..., Awaitable[Any]]])
_ai_pipeline_name = ContextVar("ai_pipeline_name", default=None)
def set_ai_pipeline_name(name):
# type: (Optional[str]) -> None
_ai_pipeline_name.set(name)
def get_ai_pipeline_name():
# type: () -> Optional[str]
return _ai_pipeline_name.get()
def ai_track(description, **span_kwargs):
# type: (str, Any) -> Callable[[F], F]
def decorator(f):
# type: (F) -> F
def sync_wrapped(*args, **kwargs):
# type: (Any, Any) -> Any
curr_pipeline = _ai_pipeline_name.get()
op = span_kwargs.pop("op", "ai.run" if curr_pipeline else "ai.pipeline")
with start_span(name=description, op=op, **span_kwargs) as span:
for k, v in kwargs.pop("sentry_tags", {}).items():
span.set_tag(k, v)
for k, v in kwargs.pop("sentry_data", {}).items():
span.set_data(k, v)
if curr_pipeline:
span.set_data(SPANDATA.GEN_AI_PIPELINE_NAME, curr_pipeline)
return f(*args, **kwargs)
else:
_ai_pipeline_name.set(description)
try:
res = f(*args, **kwargs)
except Exception as e:
event, hint = sentry_sdk.utils.event_from_exception(
e,
client_options=sentry_sdk.get_client().options,
mechanism={"type": "ai_monitoring", "handled": False},
)
sentry_sdk.capture_event(event, hint=hint)
raise e from None
finally:
_ai_pipeline_name.set(None)
return res
async def async_wrapped(*args, **kwargs):
# type: (Any, Any) -> Any
curr_pipeline = _ai_pipeline_name.get()
op = span_kwargs.pop("op", "ai.run" if curr_pipeline else "ai.pipeline")
with start_span(name=description, op=op, **span_kwargs) as span:
for k, v in kwargs.pop("sentry_tags", {}).items():
span.set_tag(k, v)
for k, v in kwargs.pop("sentry_data", {}).items():
span.set_data(k, v)
if curr_pipeline:
span.set_data(SPANDATA.GEN_AI_PIPELINE_NAME, curr_pipeline)
return await f(*args, **kwargs)
else:
_ai_pipeline_name.set(description)
try:
res = await f(*args, **kwargs)
except Exception as e:
event, hint = sentry_sdk.utils.event_from_exception(
e,
client_options=sentry_sdk.get_client().options,
mechanism={"type": "ai_monitoring", "handled": False},
)
sentry_sdk.capture_event(event, hint=hint)
raise e from None
finally:
_ai_pipeline_name.set(None)
return res
if inspect.iscoroutinefunction(f):
return wraps(f)(async_wrapped) # type: ignore
else:
return wraps(f)(sync_wrapped) # type: ignore
return decorator
def record_token_usage(
span,
input_tokens=None,
input_tokens_cached=None,
output_tokens=None,
output_tokens_reasoning=None,
total_tokens=None,
):
# type: (Span, Optional[int], Optional[int], Optional[int], Optional[int], Optional[int]) -> None
# TODO: move pipeline name elsewhere
ai_pipeline_name = get_ai_pipeline_name()
if ai_pipeline_name:
span.set_data(SPANDATA.GEN_AI_PIPELINE_NAME, ai_pipeline_name)
if input_tokens is not None:
span.set_data(SPANDATA.GEN_AI_USAGE_INPUT_TOKENS, input_tokens)
if input_tokens_cached is not None:
span.set_data(
SPANDATA.GEN_AI_USAGE_INPUT_TOKENS_CACHED,
input_tokens_cached,
)
if output_tokens is not None:
span.set_data(SPANDATA.GEN_AI_USAGE_OUTPUT_TOKENS, output_tokens)
if output_tokens_reasoning is not None:
span.set_data(
SPANDATA.GEN_AI_USAGE_OUTPUT_TOKENS_REASONING,
output_tokens_reasoning,
)
if total_tokens is None and input_tokens is not None and output_tokens is not None:
total_tokens = input_tokens + output_tokens
if total_tokens is not None:
span.set_data(SPANDATA.GEN_AI_USAGE_TOTAL_TOKENS, total_tokens)

View file

@ -0,0 +1,144 @@
import json
from collections import deque
from typing import TYPE_CHECKING
from sys import getsizeof
if TYPE_CHECKING:
from typing import Any, Callable, Dict, List, Optional, Tuple
from sentry_sdk.tracing import Span
import sentry_sdk
from sentry_sdk.utils import logger
MAX_GEN_AI_MESSAGE_BYTES = 20_000 # 20KB
class GEN_AI_ALLOWED_MESSAGE_ROLES:
SYSTEM = "system"
USER = "user"
ASSISTANT = "assistant"
TOOL = "tool"
GEN_AI_MESSAGE_ROLE_REVERSE_MAPPING = {
GEN_AI_ALLOWED_MESSAGE_ROLES.SYSTEM: ["system"],
GEN_AI_ALLOWED_MESSAGE_ROLES.USER: ["user", "human"],
GEN_AI_ALLOWED_MESSAGE_ROLES.ASSISTANT: ["assistant", "ai"],
GEN_AI_ALLOWED_MESSAGE_ROLES.TOOL: ["tool", "tool_call"],
}
GEN_AI_MESSAGE_ROLE_MAPPING = {}
for target_role, source_roles in GEN_AI_MESSAGE_ROLE_REVERSE_MAPPING.items():
for source_role in source_roles:
GEN_AI_MESSAGE_ROLE_MAPPING[source_role] = target_role
def _normalize_data(data, unpack=True):
# type: (Any, bool) -> Any
# convert pydantic data (e.g. OpenAI v1+) to json compatible format
if hasattr(data, "model_dump"):
try:
return _normalize_data(data.model_dump(), unpack=unpack)
except Exception as e:
logger.warning("Could not convert pydantic data to JSON: %s", e)
return data if isinstance(data, (int, float, bool, str)) else str(data)
if isinstance(data, list):
if unpack and len(data) == 1:
return _normalize_data(data[0], unpack=unpack) # remove empty dimensions
return list(_normalize_data(x, unpack=unpack) for x in data)
if isinstance(data, dict):
return {k: _normalize_data(v, unpack=unpack) for (k, v) in data.items()}
return data if isinstance(data, (int, float, bool, str)) else str(data)
def set_data_normalized(span, key, value, unpack=True):
# type: (Span, str, Any, bool) -> None
normalized = _normalize_data(value, unpack=unpack)
if isinstance(normalized, (int, float, bool, str)):
span.set_data(key, normalized)
else:
span.set_data(key, json.dumps(normalized))
def normalize_message_role(role):
# type: (str) -> str
"""
Normalize a message role to one of the 4 allowed gen_ai role values.
Maps "ai" -> "assistant" and keeps other standard roles unchanged.
"""
return GEN_AI_MESSAGE_ROLE_MAPPING.get(role, role)
def normalize_message_roles(messages):
# type: (list[dict[str, Any]]) -> list[dict[str, Any]]
"""
Normalize roles in a list of messages to use standard gen_ai role values.
Creates a deep copy to avoid modifying the original messages.
"""
normalized_messages = []
for message in messages:
if not isinstance(message, dict):
normalized_messages.append(message)
continue
normalized_message = message.copy()
if "role" in message:
normalized_message["role"] = normalize_message_role(message["role"])
normalized_messages.append(normalized_message)
return normalized_messages
def get_start_span_function():
# type: () -> Callable[..., Any]
current_span = sentry_sdk.get_current_span()
transaction_exists = (
current_span is not None and current_span.containing_transaction is not None
)
return sentry_sdk.start_span if transaction_exists else sentry_sdk.start_transaction
def _find_truncation_index(messages, max_bytes):
# type: (List[Dict[str, Any]], int) -> int
"""
Find the index of the first message that would exceed the max bytes limit.
Compute the individual message sizes, and return the index of the first message from the back
of the list that would exceed the max bytes limit.
"""
running_sum = 0
for idx in range(len(messages) - 1, -1, -1):
size = len(json.dumps(messages[idx], separators=(",", ":")).encode("utf-8"))
running_sum += size
if running_sum > max_bytes:
return idx + 1
return 0
def truncate_messages_by_size(messages, max_bytes=MAX_GEN_AI_MESSAGE_BYTES):
# type: (List[Dict[str, Any]], int) -> Tuple[List[Dict[str, Any]], int]
serialized_json = json.dumps(messages, separators=(",", ":"))
current_size = len(serialized_json.encode("utf-8"))
if current_size <= max_bytes:
return messages, 0
truncation_index = _find_truncation_index(messages, max_bytes)
return messages[truncation_index:], truncation_index
def truncate_and_annotate_messages(
messages, span, scope, max_bytes=MAX_GEN_AI_MESSAGE_BYTES
):
# type: (Optional[List[Dict[str, Any]]], Any, Any, int) -> Optional[List[Dict[str, Any]]]
if not messages:
return None
truncated_messages, removed_count = truncate_messages_by_size(messages, max_bytes)
if removed_count > 0:
scope._gen_ai_original_message_count[span.span_id] = len(messages)
return truncated_messages