2025-07-01

This commit is contained in:
2026-03-17 14:30:01 -06:00
parent f9a22056dd
commit 62b5978595
4579 changed files with 1257472 additions and 0 deletions
@@ -0,0 +1,293 @@
from abc import ABC, abstractmethod
from threading import Lock
from sentry_sdk.utils import logger
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from collections.abc import Sequence
from typing import Callable
from typing import Dict
from typing import Iterator
from typing import List
from typing import Optional
from typing import Set
from typing import Type
from typing import Union
_DEFAULT_FAILED_REQUEST_STATUS_CODES = frozenset(range(500, 600))
_installer_lock = Lock()
# Set of all integration identifiers we have attempted to install
_processed_integrations = set() # type: Set[str]
# Set of all integration identifiers we have actually installed
_installed_integrations = set() # type: Set[str]
def _generate_default_integrations_iterator(
integrations, # type: List[str]
auto_enabling_integrations, # type: List[str]
):
# type: (...) -> Callable[[bool], Iterator[Type[Integration]]]
def iter_default_integrations(with_auto_enabling_integrations):
# type: (bool) -> Iterator[Type[Integration]]
"""Returns an iterator of the default integration classes:"""
from importlib import import_module
if with_auto_enabling_integrations:
all_import_strings = integrations + auto_enabling_integrations
else:
all_import_strings = integrations
for import_string in all_import_strings:
try:
module, cls = import_string.rsplit(".", 1)
yield getattr(import_module(module), cls)
except (DidNotEnable, SyntaxError) as e:
logger.debug(
"Did not import default integration %s: %s", import_string, e
)
if isinstance(iter_default_integrations.__doc__, str):
for import_string in integrations:
iter_default_integrations.__doc__ += "\n- `{}`".format(import_string)
return iter_default_integrations
_DEFAULT_INTEGRATIONS = [
# stdlib/base runtime integrations
"sentry_sdk.integrations.argv.ArgvIntegration",
"sentry_sdk.integrations.atexit.AtexitIntegration",
"sentry_sdk.integrations.dedupe.DedupeIntegration",
"sentry_sdk.integrations.excepthook.ExcepthookIntegration",
"sentry_sdk.integrations.logging.LoggingIntegration",
"sentry_sdk.integrations.modules.ModulesIntegration",
"sentry_sdk.integrations.stdlib.StdlibIntegration",
"sentry_sdk.integrations.threading.ThreadingIntegration",
]
_AUTO_ENABLING_INTEGRATIONS = [
"sentry_sdk.integrations.aiohttp.AioHttpIntegration",
"sentry_sdk.integrations.anthropic.AnthropicIntegration",
"sentry_sdk.integrations.ariadne.AriadneIntegration",
"sentry_sdk.integrations.arq.ArqIntegration",
"sentry_sdk.integrations.asyncpg.AsyncPGIntegration",
"sentry_sdk.integrations.boto3.Boto3Integration",
"sentry_sdk.integrations.bottle.BottleIntegration",
"sentry_sdk.integrations.celery.CeleryIntegration",
"sentry_sdk.integrations.chalice.ChaliceIntegration",
"sentry_sdk.integrations.clickhouse_driver.ClickhouseDriverIntegration",
"sentry_sdk.integrations.cohere.CohereIntegration",
"sentry_sdk.integrations.django.DjangoIntegration",
"sentry_sdk.integrations.falcon.FalconIntegration",
"sentry_sdk.integrations.fastapi.FastApiIntegration",
"sentry_sdk.integrations.flask.FlaskIntegration",
"sentry_sdk.integrations.gql.GQLIntegration",
"sentry_sdk.integrations.graphene.GrapheneIntegration",
"sentry_sdk.integrations.httpx.HttpxIntegration",
"sentry_sdk.integrations.huey.HueyIntegration",
"sentry_sdk.integrations.huggingface_hub.HuggingfaceHubIntegration",
"sentry_sdk.integrations.langchain.LangchainIntegration",
"sentry_sdk.integrations.litestar.LitestarIntegration",
"sentry_sdk.integrations.loguru.LoguruIntegration",
"sentry_sdk.integrations.openai.OpenAIIntegration",
"sentry_sdk.integrations.pymongo.PyMongoIntegration",
"sentry_sdk.integrations.pyramid.PyramidIntegration",
"sentry_sdk.integrations.quart.QuartIntegration",
"sentry_sdk.integrations.redis.RedisIntegration",
"sentry_sdk.integrations.rq.RqIntegration",
"sentry_sdk.integrations.sanic.SanicIntegration",
"sentry_sdk.integrations.sqlalchemy.SqlalchemyIntegration",
"sentry_sdk.integrations.starlette.StarletteIntegration",
"sentry_sdk.integrations.starlite.StarliteIntegration",
"sentry_sdk.integrations.strawberry.StrawberryIntegration",
"sentry_sdk.integrations.tornado.TornadoIntegration",
]
iter_default_integrations = _generate_default_integrations_iterator(
integrations=_DEFAULT_INTEGRATIONS,
auto_enabling_integrations=_AUTO_ENABLING_INTEGRATIONS,
)
del _generate_default_integrations_iterator
_MIN_VERSIONS = {
"aiohttp": (3, 4),
"anthropic": (0, 16),
"ariadne": (0, 20),
"arq": (0, 23),
"asyncpg": (0, 23),
"beam": (2, 12),
"boto3": (1, 12), # botocore
"bottle": (0, 12),
"celery": (4, 4, 7),
"chalice": (1, 16, 0),
"clickhouse_driver": (0, 2, 0),
"django": (1, 8),
"dramatiq": (1, 9),
"falcon": (1, 4),
"fastapi": (0, 79, 0),
"flask": (1, 1, 4),
"gql": (3, 4, 1),
"graphene": (3, 3),
"grpc": (1, 32, 0), # grpcio
"huggingface_hub": (0, 22),
"langchain": (0, 0, 210),
"launchdarkly": (9, 8, 0),
"loguru": (0, 7, 0),
"openai": (1, 0, 0),
"openfeature": (0, 7, 1),
"quart": (0, 16, 0),
"ray": (2, 7, 0),
"requests": (2, 0, 0),
"rq": (0, 6),
"sanic": (0, 8),
"sqlalchemy": (1, 2),
"starlette": (0, 16),
"starlite": (1, 48),
"statsig": (0, 55, 3),
"strawberry": (0, 209, 5),
"tornado": (6, 0),
"typer": (0, 15),
"unleash": (6, 0, 1),
}
def setup_integrations(
integrations,
with_defaults=True,
with_auto_enabling_integrations=False,
disabled_integrations=None,
):
# type: (Sequence[Integration], bool, bool, Optional[Sequence[Union[type[Integration], Integration]]]) -> Dict[str, Integration]
"""
Given a list of integration instances, this installs them all.
When `with_defaults` is set to `True` all default integrations are added
unless they were already provided before.
`disabled_integrations` takes precedence over `with_defaults` and
`with_auto_enabling_integrations`.
"""
integrations = dict(
(integration.identifier, integration) for integration in integrations or ()
)
logger.debug("Setting up integrations (with default = %s)", with_defaults)
# Integrations that will not be enabled
disabled_integrations = [
integration if isinstance(integration, type) else type(integration)
for integration in disabled_integrations or []
]
# Integrations that are not explicitly set up by the user.
used_as_default_integration = set()
if with_defaults:
for integration_cls in iter_default_integrations(
with_auto_enabling_integrations
):
if integration_cls.identifier not in integrations:
instance = integration_cls()
integrations[instance.identifier] = instance
used_as_default_integration.add(instance.identifier)
for identifier, integration in integrations.items():
with _installer_lock:
if identifier not in _processed_integrations:
if type(integration) in disabled_integrations:
logger.debug("Ignoring integration %s", identifier)
else:
logger.debug(
"Setting up previously not enabled integration %s", identifier
)
try:
type(integration).setup_once()
except DidNotEnable as e:
if identifier not in used_as_default_integration:
raise
logger.debug(
"Did not enable default integration %s: %s", identifier, e
)
else:
_installed_integrations.add(identifier)
_processed_integrations.add(identifier)
integrations = {
identifier: integration
for identifier, integration in integrations.items()
if identifier in _installed_integrations
}
for identifier in integrations:
logger.debug("Enabling integration %s", identifier)
return integrations
def _check_minimum_version(integration, version, package=None):
# type: (type[Integration], Optional[tuple[int, ...]], Optional[str]) -> None
package = package or integration.identifier
if version is None:
raise DidNotEnable(f"Unparsable {package} version.")
min_version = _MIN_VERSIONS.get(integration.identifier)
if min_version is None:
return
if version < min_version:
raise DidNotEnable(
f"Integration only supports {package} {'.'.join(map(str, min_version))} or newer."
)
class DidNotEnable(Exception): # noqa: N818
"""
The integration could not be enabled due to a trivial user error like
`flask` not being installed for the `FlaskIntegration`.
This exception is silently swallowed for default integrations, but reraised
for explicitly enabled integrations.
"""
class Integration(ABC):
"""Baseclass for all integrations.
To accept options for an integration, implement your own constructor that
saves those options on `self`.
"""
install = None
"""Legacy method, do not implement."""
identifier = None # type: str
"""String unique ID of integration type"""
@staticmethod
@abstractmethod
def setup_once():
# type: () -> None
"""
Initialize the integration.
This function is only called once, ever. Configuration is not available
at this point, so the only thing to do here is to hook into exception
handlers, and perhaps do monkeypatches.
Inside those hooks `Integration.current` can be used to access the
instance again.
"""
pass
@@ -0,0 +1,108 @@
import urllib
from sentry_sdk.scope import should_send_default_pii
from sentry_sdk.integrations._wsgi_common import _filter_headers
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from typing import Any
from typing import Dict
from typing import Optional
from typing import Union
from typing_extensions import Literal
from sentry_sdk.utils import AnnotatedValue
def _get_headers(asgi_scope):
# type: (Any) -> Dict[str, str]
"""
Extract headers from the ASGI scope, in the format that the Sentry protocol expects.
"""
headers = {} # type: Dict[str, str]
for raw_key, raw_value in asgi_scope["headers"]:
key = raw_key.decode("latin-1")
value = raw_value.decode("latin-1")
if key in headers:
headers[key] = headers[key] + ", " + value
else:
headers[key] = value
return headers
def _get_url(asgi_scope, default_scheme, host):
# type: (Dict[str, Any], Literal["ws", "http"], Optional[Union[AnnotatedValue, str]]) -> str
"""
Extract URL from the ASGI scope, without also including the querystring.
"""
scheme = asgi_scope.get("scheme", default_scheme)
server = asgi_scope.get("server", None)
path = asgi_scope.get("root_path", "") + asgi_scope.get("path", "")
if host:
return "%s://%s%s" % (scheme, host, path)
if server is not None:
host, port = server
default_port = {"http": 80, "https": 443, "ws": 80, "wss": 443}.get(scheme)
if port != default_port:
return "%s://%s:%s%s" % (scheme, host, port, path)
return "%s://%s%s" % (scheme, host, path)
return path
def _get_query(asgi_scope):
# type: (Any) -> Any
"""
Extract querystring from the ASGI scope, in the format that the Sentry protocol expects.
"""
qs = asgi_scope.get("query_string")
if not qs:
return None
return urllib.parse.unquote(qs.decode("latin-1"))
def _get_ip(asgi_scope):
# type: (Any) -> str
"""
Extract IP Address from the ASGI scope based on request headers with fallback to scope client.
"""
headers = _get_headers(asgi_scope)
try:
return headers["x-forwarded-for"].split(",")[0].strip()
except (KeyError, IndexError):
pass
try:
return headers["x-real-ip"]
except KeyError:
pass
return asgi_scope.get("client")[0]
def _get_request_data(asgi_scope):
# type: (Any) -> Dict[str, Any]
"""
Returns data related to the HTTP request from the ASGI scope.
"""
request_data = {} # type: Dict[str, Any]
ty = asgi_scope["type"]
if ty in ("http", "websocket"):
request_data["method"] = asgi_scope.get("method")
request_data["headers"] = headers = _filter_headers(_get_headers(asgi_scope))
request_data["query_string"] = _get_query(asgi_scope)
request_data["url"] = _get_url(
asgi_scope, "http" if ty == "http" else "ws", headers.get("host")
)
client = asgi_scope.get("client")
if client and should_send_default_pii():
request_data["env"] = {"REMOTE_ADDR": _get_ip(asgi_scope)}
return request_data
@@ -0,0 +1,271 @@
from contextlib import contextmanager
import json
from copy import deepcopy
import sentry_sdk
from sentry_sdk.scope import should_send_default_pii
from sentry_sdk.utils import AnnotatedValue, logger
try:
from django.http.request import RawPostDataException
except ImportError:
RawPostDataException = None
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from typing import Any
from typing import Dict
from typing import Iterator
from typing import Mapping
from typing import MutableMapping
from typing import Optional
from typing import Union
from sentry_sdk._types import Event, HttpStatusCodeRange
SENSITIVE_ENV_KEYS = (
"REMOTE_ADDR",
"HTTP_X_FORWARDED_FOR",
"HTTP_SET_COOKIE",
"HTTP_COOKIE",
"HTTP_AUTHORIZATION",
"HTTP_X_API_KEY",
"HTTP_X_FORWARDED_FOR",
"HTTP_X_REAL_IP",
)
SENSITIVE_HEADERS = tuple(
x[len("HTTP_") :] for x in SENSITIVE_ENV_KEYS if x.startswith("HTTP_")
)
DEFAULT_HTTP_METHODS_TO_CAPTURE = (
"CONNECT",
"DELETE",
"GET",
# "HEAD", # do not capture HEAD requests by default
# "OPTIONS", # do not capture OPTIONS requests by default
"PATCH",
"POST",
"PUT",
"TRACE",
)
# This noop context manager can be replaced with "from contextlib import nullcontext" when we drop Python 3.6 support
@contextmanager
def nullcontext():
# type: () -> Iterator[None]
yield
def request_body_within_bounds(client, content_length):
# type: (Optional[sentry_sdk.client.BaseClient], int) -> bool
if client is None:
return False
bodies = client.options["max_request_body_size"]
return not (
bodies == "never"
or (bodies == "small" and content_length > 10**3)
or (bodies == "medium" and content_length > 10**4)
)
class RequestExtractor:
"""
Base class for request extraction.
"""
# It does not make sense to make this class an ABC because it is not used
# for typing, only so that child classes can inherit common methods from
# it. Only some child classes implement all methods that raise
# NotImplementedError in this class.
def __init__(self, request):
# type: (Any) -> None
self.request = request
def extract_into_event(self, event):
# type: (Event) -> None
client = sentry_sdk.get_client()
if not client.is_active():
return
data = None # type: Optional[Union[AnnotatedValue, Dict[str, Any]]]
content_length = self.content_length()
request_info = event.get("request", {})
if should_send_default_pii():
request_info["cookies"] = dict(self.cookies())
if not request_body_within_bounds(client, content_length):
data = AnnotatedValue.removed_because_over_size_limit()
else:
# First read the raw body data
# It is important to read this first because if it is Django
# it will cache the body and then we can read the cached version
# again in parsed_body() (or json() or wherever).
raw_data = None
try:
raw_data = self.raw_data()
except (RawPostDataException, ValueError):
# If DjangoRestFramework is used it already read the body for us
# so reading it here will fail. We can ignore this.
pass
parsed_body = self.parsed_body()
if parsed_body is not None:
data = parsed_body
elif raw_data:
data = AnnotatedValue.removed_because_raw_data()
else:
data = None
if data is not None:
request_info["data"] = data
event["request"] = deepcopy(request_info)
def content_length(self):
# type: () -> int
try:
return int(self.env().get("CONTENT_LENGTH", 0))
except ValueError:
return 0
def cookies(self):
# type: () -> MutableMapping[str, Any]
raise NotImplementedError()
def raw_data(self):
# type: () -> Optional[Union[str, bytes]]
raise NotImplementedError()
def form(self):
# type: () -> Optional[Dict[str, Any]]
raise NotImplementedError()
def parsed_body(self):
# type: () -> Optional[Dict[str, Any]]
try:
form = self.form()
except Exception:
form = None
try:
files = self.files()
except Exception:
files = None
if form or files:
data = {}
if form:
data = dict(form.items())
if files:
for key in files.keys():
data[key] = AnnotatedValue.removed_because_raw_data()
return data
return self.json()
def is_json(self):
# type: () -> bool
return _is_json_content_type(self.env().get("CONTENT_TYPE"))
def json(self):
# type: () -> Optional[Any]
try:
if not self.is_json():
return None
try:
raw_data = self.raw_data()
except (RawPostDataException, ValueError):
# The body might have already been read, in which case this will
# fail
raw_data = None
if raw_data is None:
return None
if isinstance(raw_data, str):
return json.loads(raw_data)
else:
return json.loads(raw_data.decode("utf-8"))
except ValueError:
pass
return None
def files(self):
# type: () -> Optional[Dict[str, Any]]
raise NotImplementedError()
def size_of_file(self, file):
# type: (Any) -> int
raise NotImplementedError()
def env(self):
# type: () -> Dict[str, Any]
raise NotImplementedError()
def _is_json_content_type(ct):
# type: (Optional[str]) -> bool
mt = (ct or "").split(";", 1)[0]
return (
mt == "application/json"
or (mt.startswith("application/"))
and mt.endswith("+json")
)
def _filter_headers(headers):
# type: (Mapping[str, str]) -> Mapping[str, Union[AnnotatedValue, str]]
if should_send_default_pii():
return headers
return {
k: (
v
if k.upper().replace("-", "_") not in SENSITIVE_HEADERS
else AnnotatedValue.removed_because_over_size_limit()
)
for k, v in headers.items()
}
def _in_http_status_code_range(code, code_ranges):
# type: (object, list[HttpStatusCodeRange]) -> bool
for target in code_ranges:
if isinstance(target, int):
if code == target:
return True
continue
try:
if code in target:
return True
except TypeError:
logger.warning(
"failed_request_status_codes has to be a list of integers or containers"
)
return False
class HttpCodeRangeContainer:
"""
Wrapper to make it possible to use list[HttpStatusCodeRange] as a Container[int].
Used for backwards compatibility with the old `failed_request_status_codes` option.
"""
def __init__(self, code_ranges):
# type: (list[HttpStatusCodeRange]) -> None
self._code_ranges = code_ranges
def __contains__(self, item):
# type: (object) -> bool
return _in_http_status_code_range(item, self._code_ranges)
@@ -0,0 +1,357 @@
import sys
import weakref
from functools import wraps
import sentry_sdk
from sentry_sdk.api import continue_trace
from sentry_sdk.consts import OP, SPANSTATUS, SPANDATA
from sentry_sdk.integrations import (
_DEFAULT_FAILED_REQUEST_STATUS_CODES,
_check_minimum_version,
Integration,
DidNotEnable,
)
from sentry_sdk.integrations.logging import ignore_logger
from sentry_sdk.sessions import track_session
from sentry_sdk.integrations._wsgi_common import (
_filter_headers,
request_body_within_bounds,
)
from sentry_sdk.tracing import (
BAGGAGE_HEADER_NAME,
SOURCE_FOR_STYLE,
TRANSACTION_SOURCE_ROUTE,
)
from sentry_sdk.tracing_utils import should_propagate_trace
from sentry_sdk.utils import (
capture_internal_exceptions,
ensure_integration_enabled,
event_from_exception,
logger,
parse_url,
parse_version,
reraise,
transaction_from_function,
HAS_REAL_CONTEXTVARS,
CONTEXTVARS_ERROR_MESSAGE,
SENSITIVE_DATA_SUBSTITUTE,
AnnotatedValue,
)
try:
import asyncio
from aiohttp import __version__ as AIOHTTP_VERSION
from aiohttp import ClientSession, TraceConfig
from aiohttp.web import Application, HTTPException, UrlDispatcher
except ImportError:
raise DidNotEnable("AIOHTTP not installed")
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from aiohttp.web_request import Request
from aiohttp.web_urldispatcher import UrlMappingMatchInfo
from aiohttp import TraceRequestStartParams, TraceRequestEndParams
from collections.abc import Set
from types import SimpleNamespace
from typing import Any
from typing import Optional
from typing import Tuple
from typing import Union
from sentry_sdk.utils import ExcInfo
from sentry_sdk._types import Event, EventProcessor
TRANSACTION_STYLE_VALUES = ("handler_name", "method_and_path_pattern")
class AioHttpIntegration(Integration):
identifier = "aiohttp"
origin = f"auto.http.{identifier}"
def __init__(
self,
transaction_style="handler_name", # type: str
*,
failed_request_status_codes=_DEFAULT_FAILED_REQUEST_STATUS_CODES, # type: Set[int]
):
# type: (...) -> None
if transaction_style not in TRANSACTION_STYLE_VALUES:
raise ValueError(
"Invalid value for transaction_style: %s (must be in %s)"
% (transaction_style, TRANSACTION_STYLE_VALUES)
)
self.transaction_style = transaction_style
self._failed_request_status_codes = failed_request_status_codes
@staticmethod
def setup_once():
# type: () -> None
version = parse_version(AIOHTTP_VERSION)
_check_minimum_version(AioHttpIntegration, version)
if not HAS_REAL_CONTEXTVARS:
# We better have contextvars or we're going to leak state between
# requests.
raise DidNotEnable(
"The aiohttp integration for Sentry requires Python 3.7+ "
" or aiocontextvars package." + CONTEXTVARS_ERROR_MESSAGE
)
ignore_logger("aiohttp.server")
old_handle = Application._handle
async def sentry_app_handle(self, request, *args, **kwargs):
# type: (Any, Request, *Any, **Any) -> Any
integration = sentry_sdk.get_client().get_integration(AioHttpIntegration)
if integration is None:
return await old_handle(self, request, *args, **kwargs)
weak_request = weakref.ref(request)
with sentry_sdk.isolation_scope() as scope:
with track_session(scope, session_mode="request"):
# Scope data will not leak between requests because aiohttp
# create a task to wrap each request.
scope.generate_propagation_context()
scope.clear_breadcrumbs()
scope.add_event_processor(_make_request_processor(weak_request))
headers = dict(request.headers)
transaction = continue_trace(
headers,
op=OP.HTTP_SERVER,
# If this transaction name makes it to the UI, AIOHTTP's
# URL resolver did not find a route or died trying.
name="generic AIOHTTP request",
source=TRANSACTION_SOURCE_ROUTE,
origin=AioHttpIntegration.origin,
)
with sentry_sdk.start_transaction(
transaction,
custom_sampling_context={"aiohttp_request": request},
):
try:
response = await old_handle(self, request)
except HTTPException as e:
transaction.set_http_status(e.status_code)
if (
e.status_code
in integration._failed_request_status_codes
):
_capture_exception()
raise
except (asyncio.CancelledError, ConnectionResetError):
transaction.set_status(SPANSTATUS.CANCELLED)
raise
except Exception:
# This will probably map to a 500 but seems like we
# have no way to tell. Do not set span status.
reraise(*_capture_exception())
try:
# A valid response handler will return a valid response with a status. But, if the handler
# returns an invalid response (e.g. None), the line below will raise an AttributeError.
# Even though this is likely invalid, we need to handle this case to ensure we don't break
# the application.
response_status = response.status
except AttributeError:
pass
else:
transaction.set_http_status(response_status)
return response
Application._handle = sentry_app_handle
old_urldispatcher_resolve = UrlDispatcher.resolve
@wraps(old_urldispatcher_resolve)
async def sentry_urldispatcher_resolve(self, request):
# type: (UrlDispatcher, Request) -> UrlMappingMatchInfo
rv = await old_urldispatcher_resolve(self, request)
integration = sentry_sdk.get_client().get_integration(AioHttpIntegration)
if integration is None:
return rv
name = None
try:
if integration.transaction_style == "handler_name":
name = transaction_from_function(rv.handler)
elif integration.transaction_style == "method_and_path_pattern":
route_info = rv.get_info()
pattern = route_info.get("path") or route_info.get("formatter")
name = "{} {}".format(request.method, pattern)
except Exception:
pass
if name is not None:
sentry_sdk.get_current_scope().set_transaction_name(
name,
source=SOURCE_FOR_STYLE[integration.transaction_style],
)
return rv
UrlDispatcher.resolve = sentry_urldispatcher_resolve
old_client_session_init = ClientSession.__init__
@ensure_integration_enabled(AioHttpIntegration, old_client_session_init)
def init(*args, **kwargs):
# type: (Any, Any) -> None
client_trace_configs = list(kwargs.get("trace_configs") or ())
trace_config = create_trace_config()
client_trace_configs.append(trace_config)
kwargs["trace_configs"] = client_trace_configs
return old_client_session_init(*args, **kwargs)
ClientSession.__init__ = init
def create_trace_config():
# type: () -> TraceConfig
async def on_request_start(session, trace_config_ctx, params):
# type: (ClientSession, SimpleNamespace, TraceRequestStartParams) -> None
if sentry_sdk.get_client().get_integration(AioHttpIntegration) is None:
return
method = params.method.upper()
parsed_url = None
with capture_internal_exceptions():
parsed_url = parse_url(str(params.url), sanitize=False)
span = sentry_sdk.start_span(
op=OP.HTTP_CLIENT,
name="%s %s"
% (method, parsed_url.url if parsed_url else SENSITIVE_DATA_SUBSTITUTE),
origin=AioHttpIntegration.origin,
)
span.set_data(SPANDATA.HTTP_METHOD, method)
if parsed_url is not None:
span.set_data("url", parsed_url.url)
span.set_data(SPANDATA.HTTP_QUERY, parsed_url.query)
span.set_data(SPANDATA.HTTP_FRAGMENT, parsed_url.fragment)
client = sentry_sdk.get_client()
if should_propagate_trace(client, str(params.url)):
for (
key,
value,
) in sentry_sdk.get_current_scope().iter_trace_propagation_headers(
span=span
):
logger.debug(
"[Tracing] Adding `{key}` header {value} to outgoing request to {url}.".format(
key=key, value=value, url=params.url
)
)
if key == BAGGAGE_HEADER_NAME and params.headers.get(
BAGGAGE_HEADER_NAME
):
# do not overwrite any existing baggage, just append to it
params.headers[key] += "," + value
else:
params.headers[key] = value
trace_config_ctx.span = span
async def on_request_end(session, trace_config_ctx, params):
# type: (ClientSession, SimpleNamespace, TraceRequestEndParams) -> None
if trace_config_ctx.span is None:
return
span = trace_config_ctx.span
span.set_http_status(int(params.response.status))
span.set_data("reason", params.response.reason)
span.finish()
trace_config = TraceConfig()
trace_config.on_request_start.append(on_request_start)
trace_config.on_request_end.append(on_request_end)
return trace_config
def _make_request_processor(weak_request):
# type: (weakref.ReferenceType[Request]) -> EventProcessor
def aiohttp_processor(
event, # type: Event
hint, # type: dict[str, Tuple[type, BaseException, Any]]
):
# type: (...) -> Event
request = weak_request()
if request is None:
return event
with capture_internal_exceptions():
request_info = event.setdefault("request", {})
request_info["url"] = "%s://%s%s" % (
request.scheme,
request.host,
request.path,
)
request_info["query_string"] = request.query_string
request_info["method"] = request.method
request_info["env"] = {"REMOTE_ADDR": request.remote}
request_info["headers"] = _filter_headers(dict(request.headers))
# Just attach raw data here if it is within bounds, if available.
# Unfortunately there's no way to get structured data from aiohttp
# without awaiting on some coroutine.
request_info["data"] = get_aiohttp_request_data(request)
return event
return aiohttp_processor
def _capture_exception():
# type: () -> ExcInfo
exc_info = sys.exc_info()
event, hint = event_from_exception(
exc_info,
client_options=sentry_sdk.get_client().options,
mechanism={"type": "aiohttp", "handled": False},
)
sentry_sdk.capture_event(event, hint=hint)
return exc_info
BODY_NOT_READ_MESSAGE = "[Can't show request body due to implementation details.]"
def get_aiohttp_request_data(request):
# type: (Request) -> Union[Optional[str], AnnotatedValue]
bytes_body = request._read_bytes
if bytes_body is not None:
# we have body to show
if not request_body_within_bounds(sentry_sdk.get_client(), len(bytes_body)):
return AnnotatedValue.removed_because_over_size_limit()
encoding = request.charset or "utf-8"
return bytes_body.decode(encoding, "replace")
if request.can_read_body:
# body exists but we can't show it
return BODY_NOT_READ_MESSAGE
# request has no body
return None
@@ -0,0 +1,286 @@
from functools import wraps
from typing import TYPE_CHECKING
import sentry_sdk
from sentry_sdk.ai.monitoring import record_token_usage
from sentry_sdk.consts import OP, SPANDATA
from sentry_sdk.integrations import _check_minimum_version, DidNotEnable, Integration
from sentry_sdk.scope import should_send_default_pii
from sentry_sdk.utils import (
capture_internal_exceptions,
event_from_exception,
package_version,
)
try:
from anthropic.resources import AsyncMessages, Messages
if TYPE_CHECKING:
from anthropic.types import MessageStreamEvent
except ImportError:
raise DidNotEnable("Anthropic not installed")
if TYPE_CHECKING:
from typing import Any, AsyncIterator, Iterator
from sentry_sdk.tracing import Span
class AnthropicIntegration(Integration):
identifier = "anthropic"
origin = f"auto.ai.{identifier}"
def __init__(self, include_prompts=True):
# type: (AnthropicIntegration, bool) -> None
self.include_prompts = include_prompts
@staticmethod
def setup_once():
# type: () -> None
version = package_version("anthropic")
_check_minimum_version(AnthropicIntegration, version)
Messages.create = _wrap_message_create(Messages.create)
AsyncMessages.create = _wrap_message_create_async(AsyncMessages.create)
def _capture_exception(exc):
# type: (Any) -> None
event, hint = event_from_exception(
exc,
client_options=sentry_sdk.get_client().options,
mechanism={"type": "anthropic", "handled": False},
)
sentry_sdk.capture_event(event, hint=hint)
def _calculate_token_usage(result, span):
# type: (Messages, Span) -> None
input_tokens = 0
output_tokens = 0
if hasattr(result, "usage"):
usage = result.usage
if hasattr(usage, "input_tokens") and isinstance(usage.input_tokens, int):
input_tokens = usage.input_tokens
if hasattr(usage, "output_tokens") and isinstance(usage.output_tokens, int):
output_tokens = usage.output_tokens
total_tokens = input_tokens + output_tokens
record_token_usage(span, input_tokens, output_tokens, total_tokens)
def _get_responses(content):
# type: (list[Any]) -> list[dict[str, Any]]
"""
Get JSON of a Anthropic responses.
"""
responses = []
for item in content:
if hasattr(item, "text"):
responses.append(
{
"type": item.type,
"text": item.text,
}
)
return responses
def _collect_ai_data(event, input_tokens, output_tokens, content_blocks):
# type: (MessageStreamEvent, int, int, list[str]) -> tuple[int, int, list[str]]
"""
Count token usage and collect content blocks from the AI streaming response.
"""
with capture_internal_exceptions():
if hasattr(event, "type"):
if event.type == "message_start":
usage = event.message.usage
input_tokens += usage.input_tokens
output_tokens += usage.output_tokens
elif event.type == "content_block_start":
pass
elif event.type == "content_block_delta":
if hasattr(event.delta, "text"):
content_blocks.append(event.delta.text)
elif event.type == "content_block_stop":
pass
elif event.type == "message_delta":
output_tokens += event.usage.output_tokens
return input_tokens, output_tokens, content_blocks
def _add_ai_data_to_span(
span, integration, input_tokens, output_tokens, content_blocks
):
# type: (Span, AnthropicIntegration, int, int, list[str]) -> None
"""
Add token usage and content blocks from the AI streaming response to the span.
"""
with capture_internal_exceptions():
if should_send_default_pii() and integration.include_prompts:
complete_message = "".join(content_blocks)
span.set_data(
SPANDATA.AI_RESPONSES,
[{"type": "text", "text": complete_message}],
)
total_tokens = input_tokens + output_tokens
record_token_usage(span, input_tokens, output_tokens, total_tokens)
span.set_data(SPANDATA.AI_STREAMING, True)
def _sentry_patched_create_common(f, *args, **kwargs):
# type: (Any, *Any, **Any) -> Any
integration = kwargs.pop("integration")
if integration is None:
return f(*args, **kwargs)
if "messages" not in kwargs:
return f(*args, **kwargs)
try:
iter(kwargs["messages"])
except TypeError:
return f(*args, **kwargs)
span = sentry_sdk.start_span(
op=OP.ANTHROPIC_MESSAGES_CREATE,
description="Anthropic messages create",
origin=AnthropicIntegration.origin,
)
span.__enter__()
result = yield f, args, kwargs
# add data to span and finish it
messages = list(kwargs["messages"])
model = kwargs.get("model")
with capture_internal_exceptions():
span.set_data(SPANDATA.AI_MODEL_ID, model)
span.set_data(SPANDATA.AI_STREAMING, False)
if should_send_default_pii() and integration.include_prompts:
span.set_data(SPANDATA.AI_INPUT_MESSAGES, messages)
if hasattr(result, "content"):
if should_send_default_pii() and integration.include_prompts:
span.set_data(SPANDATA.AI_RESPONSES, _get_responses(result.content))
_calculate_token_usage(result, span)
span.__exit__(None, None, None)
# Streaming response
elif hasattr(result, "_iterator"):
old_iterator = result._iterator
def new_iterator():
# type: () -> Iterator[MessageStreamEvent]
input_tokens = 0
output_tokens = 0
content_blocks = [] # type: list[str]
for event in old_iterator:
input_tokens, output_tokens, content_blocks = _collect_ai_data(
event, input_tokens, output_tokens, content_blocks
)
if event.type != "message_stop":
yield event
_add_ai_data_to_span(
span, integration, input_tokens, output_tokens, content_blocks
)
span.__exit__(None, None, None)
async def new_iterator_async():
# type: () -> AsyncIterator[MessageStreamEvent]
input_tokens = 0
output_tokens = 0
content_blocks = [] # type: list[str]
async for event in old_iterator:
input_tokens, output_tokens, content_blocks = _collect_ai_data(
event, input_tokens, output_tokens, content_blocks
)
if event.type != "message_stop":
yield event
_add_ai_data_to_span(
span, integration, input_tokens, output_tokens, content_blocks
)
span.__exit__(None, None, None)
if str(type(result._iterator)) == "<class 'async_generator'>":
result._iterator = new_iterator_async()
else:
result._iterator = new_iterator()
else:
span.set_data("unknown_response", True)
span.__exit__(None, None, None)
return result
def _wrap_message_create(f):
# type: (Any) -> Any
def _execute_sync(f, *args, **kwargs):
# type: (Any, *Any, **Any) -> Any
gen = _sentry_patched_create_common(f, *args, **kwargs)
try:
f, args, kwargs = next(gen)
except StopIteration as e:
return e.value
try:
try:
result = f(*args, **kwargs)
except Exception as exc:
_capture_exception(exc)
raise exc from None
return gen.send(result)
except StopIteration as e:
return e.value
@wraps(f)
def _sentry_patched_create_sync(*args, **kwargs):
# type: (*Any, **Any) -> Any
integration = sentry_sdk.get_client().get_integration(AnthropicIntegration)
kwargs["integration"] = integration
return _execute_sync(f, *args, **kwargs)
return _sentry_patched_create_sync
def _wrap_message_create_async(f):
# type: (Any) -> Any
async def _execute_async(f, *args, **kwargs):
# type: (Any, *Any, **Any) -> Any
gen = _sentry_patched_create_common(f, *args, **kwargs)
try:
f, args, kwargs = next(gen)
except StopIteration as e:
return await e.value
try:
try:
result = await f(*args, **kwargs)
except Exception as exc:
_capture_exception(exc)
raise exc from None
return gen.send(result)
except StopIteration as e:
return e.value
@wraps(f)
async def _sentry_patched_create_async(*args, **kwargs):
# type: (*Any, **Any) -> Any
integration = sentry_sdk.get_client().get_integration(AnthropicIntegration)
kwargs["integration"] = integration
return await _execute_async(f, *args, **kwargs)
return _sentry_patched_create_async
@@ -0,0 +1,31 @@
import sys
import sentry_sdk
from sentry_sdk.integrations import Integration
from sentry_sdk.scope import add_global_event_processor
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from typing import Optional
from sentry_sdk._types import Event, Hint
class ArgvIntegration(Integration):
identifier = "argv"
@staticmethod
def setup_once():
# type: () -> None
@add_global_event_processor
def processor(event, hint):
# type: (Event, Optional[Hint]) -> Optional[Event]
if sentry_sdk.get_client().get_integration(ArgvIntegration) is not None:
extra = event.setdefault("extra", {})
# If some event processor decided to set extra to e.g. an
# `int`, don't crash. Not here.
if isinstance(extra, dict):
extra["sys.argv"] = sys.argv
return event
@@ -0,0 +1,161 @@
from importlib import import_module
import sentry_sdk
from sentry_sdk import get_client, capture_event
from sentry_sdk.integrations import _check_minimum_version, DidNotEnable, Integration
from sentry_sdk.integrations.logging import ignore_logger
from sentry_sdk.integrations._wsgi_common import request_body_within_bounds
from sentry_sdk.scope import should_send_default_pii
from sentry_sdk.utils import (
capture_internal_exceptions,
ensure_integration_enabled,
event_from_exception,
package_version,
)
try:
# importing like this is necessary due to name shadowing in ariadne
# (ariadne.graphql is also a function)
ariadne_graphql = import_module("ariadne.graphql")
except ImportError:
raise DidNotEnable("ariadne is not installed")
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from typing import Any, Dict, List, Optional
from ariadne.types import GraphQLError, GraphQLResult, GraphQLSchema, QueryParser # type: ignore
from graphql.language.ast import DocumentNode
from sentry_sdk._types import Event, EventProcessor
class AriadneIntegration(Integration):
identifier = "ariadne"
@staticmethod
def setup_once():
# type: () -> None
version = package_version("ariadne")
_check_minimum_version(AriadneIntegration, version)
ignore_logger("ariadne")
_patch_graphql()
def _patch_graphql():
# type: () -> None
old_parse_query = ariadne_graphql.parse_query
old_handle_errors = ariadne_graphql.handle_graphql_errors
old_handle_query_result = ariadne_graphql.handle_query_result
@ensure_integration_enabled(AriadneIntegration, old_parse_query)
def _sentry_patched_parse_query(context_value, query_parser, data):
# type: (Optional[Any], Optional[QueryParser], Any) -> DocumentNode
event_processor = _make_request_event_processor(data)
sentry_sdk.get_isolation_scope().add_event_processor(event_processor)
result = old_parse_query(context_value, query_parser, data)
return result
@ensure_integration_enabled(AriadneIntegration, old_handle_errors)
def _sentry_patched_handle_graphql_errors(errors, *args, **kwargs):
# type: (List[GraphQLError], Any, Any) -> GraphQLResult
result = old_handle_errors(errors, *args, **kwargs)
event_processor = _make_response_event_processor(result[1])
sentry_sdk.get_isolation_scope().add_event_processor(event_processor)
client = get_client()
if client.is_active():
with capture_internal_exceptions():
for error in errors:
event, hint = event_from_exception(
error,
client_options=client.options,
mechanism={
"type": AriadneIntegration.identifier,
"handled": False,
},
)
capture_event(event, hint=hint)
return result
@ensure_integration_enabled(AriadneIntegration, old_handle_query_result)
def _sentry_patched_handle_query_result(result, *args, **kwargs):
# type: (Any, Any, Any) -> GraphQLResult
query_result = old_handle_query_result(result, *args, **kwargs)
event_processor = _make_response_event_processor(query_result[1])
sentry_sdk.get_isolation_scope().add_event_processor(event_processor)
client = get_client()
if client.is_active():
with capture_internal_exceptions():
for error in result.errors or []:
event, hint = event_from_exception(
error,
client_options=client.options,
mechanism={
"type": AriadneIntegration.identifier,
"handled": False,
},
)
capture_event(event, hint=hint)
return query_result
ariadne_graphql.parse_query = _sentry_patched_parse_query # type: ignore
ariadne_graphql.handle_graphql_errors = _sentry_patched_handle_graphql_errors # type: ignore
ariadne_graphql.handle_query_result = _sentry_patched_handle_query_result # type: ignore
def _make_request_event_processor(data):
# type: (GraphQLSchema) -> EventProcessor
"""Add request data and api_target to events."""
def inner(event, hint):
# type: (Event, dict[str, Any]) -> Event
if not isinstance(data, dict):
return event
with capture_internal_exceptions():
try:
content_length = int(
(data.get("headers") or {}).get("Content-Length", 0)
)
except (TypeError, ValueError):
return event
if should_send_default_pii() and request_body_within_bounds(
get_client(), content_length
):
request_info = event.setdefault("request", {})
request_info["api_target"] = "graphql"
request_info["data"] = data
elif event.get("request", {}).get("data"):
del event["request"]["data"]
return event
return inner
def _make_response_event_processor(response):
# type: (Dict[str, Any]) -> EventProcessor
"""Add response data to the event's response context."""
def inner(event, hint):
# type: (Event, dict[str, Any]) -> Event
with capture_internal_exceptions():
if should_send_default_pii() and response.get("errors"):
contexts = event.setdefault("contexts", {})
contexts["response"] = {
"data": response,
}
return event
return inner
@@ -0,0 +1,245 @@
import sys
import sentry_sdk
from sentry_sdk.consts import OP, SPANSTATUS
from sentry_sdk.integrations import _check_minimum_version, DidNotEnable, Integration
from sentry_sdk.integrations.logging import ignore_logger
from sentry_sdk.scope import should_send_default_pii
from sentry_sdk.tracing import Transaction, TRANSACTION_SOURCE_TASK
from sentry_sdk.utils import (
capture_internal_exceptions,
ensure_integration_enabled,
event_from_exception,
SENSITIVE_DATA_SUBSTITUTE,
parse_version,
reraise,
)
try:
import arq.worker
from arq.version import VERSION as ARQ_VERSION
from arq.connections import ArqRedis
from arq.worker import JobExecutionFailed, Retry, RetryJob, Worker
except ImportError:
raise DidNotEnable("Arq is not installed")
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from typing import Any, Dict, Optional, Union
from sentry_sdk._types import EventProcessor, Event, ExcInfo, Hint
from arq.cron import CronJob
from arq.jobs import Job
from arq.typing import WorkerCoroutine
from arq.worker import Function
ARQ_CONTROL_FLOW_EXCEPTIONS = (JobExecutionFailed, Retry, RetryJob)
class ArqIntegration(Integration):
identifier = "arq"
origin = f"auto.queue.{identifier}"
@staticmethod
def setup_once():
# type: () -> None
try:
if isinstance(ARQ_VERSION, str):
version = parse_version(ARQ_VERSION)
else:
version = ARQ_VERSION.version[:2]
except (TypeError, ValueError):
version = None
_check_minimum_version(ArqIntegration, version)
patch_enqueue_job()
patch_run_job()
patch_create_worker()
ignore_logger("arq.worker")
def patch_enqueue_job():
# type: () -> None
old_enqueue_job = ArqRedis.enqueue_job
original_kwdefaults = old_enqueue_job.__kwdefaults__
async def _sentry_enqueue_job(self, function, *args, **kwargs):
# type: (ArqRedis, str, *Any, **Any) -> Optional[Job]
integration = sentry_sdk.get_client().get_integration(ArqIntegration)
if integration is None:
return await old_enqueue_job(self, function, *args, **kwargs)
with sentry_sdk.start_span(
op=OP.QUEUE_SUBMIT_ARQ, name=function, origin=ArqIntegration.origin
):
return await old_enqueue_job(self, function, *args, **kwargs)
_sentry_enqueue_job.__kwdefaults__ = original_kwdefaults
ArqRedis.enqueue_job = _sentry_enqueue_job
def patch_run_job():
# type: () -> None
old_run_job = Worker.run_job
async def _sentry_run_job(self, job_id, score):
# type: (Worker, str, int) -> None
integration = sentry_sdk.get_client().get_integration(ArqIntegration)
if integration is None:
return await old_run_job(self, job_id, score)
with sentry_sdk.isolation_scope() as scope:
scope._name = "arq"
scope.clear_breadcrumbs()
transaction = Transaction(
name="unknown arq task",
status="ok",
op=OP.QUEUE_TASK_ARQ,
source=TRANSACTION_SOURCE_TASK,
origin=ArqIntegration.origin,
)
with sentry_sdk.start_transaction(transaction):
return await old_run_job(self, job_id, score)
Worker.run_job = _sentry_run_job
def _capture_exception(exc_info):
# type: (ExcInfo) -> None
scope = sentry_sdk.get_current_scope()
if scope.transaction is not None:
if exc_info[0] in ARQ_CONTROL_FLOW_EXCEPTIONS:
scope.transaction.set_status(SPANSTATUS.ABORTED)
return
scope.transaction.set_status(SPANSTATUS.INTERNAL_ERROR)
event, hint = event_from_exception(
exc_info,
client_options=sentry_sdk.get_client().options,
mechanism={"type": ArqIntegration.identifier, "handled": False},
)
sentry_sdk.capture_event(event, hint=hint)
def _make_event_processor(ctx, *args, **kwargs):
# type: (Dict[Any, Any], *Any, **Any) -> EventProcessor
def event_processor(event, hint):
# type: (Event, Hint) -> Optional[Event]
with capture_internal_exceptions():
scope = sentry_sdk.get_current_scope()
if scope.transaction is not None:
scope.transaction.name = ctx["job_name"]
event["transaction"] = ctx["job_name"]
tags = event.setdefault("tags", {})
tags["arq_task_id"] = ctx["job_id"]
tags["arq_task_retry"] = ctx["job_try"] > 1
extra = event.setdefault("extra", {})
extra["arq-job"] = {
"task": ctx["job_name"],
"args": (
args if should_send_default_pii() else SENSITIVE_DATA_SUBSTITUTE
),
"kwargs": (
kwargs if should_send_default_pii() else SENSITIVE_DATA_SUBSTITUTE
),
"retry": ctx["job_try"],
}
return event
return event_processor
def _wrap_coroutine(name, coroutine):
# type: (str, WorkerCoroutine) -> WorkerCoroutine
async def _sentry_coroutine(ctx, *args, **kwargs):
# type: (Dict[Any, Any], *Any, **Any) -> Any
integration = sentry_sdk.get_client().get_integration(ArqIntegration)
if integration is None:
return await coroutine(ctx, *args, **kwargs)
sentry_sdk.get_isolation_scope().add_event_processor(
_make_event_processor({**ctx, "job_name": name}, *args, **kwargs)
)
try:
result = await coroutine(ctx, *args, **kwargs)
except Exception:
exc_info = sys.exc_info()
_capture_exception(exc_info)
reraise(*exc_info)
return result
return _sentry_coroutine
def patch_create_worker():
# type: () -> None
old_create_worker = arq.worker.create_worker
@ensure_integration_enabled(ArqIntegration, old_create_worker)
def _sentry_create_worker(*args, **kwargs):
# type: (*Any, **Any) -> Worker
settings_cls = args[0]
if isinstance(settings_cls, dict):
if "functions" in settings_cls:
settings_cls["functions"] = [
_get_arq_function(func) for func in settings_cls["functions"]
]
if "cron_jobs" in settings_cls:
settings_cls["cron_jobs"] = [
_get_arq_cron_job(cron_job)
for cron_job in settings_cls["cron_jobs"]
]
if hasattr(settings_cls, "functions"):
settings_cls.functions = [
_get_arq_function(func) for func in settings_cls.functions
]
if hasattr(settings_cls, "cron_jobs"):
settings_cls.cron_jobs = [
_get_arq_cron_job(cron_job) for cron_job in settings_cls.cron_jobs
]
if "functions" in kwargs:
kwargs["functions"] = [
_get_arq_function(func) for func in kwargs["functions"]
]
if "cron_jobs" in kwargs:
kwargs["cron_jobs"] = [
_get_arq_cron_job(cron_job) for cron_job in kwargs["cron_jobs"]
]
return old_create_worker(*args, **kwargs)
arq.worker.create_worker = _sentry_create_worker
def _get_arq_function(func):
# type: (Union[str, Function, WorkerCoroutine]) -> Function
arq_func = arq.worker.func(func)
arq_func.coroutine = _wrap_coroutine(arq_func.name, arq_func.coroutine)
return arq_func
def _get_arq_cron_job(cron_job):
# type: (CronJob) -> CronJob
cron_job.coroutine = _wrap_coroutine(cron_job.name, cron_job.coroutine)
return cron_job
@@ -0,0 +1,335 @@
"""
An ASGI middleware.
Based on Tom Christie's `sentry-asgi <https://github.com/encode/sentry-asgi>`.
"""
import asyncio
import inspect
from copy import deepcopy
from functools import partial
import sentry_sdk
from sentry_sdk.api import continue_trace
from sentry_sdk.consts import OP
from sentry_sdk.integrations._asgi_common import (
_get_headers,
_get_request_data,
_get_url,
)
from sentry_sdk.integrations._wsgi_common import (
DEFAULT_HTTP_METHODS_TO_CAPTURE,
nullcontext,
)
from sentry_sdk.sessions import track_session
from sentry_sdk.tracing import (
SOURCE_FOR_STYLE,
TRANSACTION_SOURCE_ROUTE,
TRANSACTION_SOURCE_URL,
TRANSACTION_SOURCE_COMPONENT,
TRANSACTION_SOURCE_CUSTOM,
)
from sentry_sdk.utils import (
ContextVar,
event_from_exception,
HAS_REAL_CONTEXTVARS,
CONTEXTVARS_ERROR_MESSAGE,
logger,
transaction_from_function,
_get_installed_modules,
)
from sentry_sdk.tracing import Transaction
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from typing import Any
from typing import Callable
from typing import Dict
from typing import Optional
from typing import Tuple
from sentry_sdk._types import Event, Hint
_asgi_middleware_applied = ContextVar("sentry_asgi_middleware_applied")
_DEFAULT_TRANSACTION_NAME = "generic ASGI request"
TRANSACTION_STYLE_VALUES = ("endpoint", "url")
def _capture_exception(exc, mechanism_type="asgi"):
# type: (Any, str) -> None
event, hint = event_from_exception(
exc,
client_options=sentry_sdk.get_client().options,
mechanism={"type": mechanism_type, "handled": False},
)
sentry_sdk.capture_event(event, hint=hint)
def _looks_like_asgi3(app):
# type: (Any) -> bool
"""
Try to figure out if an application object supports ASGI3.
This is how uvicorn figures out the application version as well.
"""
if inspect.isclass(app):
return hasattr(app, "__await__")
elif inspect.isfunction(app):
return asyncio.iscoroutinefunction(app)
else:
call = getattr(app, "__call__", None) # noqa
return asyncio.iscoroutinefunction(call)
class SentryAsgiMiddleware:
__slots__ = (
"app",
"__call__",
"transaction_style",
"mechanism_type",
"span_origin",
"http_methods_to_capture",
)
def __init__(
self,
app, # type: Any
unsafe_context_data=False, # type: bool
transaction_style="endpoint", # type: str
mechanism_type="asgi", # type: str
span_origin="manual", # type: str
http_methods_to_capture=DEFAULT_HTTP_METHODS_TO_CAPTURE, # type: Tuple[str, ...]
):
# type: (...) -> None
"""
Instrument an ASGI application with Sentry. Provides HTTP/websocket
data to sent events and basic handling for exceptions bubbling up
through the middleware.
:param unsafe_context_data: Disable errors when a proper contextvars installation could not be found. We do not recommend changing this from the default.
"""
if not unsafe_context_data and not HAS_REAL_CONTEXTVARS:
# We better have contextvars or we're going to leak state between
# requests.
raise RuntimeError(
"The ASGI middleware for Sentry requires Python 3.7+ "
"or the aiocontextvars package." + CONTEXTVARS_ERROR_MESSAGE
)
if transaction_style not in TRANSACTION_STYLE_VALUES:
raise ValueError(
"Invalid value for transaction_style: %s (must be in %s)"
% (transaction_style, TRANSACTION_STYLE_VALUES)
)
asgi_middleware_while_using_starlette_or_fastapi = (
mechanism_type == "asgi" and "starlette" in _get_installed_modules()
)
if asgi_middleware_while_using_starlette_or_fastapi:
logger.warning(
"The Sentry Python SDK can now automatically support ASGI frameworks like Starlette and FastAPI. "
"Please remove 'SentryAsgiMiddleware' from your project. "
"See https://docs.sentry.io/platforms/python/guides/asgi/ for more information."
)
self.transaction_style = transaction_style
self.mechanism_type = mechanism_type
self.span_origin = span_origin
self.app = app
self.http_methods_to_capture = http_methods_to_capture
if _looks_like_asgi3(app):
self.__call__ = self._run_asgi3 # type: Callable[..., Any]
else:
self.__call__ = self._run_asgi2
def _run_asgi2(self, scope):
# type: (Any) -> Any
async def inner(receive, send):
# type: (Any, Any) -> Any
return await self._run_app(scope, receive, send, asgi_version=2)
return inner
async def _run_asgi3(self, scope, receive, send):
# type: (Any, Any, Any) -> Any
return await self._run_app(scope, receive, send, asgi_version=3)
async def _run_app(self, scope, receive, send, asgi_version):
# type: (Any, Any, Any, Any, int) -> Any
is_recursive_asgi_middleware = _asgi_middleware_applied.get(False)
is_lifespan = scope["type"] == "lifespan"
if is_recursive_asgi_middleware or is_lifespan:
try:
if asgi_version == 2:
return await self.app(scope)(receive, send)
else:
return await self.app(scope, receive, send)
except Exception as exc:
_capture_exception(exc, mechanism_type=self.mechanism_type)
raise exc from None
_asgi_middleware_applied.set(True)
try:
with sentry_sdk.isolation_scope() as sentry_scope:
with track_session(sentry_scope, session_mode="request"):
sentry_scope.clear_breadcrumbs()
sentry_scope._name = "asgi"
processor = partial(self.event_processor, asgi_scope=scope)
sentry_scope.add_event_processor(processor)
ty = scope["type"]
(
transaction_name,
transaction_source,
) = self._get_transaction_name_and_source(
self.transaction_style,
scope,
)
method = scope.get("method", "").upper()
transaction = None
if method in self.http_methods_to_capture:
if ty in ("http", "websocket"):
transaction = continue_trace(
_get_headers(scope),
op="{}.server".format(ty),
name=transaction_name,
source=transaction_source,
origin=self.span_origin,
)
logger.debug(
"[ASGI] Created transaction (continuing trace): %s",
transaction,
)
else:
transaction = Transaction(
op=OP.HTTP_SERVER,
name=transaction_name,
source=transaction_source,
origin=self.span_origin,
)
logger.debug(
"[ASGI] Created transaction (new): %s", transaction
)
transaction.set_tag("asgi.type", ty)
logger.debug(
"[ASGI] Set transaction name and source on transaction: '%s' / '%s'",
transaction.name,
transaction.source,
)
with (
sentry_sdk.start_transaction(
transaction,
custom_sampling_context={"asgi_scope": scope},
)
if transaction is not None
else nullcontext()
):
logger.debug("[ASGI] Started transaction: %s", transaction)
try:
async def _sentry_wrapped_send(event):
# type: (Dict[str, Any]) -> Any
if transaction is not None:
is_http_response = (
event.get("type") == "http.response.start"
and "status" in event
)
if is_http_response:
transaction.set_http_status(event["status"])
return await send(event)
if asgi_version == 2:
return await self.app(scope)(
receive, _sentry_wrapped_send
)
else:
return await self.app(
scope, receive, _sentry_wrapped_send
)
except Exception as exc:
_capture_exception(exc, mechanism_type=self.mechanism_type)
raise exc from None
finally:
_asgi_middleware_applied.set(False)
def event_processor(self, event, hint, asgi_scope):
# type: (Event, Hint, Any) -> Optional[Event]
request_data = event.get("request", {})
request_data.update(_get_request_data(asgi_scope))
event["request"] = deepcopy(request_data)
# Only set transaction name if not already set by Starlette or FastAPI (or other frameworks)
already_set = event["transaction"] != _DEFAULT_TRANSACTION_NAME and event[
"transaction_info"
].get("source") in [
TRANSACTION_SOURCE_COMPONENT,
TRANSACTION_SOURCE_ROUTE,
TRANSACTION_SOURCE_CUSTOM,
]
if not already_set:
name, source = self._get_transaction_name_and_source(
self.transaction_style, asgi_scope
)
event["transaction"] = name
event["transaction_info"] = {"source": source}
logger.debug(
"[ASGI] Set transaction name and source in event_processor: '%s' / '%s'",
event["transaction"],
event["transaction_info"]["source"],
)
return event
# Helper functions.
#
# Note: Those functions are not public API. If you want to mutate request
# data to your liking it's recommended to use the `before_send` callback
# for that.
def _get_transaction_name_and_source(self, transaction_style, asgi_scope):
# type: (SentryAsgiMiddleware, str, Any) -> Tuple[str, str]
name = None
source = SOURCE_FOR_STYLE[transaction_style]
ty = asgi_scope.get("type")
if transaction_style == "endpoint":
endpoint = asgi_scope.get("endpoint")
# Webframeworks like Starlette mutate the ASGI env once routing is
# done, which is sometime after the request has started. If we have
# an endpoint, overwrite our generic transaction name.
if endpoint:
name = transaction_from_function(endpoint) or ""
else:
name = _get_url(asgi_scope, "http" if ty == "http" else "ws", host=None)
source = TRANSACTION_SOURCE_URL
elif transaction_style == "url":
# FastAPI includes the route object in the scope to let Sentry extract the
# path from it for the transaction name
route = asgi_scope.get("route")
if route:
path = getattr(route, "path", None)
if path is not None:
name = path
else:
name = _get_url(asgi_scope, "http" if ty == "http" else "ws", host=None)
source = TRANSACTION_SOURCE_URL
if name is None:
name = _DEFAULT_TRANSACTION_NAME
source = TRANSACTION_SOURCE_ROUTE
return name, source
return name, source
@@ -0,0 +1,107 @@
import sys
import sentry_sdk
from sentry_sdk.consts import OP
from sentry_sdk.integrations import Integration, DidNotEnable
from sentry_sdk.utils import event_from_exception, reraise
try:
import asyncio
from asyncio.tasks import Task
except ImportError:
raise DidNotEnable("asyncio not available")
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from typing import Any
from collections.abc import Coroutine
from sentry_sdk._types import ExcInfo
def get_name(coro):
# type: (Any) -> str
return (
getattr(coro, "__qualname__", None)
or getattr(coro, "__name__", None)
or "coroutine without __name__"
)
def patch_asyncio():
# type: () -> None
orig_task_factory = None
try:
loop = asyncio.get_running_loop()
orig_task_factory = loop.get_task_factory()
def _sentry_task_factory(loop, coro, **kwargs):
# type: (asyncio.AbstractEventLoop, Coroutine[Any, Any, Any], Any) -> asyncio.Future[Any]
async def _coro_creating_hub_and_span():
# type: () -> Any
result = None
with sentry_sdk.isolation_scope():
with sentry_sdk.start_span(
op=OP.FUNCTION,
name=get_name(coro),
origin=AsyncioIntegration.origin,
):
try:
result = await coro
except Exception:
reraise(*_capture_exception())
return result
# Trying to use user set task factory (if there is one)
if orig_task_factory:
return orig_task_factory(loop, _coro_creating_hub_and_span(), **kwargs)
# The default task factory in `asyncio` does not have its own function
# but is just a couple of lines in `asyncio.base_events.create_task()`
# Those lines are copied here.
# WARNING:
# If the default behavior of the task creation in asyncio changes,
# this will break!
task = Task(_coro_creating_hub_and_span(), loop=loop, **kwargs)
if task._source_traceback: # type: ignore
del task._source_traceback[-1] # type: ignore
return task
loop.set_task_factory(_sentry_task_factory) # type: ignore
except RuntimeError:
# When there is no running loop, we have nothing to patch.
pass
def _capture_exception():
# type: () -> ExcInfo
exc_info = sys.exc_info()
client = sentry_sdk.get_client()
integration = client.get_integration(AsyncioIntegration)
if integration is not None:
event, hint = event_from_exception(
exc_info,
client_options=client.options,
mechanism={"type": "asyncio", "handled": False},
)
sentry_sdk.capture_event(event, hint=hint)
return exc_info
class AsyncioIntegration(Integration):
identifier = "asyncio"
origin = f"auto.function.{identifier}"
@staticmethod
def setup_once():
# type: () -> None
patch_asyncio()
@@ -0,0 +1,208 @@
from __future__ import annotations
import contextlib
from typing import Any, TypeVar, Callable, Awaitable, Iterator
import sentry_sdk
from sentry_sdk.consts import OP, SPANDATA
from sentry_sdk.integrations import _check_minimum_version, Integration, DidNotEnable
from sentry_sdk.tracing import Span
from sentry_sdk.tracing_utils import add_query_source, record_sql_queries
from sentry_sdk.utils import (
ensure_integration_enabled,
parse_version,
capture_internal_exceptions,
)
try:
import asyncpg # type: ignore[import-not-found]
from asyncpg.cursor import BaseCursor # type: ignore
except ImportError:
raise DidNotEnable("asyncpg not installed.")
class AsyncPGIntegration(Integration):
identifier = "asyncpg"
origin = f"auto.db.{identifier}"
_record_params = False
def __init__(self, *, record_params: bool = False):
AsyncPGIntegration._record_params = record_params
@staticmethod
def setup_once() -> None:
# asyncpg.__version__ is a string containing the semantic version in the form of "<major>.<minor>.<patch>"
asyncpg_version = parse_version(asyncpg.__version__)
_check_minimum_version(AsyncPGIntegration, asyncpg_version)
asyncpg.Connection.execute = _wrap_execute(
asyncpg.Connection.execute,
)
asyncpg.Connection._execute = _wrap_connection_method(
asyncpg.Connection._execute
)
asyncpg.Connection._executemany = _wrap_connection_method(
asyncpg.Connection._executemany, executemany=True
)
asyncpg.Connection.cursor = _wrap_cursor_creation(asyncpg.Connection.cursor)
asyncpg.Connection.prepare = _wrap_connection_method(asyncpg.Connection.prepare)
asyncpg.connect_utils._connect_addr = _wrap_connect_addr(
asyncpg.connect_utils._connect_addr
)
T = TypeVar("T")
def _wrap_execute(f: Callable[..., Awaitable[T]]) -> Callable[..., Awaitable[T]]:
async def _inner(*args: Any, **kwargs: Any) -> T:
if sentry_sdk.get_client().get_integration(AsyncPGIntegration) is None:
return await f(*args, **kwargs)
# Avoid recording calls to _execute twice.
# Calls to Connection.execute with args also call
# Connection._execute, which is recorded separately
# args[0] = the connection object, args[1] is the query
if len(args) > 2:
return await f(*args, **kwargs)
query = args[1]
with record_sql_queries(
cursor=None,
query=query,
params_list=None,
paramstyle=None,
executemany=False,
span_origin=AsyncPGIntegration.origin,
) as span:
res = await f(*args, **kwargs)
with capture_internal_exceptions():
add_query_source(span)
return res
return _inner
SubCursor = TypeVar("SubCursor", bound=BaseCursor)
@contextlib.contextmanager
def _record(
cursor: SubCursor | None,
query: str,
params_list: tuple[Any, ...] | None,
*,
executemany: bool = False,
) -> Iterator[Span]:
integration = sentry_sdk.get_client().get_integration(AsyncPGIntegration)
if integration is not None and not integration._record_params:
params_list = None
param_style = "pyformat" if params_list else None
with record_sql_queries(
cursor=cursor,
query=query,
params_list=params_list,
paramstyle=param_style,
executemany=executemany,
record_cursor_repr=cursor is not None,
span_origin=AsyncPGIntegration.origin,
) as span:
yield span
def _wrap_connection_method(
f: Callable[..., Awaitable[T]], *, executemany: bool = False
) -> Callable[..., Awaitable[T]]:
async def _inner(*args: Any, **kwargs: Any) -> T:
if sentry_sdk.get_client().get_integration(AsyncPGIntegration) is None:
return await f(*args, **kwargs)
query = args[1]
params_list = args[2] if len(args) > 2 else None
with _record(None, query, params_list, executemany=executemany) as span:
_set_db_data(span, args[0])
res = await f(*args, **kwargs)
return res
return _inner
def _wrap_cursor_creation(f: Callable[..., T]) -> Callable[..., T]:
@ensure_integration_enabled(AsyncPGIntegration, f)
def _inner(*args: Any, **kwargs: Any) -> T: # noqa: N807
query = args[1]
params_list = args[2] if len(args) > 2 else None
with _record(
None,
query,
params_list,
executemany=False,
) as span:
_set_db_data(span, args[0])
res = f(*args, **kwargs)
span.set_data("db.cursor", res)
return res
return _inner
def _wrap_connect_addr(f: Callable[..., Awaitable[T]]) -> Callable[..., Awaitable[T]]:
async def _inner(*args: Any, **kwargs: Any) -> T:
if sentry_sdk.get_client().get_integration(AsyncPGIntegration) is None:
return await f(*args, **kwargs)
user = kwargs["params"].user
database = kwargs["params"].database
with sentry_sdk.start_span(
op=OP.DB,
name="connect",
origin=AsyncPGIntegration.origin,
) as span:
span.set_data(SPANDATA.DB_SYSTEM, "postgresql")
addr = kwargs.get("addr")
if addr:
try:
span.set_data(SPANDATA.SERVER_ADDRESS, addr[0])
span.set_data(SPANDATA.SERVER_PORT, addr[1])
except IndexError:
pass
span.set_data(SPANDATA.DB_NAME, database)
span.set_data(SPANDATA.DB_USER, user)
with capture_internal_exceptions():
sentry_sdk.add_breadcrumb(
message="connect", category="query", data=span._data
)
res = await f(*args, **kwargs)
return res
return _inner
def _set_db_data(span: Span, conn: Any) -> None:
span.set_data(SPANDATA.DB_SYSTEM, "postgresql")
addr = conn._addr
if addr:
try:
span.set_data(SPANDATA.SERVER_ADDRESS, addr[0])
span.set_data(SPANDATA.SERVER_PORT, addr[1])
except IndexError:
pass
database = conn._params.database
if database:
span.set_data(SPANDATA.DB_NAME, database)
user = conn._params.user
if user:
span.set_data(SPANDATA.DB_USER, user)
@@ -0,0 +1,57 @@
import os
import sys
import atexit
import sentry_sdk
from sentry_sdk.utils import logger
from sentry_sdk.integrations import Integration
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from typing import Any
from typing import Optional
def default_callback(pending, timeout):
# type: (int, int) -> None
"""This is the default shutdown callback that is set on the options.
It prints out a message to stderr that informs the user that some events
are still pending and the process is waiting for them to flush out.
"""
def echo(msg):
# type: (str) -> None
sys.stderr.write(msg + "\n")
echo("Sentry is attempting to send %i pending events" % pending)
echo("Waiting up to %s seconds" % timeout)
echo("Press Ctrl-%s to quit" % (os.name == "nt" and "Break" or "C"))
sys.stderr.flush()
class AtexitIntegration(Integration):
identifier = "atexit"
def __init__(self, callback=None):
# type: (Optional[Any]) -> None
if callback is None:
callback = default_callback
self.callback = callback
@staticmethod
def setup_once():
# type: () -> None
@atexit.register
def _shutdown():
# type: () -> None
client = sentry_sdk.get_client()
integration = client.get_integration(AtexitIntegration)
if integration is None:
return
logger.debug("atexit: got shutdown signal")
logger.debug("atexit: shutting down client")
sentry_sdk.get_isolation_scope().end_session()
client.close(callback=integration.callback)
@@ -0,0 +1,496 @@
import functools
import json
import re
import sys
from copy import deepcopy
from datetime import datetime, timedelta, timezone
from os import environ
import sentry_sdk
from sentry_sdk.api import continue_trace
from sentry_sdk.consts import OP
from sentry_sdk.scope import should_send_default_pii
from sentry_sdk.tracing import TRANSACTION_SOURCE_COMPONENT
from sentry_sdk.utils import (
AnnotatedValue,
capture_internal_exceptions,
ensure_integration_enabled,
event_from_exception,
logger,
TimeoutThread,
reraise,
)
from sentry_sdk.integrations import Integration
from sentry_sdk.integrations._wsgi_common import _filter_headers
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from typing import Any
from typing import TypeVar
from typing import Callable
from typing import Optional
from sentry_sdk._types import EventProcessor, Event, Hint
F = TypeVar("F", bound=Callable[..., Any])
# Constants
TIMEOUT_WARNING_BUFFER = 1500 # Buffer time required to send timeout warning to Sentry
MILLIS_TO_SECONDS = 1000.0
def _wrap_init_error(init_error):
# type: (F) -> F
@ensure_integration_enabled(AwsLambdaIntegration, init_error)
def sentry_init_error(*args, **kwargs):
# type: (*Any, **Any) -> Any
client = sentry_sdk.get_client()
with capture_internal_exceptions():
sentry_sdk.get_isolation_scope().clear_breadcrumbs()
exc_info = sys.exc_info()
if exc_info and all(exc_info):
sentry_event, hint = event_from_exception(
exc_info,
client_options=client.options,
mechanism={"type": "aws_lambda", "handled": False},
)
sentry_sdk.capture_event(sentry_event, hint=hint)
else:
# Fall back to AWS lambdas JSON representation of the error
sentry_event = _event_from_error_json(json.loads(args[1]))
sentry_sdk.capture_event(sentry_event)
return init_error(*args, **kwargs)
return sentry_init_error # type: ignore
def _wrap_handler(handler):
# type: (F) -> F
@functools.wraps(handler)
def sentry_handler(aws_event, aws_context, *args, **kwargs):
# type: (Any, Any, *Any, **Any) -> Any
# Per https://docs.aws.amazon.com/lambda/latest/dg/python-handler.html,
# `event` here is *likely* a dictionary, but also might be a number of
# other types (str, int, float, None).
#
# In some cases, it is a list (if the user is batch-invoking their
# function, for example), in which case we'll use the first entry as a
# representative from which to try pulling request data. (Presumably it
# will be the same for all events in the list, since they're all hitting
# the lambda in the same request.)
client = sentry_sdk.get_client()
integration = client.get_integration(AwsLambdaIntegration)
if integration is None:
return handler(aws_event, aws_context, *args, **kwargs)
if isinstance(aws_event, list) and len(aws_event) >= 1:
request_data = aws_event[0]
batch_size = len(aws_event)
else:
request_data = aws_event
batch_size = 1
if not isinstance(request_data, dict):
# If we're not dealing with a dictionary, we won't be able to get
# headers, path, http method, etc in any case, so it's fine that
# this is empty
request_data = {}
configured_time = aws_context.get_remaining_time_in_millis()
with sentry_sdk.isolation_scope() as scope:
timeout_thread = None
with capture_internal_exceptions():
scope.clear_breadcrumbs()
scope.add_event_processor(
_make_request_event_processor(
request_data, aws_context, configured_time
)
)
scope.set_tag(
"aws_region", aws_context.invoked_function_arn.split(":")[3]
)
if batch_size > 1:
scope.set_tag("batch_request", True)
scope.set_tag("batch_size", batch_size)
# Starting the Timeout thread only if the configured time is greater than Timeout warning
# buffer and timeout_warning parameter is set True.
if (
integration.timeout_warning
and configured_time > TIMEOUT_WARNING_BUFFER
):
waiting_time = (
configured_time - TIMEOUT_WARNING_BUFFER
) / MILLIS_TO_SECONDS
timeout_thread = TimeoutThread(
waiting_time,
configured_time / MILLIS_TO_SECONDS,
)
# Starting the thread to raise timeout warning exception
timeout_thread.start()
headers = request_data.get("headers", {})
# Some AWS Services (ie. EventBridge) set headers as a list
# or None, so we must ensure it is a dict
if not isinstance(headers, dict):
headers = {}
transaction = continue_trace(
headers,
op=OP.FUNCTION_AWS,
name=aws_context.function_name,
source=TRANSACTION_SOURCE_COMPONENT,
origin=AwsLambdaIntegration.origin,
)
with sentry_sdk.start_transaction(
transaction,
custom_sampling_context={
"aws_event": aws_event,
"aws_context": aws_context,
},
):
try:
return handler(aws_event, aws_context, *args, **kwargs)
except Exception:
exc_info = sys.exc_info()
sentry_event, hint = event_from_exception(
exc_info,
client_options=client.options,
mechanism={"type": "aws_lambda", "handled": False},
)
sentry_sdk.capture_event(sentry_event, hint=hint)
reraise(*exc_info)
finally:
if timeout_thread:
timeout_thread.stop()
return sentry_handler # type: ignore
def _drain_queue():
# type: () -> None
with capture_internal_exceptions():
client = sentry_sdk.get_client()
integration = client.get_integration(AwsLambdaIntegration)
if integration is not None:
# Flush out the event queue before AWS kills the
# process.
client.flush()
class AwsLambdaIntegration(Integration):
identifier = "aws_lambda"
origin = f"auto.function.{identifier}"
def __init__(self, timeout_warning=False):
# type: (bool) -> None
self.timeout_warning = timeout_warning
@staticmethod
def setup_once():
# type: () -> None
lambda_bootstrap = get_lambda_bootstrap()
if not lambda_bootstrap:
logger.warning(
"Not running in AWS Lambda environment, "
"AwsLambdaIntegration disabled (could not find bootstrap module)"
)
return
if not hasattr(lambda_bootstrap, "handle_event_request"):
logger.warning(
"Not running in AWS Lambda environment, "
"AwsLambdaIntegration disabled (could not find handle_event_request)"
)
return
pre_37 = hasattr(lambda_bootstrap, "handle_http_request") # Python 3.6
if pre_37:
old_handle_event_request = lambda_bootstrap.handle_event_request
def sentry_handle_event_request(request_handler, *args, **kwargs):
# type: (Any, *Any, **Any) -> Any
request_handler = _wrap_handler(request_handler)
return old_handle_event_request(request_handler, *args, **kwargs)
lambda_bootstrap.handle_event_request = sentry_handle_event_request
old_handle_http_request = lambda_bootstrap.handle_http_request
def sentry_handle_http_request(request_handler, *args, **kwargs):
# type: (Any, *Any, **Any) -> Any
request_handler = _wrap_handler(request_handler)
return old_handle_http_request(request_handler, *args, **kwargs)
lambda_bootstrap.handle_http_request = sentry_handle_http_request
# Patch to_json to drain the queue. This should work even when the
# SDK is initialized inside of the handler
old_to_json = lambda_bootstrap.to_json
def sentry_to_json(*args, **kwargs):
# type: (*Any, **Any) -> Any
_drain_queue()
return old_to_json(*args, **kwargs)
lambda_bootstrap.to_json = sentry_to_json
else:
lambda_bootstrap.LambdaRuntimeClient.post_init_error = _wrap_init_error(
lambda_bootstrap.LambdaRuntimeClient.post_init_error
)
old_handle_event_request = lambda_bootstrap.handle_event_request
def sentry_handle_event_request( # type: ignore
lambda_runtime_client, request_handler, *args, **kwargs
):
request_handler = _wrap_handler(request_handler)
return old_handle_event_request(
lambda_runtime_client, request_handler, *args, **kwargs
)
lambda_bootstrap.handle_event_request = sentry_handle_event_request
# Patch the runtime client to drain the queue. This should work
# even when the SDK is initialized inside of the handler
def _wrap_post_function(f):
# type: (F) -> F
def inner(*args, **kwargs):
# type: (*Any, **Any) -> Any
_drain_queue()
return f(*args, **kwargs)
return inner # type: ignore
lambda_bootstrap.LambdaRuntimeClient.post_invocation_result = (
_wrap_post_function(
lambda_bootstrap.LambdaRuntimeClient.post_invocation_result
)
)
lambda_bootstrap.LambdaRuntimeClient.post_invocation_error = (
_wrap_post_function(
lambda_bootstrap.LambdaRuntimeClient.post_invocation_error
)
)
def get_lambda_bootstrap():
# type: () -> Optional[Any]
# Python 3.7: If the bootstrap module is *already imported*, it is the
# one we actually want to use (no idea what's in __main__)
#
# Python 3.8: bootstrap is also importable, but will be the same file
# as __main__ imported under a different name:
#
# sys.modules['__main__'].__file__ == sys.modules['bootstrap'].__file__
# sys.modules['__main__'] is not sys.modules['bootstrap']
#
# Python 3.9: bootstrap is in __main__.awslambdaricmain
#
# On container builds using the `aws-lambda-python-runtime-interface-client`
# (awslamdaric) module, bootstrap is located in sys.modules['__main__'].bootstrap
#
# Such a setup would then make all monkeypatches useless.
if "bootstrap" in sys.modules:
return sys.modules["bootstrap"]
elif "__main__" in sys.modules:
module = sys.modules["__main__"]
# python3.9 runtime
if hasattr(module, "awslambdaricmain") and hasattr(
module.awslambdaricmain, "bootstrap"
):
return module.awslambdaricmain.bootstrap
elif hasattr(module, "bootstrap"):
# awslambdaric python module in container builds
return module.bootstrap
# python3.8 runtime
return module
else:
return None
def _make_request_event_processor(aws_event, aws_context, configured_timeout):
# type: (Any, Any, Any) -> EventProcessor
start_time = datetime.now(timezone.utc)
def event_processor(sentry_event, hint, start_time=start_time):
# type: (Event, Hint, datetime) -> Optional[Event]
remaining_time_in_milis = aws_context.get_remaining_time_in_millis()
exec_duration = configured_timeout - remaining_time_in_milis
extra = sentry_event.setdefault("extra", {})
extra["lambda"] = {
"function_name": aws_context.function_name,
"function_version": aws_context.function_version,
"invoked_function_arn": aws_context.invoked_function_arn,
"aws_request_id": aws_context.aws_request_id,
"execution_duration_in_millis": exec_duration,
"remaining_time_in_millis": remaining_time_in_milis,
}
extra["cloudwatch logs"] = {
"url": _get_cloudwatch_logs_url(aws_context, start_time),
"log_group": aws_context.log_group_name,
"log_stream": aws_context.log_stream_name,
}
request = sentry_event.get("request", {})
if "httpMethod" in aws_event:
request["method"] = aws_event["httpMethod"]
request["url"] = _get_url(aws_event, aws_context)
if "queryStringParameters" in aws_event:
request["query_string"] = aws_event["queryStringParameters"]
if "headers" in aws_event:
request["headers"] = _filter_headers(aws_event["headers"])
if should_send_default_pii():
user_info = sentry_event.setdefault("user", {})
identity = aws_event.get("identity")
if identity is None:
identity = {}
id = identity.get("userArn")
if id is not None:
user_info.setdefault("id", id)
ip = identity.get("sourceIp")
if ip is not None:
user_info.setdefault("ip_address", ip)
if "body" in aws_event:
request["data"] = aws_event.get("body", "")
else:
if aws_event.get("body", None):
# Unfortunately couldn't find a way to get structured body from AWS
# event. Meaning every body is unstructured to us.
request["data"] = AnnotatedValue.removed_because_raw_data()
sentry_event["request"] = deepcopy(request)
return sentry_event
return event_processor
def _get_url(aws_event, aws_context):
# type: (Any, Any) -> str
path = aws_event.get("path", None)
headers = aws_event.get("headers")
if headers is None:
headers = {}
host = headers.get("Host", None)
proto = headers.get("X-Forwarded-Proto", None)
if proto and host and path:
return "{}://{}{}".format(proto, host, path)
return "awslambda:///{}".format(aws_context.function_name)
def _get_cloudwatch_logs_url(aws_context, start_time):
# type: (Any, datetime) -> str
"""
Generates a CloudWatchLogs console URL based on the context object
Arguments:
aws_context {Any} -- context from lambda handler
Returns:
str -- AWS Console URL to logs.
"""
formatstring = "%Y-%m-%dT%H:%M:%SZ"
region = environ.get("AWS_REGION", "")
url = (
"https://console.{domain}/cloudwatch/home?region={region}"
"#logEventViewer:group={log_group};stream={log_stream}"
";start={start_time};end={end_time}"
).format(
domain="amazonaws.cn" if region.startswith("cn-") else "aws.amazon.com",
region=region,
log_group=aws_context.log_group_name,
log_stream=aws_context.log_stream_name,
start_time=(start_time - timedelta(seconds=1)).strftime(formatstring),
end_time=(datetime.now(timezone.utc) + timedelta(seconds=2)).strftime(
formatstring
),
)
return url
def _parse_formatted_traceback(formatted_tb):
# type: (list[str]) -> list[dict[str, Any]]
frames = []
for frame in formatted_tb:
match = re.match(r'File "(.+)", line (\d+), in (.+)', frame.strip())
if match:
file_name, line_number, func_name = match.groups()
line_number = int(line_number)
frames.append(
{
"filename": file_name,
"function": func_name,
"lineno": line_number,
"vars": None,
"pre_context": None,
"context_line": None,
"post_context": None,
}
)
return frames
def _event_from_error_json(error_json):
# type: (dict[str, Any]) -> Event
"""
Converts the error JSON from AWS Lambda into a Sentry error event.
This is not a full fletched event, but better than nothing.
This is an example of where AWS creates the error JSON:
https://github.com/aws/aws-lambda-python-runtime-interface-client/blob/2.2.1/awslambdaric/bootstrap.py#L479
"""
event = {
"level": "error",
"exception": {
"values": [
{
"type": error_json.get("errorType"),
"value": error_json.get("errorMessage"),
"stacktrace": {
"frames": _parse_formatted_traceback(
error_json.get("stackTrace", [])
),
},
"mechanism": {
"type": "aws_lambda",
"handled": False,
},
}
],
},
} # type: Event
return event
@@ -0,0 +1,176 @@
import sys
import types
from functools import wraps
import sentry_sdk
from sentry_sdk.integrations import Integration
from sentry_sdk.integrations.logging import ignore_logger
from sentry_sdk.utils import (
capture_internal_exceptions,
ensure_integration_enabled,
event_from_exception,
reraise,
)
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from typing import Any
from typing import Iterator
from typing import TypeVar
from typing import Callable
from sentry_sdk._types import ExcInfo
T = TypeVar("T")
F = TypeVar("F", bound=Callable[..., Any])
WRAPPED_FUNC = "_wrapped_{}_"
INSPECT_FUNC = "_inspect_{}" # Required format per apache_beam/transforms/core.py
USED_FUNC = "_sentry_used_"
class BeamIntegration(Integration):
identifier = "beam"
@staticmethod
def setup_once():
# type: () -> None
from apache_beam.transforms.core import DoFn, ParDo # type: ignore
ignore_logger("root")
ignore_logger("bundle_processor.create")
function_patches = ["process", "start_bundle", "finish_bundle", "setup"]
for func_name in function_patches:
setattr(
DoFn,
INSPECT_FUNC.format(func_name),
_wrap_inspect_call(DoFn, func_name),
)
old_init = ParDo.__init__
def sentry_init_pardo(self, fn, *args, **kwargs):
# type: (ParDo, Any, *Any, **Any) -> Any
# Do not monkey patch init twice
if not getattr(self, "_sentry_is_patched", False):
for func_name in function_patches:
if not hasattr(fn, func_name):
continue
wrapped_func = WRAPPED_FUNC.format(func_name)
# Check to see if inspect is set and process is not
# to avoid monkey patching process twice.
# Check to see if function is part of object for
# backwards compatibility.
process_func = getattr(fn, func_name)
inspect_func = getattr(fn, INSPECT_FUNC.format(func_name))
if not getattr(inspect_func, USED_FUNC, False) and not getattr(
process_func, USED_FUNC, False
):
setattr(fn, wrapped_func, process_func)
setattr(fn, func_name, _wrap_task_call(process_func))
self._sentry_is_patched = True
old_init(self, fn, *args, **kwargs)
ParDo.__init__ = sentry_init_pardo
def _wrap_inspect_call(cls, func_name):
# type: (Any, Any) -> Any
if not hasattr(cls, func_name):
return None
def _inspect(self):
# type: (Any) -> Any
"""
Inspect function overrides the way Beam gets argspec.
"""
wrapped_func = WRAPPED_FUNC.format(func_name)
if hasattr(self, wrapped_func):
process_func = getattr(self, wrapped_func)
else:
process_func = getattr(self, func_name)
setattr(self, func_name, _wrap_task_call(process_func))
setattr(self, wrapped_func, process_func)
# getfullargspec is deprecated in more recent beam versions and get_function_args_defaults
# (which uses Signatures internally) should be used instead.
try:
from apache_beam.transforms.core import get_function_args_defaults
return get_function_args_defaults(process_func)
except ImportError:
from apache_beam.typehints.decorators import getfullargspec # type: ignore
return getfullargspec(process_func)
setattr(_inspect, USED_FUNC, True)
return _inspect
def _wrap_task_call(func):
# type: (F) -> F
"""
Wrap task call with a try catch to get exceptions.
"""
@wraps(func)
def _inner(*args, **kwargs):
# type: (*Any, **Any) -> Any
try:
gen = func(*args, **kwargs)
except Exception:
raise_exception()
if not isinstance(gen, types.GeneratorType):
return gen
return _wrap_generator_call(gen)
setattr(_inner, USED_FUNC, True)
return _inner # type: ignore
@ensure_integration_enabled(BeamIntegration)
def _capture_exception(exc_info):
# type: (ExcInfo) -> None
"""
Send Beam exception to Sentry.
"""
client = sentry_sdk.get_client()
event, hint = event_from_exception(
exc_info,
client_options=client.options,
mechanism={"type": "beam", "handled": False},
)
sentry_sdk.capture_event(event, hint=hint)
def raise_exception():
# type: () -> None
"""
Raise an exception.
"""
exc_info = sys.exc_info()
with capture_internal_exceptions():
_capture_exception(exc_info)
reraise(*exc_info)
def _wrap_generator_call(gen):
# type: (Iterator[T]) -> Iterator[T]
"""
Wrap the generator to handle any failures.
"""
while True:
try:
yield next(gen)
except StopIteration:
break
except Exception:
raise_exception()
@@ -0,0 +1,137 @@
from functools import partial
import sentry_sdk
from sentry_sdk.consts import OP, SPANDATA
from sentry_sdk.integrations import _check_minimum_version, Integration, DidNotEnable
from sentry_sdk.tracing import Span
from sentry_sdk.utils import (
capture_internal_exceptions,
ensure_integration_enabled,
parse_url,
parse_version,
)
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from typing import Any
from typing import Dict
from typing import Optional
from typing import Type
try:
from botocore import __version__ as BOTOCORE_VERSION # type: ignore
from botocore.client import BaseClient # type: ignore
from botocore.response import StreamingBody # type: ignore
from botocore.awsrequest import AWSRequest # type: ignore
except ImportError:
raise DidNotEnable("botocore is not installed")
class Boto3Integration(Integration):
identifier = "boto3"
origin = f"auto.http.{identifier}"
@staticmethod
def setup_once():
# type: () -> None
version = parse_version(BOTOCORE_VERSION)
_check_minimum_version(Boto3Integration, version, "botocore")
orig_init = BaseClient.__init__
def sentry_patched_init(self, *args, **kwargs):
# type: (Type[BaseClient], *Any, **Any) -> None
orig_init(self, *args, **kwargs)
meta = self.meta
service_id = meta.service_model.service_id.hyphenize()
meta.events.register(
"request-created",
partial(_sentry_request_created, service_id=service_id),
)
meta.events.register("after-call", _sentry_after_call)
meta.events.register("after-call-error", _sentry_after_call_error)
BaseClient.__init__ = sentry_patched_init
@ensure_integration_enabled(Boto3Integration)
def _sentry_request_created(service_id, request, operation_name, **kwargs):
# type: (str, AWSRequest, str, **Any) -> None
description = "aws.%s.%s" % (service_id, operation_name)
span = sentry_sdk.start_span(
op=OP.HTTP_CLIENT,
name=description,
origin=Boto3Integration.origin,
)
with capture_internal_exceptions():
parsed_url = parse_url(request.url, sanitize=False)
span.set_data("aws.request.url", parsed_url.url)
span.set_data(SPANDATA.HTTP_QUERY, parsed_url.query)
span.set_data(SPANDATA.HTTP_FRAGMENT, parsed_url.fragment)
span.set_tag("aws.service_id", service_id)
span.set_tag("aws.operation_name", operation_name)
span.set_data(SPANDATA.HTTP_METHOD, request.method)
# We do it in order for subsequent http calls/retries be
# attached to this span.
span.__enter__()
# request.context is an open-ended data-structure
# where we can add anything useful in request life cycle.
request.context["_sentrysdk_span"] = span
def _sentry_after_call(context, parsed, **kwargs):
# type: (Dict[str, Any], Dict[str, Any], **Any) -> None
span = context.pop("_sentrysdk_span", None) # type: Optional[Span]
# Span could be absent if the integration is disabled.
if span is None:
return
span.__exit__(None, None, None)
body = parsed.get("Body")
if not isinstance(body, StreamingBody):
return
streaming_span = span.start_child(
op=OP.HTTP_CLIENT_STREAM,
name=span.description,
origin=Boto3Integration.origin,
)
orig_read = body.read
orig_close = body.close
def sentry_streaming_body_read(*args, **kwargs):
# type: (*Any, **Any) -> bytes
try:
ret = orig_read(*args, **kwargs)
if not ret:
streaming_span.finish()
return ret
except Exception:
streaming_span.finish()
raise
body.read = sentry_streaming_body_read
def sentry_streaming_body_close(*args, **kwargs):
# type: (*Any, **Any) -> None
streaming_span.finish()
orig_close(*args, **kwargs)
body.close = sentry_streaming_body_close
def _sentry_after_call_error(context, exception, **kwargs):
# type: (Dict[str, Any], Type[BaseException], **Any) -> None
span = context.pop("_sentrysdk_span", None) # type: Optional[Span]
# Span could be absent if the integration is disabled.
if span is None:
return
span.__exit__(type(exception), exception, None)
@@ -0,0 +1,215 @@
import functools
import sentry_sdk
from sentry_sdk.tracing import SOURCE_FOR_STYLE
from sentry_sdk.utils import (
capture_internal_exceptions,
ensure_integration_enabled,
event_from_exception,
parse_version,
transaction_from_function,
)
from sentry_sdk.integrations import (
Integration,
DidNotEnable,
_DEFAULT_FAILED_REQUEST_STATUS_CODES,
_check_minimum_version,
)
from sentry_sdk.integrations.wsgi import SentryWsgiMiddleware
from sentry_sdk.integrations._wsgi_common import RequestExtractor
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from collections.abc import Set
from sentry_sdk.integrations.wsgi import _ScopedResponse
from typing import Any
from typing import Dict
from typing import Callable
from typing import Optional
from bottle import FileUpload, FormsDict, LocalRequest # type: ignore
from sentry_sdk._types import EventProcessor, Event
try:
from bottle import (
Bottle,
HTTPResponse,
Route,
request as bottle_request,
__version__ as BOTTLE_VERSION,
)
except ImportError:
raise DidNotEnable("Bottle not installed")
TRANSACTION_STYLE_VALUES = ("endpoint", "url")
class BottleIntegration(Integration):
identifier = "bottle"
origin = f"auto.http.{identifier}"
transaction_style = ""
def __init__(
self,
transaction_style="endpoint", # type: str
*,
failed_request_status_codes=_DEFAULT_FAILED_REQUEST_STATUS_CODES, # type: Set[int]
):
# type: (...) -> None
if transaction_style not in TRANSACTION_STYLE_VALUES:
raise ValueError(
"Invalid value for transaction_style: %s (must be in %s)"
% (transaction_style, TRANSACTION_STYLE_VALUES)
)
self.transaction_style = transaction_style
self.failed_request_status_codes = failed_request_status_codes
@staticmethod
def setup_once():
# type: () -> None
version = parse_version(BOTTLE_VERSION)
_check_minimum_version(BottleIntegration, version)
old_app = Bottle.__call__
@ensure_integration_enabled(BottleIntegration, old_app)
def sentry_patched_wsgi_app(self, environ, start_response):
# type: (Any, Dict[str, str], Callable[..., Any]) -> _ScopedResponse
middleware = SentryWsgiMiddleware(
lambda *a, **kw: old_app(self, *a, **kw),
span_origin=BottleIntegration.origin,
)
return middleware(environ, start_response)
Bottle.__call__ = sentry_patched_wsgi_app
old_handle = Bottle._handle
@functools.wraps(old_handle)
def _patched_handle(self, environ):
# type: (Bottle, Dict[str, Any]) -> Any
integration = sentry_sdk.get_client().get_integration(BottleIntegration)
if integration is None:
return old_handle(self, environ)
scope = sentry_sdk.get_isolation_scope()
scope._name = "bottle"
scope.add_event_processor(
_make_request_event_processor(self, bottle_request, integration)
)
res = old_handle(self, environ)
return res
Bottle._handle = _patched_handle
old_make_callback = Route._make_callback
@functools.wraps(old_make_callback)
def patched_make_callback(self, *args, **kwargs):
# type: (Route, *object, **object) -> Any
prepared_callback = old_make_callback(self, *args, **kwargs)
integration = sentry_sdk.get_client().get_integration(BottleIntegration)
if integration is None:
return prepared_callback
def wrapped_callback(*args, **kwargs):
# type: (*object, **object) -> Any
try:
res = prepared_callback(*args, **kwargs)
except Exception as exception:
_capture_exception(exception, handled=False)
raise exception
if (
isinstance(res, HTTPResponse)
and res.status_code in integration.failed_request_status_codes
):
_capture_exception(res, handled=True)
return res
return wrapped_callback
Route._make_callback = patched_make_callback
class BottleRequestExtractor(RequestExtractor):
def env(self):
# type: () -> Dict[str, str]
return self.request.environ
def cookies(self):
# type: () -> Dict[str, str]
return self.request.cookies
def raw_data(self):
# type: () -> bytes
return self.request.body.read()
def form(self):
# type: () -> FormsDict
if self.is_json():
return None
return self.request.forms.decode()
def files(self):
# type: () -> Optional[Dict[str, str]]
if self.is_json():
return None
return self.request.files
def size_of_file(self, file):
# type: (FileUpload) -> int
return file.content_length
def _set_transaction_name_and_source(event, transaction_style, request):
# type: (Event, str, Any) -> None
name = ""
if transaction_style == "url":
name = request.route.rule or ""
elif transaction_style == "endpoint":
name = (
request.route.name
or transaction_from_function(request.route.callback)
or ""
)
event["transaction"] = name
event["transaction_info"] = {"source": SOURCE_FOR_STYLE[transaction_style]}
def _make_request_event_processor(app, request, integration):
# type: (Bottle, LocalRequest, BottleIntegration) -> EventProcessor
def event_processor(event, hint):
# type: (Event, dict[str, Any]) -> Event
_set_transaction_name_and_source(event, integration.transaction_style, request)
with capture_internal_exceptions():
BottleRequestExtractor(request).extract_into_event(event)
return event
return event_processor
def _capture_exception(exception, handled):
# type: (BaseException, bool) -> None
event, hint = event_from_exception(
exception,
client_options=sentry_sdk.get_client().options,
mechanism={"type": "bottle", "handled": handled},
)
sentry_sdk.capture_event(event, hint=hint)
@@ -0,0 +1,528 @@
import sys
from collections.abc import Mapping
from functools import wraps
import sentry_sdk
from sentry_sdk import isolation_scope
from sentry_sdk.api import continue_trace
from sentry_sdk.consts import OP, SPANSTATUS, SPANDATA
from sentry_sdk.integrations import _check_minimum_version, Integration, DidNotEnable
from sentry_sdk.integrations.celery.beat import (
_patch_beat_apply_entry,
_patch_redbeat_maybe_due,
_setup_celery_beat_signals,
)
from sentry_sdk.integrations.celery.utils import _now_seconds_since_epoch
from sentry_sdk.integrations.logging import ignore_logger
from sentry_sdk.tracing import BAGGAGE_HEADER_NAME, TRANSACTION_SOURCE_TASK
from sentry_sdk.tracing_utils import Baggage
from sentry_sdk.utils import (
capture_internal_exceptions,
ensure_integration_enabled,
event_from_exception,
reraise,
)
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from typing import Any
from typing import Callable
from typing import List
from typing import Optional
from typing import TypeVar
from typing import Union
from sentry_sdk._types import EventProcessor, Event, Hint, ExcInfo
from sentry_sdk.tracing import Span
F = TypeVar("F", bound=Callable[..., Any])
try:
from celery import VERSION as CELERY_VERSION # type: ignore
from celery.app.task import Task # type: ignore
from celery.app.trace import task_has_custom
from celery.exceptions import ( # type: ignore
Ignore,
Reject,
Retry,
SoftTimeLimitExceeded,
)
from kombu import Producer # type: ignore
except ImportError:
raise DidNotEnable("Celery not installed")
CELERY_CONTROL_FLOW_EXCEPTIONS = (Retry, Ignore, Reject)
class CeleryIntegration(Integration):
identifier = "celery"
origin = f"auto.queue.{identifier}"
def __init__(
self,
propagate_traces=True,
monitor_beat_tasks=False,
exclude_beat_tasks=None,
):
# type: (bool, bool, Optional[List[str]]) -> None
self.propagate_traces = propagate_traces
self.monitor_beat_tasks = monitor_beat_tasks
self.exclude_beat_tasks = exclude_beat_tasks
_patch_beat_apply_entry()
_patch_redbeat_maybe_due()
_setup_celery_beat_signals(monitor_beat_tasks)
@staticmethod
def setup_once():
# type: () -> None
_check_minimum_version(CeleryIntegration, CELERY_VERSION)
_patch_build_tracer()
_patch_task_apply_async()
_patch_celery_send_task()
_patch_worker_exit()
_patch_producer_publish()
# This logger logs every status of every task that ran on the worker.
# Meaning that every task's breadcrumbs are full of stuff like "Task
# <foo> raised unexpected <bar>".
ignore_logger("celery.worker.job")
ignore_logger("celery.app.trace")
# This is stdout/err redirected to a logger, can't deal with this
# (need event_level=logging.WARN to reproduce)
ignore_logger("celery.redirected")
def _set_status(status):
# type: (str) -> None
with capture_internal_exceptions():
scope = sentry_sdk.get_current_scope()
if scope.span is not None:
scope.span.set_status(status)
def _capture_exception(task, exc_info):
# type: (Any, ExcInfo) -> None
client = sentry_sdk.get_client()
if client.get_integration(CeleryIntegration) is None:
return
if isinstance(exc_info[1], CELERY_CONTROL_FLOW_EXCEPTIONS):
# ??? Doesn't map to anything
_set_status("aborted")
return
_set_status("internal_error")
if hasattr(task, "throws") and isinstance(exc_info[1], task.throws):
return
event, hint = event_from_exception(
exc_info,
client_options=client.options,
mechanism={"type": "celery", "handled": False},
)
sentry_sdk.capture_event(event, hint=hint)
def _make_event_processor(task, uuid, args, kwargs, request=None):
# type: (Any, Any, Any, Any, Optional[Any]) -> EventProcessor
def event_processor(event, hint):
# type: (Event, Hint) -> Optional[Event]
with capture_internal_exceptions():
tags = event.setdefault("tags", {})
tags["celery_task_id"] = uuid
extra = event.setdefault("extra", {})
extra["celery-job"] = {
"task_name": task.name,
"args": args,
"kwargs": kwargs,
}
if "exc_info" in hint:
with capture_internal_exceptions():
if issubclass(hint["exc_info"][0], SoftTimeLimitExceeded):
event["fingerprint"] = [
"celery",
"SoftTimeLimitExceeded",
getattr(task, "name", task),
]
return event
return event_processor
def _update_celery_task_headers(original_headers, span, monitor_beat_tasks):
# type: (dict[str, Any], Optional[Span], bool) -> dict[str, Any]
"""
Updates the headers of the Celery task with the tracing information
and eventually Sentry Crons monitoring information for beat tasks.
"""
updated_headers = original_headers.copy()
with capture_internal_exceptions():
# if span is None (when the task was started by Celery Beat)
# this will return the trace headers from the scope.
headers = dict(
sentry_sdk.get_isolation_scope().iter_trace_propagation_headers(span=span)
)
if monitor_beat_tasks:
headers.update(
{
"sentry-monitor-start-timestamp-s": "%.9f"
% _now_seconds_since_epoch(),
}
)
# Add the time the task was enqueued to the headers
# This is used in the consumer to calculate the latency
updated_headers.update(
{"sentry-task-enqueued-time": _now_seconds_since_epoch()}
)
if headers:
existing_baggage = updated_headers.get(BAGGAGE_HEADER_NAME)
sentry_baggage = headers.get(BAGGAGE_HEADER_NAME)
combined_baggage = sentry_baggage or existing_baggage
if sentry_baggage and existing_baggage:
# Merge incoming and sentry baggage, where the sentry trace information
# in the incoming baggage takes precedence and the third-party items
# are concatenated.
incoming = Baggage.from_incoming_header(existing_baggage)
combined = Baggage.from_incoming_header(sentry_baggage)
combined.sentry_items.update(incoming.sentry_items)
combined.third_party_items = ",".join(
[
x
for x in [
combined.third_party_items,
incoming.third_party_items,
]
if x is not None and x != ""
]
)
combined_baggage = combined.serialize(include_third_party=True)
updated_headers.update(headers)
if combined_baggage:
updated_headers[BAGGAGE_HEADER_NAME] = combined_baggage
# https://github.com/celery/celery/issues/4875
#
# Need to setdefault the inner headers too since other
# tracing tools (dd-trace-py) also employ this exact
# workaround and we don't want to break them.
updated_headers.setdefault("headers", {}).update(headers)
if combined_baggage:
updated_headers["headers"][BAGGAGE_HEADER_NAME] = combined_baggage
# Add the Sentry options potentially added in `sentry_apply_entry`
# to the headers (done when auto-instrumenting Celery Beat tasks)
for key, value in updated_headers.items():
if key.startswith("sentry-"):
updated_headers["headers"][key] = value
return updated_headers
class NoOpMgr:
def __enter__(self):
# type: () -> None
return None
def __exit__(self, exc_type, exc_value, traceback):
# type: (Any, Any, Any) -> None
return None
def _wrap_task_run(f):
# type: (F) -> F
@wraps(f)
def apply_async(*args, **kwargs):
# type: (*Any, **Any) -> Any
# Note: kwargs can contain headers=None, so no setdefault!
# Unsure which backend though.
integration = sentry_sdk.get_client().get_integration(CeleryIntegration)
if integration is None:
return f(*args, **kwargs)
kwarg_headers = kwargs.get("headers") or {}
propagate_traces = kwarg_headers.pop(
"sentry-propagate-traces", integration.propagate_traces
)
if not propagate_traces:
return f(*args, **kwargs)
if isinstance(args[0], Task):
task_name = args[0].name # type: str
elif len(args) > 1 and isinstance(args[1], str):
task_name = args[1]
else:
task_name = "<unknown Celery task>"
task_started_from_beat = sentry_sdk.get_isolation_scope()._name == "celery-beat"
span_mgr = (
sentry_sdk.start_span(
op=OP.QUEUE_SUBMIT_CELERY,
name=task_name,
origin=CeleryIntegration.origin,
)
if not task_started_from_beat
else NoOpMgr()
) # type: Union[Span, NoOpMgr]
with span_mgr as span:
kwargs["headers"] = _update_celery_task_headers(
kwarg_headers, span, integration.monitor_beat_tasks
)
return f(*args, **kwargs)
return apply_async # type: ignore
def _wrap_tracer(task, f):
# type: (Any, F) -> F
# Need to wrap tracer for pushing the scope before prerun is sent, and
# popping it after postrun is sent.
#
# This is the reason we don't use signals for hooking in the first place.
# Also because in Celery 3, signal dispatch returns early if one handler
# crashes.
@wraps(f)
@ensure_integration_enabled(CeleryIntegration, f)
def _inner(*args, **kwargs):
# type: (*Any, **Any) -> Any
with isolation_scope() as scope:
scope._name = "celery"
scope.clear_breadcrumbs()
scope.add_event_processor(_make_event_processor(task, *args, **kwargs))
transaction = None
# Celery task objects are not a thing to be trusted. Even
# something such as attribute access can fail.
with capture_internal_exceptions():
headers = args[3].get("headers") or {}
transaction = continue_trace(
headers,
op=OP.QUEUE_TASK_CELERY,
name="unknown celery task",
source=TRANSACTION_SOURCE_TASK,
origin=CeleryIntegration.origin,
)
transaction.name = task.name
transaction.set_status(SPANSTATUS.OK)
if transaction is None:
return f(*args, **kwargs)
with sentry_sdk.start_transaction(
transaction,
custom_sampling_context={
"celery_job": {
"task": task.name,
# for some reason, args[1] is a list if non-empty but a
# tuple if empty
"args": list(args[1]),
"kwargs": args[2],
}
},
):
return f(*args, **kwargs)
return _inner # type: ignore
def _set_messaging_destination_name(task, span):
# type: (Any, Span) -> None
"""Set "messaging.destination.name" tag for span"""
with capture_internal_exceptions():
delivery_info = task.request.delivery_info
if delivery_info:
routing_key = delivery_info.get("routing_key")
if delivery_info.get("exchange") == "" and routing_key is not None:
# Empty exchange indicates the default exchange, meaning the tasks
# are sent to the queue with the same name as the routing key.
span.set_data(SPANDATA.MESSAGING_DESTINATION_NAME, routing_key)
def _wrap_task_call(task, f):
# type: (Any, F) -> F
# Need to wrap task call because the exception is caught before we get to
# see it. Also celery's reported stacktrace is untrustworthy.
# functools.wraps is important here because celery-once looks at this
# method's name. @ensure_integration_enabled internally calls functools.wraps,
# but if we ever remove the @ensure_integration_enabled decorator, we need
# to add @functools.wraps(f) here.
# https://github.com/getsentry/sentry-python/issues/421
@ensure_integration_enabled(CeleryIntegration, f)
def _inner(*args, **kwargs):
# type: (*Any, **Any) -> Any
try:
with sentry_sdk.start_span(
op=OP.QUEUE_PROCESS,
name=task.name,
origin=CeleryIntegration.origin,
) as span:
_set_messaging_destination_name(task, span)
latency = None
with capture_internal_exceptions():
if (
task.request.headers is not None
and "sentry-task-enqueued-time" in task.request.headers
):
latency = _now_seconds_since_epoch() - task.request.headers.pop(
"sentry-task-enqueued-time"
)
if latency is not None:
span.set_data(SPANDATA.MESSAGING_MESSAGE_RECEIVE_LATENCY, latency)
with capture_internal_exceptions():
span.set_data(SPANDATA.MESSAGING_MESSAGE_ID, task.request.id)
with capture_internal_exceptions():
span.set_data(
SPANDATA.MESSAGING_MESSAGE_RETRY_COUNT, task.request.retries
)
with capture_internal_exceptions():
span.set_data(
SPANDATA.MESSAGING_SYSTEM,
task.app.connection().transport.driver_type,
)
return f(*args, **kwargs)
except Exception:
exc_info = sys.exc_info()
with capture_internal_exceptions():
_capture_exception(task, exc_info)
reraise(*exc_info)
return _inner # type: ignore
def _patch_build_tracer():
# type: () -> None
import celery.app.trace as trace # type: ignore
original_build_tracer = trace.build_tracer
def sentry_build_tracer(name, task, *args, **kwargs):
# type: (Any, Any, *Any, **Any) -> Any
if not getattr(task, "_sentry_is_patched", False):
# determine whether Celery will use __call__ or run and patch
# accordingly
if task_has_custom(task, "__call__"):
type(task).__call__ = _wrap_task_call(task, type(task).__call__)
else:
task.run = _wrap_task_call(task, task.run)
# `build_tracer` is apparently called for every task
# invocation. Can't wrap every celery task for every invocation
# or we will get infinitely nested wrapper functions.
task._sentry_is_patched = True
return _wrap_tracer(task, original_build_tracer(name, task, *args, **kwargs))
trace.build_tracer = sentry_build_tracer
def _patch_task_apply_async():
# type: () -> None
Task.apply_async = _wrap_task_run(Task.apply_async)
def _patch_celery_send_task():
# type: () -> None
from celery import Celery
Celery.send_task = _wrap_task_run(Celery.send_task)
def _patch_worker_exit():
# type: () -> None
# Need to flush queue before worker shutdown because a crashing worker will
# call os._exit
from billiard.pool import Worker # type: ignore
original_workloop = Worker.workloop
def sentry_workloop(*args, **kwargs):
# type: (*Any, **Any) -> Any
try:
return original_workloop(*args, **kwargs)
finally:
with capture_internal_exceptions():
if (
sentry_sdk.get_client().get_integration(CeleryIntegration)
is not None
):
sentry_sdk.flush()
Worker.workloop = sentry_workloop
def _patch_producer_publish():
# type: () -> None
original_publish = Producer.publish
@ensure_integration_enabled(CeleryIntegration, original_publish)
def sentry_publish(self, *args, **kwargs):
# type: (Producer, *Any, **Any) -> Any
kwargs_headers = kwargs.get("headers", {})
if not isinstance(kwargs_headers, Mapping):
# Ensure kwargs_headers is a Mapping, so we can safely call get().
# We don't expect this to happen, but it's better to be safe. Even
# if it does happen, only our instrumentation breaks. This line
# does not overwrite kwargs["headers"], so the original publish
# method will still work.
kwargs_headers = {}
task_name = kwargs_headers.get("task")
task_id = kwargs_headers.get("id")
retries = kwargs_headers.get("retries")
routing_key = kwargs.get("routing_key")
exchange = kwargs.get("exchange")
with sentry_sdk.start_span(
op=OP.QUEUE_PUBLISH,
name=task_name,
origin=CeleryIntegration.origin,
) as span:
if task_id is not None:
span.set_data(SPANDATA.MESSAGING_MESSAGE_ID, task_id)
if exchange == "" and routing_key is not None:
# Empty exchange indicates the default exchange, meaning messages are
# routed to the queue with the same name as the routing key.
span.set_data(SPANDATA.MESSAGING_DESTINATION_NAME, routing_key)
if retries is not None:
span.set_data(SPANDATA.MESSAGING_MESSAGE_RETRY_COUNT, retries)
with capture_internal_exceptions():
span.set_data(
SPANDATA.MESSAGING_SYSTEM, self.connection.transport.driver_type
)
return original_publish(self, *args, **kwargs)
Producer.publish = sentry_publish
@@ -0,0 +1,293 @@
import sentry_sdk
from sentry_sdk.crons import capture_checkin, MonitorStatus
from sentry_sdk.integrations import DidNotEnable
from sentry_sdk.integrations.celery.utils import (
_get_humanized_interval,
_now_seconds_since_epoch,
)
from sentry_sdk.utils import (
logger,
match_regex_list,
)
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from collections.abc import Callable
from typing import Any, Optional, TypeVar, Union
from sentry_sdk._types import (
MonitorConfig,
MonitorConfigScheduleType,
MonitorConfigScheduleUnit,
)
F = TypeVar("F", bound=Callable[..., Any])
try:
from celery import Task, Celery # type: ignore
from celery.beat import Scheduler # type: ignore
from celery.schedules import crontab, schedule # type: ignore
from celery.signals import ( # type: ignore
task_failure,
task_success,
task_retry,
)
except ImportError:
raise DidNotEnable("Celery not installed")
try:
from redbeat.schedulers import RedBeatScheduler # type: ignore
except ImportError:
RedBeatScheduler = None
def _get_headers(task):
# type: (Task) -> dict[str, Any]
headers = task.request.get("headers") or {}
# flatten nested headers
if "headers" in headers:
headers.update(headers["headers"])
del headers["headers"]
headers.update(task.request.get("properties") or {})
return headers
def _get_monitor_config(celery_schedule, app, monitor_name):
# type: (Any, Celery, str) -> MonitorConfig
monitor_config = {} # type: MonitorConfig
schedule_type = None # type: Optional[MonitorConfigScheduleType]
schedule_value = None # type: Optional[Union[str, int]]
schedule_unit = None # type: Optional[MonitorConfigScheduleUnit]
if isinstance(celery_schedule, crontab):
schedule_type = "crontab"
schedule_value = (
"{0._orig_minute} "
"{0._orig_hour} "
"{0._orig_day_of_month} "
"{0._orig_month_of_year} "
"{0._orig_day_of_week}".format(celery_schedule)
)
elif isinstance(celery_schedule, schedule):
schedule_type = "interval"
(schedule_value, schedule_unit) = _get_humanized_interval(
celery_schedule.seconds
)
if schedule_unit == "second":
logger.warning(
"Intervals shorter than one minute are not supported by Sentry Crons. Monitor '%s' has an interval of %s seconds. Use the `exclude_beat_tasks` option in the celery integration to exclude it.",
monitor_name,
schedule_value,
)
return {}
else:
logger.warning(
"Celery schedule type '%s' not supported by Sentry Crons.",
type(celery_schedule),
)
return {}
monitor_config["schedule"] = {}
monitor_config["schedule"]["type"] = schedule_type
monitor_config["schedule"]["value"] = schedule_value
if schedule_unit is not None:
monitor_config["schedule"]["unit"] = schedule_unit
monitor_config["timezone"] = (
(
hasattr(celery_schedule, "tz")
and celery_schedule.tz is not None
and str(celery_schedule.tz)
)
or app.timezone
or "UTC"
)
return monitor_config
def _apply_crons_data_to_schedule_entry(scheduler, schedule_entry, integration):
# type: (Any, Any, sentry_sdk.integrations.celery.CeleryIntegration) -> None
"""
Add Sentry Crons information to the schedule_entry headers.
"""
if not integration.monitor_beat_tasks:
return
monitor_name = schedule_entry.name
task_should_be_excluded = match_regex_list(
monitor_name, integration.exclude_beat_tasks
)
if task_should_be_excluded:
return
celery_schedule = schedule_entry.schedule
app = scheduler.app
monitor_config = _get_monitor_config(celery_schedule, app, monitor_name)
is_supported_schedule = bool(monitor_config)
if not is_supported_schedule:
return
headers = schedule_entry.options.pop("headers", {})
headers.update(
{
"sentry-monitor-slug": monitor_name,
"sentry-monitor-config": monitor_config,
}
)
check_in_id = capture_checkin(
monitor_slug=monitor_name,
monitor_config=monitor_config,
status=MonitorStatus.IN_PROGRESS,
)
headers.update({"sentry-monitor-check-in-id": check_in_id})
# Set the Sentry configuration in the options of the ScheduleEntry.
# Those will be picked up in `apply_async` and added to the headers.
schedule_entry.options["headers"] = headers
def _wrap_beat_scheduler(original_function):
# type: (Callable[..., Any]) -> Callable[..., Any]
"""
Makes sure that:
- a new Sentry trace is started for each task started by Celery Beat and
it is propagated to the task.
- the Sentry Crons information is set in the Celery Beat task's
headers so that is is monitored with Sentry Crons.
After the patched function is called,
Celery Beat will call apply_async to put the task in the queue.
"""
# Patch only once
# Can't use __name__ here, because some of our tests mock original_apply_entry
already_patched = "sentry_patched_scheduler" in str(original_function)
if already_patched:
return original_function
from sentry_sdk.integrations.celery import CeleryIntegration
def sentry_patched_scheduler(*args, **kwargs):
# type: (*Any, **Any) -> None
integration = sentry_sdk.get_client().get_integration(CeleryIntegration)
if integration is None:
return original_function(*args, **kwargs)
# Tasks started by Celery Beat start a new Trace
scope = sentry_sdk.get_isolation_scope()
scope.set_new_propagation_context()
scope._name = "celery-beat"
scheduler, schedule_entry = args
_apply_crons_data_to_schedule_entry(scheduler, schedule_entry, integration)
return original_function(*args, **kwargs)
return sentry_patched_scheduler
def _patch_beat_apply_entry():
# type: () -> None
Scheduler.apply_entry = _wrap_beat_scheduler(Scheduler.apply_entry)
def _patch_redbeat_maybe_due():
# type: () -> None
if RedBeatScheduler is None:
return
RedBeatScheduler.maybe_due = _wrap_beat_scheduler(RedBeatScheduler.maybe_due)
def _setup_celery_beat_signals(monitor_beat_tasks):
# type: (bool) -> None
if monitor_beat_tasks:
task_success.connect(crons_task_success)
task_failure.connect(crons_task_failure)
task_retry.connect(crons_task_retry)
def crons_task_success(sender, **kwargs):
# type: (Task, dict[Any, Any]) -> None
logger.debug("celery_task_success %s", sender)
headers = _get_headers(sender)
if "sentry-monitor-slug" not in headers:
return
monitor_config = headers.get("sentry-monitor-config", {})
start_timestamp_s = headers.get("sentry-monitor-start-timestamp-s")
capture_checkin(
monitor_slug=headers["sentry-monitor-slug"],
monitor_config=monitor_config,
check_in_id=headers["sentry-monitor-check-in-id"],
duration=(
_now_seconds_since_epoch() - float(start_timestamp_s)
if start_timestamp_s
else None
),
status=MonitorStatus.OK,
)
def crons_task_failure(sender, **kwargs):
# type: (Task, dict[Any, Any]) -> None
logger.debug("celery_task_failure %s", sender)
headers = _get_headers(sender)
if "sentry-monitor-slug" not in headers:
return
monitor_config = headers.get("sentry-monitor-config", {})
start_timestamp_s = headers.get("sentry-monitor-start-timestamp-s")
capture_checkin(
monitor_slug=headers["sentry-monitor-slug"],
monitor_config=monitor_config,
check_in_id=headers["sentry-monitor-check-in-id"],
duration=(
_now_seconds_since_epoch() - float(start_timestamp_s)
if start_timestamp_s
else None
),
status=MonitorStatus.ERROR,
)
def crons_task_retry(sender, **kwargs):
# type: (Task, dict[Any, Any]) -> None
logger.debug("celery_task_retry %s", sender)
headers = _get_headers(sender)
if "sentry-monitor-slug" not in headers:
return
monitor_config = headers.get("sentry-monitor-config", {})
start_timestamp_s = headers.get("sentry-monitor-start-timestamp-s")
capture_checkin(
monitor_slug=headers["sentry-monitor-slug"],
monitor_config=monitor_config,
check_in_id=headers["sentry-monitor-check-in-id"],
duration=(
_now_seconds_since_epoch() - float(start_timestamp_s)
if start_timestamp_s
else None
),
status=MonitorStatus.ERROR,
)
@@ -0,0 +1,43 @@
import time
from typing import TYPE_CHECKING, cast
if TYPE_CHECKING:
from typing import Any, Tuple
from sentry_sdk._types import MonitorConfigScheduleUnit
def _now_seconds_since_epoch():
# type: () -> float
# We cannot use `time.perf_counter()` when dealing with the duration
# of a Celery task, because the start of a Celery task and
# the end are recorded in different processes.
# Start happens in the Celery Beat process,
# the end in a Celery Worker process.
return time.time()
def _get_humanized_interval(seconds):
# type: (float) -> Tuple[int, MonitorConfigScheduleUnit]
TIME_UNITS = ( # noqa: N806
("day", 60 * 60 * 24.0),
("hour", 60 * 60.0),
("minute", 60.0),
)
seconds = float(seconds)
for unit, divider in TIME_UNITS:
if seconds >= divider:
interval = int(seconds / divider)
return (interval, cast("MonitorConfigScheduleUnit", unit))
return (int(seconds), "second")
class NoOpMgr:
def __enter__(self):
# type: () -> None
return None
def __exit__(self, exc_type, exc_value, traceback):
# type: (Any, Any, Any) -> None
return None
@@ -0,0 +1,134 @@
import sys
from functools import wraps
import sentry_sdk
from sentry_sdk.integrations import Integration, DidNotEnable
from sentry_sdk.integrations.aws_lambda import _make_request_event_processor
from sentry_sdk.tracing import TRANSACTION_SOURCE_COMPONENT
from sentry_sdk.utils import (
capture_internal_exceptions,
event_from_exception,
parse_version,
reraise,
)
try:
import chalice # type: ignore
from chalice import __version__ as CHALICE_VERSION
from chalice import Chalice, ChaliceViewError
from chalice.app import EventSourceHandler as ChaliceEventSourceHandler # type: ignore
except ImportError:
raise DidNotEnable("Chalice is not installed")
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from typing import Any
from typing import Dict
from typing import TypeVar
from typing import Callable
F = TypeVar("F", bound=Callable[..., Any])
class EventSourceHandler(ChaliceEventSourceHandler): # type: ignore
def __call__(self, event, context):
# type: (Any, Any) -> Any
client = sentry_sdk.get_client()
with sentry_sdk.isolation_scope() as scope:
with capture_internal_exceptions():
configured_time = context.get_remaining_time_in_millis()
scope.add_event_processor(
_make_request_event_processor(event, context, configured_time)
)
try:
return ChaliceEventSourceHandler.__call__(self, event, context)
except Exception:
exc_info = sys.exc_info()
event, hint = event_from_exception(
exc_info,
client_options=client.options,
mechanism={"type": "chalice", "handled": False},
)
sentry_sdk.capture_event(event, hint=hint)
client.flush()
reraise(*exc_info)
def _get_view_function_response(app, view_function, function_args):
# type: (Any, F, Any) -> F
@wraps(view_function)
def wrapped_view_function(**function_args):
# type: (**Any) -> Any
client = sentry_sdk.get_client()
with sentry_sdk.isolation_scope() as scope:
with capture_internal_exceptions():
configured_time = app.lambda_context.get_remaining_time_in_millis()
scope.set_transaction_name(
app.lambda_context.function_name,
source=TRANSACTION_SOURCE_COMPONENT,
)
scope.add_event_processor(
_make_request_event_processor(
app.current_request.to_dict(),
app.lambda_context,
configured_time,
)
)
try:
return view_function(**function_args)
except Exception as exc:
if isinstance(exc, ChaliceViewError):
raise
exc_info = sys.exc_info()
event, hint = event_from_exception(
exc_info,
client_options=client.options,
mechanism={"type": "chalice", "handled": False},
)
sentry_sdk.capture_event(event, hint=hint)
client.flush()
raise
return wrapped_view_function # type: ignore
class ChaliceIntegration(Integration):
identifier = "chalice"
@staticmethod
def setup_once():
# type: () -> None
version = parse_version(CHALICE_VERSION)
if version is None:
raise DidNotEnable("Unparsable Chalice version: {}".format(CHALICE_VERSION))
if version < (1, 20):
old_get_view_function_response = Chalice._get_view_function_response
else:
from chalice.app import RestAPIEventHandler
old_get_view_function_response = (
RestAPIEventHandler._get_view_function_response
)
def sentry_event_response(app, view_function, function_args):
# type: (Any, F, Dict[str, Any]) -> Any
wrapped_view_function = _get_view_function_response(
app, view_function, function_args
)
return old_get_view_function_response(
app, wrapped_view_function, function_args
)
if version < (1, 20):
Chalice._get_view_function_response = sentry_event_response
else:
RestAPIEventHandler._get_view_function_response = sentry_event_response
# for everything else (like events)
chalice.app.EventSourceHandler = EventSourceHandler
@@ -0,0 +1,157 @@
import sentry_sdk
from sentry_sdk.consts import OP, SPANDATA
from sentry_sdk.integrations import _check_minimum_version, Integration, DidNotEnable
from sentry_sdk.tracing import Span
from sentry_sdk.scope import should_send_default_pii
from sentry_sdk.utils import capture_internal_exceptions, ensure_integration_enabled
from typing import TYPE_CHECKING, TypeVar
# Hack to get new Python features working in older versions
# without introducing a hard dependency on `typing_extensions`
# from: https://stackoverflow.com/a/71944042/300572
if TYPE_CHECKING:
from typing import ParamSpec, Callable
else:
# Fake ParamSpec
class ParamSpec:
def __init__(self, _):
self.args = None
self.kwargs = None
# Callable[anything] will return None
class _Callable:
def __getitem__(self, _):
return None
# Make instances
Callable = _Callable()
try:
import clickhouse_driver # type: ignore[import-not-found]
except ImportError:
raise DidNotEnable("clickhouse-driver not installed.")
class ClickhouseDriverIntegration(Integration):
identifier = "clickhouse_driver"
origin = f"auto.db.{identifier}"
@staticmethod
def setup_once() -> None:
_check_minimum_version(ClickhouseDriverIntegration, clickhouse_driver.VERSION)
# Every query is done using the Connection's `send_query` function
clickhouse_driver.connection.Connection.send_query = _wrap_start(
clickhouse_driver.connection.Connection.send_query
)
# If the query contains parameters then the send_data function is used to send those parameters to clickhouse
clickhouse_driver.client.Client.send_data = _wrap_send_data(
clickhouse_driver.client.Client.send_data
)
# Every query ends either with the Client's `receive_end_of_query` (no result expected)
# or its `receive_result` (result expected)
clickhouse_driver.client.Client.receive_end_of_query = _wrap_end(
clickhouse_driver.client.Client.receive_end_of_query
)
if hasattr(clickhouse_driver.client.Client, "receive_end_of_insert_query"):
# In 0.2.7, insert queries are handled separately via `receive_end_of_insert_query`
clickhouse_driver.client.Client.receive_end_of_insert_query = _wrap_end(
clickhouse_driver.client.Client.receive_end_of_insert_query
)
clickhouse_driver.client.Client.receive_result = _wrap_end(
clickhouse_driver.client.Client.receive_result
)
P = ParamSpec("P")
T = TypeVar("T")
def _wrap_start(f: Callable[P, T]) -> Callable[P, T]:
@ensure_integration_enabled(ClickhouseDriverIntegration, f)
def _inner(*args: P.args, **kwargs: P.kwargs) -> T:
connection = args[0]
query = args[1]
query_id = args[2] if len(args) > 2 else kwargs.get("query_id")
params = args[3] if len(args) > 3 else kwargs.get("params")
span = sentry_sdk.start_span(
op=OP.DB,
name=query,
origin=ClickhouseDriverIntegration.origin,
)
connection._sentry_span = span # type: ignore[attr-defined]
_set_db_data(span, connection)
span.set_data("query", query)
if query_id:
span.set_data("db.query_id", query_id)
if params and should_send_default_pii():
span.set_data("db.params", params)
# run the original code
ret = f(*args, **kwargs)
return ret
return _inner
def _wrap_end(f: Callable[P, T]) -> Callable[P, T]:
def _inner_end(*args: P.args, **kwargs: P.kwargs) -> T:
res = f(*args, **kwargs)
instance = args[0]
span = getattr(instance.connection, "_sentry_span", None) # type: ignore[attr-defined]
if span is not None:
if res is not None and should_send_default_pii():
span.set_data("db.result", res)
with capture_internal_exceptions():
span.scope.add_breadcrumb(
message=span._data.pop("query"), category="query", data=span._data
)
span.finish()
return res
return _inner_end
def _wrap_send_data(f: Callable[P, T]) -> Callable[P, T]:
def _inner_send_data(*args: P.args, **kwargs: P.kwargs) -> T:
instance = args[0] # type: clickhouse_driver.client.Client
data = args[2]
span = getattr(instance.connection, "_sentry_span", None)
if span is not None:
_set_db_data(span, instance.connection)
if should_send_default_pii():
db_params = span._data.get("db.params", [])
db_params.extend(data)
span.set_data("db.params", db_params)
return f(*args, **kwargs)
return _inner_send_data
def _set_db_data(
span: Span, connection: clickhouse_driver.connection.Connection
) -> None:
span.set_data(SPANDATA.DB_SYSTEM, "clickhouse")
span.set_data(SPANDATA.SERVER_ADDRESS, connection.host)
span.set_data(SPANDATA.SERVER_PORT, connection.port)
span.set_data(SPANDATA.DB_NAME, connection.database)
span.set_data(SPANDATA.DB_USER, connection.user)
@@ -0,0 +1,258 @@
import json
import urllib3
from sentry_sdk.integrations import Integration
from sentry_sdk.api import set_context
from sentry_sdk.utils import logger
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from typing import Dict
CONTEXT_TYPE = "cloud_resource"
AWS_METADATA_HOST = "169.254.169.254"
AWS_TOKEN_URL = "http://{}/latest/api/token".format(AWS_METADATA_HOST)
AWS_METADATA_URL = "http://{}/latest/dynamic/instance-identity/document".format(
AWS_METADATA_HOST
)
GCP_METADATA_HOST = "metadata.google.internal"
GCP_METADATA_URL = "http://{}/computeMetadata/v1/?recursive=true".format(
GCP_METADATA_HOST
)
class CLOUD_PROVIDER: # noqa: N801
"""
Name of the cloud provider.
see https://opentelemetry.io/docs/reference/specification/resource/semantic_conventions/cloud/
"""
ALIBABA = "alibaba_cloud"
AWS = "aws"
AZURE = "azure"
GCP = "gcp"
IBM = "ibm_cloud"
TENCENT = "tencent_cloud"
class CLOUD_PLATFORM: # noqa: N801
"""
The cloud platform.
see https://opentelemetry.io/docs/reference/specification/resource/semantic_conventions/cloud/
"""
AWS_EC2 = "aws_ec2"
GCP_COMPUTE_ENGINE = "gcp_compute_engine"
class CloudResourceContextIntegration(Integration):
"""
Adds cloud resource context to the Senty scope
"""
identifier = "cloudresourcecontext"
cloud_provider = ""
aws_token = ""
http = urllib3.PoolManager()
gcp_metadata = None
def __init__(self, cloud_provider=""):
# type: (str) -> None
CloudResourceContextIntegration.cloud_provider = cloud_provider
@classmethod
def _is_aws(cls):
# type: () -> bool
try:
r = cls.http.request(
"PUT",
AWS_TOKEN_URL,
headers={"X-aws-ec2-metadata-token-ttl-seconds": "60"},
)
if r.status != 200:
return False
cls.aws_token = r.data.decode()
return True
except Exception:
return False
@classmethod
def _get_aws_context(cls):
# type: () -> Dict[str, str]
ctx = {
"cloud.provider": CLOUD_PROVIDER.AWS,
"cloud.platform": CLOUD_PLATFORM.AWS_EC2,
}
try:
r = cls.http.request(
"GET",
AWS_METADATA_URL,
headers={"X-aws-ec2-metadata-token": cls.aws_token},
)
if r.status != 200:
return ctx
data = json.loads(r.data.decode("utf-8"))
try:
ctx["cloud.account.id"] = data["accountId"]
except Exception:
pass
try:
ctx["cloud.availability_zone"] = data["availabilityZone"]
except Exception:
pass
try:
ctx["cloud.region"] = data["region"]
except Exception:
pass
try:
ctx["host.id"] = data["instanceId"]
except Exception:
pass
try:
ctx["host.type"] = data["instanceType"]
except Exception:
pass
except Exception:
pass
return ctx
@classmethod
def _is_gcp(cls):
# type: () -> bool
try:
r = cls.http.request(
"GET",
GCP_METADATA_URL,
headers={"Metadata-Flavor": "Google"},
)
if r.status != 200:
return False
cls.gcp_metadata = json.loads(r.data.decode("utf-8"))
return True
except Exception:
return False
@classmethod
def _get_gcp_context(cls):
# type: () -> Dict[str, str]
ctx = {
"cloud.provider": CLOUD_PROVIDER.GCP,
"cloud.platform": CLOUD_PLATFORM.GCP_COMPUTE_ENGINE,
}
try:
if cls.gcp_metadata is None:
r = cls.http.request(
"GET",
GCP_METADATA_URL,
headers={"Metadata-Flavor": "Google"},
)
if r.status != 200:
return ctx
cls.gcp_metadata = json.loads(r.data.decode("utf-8"))
try:
ctx["cloud.account.id"] = cls.gcp_metadata["project"]["projectId"]
except Exception:
pass
try:
ctx["cloud.availability_zone"] = cls.gcp_metadata["instance"][
"zone"
].split("/")[-1]
except Exception:
pass
try:
# only populated in google cloud run
ctx["cloud.region"] = cls.gcp_metadata["instance"]["region"].split("/")[
-1
]
except Exception:
pass
try:
ctx["host.id"] = cls.gcp_metadata["instance"]["id"]
except Exception:
pass
except Exception:
pass
return ctx
@classmethod
def _get_cloud_provider(cls):
# type: () -> str
if cls._is_aws():
return CLOUD_PROVIDER.AWS
if cls._is_gcp():
return CLOUD_PROVIDER.GCP
return ""
@classmethod
def _get_cloud_resource_context(cls):
# type: () -> Dict[str, str]
cloud_provider = (
cls.cloud_provider
if cls.cloud_provider != ""
else CloudResourceContextIntegration._get_cloud_provider()
)
if cloud_provider in context_getters.keys():
return context_getters[cloud_provider]()
return {}
@staticmethod
def setup_once():
# type: () -> None
cloud_provider = CloudResourceContextIntegration.cloud_provider
unsupported_cloud_provider = (
cloud_provider != "" and cloud_provider not in context_getters.keys()
)
if unsupported_cloud_provider:
logger.warning(
"Invalid value for cloud_provider: %s (must be in %s). Falling back to autodetection...",
CloudResourceContextIntegration.cloud_provider,
list(context_getters.keys()),
)
context = CloudResourceContextIntegration._get_cloud_resource_context()
if context != {}:
set_context(CONTEXT_TYPE, context)
# Map with the currently supported cloud providers
# mapping to functions extracting the context
context_getters = {
CLOUD_PROVIDER.AWS: CloudResourceContextIntegration._get_aws_context,
CLOUD_PROVIDER.GCP: CloudResourceContextIntegration._get_gcp_context,
}
@@ -0,0 +1,270 @@
from functools import wraps
from sentry_sdk import consts
from sentry_sdk.ai.monitoring import record_token_usage
from sentry_sdk.consts import SPANDATA
from sentry_sdk.ai.utils import set_data_normalized
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from typing import Any, Callable, Iterator
from sentry_sdk.tracing import Span
import sentry_sdk
from sentry_sdk.scope import should_send_default_pii
from sentry_sdk.integrations import DidNotEnable, Integration
from sentry_sdk.utils import capture_internal_exceptions, event_from_exception
try:
from cohere.client import Client
from cohere.base_client import BaseCohere
from cohere import (
ChatStreamEndEvent,
NonStreamedChatResponse,
)
if TYPE_CHECKING:
from cohere import StreamedChatResponse
except ImportError:
raise DidNotEnable("Cohere not installed")
try:
# cohere 5.9.3+
from cohere import StreamEndStreamedChatResponse
except ImportError:
from cohere import StreamedChatResponse_StreamEnd as StreamEndStreamedChatResponse
COLLECTED_CHAT_PARAMS = {
"model": SPANDATA.AI_MODEL_ID,
"k": SPANDATA.AI_TOP_K,
"p": SPANDATA.AI_TOP_P,
"seed": SPANDATA.AI_SEED,
"frequency_penalty": SPANDATA.AI_FREQUENCY_PENALTY,
"presence_penalty": SPANDATA.AI_PRESENCE_PENALTY,
"raw_prompting": SPANDATA.AI_RAW_PROMPTING,
}
COLLECTED_PII_CHAT_PARAMS = {
"tools": SPANDATA.AI_TOOLS,
"preamble": SPANDATA.AI_PREAMBLE,
}
COLLECTED_CHAT_RESP_ATTRS = {
"generation_id": "ai.generation_id",
"is_search_required": "ai.is_search_required",
"finish_reason": "ai.finish_reason",
}
COLLECTED_PII_CHAT_RESP_ATTRS = {
"citations": "ai.citations",
"documents": "ai.documents",
"search_queries": "ai.search_queries",
"search_results": "ai.search_results",
"tool_calls": "ai.tool_calls",
}
class CohereIntegration(Integration):
identifier = "cohere"
origin = f"auto.ai.{identifier}"
def __init__(self, include_prompts=True):
# type: (CohereIntegration, bool) -> None
self.include_prompts = include_prompts
@staticmethod
def setup_once():
# type: () -> None
BaseCohere.chat = _wrap_chat(BaseCohere.chat, streaming=False)
Client.embed = _wrap_embed(Client.embed)
BaseCohere.chat_stream = _wrap_chat(BaseCohere.chat_stream, streaming=True)
def _capture_exception(exc):
# type: (Any) -> None
event, hint = event_from_exception(
exc,
client_options=sentry_sdk.get_client().options,
mechanism={"type": "cohere", "handled": False},
)
sentry_sdk.capture_event(event, hint=hint)
def _wrap_chat(f, streaming):
# type: (Callable[..., Any], bool) -> Callable[..., Any]
def collect_chat_response_fields(span, res, include_pii):
# type: (Span, NonStreamedChatResponse, bool) -> None
if include_pii:
if hasattr(res, "text"):
set_data_normalized(
span,
SPANDATA.AI_RESPONSES,
[res.text],
)
for pii_attr in COLLECTED_PII_CHAT_RESP_ATTRS:
if hasattr(res, pii_attr):
set_data_normalized(span, "ai." + pii_attr, getattr(res, pii_attr))
for attr in COLLECTED_CHAT_RESP_ATTRS:
if hasattr(res, attr):
set_data_normalized(span, "ai." + attr, getattr(res, attr))
if hasattr(res, "meta"):
if hasattr(res.meta, "billed_units"):
record_token_usage(
span,
prompt_tokens=res.meta.billed_units.input_tokens,
completion_tokens=res.meta.billed_units.output_tokens,
)
elif hasattr(res.meta, "tokens"):
record_token_usage(
span,
prompt_tokens=res.meta.tokens.input_tokens,
completion_tokens=res.meta.tokens.output_tokens,
)
if hasattr(res.meta, "warnings"):
set_data_normalized(span, "ai.warnings", res.meta.warnings)
@wraps(f)
def new_chat(*args, **kwargs):
# type: (*Any, **Any) -> Any
integration = sentry_sdk.get_client().get_integration(CohereIntegration)
if (
integration is None
or "message" not in kwargs
or not isinstance(kwargs.get("message"), str)
):
return f(*args, **kwargs)
message = kwargs.get("message")
span = sentry_sdk.start_span(
op=consts.OP.COHERE_CHAT_COMPLETIONS_CREATE,
name="cohere.client.Chat",
origin=CohereIntegration.origin,
)
span.__enter__()
try:
res = f(*args, **kwargs)
except Exception as e:
_capture_exception(e)
span.__exit__(None, None, None)
raise e from None
with capture_internal_exceptions():
if should_send_default_pii() and integration.include_prompts:
set_data_normalized(
span,
SPANDATA.AI_INPUT_MESSAGES,
list(
map(
lambda x: {
"role": getattr(x, "role", "").lower(),
"content": getattr(x, "message", ""),
},
kwargs.get("chat_history", []),
)
)
+ [{"role": "user", "content": message}],
)
for k, v in COLLECTED_PII_CHAT_PARAMS.items():
if k in kwargs:
set_data_normalized(span, v, kwargs[k])
for k, v in COLLECTED_CHAT_PARAMS.items():
if k in kwargs:
set_data_normalized(span, v, kwargs[k])
set_data_normalized(span, SPANDATA.AI_STREAMING, False)
if streaming:
old_iterator = res
def new_iterator():
# type: () -> Iterator[StreamedChatResponse]
with capture_internal_exceptions():
for x in old_iterator:
if isinstance(x, ChatStreamEndEvent) or isinstance(
x, StreamEndStreamedChatResponse
):
collect_chat_response_fields(
span,
x.response,
include_pii=should_send_default_pii()
and integration.include_prompts,
)
yield x
span.__exit__(None, None, None)
return new_iterator()
elif isinstance(res, NonStreamedChatResponse):
collect_chat_response_fields(
span,
res,
include_pii=should_send_default_pii()
and integration.include_prompts,
)
span.__exit__(None, None, None)
else:
set_data_normalized(span, "unknown_response", True)
span.__exit__(None, None, None)
return res
return new_chat
def _wrap_embed(f):
# type: (Callable[..., Any]) -> Callable[..., Any]
@wraps(f)
def new_embed(*args, **kwargs):
# type: (*Any, **Any) -> Any
integration = sentry_sdk.get_client().get_integration(CohereIntegration)
if integration is None:
return f(*args, **kwargs)
with sentry_sdk.start_span(
op=consts.OP.COHERE_EMBEDDINGS_CREATE,
name="Cohere Embedding Creation",
origin=CohereIntegration.origin,
) as span:
if "texts" in kwargs and (
should_send_default_pii() and integration.include_prompts
):
if isinstance(kwargs["texts"], str):
set_data_normalized(span, "ai.texts", [kwargs["texts"]])
elif (
isinstance(kwargs["texts"], list)
and len(kwargs["texts"]) > 0
and isinstance(kwargs["texts"][0], str)
):
set_data_normalized(
span, SPANDATA.AI_INPUT_MESSAGES, kwargs["texts"]
)
if "model" in kwargs:
set_data_normalized(span, SPANDATA.AI_MODEL_ID, kwargs["model"])
try:
res = f(*args, **kwargs)
except Exception as e:
_capture_exception(e)
raise e from None
if (
hasattr(res, "meta")
and hasattr(res.meta, "billed_units")
and hasattr(res.meta.billed_units, "input_tokens")
):
record_token_usage(
span,
prompt_tokens=res.meta.billed_units.input_tokens,
total_tokens=res.meta.billed_units.input_tokens,
)
return res
return new_embed
@@ -0,0 +1,42 @@
import sentry_sdk
from sentry_sdk.utils import ContextVar
from sentry_sdk.integrations import Integration
from sentry_sdk.scope import add_global_event_processor
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from typing import Optional
from sentry_sdk._types import Event, Hint
class DedupeIntegration(Integration):
identifier = "dedupe"
def __init__(self):
# type: () -> None
self._last_seen = ContextVar("last-seen")
@staticmethod
def setup_once():
# type: () -> None
@add_global_event_processor
def processor(event, hint):
# type: (Event, Optional[Hint]) -> Optional[Event]
if hint is None:
return event
integration = sentry_sdk.get_client().get_integration(DedupeIntegration)
if integration is None:
return event
exc_info = hint.get("exc_info", None)
if exc_info is None:
return event
exc = exc_info[1]
if integration._last_seen.get(None) is exc:
return None
integration._last_seen.set(exc)
return event
@@ -0,0 +1,747 @@
import inspect
import sys
import threading
import weakref
from importlib import import_module
import sentry_sdk
from sentry_sdk.consts import OP, SPANDATA
from sentry_sdk.scope import add_global_event_processor, should_send_default_pii
from sentry_sdk.serializer import add_global_repr_processor
from sentry_sdk.tracing import SOURCE_FOR_STYLE, TRANSACTION_SOURCE_URL
from sentry_sdk.tracing_utils import add_query_source, record_sql_queries
from sentry_sdk.utils import (
AnnotatedValue,
HAS_REAL_CONTEXTVARS,
CONTEXTVARS_ERROR_MESSAGE,
SENSITIVE_DATA_SUBSTITUTE,
logger,
capture_internal_exceptions,
ensure_integration_enabled,
event_from_exception,
transaction_from_function,
walk_exception_chain,
)
from sentry_sdk.integrations import _check_minimum_version, Integration, DidNotEnable
from sentry_sdk.integrations.logging import ignore_logger
from sentry_sdk.integrations.wsgi import SentryWsgiMiddleware
from sentry_sdk.integrations._wsgi_common import (
DEFAULT_HTTP_METHODS_TO_CAPTURE,
RequestExtractor,
)
try:
from django import VERSION as DJANGO_VERSION
from django.conf import settings as django_settings
from django.core import signals
from django.conf import settings
try:
from django.urls import resolve
except ImportError:
from django.core.urlresolvers import resolve
try:
from django.urls import Resolver404
except ImportError:
from django.core.urlresolvers import Resolver404
# Only available in Django 3.0+
try:
from django.core.handlers.asgi import ASGIRequest
except Exception:
ASGIRequest = None
except ImportError:
raise DidNotEnable("Django not installed")
from sentry_sdk.integrations.django.transactions import LEGACY_RESOLVER
from sentry_sdk.integrations.django.templates import (
get_template_frame_from_exception,
patch_templates,
)
from sentry_sdk.integrations.django.middleware import patch_django_middlewares
from sentry_sdk.integrations.django.signals_handlers import patch_signals
from sentry_sdk.integrations.django.views import patch_views
if DJANGO_VERSION[:2] > (1, 8):
from sentry_sdk.integrations.django.caching import patch_caching
else:
patch_caching = None # type: ignore
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from typing import Any
from typing import Callable
from typing import Dict
from typing import Optional
from typing import Union
from typing import List
from django.core.handlers.wsgi import WSGIRequest
from django.http.response import HttpResponse
from django.http.request import QueryDict
from django.utils.datastructures import MultiValueDict
from sentry_sdk.tracing import Span
from sentry_sdk.integrations.wsgi import _ScopedResponse
from sentry_sdk._types import Event, Hint, EventProcessor, NotImplementedType
if DJANGO_VERSION < (1, 10):
def is_authenticated(request_user):
# type: (Any) -> bool
return request_user.is_authenticated()
else:
def is_authenticated(request_user):
# type: (Any) -> bool
return request_user.is_authenticated
TRANSACTION_STYLE_VALUES = ("function_name", "url")
class DjangoIntegration(Integration):
"""
Auto instrument a Django application.
:param transaction_style: How to derive transaction names. Either `"function_name"` or `"url"`. Defaults to `"url"`.
:param middleware_spans: Whether to create spans for middleware. Defaults to `True`.
:param signals_spans: Whether to create spans for signals. Defaults to `True`.
:param signals_denylist: A list of signals to ignore when creating spans.
:param cache_spans: Whether to create spans for cache operations. Defaults to `False`.
"""
identifier = "django"
origin = f"auto.http.{identifier}"
origin_db = f"auto.db.{identifier}"
transaction_style = ""
middleware_spans = None
signals_spans = None
cache_spans = None
signals_denylist = [] # type: list[signals.Signal]
def __init__(
self,
transaction_style="url", # type: str
middleware_spans=True, # type: bool
signals_spans=True, # type: bool
cache_spans=False, # type: bool
signals_denylist=None, # type: Optional[list[signals.Signal]]
http_methods_to_capture=DEFAULT_HTTP_METHODS_TO_CAPTURE, # type: tuple[str, ...]
):
# type: (...) -> None
if transaction_style not in TRANSACTION_STYLE_VALUES:
raise ValueError(
"Invalid value for transaction_style: %s (must be in %s)"
% (transaction_style, TRANSACTION_STYLE_VALUES)
)
self.transaction_style = transaction_style
self.middleware_spans = middleware_spans
self.signals_spans = signals_spans
self.signals_denylist = signals_denylist or []
self.cache_spans = cache_spans
self.http_methods_to_capture = tuple(map(str.upper, http_methods_to_capture))
@staticmethod
def setup_once():
# type: () -> None
_check_minimum_version(DjangoIntegration, DJANGO_VERSION)
install_sql_hook()
# Patch in our custom middleware.
# logs an error for every 500
ignore_logger("django.server")
ignore_logger("django.request")
from django.core.handlers.wsgi import WSGIHandler
old_app = WSGIHandler.__call__
@ensure_integration_enabled(DjangoIntegration, old_app)
def sentry_patched_wsgi_handler(self, environ, start_response):
# type: (Any, Dict[str, str], Callable[..., Any]) -> _ScopedResponse
bound_old_app = old_app.__get__(self, WSGIHandler)
from django.conf import settings
use_x_forwarded_for = settings.USE_X_FORWARDED_HOST
integration = sentry_sdk.get_client().get_integration(DjangoIntegration)
middleware = SentryWsgiMiddleware(
bound_old_app,
use_x_forwarded_for,
span_origin=DjangoIntegration.origin,
http_methods_to_capture=(
integration.http_methods_to_capture
if integration
else DEFAULT_HTTP_METHODS_TO_CAPTURE
),
)
return middleware(environ, start_response)
WSGIHandler.__call__ = sentry_patched_wsgi_handler
_patch_get_response()
_patch_django_asgi_handler()
signals.got_request_exception.connect(_got_request_exception)
@add_global_event_processor
def process_django_templates(event, hint):
# type: (Event, Optional[Hint]) -> Optional[Event]
if hint is None:
return event
exc_info = hint.get("exc_info", None)
if exc_info is None:
return event
exception = event.get("exception", None)
if exception is None:
return event
values = exception.get("values", None)
if values is None:
return event
for exception, (_, exc_value, _) in zip(
reversed(values), walk_exception_chain(exc_info)
):
frame = get_template_frame_from_exception(exc_value)
if frame is not None:
frames = exception.get("stacktrace", {}).get("frames", [])
for i in reversed(range(len(frames))):
f = frames[i]
if (
f.get("function") in ("Parser.parse", "parse", "render")
and f.get("module") == "django.template.base"
):
i += 1
break
else:
i = len(frames)
frames.insert(i, frame)
return event
@add_global_repr_processor
def _django_queryset_repr(value, hint):
# type: (Any, Dict[str, Any]) -> Union[NotImplementedType, str]
try:
# Django 1.6 can fail to import `QuerySet` when Django settings
# have not yet been initialized.
#
# If we fail to import, return `NotImplemented`. It's at least
# unlikely that we have a query set in `value` when importing
# `QuerySet` fails.
from django.db.models.query import QuerySet
except Exception:
return NotImplemented
if not isinstance(value, QuerySet) or value._result_cache:
return NotImplemented
return "<%s from %s at 0x%x>" % (
value.__class__.__name__,
value.__module__,
id(value),
)
_patch_channels()
patch_django_middlewares()
patch_views()
patch_templates()
patch_signals()
if patch_caching is not None:
patch_caching()
_DRF_PATCHED = False
_DRF_PATCH_LOCK = threading.Lock()
def _patch_drf():
# type: () -> None
"""
Patch Django Rest Framework for more/better request data. DRF's request
type is a wrapper around Django's request type. The attribute we're
interested in is `request.data`, which is a cached property containing a
parsed request body. Reading a request body from that property is more
reliable than reading from any of Django's own properties, as those don't
hold payloads in memory and therefore can only be accessed once.
We patch the Django request object to include a weak backreference to the
DRF request object, such that we can later use either in
`DjangoRequestExtractor`.
This function is not called directly on SDK setup, because importing almost
any part of Django Rest Framework will try to access Django settings (where
`sentry_sdk.init()` might be called from in the first place). Instead we
run this function on every request and do the patching on the first
request.
"""
global _DRF_PATCHED
if _DRF_PATCHED:
# Double-checked locking
return
with _DRF_PATCH_LOCK:
if _DRF_PATCHED:
return
# We set this regardless of whether the code below succeeds or fails.
# There is no point in trying to patch again on the next request.
_DRF_PATCHED = True
with capture_internal_exceptions():
try:
from rest_framework.views import APIView # type: ignore
except ImportError:
pass
else:
old_drf_initial = APIView.initial
def sentry_patched_drf_initial(self, request, *args, **kwargs):
# type: (APIView, Any, *Any, **Any) -> Any
with capture_internal_exceptions():
request._request._sentry_drf_request_backref = weakref.ref(
request
)
pass
return old_drf_initial(self, request, *args, **kwargs)
APIView.initial = sentry_patched_drf_initial
def _patch_channels():
# type: () -> None
try:
from channels.http import AsgiHandler # type: ignore
except ImportError:
return
if not HAS_REAL_CONTEXTVARS:
# We better have contextvars or we're going to leak state between
# requests.
#
# We cannot hard-raise here because channels may not be used at all in
# the current process. That is the case when running traditional WSGI
# workers in gunicorn+gevent and the websocket stuff in a separate
# process.
logger.warning(
"We detected that you are using Django channels 2.0."
+ CONTEXTVARS_ERROR_MESSAGE
)
from sentry_sdk.integrations.django.asgi import patch_channels_asgi_handler_impl
patch_channels_asgi_handler_impl(AsgiHandler)
def _patch_django_asgi_handler():
# type: () -> None
try:
from django.core.handlers.asgi import ASGIHandler
except ImportError:
return
if not HAS_REAL_CONTEXTVARS:
# We better have contextvars or we're going to leak state between
# requests.
#
# We cannot hard-raise here because Django's ASGI stuff may not be used
# at all.
logger.warning(
"We detected that you are using Django 3." + CONTEXTVARS_ERROR_MESSAGE
)
from sentry_sdk.integrations.django.asgi import patch_django_asgi_handler_impl
patch_django_asgi_handler_impl(ASGIHandler)
def _set_transaction_name_and_source(scope, transaction_style, request):
# type: (sentry_sdk.Scope, str, WSGIRequest) -> None
try:
transaction_name = None
if transaction_style == "function_name":
fn = resolve(request.path).func
transaction_name = transaction_from_function(getattr(fn, "view_class", fn))
elif transaction_style == "url":
if hasattr(request, "urlconf"):
transaction_name = LEGACY_RESOLVER.resolve(
request.path_info, urlconf=request.urlconf
)
else:
transaction_name = LEGACY_RESOLVER.resolve(request.path_info)
if transaction_name is None:
transaction_name = request.path_info
source = TRANSACTION_SOURCE_URL
else:
source = SOURCE_FOR_STYLE[transaction_style]
scope.set_transaction_name(
transaction_name,
source=source,
)
except Resolver404:
urlconf = import_module(settings.ROOT_URLCONF)
# This exception only gets thrown when transaction_style is `function_name`
# So we don't check here what style is configured
if hasattr(urlconf, "handler404"):
handler = urlconf.handler404
if isinstance(handler, str):
scope.transaction = handler
else:
scope.transaction = transaction_from_function(
getattr(handler, "view_class", handler)
)
except Exception:
pass
def _before_get_response(request):
# type: (WSGIRequest) -> None
integration = sentry_sdk.get_client().get_integration(DjangoIntegration)
if integration is None:
return
_patch_drf()
scope = sentry_sdk.get_current_scope()
# Rely on WSGI middleware to start a trace
_set_transaction_name_and_source(scope, integration.transaction_style, request)
scope.add_event_processor(
_make_wsgi_request_event_processor(weakref.ref(request), integration)
)
def _attempt_resolve_again(request, scope, transaction_style):
# type: (WSGIRequest, sentry_sdk.Scope, str) -> None
"""
Some django middlewares overwrite request.urlconf
so we need to respect that contract,
so we try to resolve the url again.
"""
if not hasattr(request, "urlconf"):
return
_set_transaction_name_and_source(scope, transaction_style, request)
def _after_get_response(request):
# type: (WSGIRequest) -> None
integration = sentry_sdk.get_client().get_integration(DjangoIntegration)
if integration is None or integration.transaction_style != "url":
return
scope = sentry_sdk.get_current_scope()
_attempt_resolve_again(request, scope, integration.transaction_style)
def _patch_get_response():
# type: () -> None
"""
patch get_response, because at that point we have the Django request object
"""
from django.core.handlers.base import BaseHandler
old_get_response = BaseHandler.get_response
def sentry_patched_get_response(self, request):
# type: (Any, WSGIRequest) -> Union[HttpResponse, BaseException]
_before_get_response(request)
rv = old_get_response(self, request)
_after_get_response(request)
return rv
BaseHandler.get_response = sentry_patched_get_response
if hasattr(BaseHandler, "get_response_async"):
from sentry_sdk.integrations.django.asgi import patch_get_response_async
patch_get_response_async(BaseHandler, _before_get_response)
def _make_wsgi_request_event_processor(weak_request, integration):
# type: (Callable[[], WSGIRequest], DjangoIntegration) -> EventProcessor
def wsgi_request_event_processor(event, hint):
# type: (Event, dict[str, Any]) -> Event
# if the request is gone we are fine not logging the data from
# it. This might happen if the processor is pushed away to
# another thread.
request = weak_request()
if request is None:
return event
django_3 = ASGIRequest is not None
if django_3 and type(request) == ASGIRequest:
# We have a `asgi_request_event_processor` for this.
return event
with capture_internal_exceptions():
DjangoRequestExtractor(request).extract_into_event(event)
if should_send_default_pii():
with capture_internal_exceptions():
_set_user_info(request, event)
return event
return wsgi_request_event_processor
def _got_request_exception(request=None, **kwargs):
# type: (WSGIRequest, **Any) -> None
client = sentry_sdk.get_client()
integration = client.get_integration(DjangoIntegration)
if integration is None:
return
if request is not None and integration.transaction_style == "url":
scope = sentry_sdk.get_current_scope()
_attempt_resolve_again(request, scope, integration.transaction_style)
event, hint = event_from_exception(
sys.exc_info(),
client_options=client.options,
mechanism={"type": "django", "handled": False},
)
sentry_sdk.capture_event(event, hint=hint)
class DjangoRequestExtractor(RequestExtractor):
def __init__(self, request):
# type: (Union[WSGIRequest, ASGIRequest]) -> None
try:
drf_request = request._sentry_drf_request_backref()
if drf_request is not None:
request = drf_request
except AttributeError:
pass
self.request = request
def env(self):
# type: () -> Dict[str, str]
return self.request.META
def cookies(self):
# type: () -> Dict[str, Union[str, AnnotatedValue]]
privacy_cookies = [
django_settings.CSRF_COOKIE_NAME,
django_settings.SESSION_COOKIE_NAME,
]
clean_cookies = {} # type: Dict[str, Union[str, AnnotatedValue]]
for key, val in self.request.COOKIES.items():
if key in privacy_cookies:
clean_cookies[key] = SENSITIVE_DATA_SUBSTITUTE
else:
clean_cookies[key] = val
return clean_cookies
def raw_data(self):
# type: () -> bytes
return self.request.body
def form(self):
# type: () -> QueryDict
return self.request.POST
def files(self):
# type: () -> MultiValueDict
return self.request.FILES
def size_of_file(self, file):
# type: (Any) -> int
return file.size
def parsed_body(self):
# type: () -> Optional[Dict[str, Any]]
try:
return self.request.data
except AttributeError:
return RequestExtractor.parsed_body(self)
def _set_user_info(request, event):
# type: (WSGIRequest, Event) -> None
user_info = event.setdefault("user", {})
user = getattr(request, "user", None)
if user is None or not is_authenticated(user):
return
try:
user_info.setdefault("id", str(user.pk))
except Exception:
pass
try:
user_info.setdefault("email", user.email)
except Exception:
pass
try:
user_info.setdefault("username", user.get_username())
except Exception:
pass
def install_sql_hook():
# type: () -> None
"""If installed this causes Django's queries to be captured."""
try:
from django.db.backends.utils import CursorWrapper
except ImportError:
from django.db.backends.util import CursorWrapper
try:
# django 1.6 and 1.7 compatability
from django.db.backends import BaseDatabaseWrapper
except ImportError:
# django 1.8 or later
from django.db.backends.base.base import BaseDatabaseWrapper
try:
real_execute = CursorWrapper.execute
real_executemany = CursorWrapper.executemany
real_connect = BaseDatabaseWrapper.connect
except AttributeError:
# This won't work on Django versions < 1.6
return
@ensure_integration_enabled(DjangoIntegration, real_execute)
def execute(self, sql, params=None):
# type: (CursorWrapper, Any, Optional[Any]) -> Any
with record_sql_queries(
cursor=self.cursor,
query=sql,
params_list=params,
paramstyle="format",
executemany=False,
span_origin=DjangoIntegration.origin_db,
) as span:
_set_db_data(span, self)
result = real_execute(self, sql, params)
with capture_internal_exceptions():
add_query_source(span)
return result
@ensure_integration_enabled(DjangoIntegration, real_executemany)
def executemany(self, sql, param_list):
# type: (CursorWrapper, Any, List[Any]) -> Any
with record_sql_queries(
cursor=self.cursor,
query=sql,
params_list=param_list,
paramstyle="format",
executemany=True,
span_origin=DjangoIntegration.origin_db,
) as span:
_set_db_data(span, self)
result = real_executemany(self, sql, param_list)
with capture_internal_exceptions():
add_query_source(span)
return result
@ensure_integration_enabled(DjangoIntegration, real_connect)
def connect(self):
# type: (BaseDatabaseWrapper) -> None
with capture_internal_exceptions():
sentry_sdk.add_breadcrumb(message="connect", category="query")
with sentry_sdk.start_span(
op=OP.DB,
name="connect",
origin=DjangoIntegration.origin_db,
) as span:
_set_db_data(span, self)
return real_connect(self)
CursorWrapper.execute = execute
CursorWrapper.executemany = executemany
BaseDatabaseWrapper.connect = connect
ignore_logger("django.db.backends")
def _set_db_data(span, cursor_or_db):
# type: (Span, Any) -> None
db = cursor_or_db.db if hasattr(cursor_or_db, "db") else cursor_or_db
vendor = db.vendor
span.set_data(SPANDATA.DB_SYSTEM, vendor)
# Some custom backends override `__getattr__`, making it look like `cursor_or_db`
# actually has a `connection` and the `connection` has a `get_dsn_parameters`
# attribute, only to throw an error once you actually want to call it.
# Hence the `inspect` check whether `get_dsn_parameters` is an actual callable
# function.
is_psycopg2 = (
hasattr(cursor_or_db, "connection")
and hasattr(cursor_or_db.connection, "get_dsn_parameters")
and inspect.isroutine(cursor_or_db.connection.get_dsn_parameters)
)
if is_psycopg2:
connection_params = cursor_or_db.connection.get_dsn_parameters()
else:
try:
# psycopg3, only extract needed params as get_parameters
# can be slow because of the additional logic to filter out default
# values
connection_params = {
"dbname": cursor_or_db.connection.info.dbname,
"port": cursor_or_db.connection.info.port,
}
# PGhost returns host or base dir of UNIX socket as an absolute path
# starting with /, use it only when it contains host
pg_host = cursor_or_db.connection.info.host
if pg_host and not pg_host.startswith("/"):
connection_params["host"] = pg_host
except Exception:
connection_params = db.get_connection_params()
db_name = connection_params.get("dbname") or connection_params.get("database")
if db_name is not None:
span.set_data(SPANDATA.DB_NAME, db_name)
server_address = connection_params.get("host")
if server_address is not None:
span.set_data(SPANDATA.SERVER_ADDRESS, server_address)
server_port = connection_params.get("port")
if server_port is not None:
span.set_data(SPANDATA.SERVER_PORT, str(server_port))
server_socket_address = connection_params.get("unix_socket")
if server_socket_address is not None:
span.set_data(SPANDATA.SERVER_SOCKET_ADDRESS, server_socket_address)
@@ -0,0 +1,245 @@
"""
Instrumentation for Django 3.0
Since this file contains `async def` it is conditionally imported in
`sentry_sdk.integrations.django` (depending on the existence of
`django.core.handlers.asgi`.
"""
import asyncio
import functools
import inspect
from django.core.handlers.wsgi import WSGIRequest
import sentry_sdk
from sentry_sdk.consts import OP
from sentry_sdk.integrations.asgi import SentryAsgiMiddleware
from sentry_sdk.scope import should_send_default_pii
from sentry_sdk.utils import (
capture_internal_exceptions,
ensure_integration_enabled,
)
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from typing import Any, Callable, Union, TypeVar
from django.core.handlers.asgi import ASGIRequest
from django.http.response import HttpResponse
from sentry_sdk._types import Event, EventProcessor
_F = TypeVar("_F", bound=Callable[..., Any])
# Python 3.12 deprecates asyncio.iscoroutinefunction() as an alias for
# inspect.iscoroutinefunction(), whilst also removing the _is_coroutine marker.
# The latter is replaced with the inspect.markcoroutinefunction decorator.
# Until 3.12 is the minimum supported Python version, provide a shim.
# This was copied from https://github.com/django/asgiref/blob/main/asgiref/sync.py
if hasattr(inspect, "markcoroutinefunction"):
iscoroutinefunction = inspect.iscoroutinefunction
markcoroutinefunction = inspect.markcoroutinefunction
else:
iscoroutinefunction = asyncio.iscoroutinefunction # type: ignore[assignment]
def markcoroutinefunction(func: "_F") -> "_F":
func._is_coroutine = asyncio.coroutines._is_coroutine # type: ignore
return func
def _make_asgi_request_event_processor(request):
# type: (ASGIRequest) -> EventProcessor
def asgi_request_event_processor(event, hint):
# type: (Event, dict[str, Any]) -> Event
# if the request is gone we are fine not logging the data from
# it. This might happen if the processor is pushed away to
# another thread.
from sentry_sdk.integrations.django import (
DjangoRequestExtractor,
_set_user_info,
)
if request is None:
return event
if type(request) == WSGIRequest:
return event
with capture_internal_exceptions():
DjangoRequestExtractor(request).extract_into_event(event)
if should_send_default_pii():
with capture_internal_exceptions():
_set_user_info(request, event)
return event
return asgi_request_event_processor
def patch_django_asgi_handler_impl(cls):
# type: (Any) -> None
from sentry_sdk.integrations.django import DjangoIntegration
old_app = cls.__call__
async def sentry_patched_asgi_handler(self, scope, receive, send):
# type: (Any, Any, Any, Any) -> Any
integration = sentry_sdk.get_client().get_integration(DjangoIntegration)
if integration is None:
return await old_app(self, scope, receive, send)
middleware = SentryAsgiMiddleware(
old_app.__get__(self, cls),
unsafe_context_data=True,
span_origin=DjangoIntegration.origin,
http_methods_to_capture=integration.http_methods_to_capture,
)._run_asgi3
return await middleware(scope, receive, send)
cls.__call__ = sentry_patched_asgi_handler
modern_django_asgi_support = hasattr(cls, "create_request")
if modern_django_asgi_support:
old_create_request = cls.create_request
@ensure_integration_enabled(DjangoIntegration, old_create_request)
def sentry_patched_create_request(self, *args, **kwargs):
# type: (Any, *Any, **Any) -> Any
request, error_response = old_create_request(self, *args, **kwargs)
scope = sentry_sdk.get_isolation_scope()
scope.add_event_processor(_make_asgi_request_event_processor(request))
return request, error_response
cls.create_request = sentry_patched_create_request
def patch_get_response_async(cls, _before_get_response):
# type: (Any, Any) -> None
old_get_response_async = cls.get_response_async
async def sentry_patched_get_response_async(self, request):
# type: (Any, Any) -> Union[HttpResponse, BaseException]
_before_get_response(request)
return await old_get_response_async(self, request)
cls.get_response_async = sentry_patched_get_response_async
def patch_channels_asgi_handler_impl(cls):
# type: (Any) -> None
import channels # type: ignore
from sentry_sdk.integrations.django import DjangoIntegration
if channels.__version__ < "3.0.0":
old_app = cls.__call__
async def sentry_patched_asgi_handler(self, receive, send):
# type: (Any, Any, Any) -> Any
integration = sentry_sdk.get_client().get_integration(DjangoIntegration)
if integration is None:
return await old_app(self, receive, send)
middleware = SentryAsgiMiddleware(
lambda _scope: old_app.__get__(self, cls),
unsafe_context_data=True,
span_origin=DjangoIntegration.origin,
http_methods_to_capture=integration.http_methods_to_capture,
)
return await middleware(self.scope)(receive, send)
cls.__call__ = sentry_patched_asgi_handler
else:
# The ASGI handler in Channels >= 3 has the same signature as
# the Django handler.
patch_django_asgi_handler_impl(cls)
def wrap_async_view(callback):
# type: (Any) -> Any
from sentry_sdk.integrations.django import DjangoIntegration
@functools.wraps(callback)
async def sentry_wrapped_callback(request, *args, **kwargs):
# type: (Any, *Any, **Any) -> Any
current_scope = sentry_sdk.get_current_scope()
if current_scope.transaction is not None:
current_scope.transaction.update_active_thread()
sentry_scope = sentry_sdk.get_isolation_scope()
if sentry_scope.profile is not None:
sentry_scope.profile.update_active_thread_id()
with sentry_sdk.start_span(
op=OP.VIEW_RENDER,
name=request.resolver_match.view_name,
origin=DjangoIntegration.origin,
):
return await callback(request, *args, **kwargs)
return sentry_wrapped_callback
def _asgi_middleware_mixin_factory(_check_middleware_span):
# type: (Callable[..., Any]) -> Any
"""
Mixin class factory that generates a middleware mixin for handling requests
in async mode.
"""
class SentryASGIMixin:
if TYPE_CHECKING:
_inner = None
def __init__(self, get_response):
# type: (Callable[..., Any]) -> None
self.get_response = get_response
self._acall_method = None
self._async_check()
def _async_check(self):
# type: () -> None
"""
If get_response is a coroutine function, turns us into async mode so
a thread is not consumed during a whole request.
Taken from django.utils.deprecation::MiddlewareMixin._async_check
"""
if iscoroutinefunction(self.get_response):
markcoroutinefunction(self)
def async_route_check(self):
# type: () -> bool
"""
Function that checks if we are in async mode,
and if we are forwards the handling of requests to __acall__
"""
return iscoroutinefunction(self.get_response)
async def __acall__(self, *args, **kwargs):
# type: (*Any, **Any) -> Any
f = self._acall_method
if f is None:
if hasattr(self._inner, "__acall__"):
self._acall_method = f = self._inner.__acall__ # type: ignore
else:
self._acall_method = f = self._inner
middleware_span = _check_middleware_span(old_method=f)
if middleware_span is None:
return await f(*args, **kwargs)
with middleware_span:
return await f(*args, **kwargs)
return SentryASGIMixin
@@ -0,0 +1,191 @@
import functools
from typing import TYPE_CHECKING
from sentry_sdk.integrations.redis.utils import _get_safe_key, _key_as_string
from urllib3.util import parse_url as urlparse
from django import VERSION as DJANGO_VERSION
from django.core.cache import CacheHandler
import sentry_sdk
from sentry_sdk.consts import OP, SPANDATA
from sentry_sdk.utils import (
capture_internal_exceptions,
ensure_integration_enabled,
)
if TYPE_CHECKING:
from typing import Any
from typing import Callable
from typing import Optional
METHODS_TO_INSTRUMENT = [
"set",
"set_many",
"get",
"get_many",
]
def _get_span_description(method_name, args, kwargs):
# type: (str, tuple[Any], dict[str, Any]) -> str
return _key_as_string(_get_safe_key(method_name, args, kwargs))
def _patch_cache_method(cache, method_name, address, port):
# type: (CacheHandler, str, Optional[str], Optional[int]) -> None
from sentry_sdk.integrations.django import DjangoIntegration
original_method = getattr(cache, method_name)
@ensure_integration_enabled(DjangoIntegration, original_method)
def _instrument_call(
cache, method_name, original_method, args, kwargs, address, port
):
# type: (CacheHandler, str, Callable[..., Any], tuple[Any, ...], dict[str, Any], Optional[str], Optional[int]) -> Any
is_set_operation = method_name.startswith("set")
is_get_operation = not is_set_operation
op = OP.CACHE_PUT if is_set_operation else OP.CACHE_GET
description = _get_span_description(method_name, args, kwargs)
with sentry_sdk.start_span(
op=op,
name=description,
origin=DjangoIntegration.origin,
) as span:
value = original_method(*args, **kwargs)
with capture_internal_exceptions():
if address is not None:
span.set_data(SPANDATA.NETWORK_PEER_ADDRESS, address)
if port is not None:
span.set_data(SPANDATA.NETWORK_PEER_PORT, port)
key = _get_safe_key(method_name, args, kwargs)
if key is not None:
span.set_data(SPANDATA.CACHE_KEY, key)
item_size = None
if is_get_operation:
if value:
item_size = len(str(value))
span.set_data(SPANDATA.CACHE_HIT, True)
else:
span.set_data(SPANDATA.CACHE_HIT, False)
else: # TODO: We don't handle `get_or_set` which we should
arg_count = len(args)
if arg_count >= 2:
# 'set' command
item_size = len(str(args[1]))
elif arg_count == 1:
# 'set_many' command
item_size = len(str(args[0]))
if item_size is not None:
span.set_data(SPANDATA.CACHE_ITEM_SIZE, item_size)
return value
@functools.wraps(original_method)
def sentry_method(*args, **kwargs):
# type: (*Any, **Any) -> Any
return _instrument_call(
cache, method_name, original_method, args, kwargs, address, port
)
setattr(cache, method_name, sentry_method)
def _patch_cache(cache, address=None, port=None):
# type: (CacheHandler, Optional[str], Optional[int]) -> None
if not hasattr(cache, "_sentry_patched"):
for method_name in METHODS_TO_INSTRUMENT:
_patch_cache_method(cache, method_name, address, port)
cache._sentry_patched = True
def _get_address_port(settings):
# type: (dict[str, Any]) -> tuple[Optional[str], Optional[int]]
location = settings.get("LOCATION")
# TODO: location can also be an array of locations
# see: https://docs.djangoproject.com/en/5.0/topics/cache/#redis
# GitHub issue: https://github.com/getsentry/sentry-python/issues/3062
if not isinstance(location, str):
return None, None
if "://" in location:
parsed_url = urlparse(location)
# remove the username and password from URL to not leak sensitive data.
address = "{}://{}{}".format(
parsed_url.scheme or "",
parsed_url.hostname or "",
parsed_url.path or "",
)
port = parsed_url.port
else:
address = location
port = None
return address, int(port) if port is not None else None
def should_enable_cache_spans():
# type: () -> bool
from sentry_sdk.integrations.django import DjangoIntegration
client = sentry_sdk.get_client()
integration = client.get_integration(DjangoIntegration)
from django.conf import settings
return integration is not None and (
(client.spotlight is not None and settings.DEBUG is True)
or integration.cache_spans is True
)
def patch_caching():
# type: () -> None
if not hasattr(CacheHandler, "_sentry_patched"):
if DJANGO_VERSION < (3, 2):
original_get_item = CacheHandler.__getitem__
@functools.wraps(original_get_item)
def sentry_get_item(self, alias):
# type: (CacheHandler, str) -> Any
cache = original_get_item(self, alias)
if should_enable_cache_spans():
from django.conf import settings
address, port = _get_address_port(
settings.CACHES[alias or "default"]
)
_patch_cache(cache, address, port)
return cache
CacheHandler.__getitem__ = sentry_get_item
CacheHandler._sentry_patched = True
else:
original_create_connection = CacheHandler.create_connection
@functools.wraps(original_create_connection)
def sentry_create_connection(self, alias):
# type: (CacheHandler, str) -> Any
cache = original_create_connection(self, alias)
if should_enable_cache_spans():
address, port = _get_address_port(self.settings[alias or "default"])
_patch_cache(cache, address, port)
return cache
CacheHandler.create_connection = sentry_create_connection
CacheHandler._sentry_patched = True
@@ -0,0 +1,187 @@
"""
Create spans from Django middleware invocations
"""
from functools import wraps
from django import VERSION as DJANGO_VERSION
import sentry_sdk
from sentry_sdk.consts import OP
from sentry_sdk.utils import (
ContextVar,
transaction_from_function,
capture_internal_exceptions,
)
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from typing import Any
from typing import Callable
from typing import Optional
from typing import TypeVar
from sentry_sdk.tracing import Span
F = TypeVar("F", bound=Callable[..., Any])
_import_string_should_wrap_middleware = ContextVar(
"import_string_should_wrap_middleware"
)
DJANGO_SUPPORTS_ASYNC_MIDDLEWARE = DJANGO_VERSION >= (3, 1)
if not DJANGO_SUPPORTS_ASYNC_MIDDLEWARE:
_asgi_middleware_mixin_factory = lambda _: object
else:
from .asgi import _asgi_middleware_mixin_factory
def patch_django_middlewares():
# type: () -> None
from django.core.handlers import base
old_import_string = base.import_string
def sentry_patched_import_string(dotted_path):
# type: (str) -> Any
rv = old_import_string(dotted_path)
if _import_string_should_wrap_middleware.get(None):
rv = _wrap_middleware(rv, dotted_path)
return rv
base.import_string = sentry_patched_import_string
old_load_middleware = base.BaseHandler.load_middleware
def sentry_patched_load_middleware(*args, **kwargs):
# type: (Any, Any) -> Any
_import_string_should_wrap_middleware.set(True)
try:
return old_load_middleware(*args, **kwargs)
finally:
_import_string_should_wrap_middleware.set(False)
base.BaseHandler.load_middleware = sentry_patched_load_middleware
def _wrap_middleware(middleware, middleware_name):
# type: (Any, str) -> Any
from sentry_sdk.integrations.django import DjangoIntegration
def _check_middleware_span(old_method):
# type: (Callable[..., Any]) -> Optional[Span]
integration = sentry_sdk.get_client().get_integration(DjangoIntegration)
if integration is None or not integration.middleware_spans:
return None
function_name = transaction_from_function(old_method)
description = middleware_name
function_basename = getattr(old_method, "__name__", None)
if function_basename:
description = "{}.{}".format(description, function_basename)
middleware_span = sentry_sdk.start_span(
op=OP.MIDDLEWARE_DJANGO,
name=description,
origin=DjangoIntegration.origin,
)
middleware_span.set_tag("django.function_name", function_name)
middleware_span.set_tag("django.middleware_name", middleware_name)
return middleware_span
def _get_wrapped_method(old_method):
# type: (F) -> F
with capture_internal_exceptions():
def sentry_wrapped_method(*args, **kwargs):
# type: (*Any, **Any) -> Any
middleware_span = _check_middleware_span(old_method)
if middleware_span is None:
return old_method(*args, **kwargs)
with middleware_span:
return old_method(*args, **kwargs)
try:
# fails for __call__ of function on Python 2 (see py2.7-django-1.11)
sentry_wrapped_method = wraps(old_method)(sentry_wrapped_method)
# Necessary for Django 3.1
sentry_wrapped_method.__self__ = old_method.__self__ # type: ignore
except Exception:
pass
return sentry_wrapped_method # type: ignore
return old_method
class SentryWrappingMiddleware(
_asgi_middleware_mixin_factory(_check_middleware_span) # type: ignore
):
sync_capable = getattr(middleware, "sync_capable", True)
async_capable = DJANGO_SUPPORTS_ASYNC_MIDDLEWARE and getattr(
middleware, "async_capable", False
)
def __init__(self, get_response=None, *args, **kwargs):
# type: (Optional[Callable[..., Any]], *Any, **Any) -> None
if get_response:
self._inner = middleware(get_response, *args, **kwargs)
else:
self._inner = middleware(*args, **kwargs)
self.get_response = get_response
self._call_method = None
if self.async_capable:
super().__init__(get_response)
# We need correct behavior for `hasattr()`, which we can only determine
# when we have an instance of the middleware we're wrapping.
def __getattr__(self, method_name):
# type: (str) -> Any
if method_name not in (
"process_request",
"process_view",
"process_template_response",
"process_response",
"process_exception",
):
raise AttributeError()
old_method = getattr(self._inner, method_name)
rv = _get_wrapped_method(old_method)
self.__dict__[method_name] = rv
return rv
def __call__(self, *args, **kwargs):
# type: (*Any, **Any) -> Any
if hasattr(self, "async_route_check") and self.async_route_check():
return self.__acall__(*args, **kwargs)
f = self._call_method
if f is None:
self._call_method = f = self._inner.__call__
middleware_span = _check_middleware_span(old_method=f)
if middleware_span is None:
return f(*args, **kwargs)
with middleware_span:
return f(*args, **kwargs)
for attr in (
"__name__",
"__module__",
"__qualname__",
):
if hasattr(middleware, attr):
setattr(SentryWrappingMiddleware, attr, getattr(middleware, attr))
return SentryWrappingMiddleware
@@ -0,0 +1,91 @@
from functools import wraps
from django.dispatch import Signal
import sentry_sdk
from sentry_sdk.consts import OP
from sentry_sdk.integrations.django import DJANGO_VERSION
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from collections.abc import Callable
from typing import Any, Union
def _get_receiver_name(receiver):
# type: (Callable[..., Any]) -> str
name = ""
if hasattr(receiver, "__qualname__"):
name = receiver.__qualname__
elif hasattr(receiver, "__name__"): # Python 2.7 has no __qualname__
name = receiver.__name__
elif hasattr(
receiver, "func"
): # certain functions (like partials) dont have a name
if hasattr(receiver, "func") and hasattr(receiver.func, "__name__"):
name = "partial(<function " + receiver.func.__name__ + ">)"
if (
name == ""
): # In case nothing was found, return the string representation (this is the slowest case)
return str(receiver)
if hasattr(receiver, "__module__"): # prepend with module, if there is one
name = receiver.__module__ + "." + name
return name
def patch_signals():
# type: () -> None
"""
Patch django signal receivers to create a span.
This only wraps sync receivers. Django>=5.0 introduced async receivers, but
since we don't create transactions for ASGI Django, we don't wrap them.
"""
from sentry_sdk.integrations.django import DjangoIntegration
old_live_receivers = Signal._live_receivers
def _sentry_live_receivers(self, sender):
# type: (Signal, Any) -> Union[tuple[list[Callable[..., Any]], list[Callable[..., Any]]], list[Callable[..., Any]]]
if DJANGO_VERSION >= (5, 0):
sync_receivers, async_receivers = old_live_receivers(self, sender)
else:
sync_receivers = old_live_receivers(self, sender)
async_receivers = []
def sentry_sync_receiver_wrapper(receiver):
# type: (Callable[..., Any]) -> Callable[..., Any]
@wraps(receiver)
def wrapper(*args, **kwargs):
# type: (Any, Any) -> Any
signal_name = _get_receiver_name(receiver)
with sentry_sdk.start_span(
op=OP.EVENT_DJANGO,
name=signal_name,
origin=DjangoIntegration.origin,
) as span:
span.set_data("signal", signal_name)
return receiver(*args, **kwargs)
return wrapper
integration = sentry_sdk.get_client().get_integration(DjangoIntegration)
if (
integration
and integration.signals_spans
and self not in integration.signals_denylist
):
for idx, receiver in enumerate(sync_receivers):
sync_receivers[idx] = sentry_sync_receiver_wrapper(receiver)
if DJANGO_VERSION >= (5, 0):
return sync_receivers, async_receivers
else:
return sync_receivers
Signal._live_receivers = _sentry_live_receivers
@@ -0,0 +1,188 @@
import functools
from django.template import TemplateSyntaxError
from django.utils.safestring import mark_safe
from django import VERSION as DJANGO_VERSION
import sentry_sdk
from sentry_sdk.consts import OP
from sentry_sdk.utils import ensure_integration_enabled
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from typing import Any
from typing import Dict
from typing import Optional
from typing import Iterator
from typing import Tuple
try:
# support Django 1.9
from django.template.base import Origin
except ImportError:
# backward compatibility
from django.template.loader import LoaderOrigin as Origin
def get_template_frame_from_exception(exc_value):
# type: (Optional[BaseException]) -> Optional[Dict[str, Any]]
# As of Django 1.9 or so the new template debug thing showed up.
if hasattr(exc_value, "template_debug"):
return _get_template_frame_from_debug(exc_value.template_debug) # type: ignore
# As of r16833 (Django) all exceptions may contain a
# ``django_template_source`` attribute (rather than the legacy
# ``TemplateSyntaxError.source`` check)
if hasattr(exc_value, "django_template_source"):
return _get_template_frame_from_source(
exc_value.django_template_source # type: ignore
)
if isinstance(exc_value, TemplateSyntaxError) and hasattr(exc_value, "source"):
source = exc_value.source
if isinstance(source, (tuple, list)) and isinstance(source[0], Origin):
return _get_template_frame_from_source(source) # type: ignore
return None
def _get_template_name_description(template_name):
# type: (str) -> str
if isinstance(template_name, (list, tuple)):
if template_name:
return "[{}, ...]".format(template_name[0])
else:
return template_name
def patch_templates():
# type: () -> None
from django.template.response import SimpleTemplateResponse
from sentry_sdk.integrations.django import DjangoIntegration
real_rendered_content = SimpleTemplateResponse.rendered_content
@property # type: ignore
@ensure_integration_enabled(DjangoIntegration, real_rendered_content.fget)
def rendered_content(self):
# type: (SimpleTemplateResponse) -> str
with sentry_sdk.start_span(
op=OP.TEMPLATE_RENDER,
name=_get_template_name_description(self.template_name),
origin=DjangoIntegration.origin,
) as span:
span.set_data("context", self.context_data)
return real_rendered_content.fget(self)
SimpleTemplateResponse.rendered_content = rendered_content
if DJANGO_VERSION < (1, 7):
return
import django.shortcuts
real_render = django.shortcuts.render
@functools.wraps(real_render)
@ensure_integration_enabled(DjangoIntegration, real_render)
def render(request, template_name, context=None, *args, **kwargs):
# type: (django.http.HttpRequest, str, Optional[Dict[str, Any]], *Any, **Any) -> django.http.HttpResponse
# Inject trace meta tags into template context
context = context or {}
if "sentry_trace_meta" not in context:
context["sentry_trace_meta"] = mark_safe(
sentry_sdk.get_current_scope().trace_propagation_meta()
)
with sentry_sdk.start_span(
op=OP.TEMPLATE_RENDER,
name=_get_template_name_description(template_name),
origin=DjangoIntegration.origin,
) as span:
span.set_data("context", context)
return real_render(request, template_name, context, *args, **kwargs)
django.shortcuts.render = render
def _get_template_frame_from_debug(debug):
# type: (Dict[str, Any]) -> Dict[str, Any]
if debug is None:
return None
lineno = debug["line"]
filename = debug["name"]
if filename is None:
filename = "<django template>"
pre_context = []
post_context = []
context_line = None
for i, line in debug["source_lines"]:
if i < lineno:
pre_context.append(line)
elif i > lineno:
post_context.append(line)
else:
context_line = line
return {
"filename": filename,
"lineno": lineno,
"pre_context": pre_context[-5:],
"post_context": post_context[:5],
"context_line": context_line,
"in_app": True,
}
def _linebreak_iter(template_source):
# type: (str) -> Iterator[int]
yield 0
p = template_source.find("\n")
while p >= 0:
yield p + 1
p = template_source.find("\n", p + 1)
def _get_template_frame_from_source(source):
# type: (Tuple[Origin, Tuple[int, int]]) -> Optional[Dict[str, Any]]
if not source:
return None
origin, (start, end) = source
filename = getattr(origin, "loadname", None)
if filename is None:
filename = "<django template>"
template_source = origin.reload()
lineno = None
upto = 0
pre_context = []
post_context = []
context_line = None
for num, next in enumerate(_linebreak_iter(template_source)):
line = template_source[upto:next]
if start >= upto and end <= next:
lineno = num
context_line = line
elif lineno is None:
pre_context.append(line)
else:
post_context.append(line)
upto = next
if context_line is None or lineno is None:
return None
return {
"filename": filename,
"lineno": lineno,
"pre_context": pre_context[-5:],
"post_context": post_context[:5],
"context_line": context_line,
}
@@ -0,0 +1,159 @@
"""
Copied from raven-python.
Despite being called "legacy" in some places this resolver is very much still
in use.
"""
import re
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from django.urls.resolvers import URLResolver
from typing import Dict
from typing import List
from typing import Optional
from django.urls.resolvers import URLPattern
from typing import Tuple
from typing import Union
from re import Pattern
from django import VERSION as DJANGO_VERSION
if DJANGO_VERSION >= (2, 0):
from django.urls.resolvers import RoutePattern
else:
RoutePattern = None
try:
from django.urls import get_resolver
except ImportError:
from django.core.urlresolvers import get_resolver
def get_regex(resolver_or_pattern):
# type: (Union[URLPattern, URLResolver]) -> Pattern[str]
"""Utility method for django's deprecated resolver.regex"""
try:
regex = resolver_or_pattern.regex
except AttributeError:
regex = resolver_or_pattern.pattern.regex
return regex
class RavenResolver:
_new_style_group_matcher = re.compile(
r"<(?:([^>:]+):)?([^>]+)>"
) # https://github.com/django/django/blob/21382e2743d06efbf5623e7c9b6dccf2a325669b/django/urls/resolvers.py#L245-L247
_optional_group_matcher = re.compile(r"\(\?\:([^\)]+)\)")
_named_group_matcher = re.compile(r"\(\?P<(\w+)>[^\)]+\)+")
_non_named_group_matcher = re.compile(r"\([^\)]+\)")
# [foo|bar|baz]
_either_option_matcher = re.compile(r"\[([^\]]+)\|([^\]]+)\]")
_camel_re = re.compile(r"([A-Z]+)([a-z])")
_cache = {} # type: Dict[URLPattern, str]
def _simplify(self, pattern):
# type: (Union[URLPattern, URLResolver]) -> str
r"""
Clean up urlpattern regexes into something readable by humans:
From:
> "^(?P<sport_slug>\w+)/athletes/(?P<athlete_slug>\w+)/$"
To:
> "{sport_slug}/athletes/{athlete_slug}/"
"""
# "new-style" path patterns can be parsed directly without turning them
# into regexes first
if (
RoutePattern is not None
and hasattr(pattern, "pattern")
and isinstance(pattern.pattern, RoutePattern)
):
return self._new_style_group_matcher.sub(
lambda m: "{%s}" % m.group(2), str(pattern.pattern._route)
)
result = get_regex(pattern).pattern
# remove optional params
# TODO(dcramer): it'd be nice to change these into [%s] but it currently
# conflicts with the other rules because we're doing regexp matches
# rather than parsing tokens
result = self._optional_group_matcher.sub(lambda m: "%s" % m.group(1), result)
# handle named groups first
result = self._named_group_matcher.sub(lambda m: "{%s}" % m.group(1), result)
# handle non-named groups
result = self._non_named_group_matcher.sub("{var}", result)
# handle optional params
result = self._either_option_matcher.sub(lambda m: m.group(1), result)
# clean up any outstanding regex-y characters.
result = (
result.replace("^", "")
.replace("$", "")
.replace("?", "")
.replace("\\A", "")
.replace("\\Z", "")
.replace("//", "/")
.replace("\\", "")
)
return result
def _resolve(self, resolver, path, parents=None):
# type: (URLResolver, str, Optional[List[URLResolver]]) -> Optional[str]
match = get_regex(resolver).search(path) # Django < 2.0
if not match:
return None
if parents is None:
parents = [resolver]
elif resolver not in parents:
parents = parents + [resolver]
new_path = path[match.end() :]
for pattern in resolver.url_patterns:
# this is an include()
if not pattern.callback:
match_ = self._resolve(pattern, new_path, parents)
if match_:
return match_
continue
elif not get_regex(pattern).search(new_path):
continue
try:
return self._cache[pattern]
except KeyError:
pass
prefix = "".join(self._simplify(p) for p in parents)
result = prefix + self._simplify(pattern)
if not result.startswith("/"):
result = "/" + result
self._cache[pattern] = result
return result
return None
def resolve(
self,
path, # type: str
urlconf=None, # type: Union[None, Tuple[URLPattern, URLPattern, URLResolver], Tuple[URLPattern]]
):
# type: (...) -> Optional[str]
resolver = get_resolver(urlconf)
match = self._resolve(resolver, path)
return match
LEGACY_RESOLVER = RavenResolver()
@@ -0,0 +1,96 @@
import functools
import sentry_sdk
from sentry_sdk.consts import OP
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from typing import Any
try:
from asyncio import iscoroutinefunction
except ImportError:
iscoroutinefunction = None # type: ignore
try:
from sentry_sdk.integrations.django.asgi import wrap_async_view
except (ImportError, SyntaxError):
wrap_async_view = None # type: ignore
def patch_views():
# type: () -> None
from django.core.handlers.base import BaseHandler
from django.template.response import SimpleTemplateResponse
from sentry_sdk.integrations.django import DjangoIntegration
old_make_view_atomic = BaseHandler.make_view_atomic
old_render = SimpleTemplateResponse.render
def sentry_patched_render(self):
# type: (SimpleTemplateResponse) -> Any
with sentry_sdk.start_span(
op=OP.VIEW_RESPONSE_RENDER,
name="serialize response",
origin=DjangoIntegration.origin,
):
return old_render(self)
@functools.wraps(old_make_view_atomic)
def sentry_patched_make_view_atomic(self, *args, **kwargs):
# type: (Any, *Any, **Any) -> Any
callback = old_make_view_atomic(self, *args, **kwargs)
# XXX: The wrapper function is created for every request. Find more
# efficient way to wrap views (or build a cache?)
integration = sentry_sdk.get_client().get_integration(DjangoIntegration)
if integration is not None and integration.middleware_spans:
is_async_view = (
iscoroutinefunction is not None
and wrap_async_view is not None
and iscoroutinefunction(callback)
)
if is_async_view:
sentry_wrapped_callback = wrap_async_view(callback)
else:
sentry_wrapped_callback = _wrap_sync_view(callback)
else:
sentry_wrapped_callback = callback
return sentry_wrapped_callback
SimpleTemplateResponse.render = sentry_patched_render
BaseHandler.make_view_atomic = sentry_patched_make_view_atomic
def _wrap_sync_view(callback):
# type: (Any) -> Any
from sentry_sdk.integrations.django import DjangoIntegration
@functools.wraps(callback)
def sentry_wrapped_callback(request, *args, **kwargs):
# type: (Any, *Any, **Any) -> Any
current_scope = sentry_sdk.get_current_scope()
if current_scope.transaction is not None:
current_scope.transaction.update_active_thread()
sentry_scope = sentry_sdk.get_isolation_scope()
# set the active thread id to the handler thread for sync views
# this isn't necessary for async views since that runs on main
if sentry_scope.profile is not None:
sentry_scope.profile.update_active_thread_id()
with sentry_sdk.start_span(
op=OP.VIEW_RENDER,
name=request.resolver_match.view_name,
origin=DjangoIntegration.origin,
):
return callback(request, *args, **kwargs)
return sentry_wrapped_callback
@@ -0,0 +1,168 @@
import json
import sentry_sdk
from sentry_sdk.integrations import Integration
from sentry_sdk.integrations._wsgi_common import request_body_within_bounds
from sentry_sdk.utils import (
AnnotatedValue,
capture_internal_exceptions,
event_from_exception,
)
from dramatiq.broker import Broker # type: ignore
from dramatiq.message import Message # type: ignore
from dramatiq.middleware import Middleware, default_middleware # type: ignore
from dramatiq.errors import Retry # type: ignore
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from typing import Any, Callable, Dict, Optional, Union
from sentry_sdk._types import Event, Hint
class DramatiqIntegration(Integration):
"""
Dramatiq integration for Sentry
Please make sure that you call `sentry_sdk.init` *before* initializing
your broker, as it monkey patches `Broker.__init__`.
This integration was originally developed and maintained
by https://github.com/jacobsvante and later donated to the Sentry
project.
"""
identifier = "dramatiq"
@staticmethod
def setup_once():
# type: () -> None
_patch_dramatiq_broker()
def _patch_dramatiq_broker():
# type: () -> None
original_broker__init__ = Broker.__init__
def sentry_patched_broker__init__(self, *args, **kw):
# type: (Broker, *Any, **Any) -> None
integration = sentry_sdk.get_client().get_integration(DramatiqIntegration)
try:
middleware = kw.pop("middleware")
except KeyError:
# Unfortunately Broker and StubBroker allows middleware to be
# passed in as positional arguments, whilst RabbitmqBroker and
# RedisBroker does not.
if len(args) == 1:
middleware = args[0]
args = [] # type: ignore
else:
middleware = None
if middleware is None:
middleware = list(m() for m in default_middleware)
else:
middleware = list(middleware)
if integration is not None:
middleware = [m for m in middleware if not isinstance(m, SentryMiddleware)]
middleware.insert(0, SentryMiddleware())
kw["middleware"] = middleware
original_broker__init__(self, *args, **kw)
Broker.__init__ = sentry_patched_broker__init__
class SentryMiddleware(Middleware): # type: ignore[misc]
"""
A Dramatiq middleware that automatically captures and sends
exceptions to Sentry.
This is automatically added to every instantiated broker via the
DramatiqIntegration.
"""
def before_process_message(self, broker, message):
# type: (Broker, Message) -> None
integration = sentry_sdk.get_client().get_integration(DramatiqIntegration)
if integration is None:
return
message._scope_manager = sentry_sdk.new_scope()
message._scope_manager.__enter__()
scope = sentry_sdk.get_current_scope()
scope.transaction = message.actor_name
scope.set_extra("dramatiq_message_id", message.message_id)
scope.add_event_processor(_make_message_event_processor(message, integration))
def after_process_message(self, broker, message, *, result=None, exception=None):
# type: (Broker, Message, Any, Optional[Any], Optional[Exception]) -> None
integration = sentry_sdk.get_client().get_integration(DramatiqIntegration)
if integration is None:
return
actor = broker.get_actor(message.actor_name)
throws = message.options.get("throws") or actor.options.get("throws")
try:
if (
exception is not None
and not (throws and isinstance(exception, throws))
and not isinstance(exception, Retry)
):
event, hint = event_from_exception(
exception,
client_options=sentry_sdk.get_client().options,
mechanism={
"type": DramatiqIntegration.identifier,
"handled": False,
},
)
sentry_sdk.capture_event(event, hint=hint)
finally:
message._scope_manager.__exit__(None, None, None)
def _make_message_event_processor(message, integration):
# type: (Message, DramatiqIntegration) -> Callable[[Event, Hint], Optional[Event]]
def inner(event, hint):
# type: (Event, Hint) -> Optional[Event]
with capture_internal_exceptions():
DramatiqMessageExtractor(message).extract_into_event(event)
return event
return inner
class DramatiqMessageExtractor:
def __init__(self, message):
# type: (Message) -> None
self.message_data = dict(message.asdict())
def content_length(self):
# type: () -> int
return len(json.dumps(self.message_data))
def extract_into_event(self, event):
# type: (Event) -> None
client = sentry_sdk.get_client()
if not client.is_active():
return
contexts = event.setdefault("contexts", {})
request_info = contexts.setdefault("dramatiq", {})
request_info["type"] = "dramatiq"
data = None # type: Optional[Union[AnnotatedValue, Dict[str, Any]]]
if not request_body_within_bounds(client, self.content_length()):
data = AnnotatedValue.removed_because_over_size_limit()
else:
data = self.message_data
request_info["data"] = data
@@ -0,0 +1,83 @@
import sys
import sentry_sdk
from sentry_sdk.utils import (
capture_internal_exceptions,
event_from_exception,
)
from sentry_sdk.integrations import Integration
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from typing import Callable
from typing import Any
from typing import Type
from typing import Optional
from types import TracebackType
Excepthook = Callable[
[Type[BaseException], BaseException, Optional[TracebackType]],
Any,
]
class ExcepthookIntegration(Integration):
identifier = "excepthook"
always_run = False
def __init__(self, always_run=False):
# type: (bool) -> None
if not isinstance(always_run, bool):
raise ValueError(
"Invalid value for always_run: %s (must be type boolean)"
% (always_run,)
)
self.always_run = always_run
@staticmethod
def setup_once():
# type: () -> None
sys.excepthook = _make_excepthook(sys.excepthook)
def _make_excepthook(old_excepthook):
# type: (Excepthook) -> Excepthook
def sentry_sdk_excepthook(type_, value, traceback):
# type: (Type[BaseException], BaseException, Optional[TracebackType]) -> None
integration = sentry_sdk.get_client().get_integration(ExcepthookIntegration)
# Note: If we replace this with ensure_integration_enabled then
# we break the exceptiongroup backport;
# See: https://github.com/getsentry/sentry-python/issues/3097
if integration is None:
return old_excepthook(type_, value, traceback)
if _should_send(integration.always_run):
with capture_internal_exceptions():
event, hint = event_from_exception(
(type_, value, traceback),
client_options=sentry_sdk.get_client().options,
mechanism={"type": "excepthook", "handled": False},
)
sentry_sdk.capture_event(event, hint=hint)
return old_excepthook(type_, value, traceback)
return sentry_sdk_excepthook
def _should_send(always_run=False):
# type: (bool) -> bool
if always_run:
return True
if hasattr(sys, "ps1"):
# Disable the excepthook for interactive Python shells, otherwise
# every typo gets sent to Sentry.
return False
return True
@@ -0,0 +1,67 @@
import sentry_sdk
from sentry_sdk.integrations import Integration, DidNotEnable
from sentry_sdk.scope import add_global_event_processor
from sentry_sdk.utils import walk_exception_chain, iter_stacks
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from typing import Optional
from sentry_sdk._types import Event, Hint
try:
import executing
except ImportError:
raise DidNotEnable("executing is not installed")
class ExecutingIntegration(Integration):
identifier = "executing"
@staticmethod
def setup_once():
# type: () -> None
@add_global_event_processor
def add_executing_info(event, hint):
# type: (Event, Optional[Hint]) -> Optional[Event]
if sentry_sdk.get_client().get_integration(ExecutingIntegration) is None:
return event
if hint is None:
return event
exc_info = hint.get("exc_info", None)
if exc_info is None:
return event
exception = event.get("exception", None)
if exception is None:
return event
values = exception.get("values", None)
if values is None:
return event
for exception, (_exc_type, _exc_value, exc_tb) in zip(
reversed(values), walk_exception_chain(exc_info)
):
sentry_frames = [
frame
for frame in exception.get("stacktrace", {}).get("frames", [])
if frame.get("function")
]
tbs = list(iter_stacks(exc_tb))
if len(sentry_frames) != len(tbs):
continue
for sentry_frame, tb in zip(sentry_frames, tbs):
frame = tb.tb_frame
source = executing.Source.for_frame(frame)
sentry_frame["function"] = source.code_qualname(frame.f_code)
return event
@@ -0,0 +1,272 @@
import sentry_sdk
from sentry_sdk.integrations import _check_minimum_version, Integration, DidNotEnable
from sentry_sdk.integrations._wsgi_common import RequestExtractor
from sentry_sdk.integrations.wsgi import SentryWsgiMiddleware
from sentry_sdk.tracing import SOURCE_FOR_STYLE
from sentry_sdk.utils import (
capture_internal_exceptions,
ensure_integration_enabled,
event_from_exception,
parse_version,
)
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from typing import Any
from typing import Dict
from typing import Optional
from sentry_sdk._types import Event, EventProcessor
# In Falcon 3.0 `falcon.api_helpers` is renamed to `falcon.app_helpers`
# and `falcon.API` to `falcon.App`
try:
import falcon # type: ignore
from falcon import __version__ as FALCON_VERSION
except ImportError:
raise DidNotEnable("Falcon not installed")
try:
import falcon.app_helpers # type: ignore
falcon_helpers = falcon.app_helpers
falcon_app_class = falcon.App
FALCON3 = True
except ImportError:
import falcon.api_helpers # type: ignore
falcon_helpers = falcon.api_helpers
falcon_app_class = falcon.API
FALCON3 = False
_FALCON_UNSET = None # type: Optional[object]
if FALCON3: # falcon.request._UNSET is only available in Falcon 3.0+
with capture_internal_exceptions():
from falcon.request import _UNSET as _FALCON_UNSET # type: ignore[import-not-found, no-redef]
class FalconRequestExtractor(RequestExtractor):
def env(self):
# type: () -> Dict[str, Any]
return self.request.env
def cookies(self):
# type: () -> Dict[str, Any]
return self.request.cookies
def form(self):
# type: () -> None
return None # No such concept in Falcon
def files(self):
# type: () -> None
return None # No such concept in Falcon
def raw_data(self):
# type: () -> Optional[str]
# As request data can only be read once we won't make this available
# to Sentry. Just send back a dummy string in case there was a
# content length.
# TODO(jmagnusson): Figure out if there's a way to support this
content_length = self.content_length()
if content_length > 0:
return "[REQUEST_CONTAINING_RAW_DATA]"
else:
return None
def json(self):
# type: () -> Optional[Dict[str, Any]]
# fallback to cached_media = None if self.request._media is not available
cached_media = None
with capture_internal_exceptions():
# self.request._media is the cached self.request.media
# value. It is only available if self.request.media
# has already been accessed. Therefore, reading
# self.request._media will not exhaust the raw request
# stream (self.request.bounded_stream) because it has
# already been read if self.request._media is set.
cached_media = self.request._media
if cached_media is not _FALCON_UNSET:
return cached_media
return None
class SentryFalconMiddleware:
"""Captures exceptions in Falcon requests and send to Sentry"""
def process_request(self, req, resp, *args, **kwargs):
# type: (Any, Any, *Any, **Any) -> None
integration = sentry_sdk.get_client().get_integration(FalconIntegration)
if integration is None:
return
scope = sentry_sdk.get_isolation_scope()
scope._name = "falcon"
scope.add_event_processor(_make_request_event_processor(req, integration))
TRANSACTION_STYLE_VALUES = ("uri_template", "path")
class FalconIntegration(Integration):
identifier = "falcon"
origin = f"auto.http.{identifier}"
transaction_style = ""
def __init__(self, transaction_style="uri_template"):
# type: (str) -> None
if transaction_style not in TRANSACTION_STYLE_VALUES:
raise ValueError(
"Invalid value for transaction_style: %s (must be in %s)"
% (transaction_style, TRANSACTION_STYLE_VALUES)
)
self.transaction_style = transaction_style
@staticmethod
def setup_once():
# type: () -> None
version = parse_version(FALCON_VERSION)
_check_minimum_version(FalconIntegration, version)
_patch_wsgi_app()
_patch_handle_exception()
_patch_prepare_middleware()
def _patch_wsgi_app():
# type: () -> None
original_wsgi_app = falcon_app_class.__call__
def sentry_patched_wsgi_app(self, env, start_response):
# type: (falcon.API, Any, Any) -> Any
integration = sentry_sdk.get_client().get_integration(FalconIntegration)
if integration is None:
return original_wsgi_app(self, env, start_response)
sentry_wrapped = SentryWsgiMiddleware(
lambda envi, start_resp: original_wsgi_app(self, envi, start_resp),
span_origin=FalconIntegration.origin,
)
return sentry_wrapped(env, start_response)
falcon_app_class.__call__ = sentry_patched_wsgi_app
def _patch_handle_exception():
# type: () -> None
original_handle_exception = falcon_app_class._handle_exception
@ensure_integration_enabled(FalconIntegration, original_handle_exception)
def sentry_patched_handle_exception(self, *args):
# type: (falcon.API, *Any) -> Any
# NOTE(jmagnusson): falcon 2.0 changed falcon.API._handle_exception
# method signature from `(ex, req, resp, params)` to
# `(req, resp, ex, params)`
ex = response = None
with capture_internal_exceptions():
ex = next(argument for argument in args if isinstance(argument, Exception))
response = next(
argument for argument in args if isinstance(argument, falcon.Response)
)
was_handled = original_handle_exception(self, *args)
if ex is None or response is None:
# Both ex and response should have a non-None value at this point; otherwise,
# there is an error with the SDK that will have been captured in the
# capture_internal_exceptions block above.
return was_handled
if _exception_leads_to_http_5xx(ex, response):
event, hint = event_from_exception(
ex,
client_options=sentry_sdk.get_client().options,
mechanism={"type": "falcon", "handled": False},
)
sentry_sdk.capture_event(event, hint=hint)
return was_handled
falcon_app_class._handle_exception = sentry_patched_handle_exception
def _patch_prepare_middleware():
# type: () -> None
original_prepare_middleware = falcon_helpers.prepare_middleware
def sentry_patched_prepare_middleware(
middleware=None, independent_middleware=False, asgi=False
):
# type: (Any, Any, bool) -> Any
if asgi:
# We don't support ASGI Falcon apps, so we don't patch anything here
return original_prepare_middleware(middleware, independent_middleware, asgi)
integration = sentry_sdk.get_client().get_integration(FalconIntegration)
if integration is not None:
middleware = [SentryFalconMiddleware()] + (middleware or [])
# We intentionally omit the asgi argument here, since the default is False anyways,
# and this way, we remain backwards-compatible with pre-3.0.0 Falcon versions.
return original_prepare_middleware(middleware, independent_middleware)
falcon_helpers.prepare_middleware = sentry_patched_prepare_middleware
def _exception_leads_to_http_5xx(ex, response):
# type: (Exception, falcon.Response) -> bool
is_server_error = isinstance(ex, falcon.HTTPError) and (ex.status or "").startswith(
"5"
)
is_unhandled_error = not isinstance(
ex, (falcon.HTTPError, falcon.http_status.HTTPStatus)
)
# We only check the HTTP status on Falcon 3 because in Falcon 2, the status on the response
# at the stage where we capture it is listed as 200, even though we would expect to see a 500
# status. Since at the time of this change, Falcon 2 is ca. 4 years old, we have decided to
# only perform this check on Falcon 3+, despite the risk that some handled errors might be
# reported to Sentry as unhandled on Falcon 2.
return (is_server_error or is_unhandled_error) and (
not FALCON3 or _has_http_5xx_status(response)
)
def _has_http_5xx_status(response):
# type: (falcon.Response) -> bool
return response.status.startswith("5")
def _set_transaction_name_and_source(event, transaction_style, request):
# type: (Event, str, falcon.Request) -> None
name_for_style = {
"uri_template": request.uri_template,
"path": request.path,
}
event["transaction"] = name_for_style[transaction_style]
event["transaction_info"] = {"source": SOURCE_FOR_STYLE[transaction_style]}
def _make_request_event_processor(req, integration):
# type: (falcon.Request, FalconIntegration) -> EventProcessor
def event_processor(event, hint):
# type: (Event, dict[str, Any]) -> Event
_set_transaction_name_and_source(event, integration.transaction_style, req)
with capture_internal_exceptions():
FalconRequestExtractor(req).extract_into_event(event)
return event
return event_processor
@@ -0,0 +1,147 @@
import asyncio
from copy import deepcopy
from functools import wraps
import sentry_sdk
from sentry_sdk.integrations import DidNotEnable
from sentry_sdk.scope import should_send_default_pii
from sentry_sdk.tracing import SOURCE_FOR_STYLE, TRANSACTION_SOURCE_ROUTE
from sentry_sdk.utils import (
transaction_from_function,
logger,
)
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from typing import Any, Callable, Dict
from sentry_sdk._types import Event
try:
from sentry_sdk.integrations.starlette import (
StarletteIntegration,
StarletteRequestExtractor,
)
except DidNotEnable:
raise DidNotEnable("Starlette is not installed")
try:
import fastapi # type: ignore
except ImportError:
raise DidNotEnable("FastAPI is not installed")
_DEFAULT_TRANSACTION_NAME = "generic FastAPI request"
class FastApiIntegration(StarletteIntegration):
identifier = "fastapi"
@staticmethod
def setup_once():
# type: () -> None
patch_get_request_handler()
def _set_transaction_name_and_source(scope, transaction_style, request):
# type: (sentry_sdk.Scope, str, Any) -> None
name = ""
if transaction_style == "endpoint":
endpoint = request.scope.get("endpoint")
if endpoint:
name = transaction_from_function(endpoint) or ""
elif transaction_style == "url":
route = request.scope.get("route")
if route:
path = getattr(route, "path", None)
if path is not None:
name = path
if not name:
name = _DEFAULT_TRANSACTION_NAME
source = TRANSACTION_SOURCE_ROUTE
else:
source = SOURCE_FOR_STYLE[transaction_style]
scope.set_transaction_name(name, source=source)
logger.debug(
"[FastAPI] Set transaction name and source on scope: %s / %s", name, source
)
def patch_get_request_handler():
# type: () -> None
old_get_request_handler = fastapi.routing.get_request_handler
def _sentry_get_request_handler(*args, **kwargs):
# type: (*Any, **Any) -> Any
dependant = kwargs.get("dependant")
if (
dependant
and dependant.call is not None
and not asyncio.iscoroutinefunction(dependant.call)
):
old_call = dependant.call
@wraps(old_call)
def _sentry_call(*args, **kwargs):
# type: (*Any, **Any) -> Any
current_scope = sentry_sdk.get_current_scope()
if current_scope.transaction is not None:
current_scope.transaction.update_active_thread()
sentry_scope = sentry_sdk.get_isolation_scope()
if sentry_scope.profile is not None:
sentry_scope.profile.update_active_thread_id()
return old_call(*args, **kwargs)
dependant.call = _sentry_call
old_app = old_get_request_handler(*args, **kwargs)
async def _sentry_app(*args, **kwargs):
# type: (*Any, **Any) -> Any
integration = sentry_sdk.get_client().get_integration(FastApiIntegration)
if integration is None:
return await old_app(*args, **kwargs)
request = args[0]
_set_transaction_name_and_source(
sentry_sdk.get_current_scope(), integration.transaction_style, request
)
sentry_scope = sentry_sdk.get_isolation_scope()
extractor = StarletteRequestExtractor(request)
info = await extractor.extract_request_info()
def _make_request_event_processor(req, integration):
# type: (Any, Any) -> Callable[[Event, Dict[str, Any]], Event]
def event_processor(event, hint):
# type: (Event, Dict[str, Any]) -> Event
# Extract information from request
request_info = event.get("request", {})
if info:
if "cookies" in info and should_send_default_pii():
request_info["cookies"] = info["cookies"]
if "data" in info:
request_info["data"] = info["data"]
event["request"] = deepcopy(request_info)
return event
return event_processor
sentry_scope._name = FastApiIntegration.identifier
sentry_scope.add_event_processor(
_make_request_event_processor(request, integration)
)
return await old_app(*args, **kwargs)
return _sentry_app
fastapi.routing.get_request_handler = _sentry_get_request_handler
@@ -0,0 +1,263 @@
import sentry_sdk
from sentry_sdk.integrations import _check_minimum_version, DidNotEnable, Integration
from sentry_sdk.integrations._wsgi_common import (
DEFAULT_HTTP_METHODS_TO_CAPTURE,
RequestExtractor,
)
from sentry_sdk.integrations.wsgi import SentryWsgiMiddleware
from sentry_sdk.scope import should_send_default_pii
from sentry_sdk.tracing import SOURCE_FOR_STYLE
from sentry_sdk.utils import (
capture_internal_exceptions,
ensure_integration_enabled,
event_from_exception,
package_version,
)
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from typing import Any, Callable, Dict, Union
from sentry_sdk._types import Event, EventProcessor
from sentry_sdk.integrations.wsgi import _ScopedResponse
from werkzeug.datastructures import FileStorage, ImmutableMultiDict
try:
import flask_login # type: ignore
except ImportError:
flask_login = None
try:
from flask import Flask, Request # type: ignore
from flask import request as flask_request
from flask.signals import (
before_render_template,
got_request_exception,
request_started,
)
from markupsafe import Markup
except ImportError:
raise DidNotEnable("Flask is not installed")
try:
import blinker # noqa
except ImportError:
raise DidNotEnable("blinker is not installed")
TRANSACTION_STYLE_VALUES = ("endpoint", "url")
class FlaskIntegration(Integration):
identifier = "flask"
origin = f"auto.http.{identifier}"
transaction_style = ""
def __init__(
self,
transaction_style="endpoint", # type: str
http_methods_to_capture=DEFAULT_HTTP_METHODS_TO_CAPTURE, # type: tuple[str, ...]
):
# type: (...) -> None
if transaction_style not in TRANSACTION_STYLE_VALUES:
raise ValueError(
"Invalid value for transaction_style: %s (must be in %s)"
% (transaction_style, TRANSACTION_STYLE_VALUES)
)
self.transaction_style = transaction_style
self.http_methods_to_capture = tuple(map(str.upper, http_methods_to_capture))
@staticmethod
def setup_once():
# type: () -> None
version = package_version("flask")
_check_minimum_version(FlaskIntegration, version)
before_render_template.connect(_add_sentry_trace)
request_started.connect(_request_started)
got_request_exception.connect(_capture_exception)
old_app = Flask.__call__
def sentry_patched_wsgi_app(self, environ, start_response):
# type: (Any, Dict[str, str], Callable[..., Any]) -> _ScopedResponse
if sentry_sdk.get_client().get_integration(FlaskIntegration) is None:
return old_app(self, environ, start_response)
integration = sentry_sdk.get_client().get_integration(FlaskIntegration)
middleware = SentryWsgiMiddleware(
lambda *a, **kw: old_app(self, *a, **kw),
span_origin=FlaskIntegration.origin,
http_methods_to_capture=(
integration.http_methods_to_capture
if integration
else DEFAULT_HTTP_METHODS_TO_CAPTURE
),
)
return middleware(environ, start_response)
Flask.__call__ = sentry_patched_wsgi_app
def _add_sentry_trace(sender, template, context, **extra):
# type: (Flask, Any, Dict[str, Any], **Any) -> None
if "sentry_trace" in context:
return
scope = sentry_sdk.get_current_scope()
trace_meta = Markup(scope.trace_propagation_meta())
context["sentry_trace"] = trace_meta # for backwards compatibility
context["sentry_trace_meta"] = trace_meta
def _set_transaction_name_and_source(scope, transaction_style, request):
# type: (sentry_sdk.Scope, str, Request) -> None
try:
name_for_style = {
"url": request.url_rule.rule,
"endpoint": request.url_rule.endpoint,
}
scope.set_transaction_name(
name_for_style[transaction_style],
source=SOURCE_FOR_STYLE[transaction_style],
)
except Exception:
pass
def _request_started(app, **kwargs):
# type: (Flask, **Any) -> None
integration = sentry_sdk.get_client().get_integration(FlaskIntegration)
if integration is None:
return
request = flask_request._get_current_object()
# Set the transaction name and source here,
# but rely on WSGI middleware to actually start the transaction
_set_transaction_name_and_source(
sentry_sdk.get_current_scope(), integration.transaction_style, request
)
scope = sentry_sdk.get_isolation_scope()
evt_processor = _make_request_event_processor(app, request, integration)
scope.add_event_processor(evt_processor)
class FlaskRequestExtractor(RequestExtractor):
def env(self):
# type: () -> Dict[str, str]
return self.request.environ
def cookies(self):
# type: () -> Dict[Any, Any]
return {
k: v[0] if isinstance(v, list) and len(v) == 1 else v
for k, v in self.request.cookies.items()
}
def raw_data(self):
# type: () -> bytes
return self.request.get_data()
def form(self):
# type: () -> ImmutableMultiDict[str, Any]
return self.request.form
def files(self):
# type: () -> ImmutableMultiDict[str, Any]
return self.request.files
def is_json(self):
# type: () -> bool
return self.request.is_json
def json(self):
# type: () -> Any
return self.request.get_json(silent=True)
def size_of_file(self, file):
# type: (FileStorage) -> int
return file.content_length
def _make_request_event_processor(app, request, integration):
# type: (Flask, Callable[[], Request], FlaskIntegration) -> EventProcessor
def inner(event, hint):
# type: (Event, dict[str, Any]) -> Event
# if the request is gone we are fine not logging the data from
# it. This might happen if the processor is pushed away to
# another thread.
if request is None:
return event
with capture_internal_exceptions():
FlaskRequestExtractor(request).extract_into_event(event)
if should_send_default_pii():
with capture_internal_exceptions():
_add_user_to_event(event)
return event
return inner
@ensure_integration_enabled(FlaskIntegration)
def _capture_exception(sender, exception, **kwargs):
# type: (Flask, Union[ValueError, BaseException], **Any) -> None
event, hint = event_from_exception(
exception,
client_options=sentry_sdk.get_client().options,
mechanism={"type": "flask", "handled": False},
)
sentry_sdk.capture_event(event, hint=hint)
def _add_user_to_event(event):
# type: (Event) -> None
if flask_login is None:
return
user = flask_login.current_user
if user is None:
return
with capture_internal_exceptions():
# Access this object as late as possible as accessing the user
# is relatively costly
user_info = event.setdefault("user", {})
try:
user_info.setdefault("id", user.get_id())
# TODO: more configurable user attrs here
except AttributeError:
# might happen if:
# - flask_login could not be imported
# - flask_login is not configured
# - no user is logged in
pass
# The following attribute accesses are ineffective for the general
# Flask-Login case, because the User interface of Flask-Login does not
# care about anything but the ID. However, Flask-User (based on
# Flask-Login) documents a few optional extra attributes.
#
# https://github.com/lingthio/Flask-User/blob/a379fa0a281789618c484b459cb41236779b95b1/docs/source/data_models.rst#fixed-data-model-property-names
try:
user_info.setdefault("email", user.email)
except Exception:
pass
try:
user_info.setdefault("username", user.username)
except Exception:
pass
@@ -0,0 +1,234 @@
import functools
import sys
from copy import deepcopy
from datetime import datetime, timedelta, timezone
from os import environ
import sentry_sdk
from sentry_sdk.api import continue_trace
from sentry_sdk.consts import OP
from sentry_sdk.integrations import Integration
from sentry_sdk.integrations._wsgi_common import _filter_headers
from sentry_sdk.scope import should_send_default_pii
from sentry_sdk.tracing import TRANSACTION_SOURCE_COMPONENT
from sentry_sdk.utils import (
AnnotatedValue,
capture_internal_exceptions,
event_from_exception,
logger,
TimeoutThread,
reraise,
)
from typing import TYPE_CHECKING
# Constants
TIMEOUT_WARNING_BUFFER = 1.5 # Buffer time required to send timeout warning to Sentry
MILLIS_TO_SECONDS = 1000.0
if TYPE_CHECKING:
from typing import Any
from typing import TypeVar
from typing import Callable
from typing import Optional
from sentry_sdk._types import EventProcessor, Event, Hint
F = TypeVar("F", bound=Callable[..., Any])
def _wrap_func(func):
# type: (F) -> F
@functools.wraps(func)
def sentry_func(functionhandler, gcp_event, *args, **kwargs):
# type: (Any, Any, *Any, **Any) -> Any
client = sentry_sdk.get_client()
integration = client.get_integration(GcpIntegration)
if integration is None:
return func(functionhandler, gcp_event, *args, **kwargs)
configured_time = environ.get("FUNCTION_TIMEOUT_SEC")
if not configured_time:
logger.debug(
"The configured timeout could not be fetched from Cloud Functions configuration."
)
return func(functionhandler, gcp_event, *args, **kwargs)
configured_time = int(configured_time)
initial_time = datetime.now(timezone.utc)
with sentry_sdk.isolation_scope() as scope:
with capture_internal_exceptions():
scope.clear_breadcrumbs()
scope.add_event_processor(
_make_request_event_processor(
gcp_event, configured_time, initial_time
)
)
scope.set_tag("gcp_region", environ.get("FUNCTION_REGION"))
timeout_thread = None
if (
integration.timeout_warning
and configured_time > TIMEOUT_WARNING_BUFFER
):
waiting_time = configured_time - TIMEOUT_WARNING_BUFFER
timeout_thread = TimeoutThread(waiting_time, configured_time)
# Starting the thread to raise timeout warning exception
timeout_thread.start()
headers = {}
if hasattr(gcp_event, "headers"):
headers = gcp_event.headers
transaction = continue_trace(
headers,
op=OP.FUNCTION_GCP,
name=environ.get("FUNCTION_NAME", ""),
source=TRANSACTION_SOURCE_COMPONENT,
origin=GcpIntegration.origin,
)
sampling_context = {
"gcp_env": {
"function_name": environ.get("FUNCTION_NAME"),
"function_entry_point": environ.get("ENTRY_POINT"),
"function_identity": environ.get("FUNCTION_IDENTITY"),
"function_region": environ.get("FUNCTION_REGION"),
"function_project": environ.get("GCP_PROJECT"),
},
"gcp_event": gcp_event,
}
with sentry_sdk.start_transaction(
transaction, custom_sampling_context=sampling_context
):
try:
return func(functionhandler, gcp_event, *args, **kwargs)
except Exception:
exc_info = sys.exc_info()
sentry_event, hint = event_from_exception(
exc_info,
client_options=client.options,
mechanism={"type": "gcp", "handled": False},
)
sentry_sdk.capture_event(sentry_event, hint=hint)
reraise(*exc_info)
finally:
if timeout_thread:
timeout_thread.stop()
# Flush out the event queue
client.flush()
return sentry_func # type: ignore
class GcpIntegration(Integration):
identifier = "gcp"
origin = f"auto.function.{identifier}"
def __init__(self, timeout_warning=False):
# type: (bool) -> None
self.timeout_warning = timeout_warning
@staticmethod
def setup_once():
# type: () -> None
import __main__ as gcp_functions
if not hasattr(gcp_functions, "worker_v1"):
logger.warning(
"GcpIntegration currently supports only Python 3.7 runtime environment."
)
return
worker1 = gcp_functions.worker_v1
worker1.FunctionHandler.invoke_user_function = _wrap_func(
worker1.FunctionHandler.invoke_user_function
)
def _make_request_event_processor(gcp_event, configured_timeout, initial_time):
# type: (Any, Any, Any) -> EventProcessor
def event_processor(event, hint):
# type: (Event, Hint) -> Optional[Event]
final_time = datetime.now(timezone.utc)
time_diff = final_time - initial_time
execution_duration_in_millis = time_diff / timedelta(milliseconds=1)
extra = event.setdefault("extra", {})
extra["google cloud functions"] = {
"function_name": environ.get("FUNCTION_NAME"),
"function_entry_point": environ.get("ENTRY_POINT"),
"function_identity": environ.get("FUNCTION_IDENTITY"),
"function_region": environ.get("FUNCTION_REGION"),
"function_project": environ.get("GCP_PROJECT"),
"execution_duration_in_millis": execution_duration_in_millis,
"configured_timeout_in_seconds": configured_timeout,
}
extra["google cloud logs"] = {
"url": _get_google_cloud_logs_url(final_time),
}
request = event.get("request", {})
request["url"] = "gcp:///{}".format(environ.get("FUNCTION_NAME"))
if hasattr(gcp_event, "method"):
request["method"] = gcp_event.method
if hasattr(gcp_event, "query_string"):
request["query_string"] = gcp_event.query_string.decode("utf-8")
if hasattr(gcp_event, "headers"):
request["headers"] = _filter_headers(gcp_event.headers)
if should_send_default_pii():
if hasattr(gcp_event, "data"):
request["data"] = gcp_event.data
else:
if hasattr(gcp_event, "data"):
# Unfortunately couldn't find a way to get structured body from GCP
# event. Meaning every body is unstructured to us.
request["data"] = AnnotatedValue.removed_because_raw_data()
event["request"] = deepcopy(request)
return event
return event_processor
def _get_google_cloud_logs_url(final_time):
# type: (datetime) -> str
"""
Generates a Google Cloud Logs console URL based on the environment variables
Arguments:
final_time {datetime} -- Final time
Returns:
str -- Google Cloud Logs Console URL to logs.
"""
hour_ago = final_time - timedelta(hours=1)
formatstring = "%Y-%m-%dT%H:%M:%SZ"
url = (
"https://console.cloud.google.com/logs/viewer?project={project}&resource=cloud_function"
"%2Ffunction_name%2F{function_name}%2Fregion%2F{region}&minLogLevel=0&expandAll=false"
"&timestamp={timestamp_end}&customFacets=&limitCustomFacetWidth=true"
"&dateRangeStart={timestamp_start}&dateRangeEnd={timestamp_end}"
"&interval=PT1H&scrollTimestamp={timestamp_end}"
).format(
project=environ.get("GCP_PROJECT"),
function_name=environ.get("FUNCTION_NAME"),
region=environ.get("FUNCTION_REGION"),
timestamp_end=final_time.strftime(formatstring),
timestamp_start=hour_ago.strftime(formatstring),
)
return url
@@ -0,0 +1,107 @@
import re
import sentry_sdk
from sentry_sdk.integrations import Integration
from sentry_sdk.scope import add_global_event_processor
from sentry_sdk.utils import capture_internal_exceptions
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from typing import Any
from sentry_sdk._types import Event
MODULE_RE = r"[a-zA-Z0-9/._:\\-]+"
TYPE_RE = r"[a-zA-Z0-9._:<>,-]+"
HEXVAL_RE = r"[A-Fa-f0-9]+"
FRAME_RE = r"""
^(?P<index>\d+)\.\s
(?P<package>{MODULE_RE})\(
(?P<retval>{TYPE_RE}\ )?
((?P<function>{TYPE_RE})
(?P<args>\(.*\))?
)?
((?P<constoffset>\ const)?\+0x(?P<offset>{HEXVAL_RE}))?
\)\s
\[0x(?P<retaddr>{HEXVAL_RE})\]$
""".format(
MODULE_RE=MODULE_RE, HEXVAL_RE=HEXVAL_RE, TYPE_RE=TYPE_RE
)
FRAME_RE = re.compile(FRAME_RE, re.MULTILINE | re.VERBOSE)
class GnuBacktraceIntegration(Integration):
identifier = "gnu_backtrace"
@staticmethod
def setup_once():
# type: () -> None
@add_global_event_processor
def process_gnu_backtrace(event, hint):
# type: (Event, dict[str, Any]) -> Event
with capture_internal_exceptions():
return _process_gnu_backtrace(event, hint)
def _process_gnu_backtrace(event, hint):
# type: (Event, dict[str, Any]) -> Event
if sentry_sdk.get_client().get_integration(GnuBacktraceIntegration) is None:
return event
exc_info = hint.get("exc_info", None)
if exc_info is None:
return event
exception = event.get("exception", None)
if exception is None:
return event
values = exception.get("values", None)
if values is None:
return event
for exception in values:
frames = exception.get("stacktrace", {}).get("frames", [])
if not frames:
continue
msg = exception.get("value", None)
if not msg:
continue
additional_frames = []
new_msg = []
for line in msg.splitlines():
match = FRAME_RE.match(line)
if match:
additional_frames.append(
(
int(match.group("index")),
{
"package": match.group("package") or None,
"function": match.group("function") or None,
"platform": "native",
},
)
)
else:
# Put garbage lines back into message, not sure what to do with them.
new_msg.append(line)
if additional_frames:
additional_frames.sort(key=lambda x: -x[0])
for _, frame in additional_frames:
frames.append(frame)
new_msg.append("<stacktrace parsed and removed by GnuBacktraceIntegration>")
exception["value"] = "\n".join(new_msg)
return event
@@ -0,0 +1,145 @@
import sentry_sdk
from sentry_sdk.utils import (
event_from_exception,
ensure_integration_enabled,
parse_version,
)
from sentry_sdk.integrations import _check_minimum_version, DidNotEnable, Integration
from sentry_sdk.scope import should_send_default_pii
try:
import gql # type: ignore[import-not-found]
from graphql import (
print_ast,
get_operation_ast,
DocumentNode,
VariableDefinitionNode,
)
from gql.transport import Transport, AsyncTransport # type: ignore[import-not-found]
from gql.transport.exceptions import TransportQueryError # type: ignore[import-not-found]
except ImportError:
raise DidNotEnable("gql is not installed")
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from typing import Any, Dict, Tuple, Union
from sentry_sdk._types import Event, EventProcessor
EventDataType = Dict[str, Union[str, Tuple[VariableDefinitionNode, ...]]]
class GQLIntegration(Integration):
identifier = "gql"
@staticmethod
def setup_once():
# type: () -> None
gql_version = parse_version(gql.__version__)
_check_minimum_version(GQLIntegration, gql_version)
_patch_execute()
def _data_from_document(document):
# type: (DocumentNode) -> EventDataType
try:
operation_ast = get_operation_ast(document)
data = {"query": print_ast(document)} # type: EventDataType
if operation_ast is not None:
data["variables"] = operation_ast.variable_definitions
if operation_ast.name is not None:
data["operationName"] = operation_ast.name.value
return data
except (AttributeError, TypeError):
return dict()
def _transport_method(transport):
# type: (Union[Transport, AsyncTransport]) -> str
"""
The RequestsHTTPTransport allows defining the HTTP method; all
other transports use POST.
"""
try:
return transport.method
except AttributeError:
return "POST"
def _request_info_from_transport(transport):
# type: (Union[Transport, AsyncTransport, None]) -> Dict[str, str]
if transport is None:
return {}
request_info = {
"method": _transport_method(transport),
}
try:
request_info["url"] = transport.url
except AttributeError:
pass
return request_info
def _patch_execute():
# type: () -> None
real_execute = gql.Client.execute
@ensure_integration_enabled(GQLIntegration, real_execute)
def sentry_patched_execute(self, document, *args, **kwargs):
# type: (gql.Client, DocumentNode, Any, Any) -> Any
scope = sentry_sdk.get_isolation_scope()
scope.add_event_processor(_make_gql_event_processor(self, document))
try:
return real_execute(self, document, *args, **kwargs)
except TransportQueryError as e:
event, hint = event_from_exception(
e,
client_options=sentry_sdk.get_client().options,
mechanism={"type": "gql", "handled": False},
)
sentry_sdk.capture_event(event, hint)
raise e
gql.Client.execute = sentry_patched_execute
def _make_gql_event_processor(client, document):
# type: (gql.Client, DocumentNode) -> EventProcessor
def processor(event, hint):
# type: (Event, dict[str, Any]) -> Event
try:
errors = hint["exc_info"][1].errors
except (AttributeError, KeyError):
errors = None
request = event.setdefault("request", {})
request.update(
{
"api_target": "graphql",
**_request_info_from_transport(client.transport),
}
)
if should_send_default_pii():
request["data"] = _data_from_document(document)
contexts = event.setdefault("contexts", {})
response = contexts.setdefault("response", {})
response.update(
{
"data": {"errors": errors},
"type": response,
}
)
return event
return processor
@@ -0,0 +1,151 @@
from contextlib import contextmanager
import sentry_sdk
from sentry_sdk.consts import OP
from sentry_sdk.integrations import _check_minimum_version, DidNotEnable, Integration
from sentry_sdk.scope import should_send_default_pii
from sentry_sdk.utils import (
capture_internal_exceptions,
ensure_integration_enabled,
event_from_exception,
package_version,
)
try:
from graphene.types import schema as graphene_schema # type: ignore
except ImportError:
raise DidNotEnable("graphene is not installed")
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from collections.abc import Generator
from typing import Any, Dict, Union
from graphene.language.source import Source # type: ignore
from graphql.execution import ExecutionResult
from graphql.type import GraphQLSchema
from sentry_sdk._types import Event
class GrapheneIntegration(Integration):
identifier = "graphene"
@staticmethod
def setup_once():
# type: () -> None
version = package_version("graphene")
_check_minimum_version(GrapheneIntegration, version)
_patch_graphql()
def _patch_graphql():
# type: () -> None
old_graphql_sync = graphene_schema.graphql_sync
old_graphql_async = graphene_schema.graphql
@ensure_integration_enabled(GrapheneIntegration, old_graphql_sync)
def _sentry_patched_graphql_sync(schema, source, *args, **kwargs):
# type: (GraphQLSchema, Union[str, Source], Any, Any) -> ExecutionResult
scope = sentry_sdk.get_isolation_scope()
scope.add_event_processor(_event_processor)
with graphql_span(schema, source, kwargs):
result = old_graphql_sync(schema, source, *args, **kwargs)
with capture_internal_exceptions():
client = sentry_sdk.get_client()
for error in result.errors or []:
event, hint = event_from_exception(
error,
client_options=client.options,
mechanism={
"type": GrapheneIntegration.identifier,
"handled": False,
},
)
sentry_sdk.capture_event(event, hint=hint)
return result
async def _sentry_patched_graphql_async(schema, source, *args, **kwargs):
# type: (GraphQLSchema, Union[str, Source], Any, Any) -> ExecutionResult
integration = sentry_sdk.get_client().get_integration(GrapheneIntegration)
if integration is None:
return await old_graphql_async(schema, source, *args, **kwargs)
scope = sentry_sdk.get_isolation_scope()
scope.add_event_processor(_event_processor)
with graphql_span(schema, source, kwargs):
result = await old_graphql_async(schema, source, *args, **kwargs)
with capture_internal_exceptions():
client = sentry_sdk.get_client()
for error in result.errors or []:
event, hint = event_from_exception(
error,
client_options=client.options,
mechanism={
"type": GrapheneIntegration.identifier,
"handled": False,
},
)
sentry_sdk.capture_event(event, hint=hint)
return result
graphene_schema.graphql_sync = _sentry_patched_graphql_sync
graphene_schema.graphql = _sentry_patched_graphql_async
def _event_processor(event, hint):
# type: (Event, Dict[str, Any]) -> Event
if should_send_default_pii():
request_info = event.setdefault("request", {})
request_info["api_target"] = "graphql"
elif event.get("request", {}).get("data"):
del event["request"]["data"]
return event
@contextmanager
def graphql_span(schema, source, kwargs):
# type: (GraphQLSchema, Union[str, Source], Dict[str, Any]) -> Generator[None, None, None]
operation_name = kwargs.get("operation_name")
operation_type = "query"
op = OP.GRAPHQL_QUERY
if source.strip().startswith("mutation"):
operation_type = "mutation"
op = OP.GRAPHQL_MUTATION
elif source.strip().startswith("subscription"):
operation_type = "subscription"
op = OP.GRAPHQL_SUBSCRIPTION
sentry_sdk.add_breadcrumb(
crumb={
"data": {
"operation_name": operation_name,
"operation_type": operation_type,
},
"category": "graphql.operation",
},
)
scope = sentry_sdk.get_current_scope()
if scope.span:
_graphql_span = scope.span.start_child(op=op, name=operation_name)
else:
_graphql_span = sentry_sdk.start_span(op=op, name=operation_name)
_graphql_span.set_data("graphql.document", source)
_graphql_span.set_data("graphql.operation.name", operation_name)
_graphql_span.set_data("graphql.operation.type", operation_type)
try:
yield
finally:
_graphql_span.finish()
@@ -0,0 +1,151 @@
from functools import wraps
import grpc
from grpc import Channel, Server, intercept_channel
from grpc.aio import Channel as AsyncChannel
from grpc.aio import Server as AsyncServer
from sentry_sdk.integrations import Integration
from .client import ClientInterceptor
from .server import ServerInterceptor
from .aio.server import ServerInterceptor as AsyncServerInterceptor
from .aio.client import (
SentryUnaryUnaryClientInterceptor as AsyncUnaryUnaryClientInterceptor,
)
from .aio.client import (
SentryUnaryStreamClientInterceptor as AsyncUnaryStreamClientIntercetor,
)
from typing import TYPE_CHECKING, Any, Optional, Sequence
# Hack to get new Python features working in older versions
# without introducing a hard dependency on `typing_extensions`
# from: https://stackoverflow.com/a/71944042/300572
if TYPE_CHECKING:
from typing import ParamSpec, Callable
else:
# Fake ParamSpec
class ParamSpec:
def __init__(self, _):
self.args = None
self.kwargs = None
# Callable[anything] will return None
class _Callable:
def __getitem__(self, _):
return None
# Make instances
Callable = _Callable()
P = ParamSpec("P")
def _wrap_channel_sync(func: Callable[P, Channel]) -> Callable[P, Channel]:
"Wrapper for synchronous secure and insecure channel."
@wraps(func)
def patched_channel(*args: Any, **kwargs: Any) -> Channel:
channel = func(*args, **kwargs)
if not ClientInterceptor._is_intercepted:
ClientInterceptor._is_intercepted = True
return intercept_channel(channel, ClientInterceptor())
else:
return channel
return patched_channel
def _wrap_intercept_channel(func: Callable[P, Channel]) -> Callable[P, Channel]:
@wraps(func)
def patched_intercept_channel(
channel: Channel, *interceptors: grpc.ServerInterceptor
) -> Channel:
if ClientInterceptor._is_intercepted:
interceptors = tuple(
[
interceptor
for interceptor in interceptors
if not isinstance(interceptor, ClientInterceptor)
]
)
else:
interceptors = interceptors
return intercept_channel(channel, *interceptors)
return patched_intercept_channel # type: ignore
def _wrap_channel_async(func: Callable[P, AsyncChannel]) -> Callable[P, AsyncChannel]:
"Wrapper for asynchronous secure and insecure channel."
@wraps(func)
def patched_channel( # type: ignore
*args: P.args,
interceptors: Optional[Sequence[grpc.aio.ClientInterceptor]] = None,
**kwargs: P.kwargs,
) -> Channel:
sentry_interceptors = [
AsyncUnaryUnaryClientInterceptor(),
AsyncUnaryStreamClientIntercetor(),
]
interceptors = [*sentry_interceptors, *(interceptors or [])]
return func(*args, interceptors=interceptors, **kwargs) # type: ignore
return patched_channel # type: ignore
def _wrap_sync_server(func: Callable[P, Server]) -> Callable[P, Server]:
"""Wrapper for synchronous server."""
@wraps(func)
def patched_server( # type: ignore
*args: P.args,
interceptors: Optional[Sequence[grpc.ServerInterceptor]] = None,
**kwargs: P.kwargs,
) -> Server:
interceptors = [
interceptor
for interceptor in interceptors or []
if not isinstance(interceptor, ServerInterceptor)
]
server_interceptor = ServerInterceptor()
interceptors = [server_interceptor, *(interceptors or [])]
return func(*args, interceptors=interceptors, **kwargs) # type: ignore
return patched_server # type: ignore
def _wrap_async_server(func: Callable[P, AsyncServer]) -> Callable[P, AsyncServer]:
"""Wrapper for asynchronous server."""
@wraps(func)
def patched_aio_server( # type: ignore
*args: P.args,
interceptors: Optional[Sequence[grpc.ServerInterceptor]] = None,
**kwargs: P.kwargs,
) -> Server:
server_interceptor = AsyncServerInterceptor()
interceptors = (server_interceptor, *(interceptors or []))
return func(*args, interceptors=interceptors, **kwargs) # type: ignore
return patched_aio_server # type: ignore
class GRPCIntegration(Integration):
identifier = "grpc"
@staticmethod
def setup_once() -> None:
import grpc
grpc.insecure_channel = _wrap_channel_sync(grpc.insecure_channel)
grpc.secure_channel = _wrap_channel_sync(grpc.secure_channel)
grpc.intercept_channel = _wrap_intercept_channel(grpc.intercept_channel)
grpc.aio.insecure_channel = _wrap_channel_async(grpc.aio.insecure_channel)
grpc.aio.secure_channel = _wrap_channel_async(grpc.aio.secure_channel)
grpc.server = _wrap_sync_server(grpc.server)
grpc.aio.server = _wrap_async_server(grpc.aio.server)
@@ -0,0 +1,7 @@
from .server import ServerInterceptor
from .client import ClientInterceptor
__all__ = [
"ClientInterceptor",
"ServerInterceptor",
]
@@ -0,0 +1,94 @@
from typing import Callable, Union, AsyncIterable, Any
from grpc.aio import (
UnaryUnaryClientInterceptor,
UnaryStreamClientInterceptor,
ClientCallDetails,
UnaryUnaryCall,
UnaryStreamCall,
Metadata,
)
from google.protobuf.message import Message
import sentry_sdk
from sentry_sdk.consts import OP
from sentry_sdk.integrations.grpc.consts import SPAN_ORIGIN
class ClientInterceptor:
@staticmethod
def _update_client_call_details_metadata_from_scope(
client_call_details: ClientCallDetails,
) -> ClientCallDetails:
if client_call_details.metadata is None:
client_call_details = client_call_details._replace(metadata=Metadata())
elif not isinstance(client_call_details.metadata, Metadata):
# This is a workaround for a GRPC bug, which was fixed in grpcio v1.60.0
# See https://github.com/grpc/grpc/issues/34298.
client_call_details = client_call_details._replace(
metadata=Metadata.from_tuple(client_call_details.metadata)
)
for (
key,
value,
) in sentry_sdk.get_current_scope().iter_trace_propagation_headers():
client_call_details.metadata.add(key, value)
return client_call_details
class SentryUnaryUnaryClientInterceptor(ClientInterceptor, UnaryUnaryClientInterceptor): # type: ignore
async def intercept_unary_unary(
self,
continuation: Callable[[ClientCallDetails, Message], UnaryUnaryCall],
client_call_details: ClientCallDetails,
request: Message,
) -> Union[UnaryUnaryCall, Message]:
method = client_call_details.method
with sentry_sdk.start_span(
op=OP.GRPC_CLIENT,
name="unary unary call to %s" % method.decode(),
origin=SPAN_ORIGIN,
) as span:
span.set_data("type", "unary unary")
span.set_data("method", method)
client_call_details = self._update_client_call_details_metadata_from_scope(
client_call_details
)
response = await continuation(client_call_details, request)
status_code = await response.code()
span.set_data("code", status_code.name)
return response
class SentryUnaryStreamClientInterceptor(
ClientInterceptor, UnaryStreamClientInterceptor # type: ignore
):
async def intercept_unary_stream(
self,
continuation: Callable[[ClientCallDetails, Message], UnaryStreamCall],
client_call_details: ClientCallDetails,
request: Message,
) -> Union[AsyncIterable[Any], UnaryStreamCall]:
method = client_call_details.method
with sentry_sdk.start_span(
op=OP.GRPC_CLIENT,
name="unary stream call to %s" % method.decode(),
origin=SPAN_ORIGIN,
) as span:
span.set_data("type", "unary stream")
span.set_data("method", method)
client_call_details = self._update_client_call_details_metadata_from_scope(
client_call_details
)
response = await continuation(client_call_details, request)
# status_code = await response.code()
# span.set_data("code", status_code)
return response
@@ -0,0 +1,100 @@
import sentry_sdk
from sentry_sdk.consts import OP
from sentry_sdk.integrations import DidNotEnable
from sentry_sdk.integrations.grpc.consts import SPAN_ORIGIN
from sentry_sdk.tracing import Transaction, TRANSACTION_SOURCE_CUSTOM
from sentry_sdk.utils import event_from_exception
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from collections.abc import Awaitable, Callable
from typing import Any, Optional
try:
import grpc
from grpc import HandlerCallDetails, RpcMethodHandler
from grpc.aio import AbortError, ServicerContext
except ImportError:
raise DidNotEnable("grpcio is not installed")
class ServerInterceptor(grpc.aio.ServerInterceptor): # type: ignore
def __init__(self, find_name=None):
# type: (ServerInterceptor, Callable[[ServicerContext], str] | None) -> None
self._find_method_name = find_name or self._find_name
super().__init__()
async def intercept_service(self, continuation, handler_call_details):
# type: (ServerInterceptor, Callable[[HandlerCallDetails], Awaitable[RpcMethodHandler]], HandlerCallDetails) -> Optional[Awaitable[RpcMethodHandler]]
self._handler_call_details = handler_call_details
handler = await continuation(handler_call_details)
if handler is None:
return None
if not handler.request_streaming and not handler.response_streaming:
handler_factory = grpc.unary_unary_rpc_method_handler
async def wrapped(request, context):
# type: (Any, ServicerContext) -> Any
name = self._find_method_name(context)
if not name:
return await handler(request, context)
# What if the headers are empty?
transaction = Transaction.continue_from_headers(
dict(context.invocation_metadata()),
op=OP.GRPC_SERVER,
name=name,
source=TRANSACTION_SOURCE_CUSTOM,
origin=SPAN_ORIGIN,
)
with sentry_sdk.start_transaction(transaction=transaction):
try:
return await handler.unary_unary(request, context)
except AbortError:
raise
except Exception as exc:
event, hint = event_from_exception(
exc,
mechanism={"type": "grpc", "handled": False},
)
sentry_sdk.capture_event(event, hint=hint)
raise
elif not handler.request_streaming and handler.response_streaming:
handler_factory = grpc.unary_stream_rpc_method_handler
async def wrapped(request, context): # type: ignore
# type: (Any, ServicerContext) -> Any
async for r in handler.unary_stream(request, context):
yield r
elif handler.request_streaming and not handler.response_streaming:
handler_factory = grpc.stream_unary_rpc_method_handler
async def wrapped(request, context):
# type: (Any, ServicerContext) -> Any
response = handler.stream_unary(request, context)
return await response
elif handler.request_streaming and handler.response_streaming:
handler_factory = grpc.stream_stream_rpc_method_handler
async def wrapped(request, context): # type: ignore
# type: (Any, ServicerContext) -> Any
async for r in handler.stream_stream(request, context):
yield r
return handler_factory(
wrapped,
request_deserializer=handler.request_deserializer,
response_serializer=handler.response_serializer,
)
def _find_name(self, context):
# type: (ServicerContext) -> str
return self._handler_call_details.method
@@ -0,0 +1,92 @@
import sentry_sdk
from sentry_sdk.consts import OP
from sentry_sdk.integrations import DidNotEnable
from sentry_sdk.integrations.grpc.consts import SPAN_ORIGIN
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from typing import Any, Callable, Iterator, Iterable, Union
try:
import grpc
from grpc import ClientCallDetails, Call
from grpc._interceptor import _UnaryOutcome
from grpc.aio._interceptor import UnaryStreamCall
from google.protobuf.message import Message
except ImportError:
raise DidNotEnable("grpcio is not installed")
class ClientInterceptor(
grpc.UnaryUnaryClientInterceptor, grpc.UnaryStreamClientInterceptor # type: ignore
):
_is_intercepted = False
def intercept_unary_unary(self, continuation, client_call_details, request):
# type: (ClientInterceptor, Callable[[ClientCallDetails, Message], _UnaryOutcome], ClientCallDetails, Message) -> _UnaryOutcome
method = client_call_details.method
with sentry_sdk.start_span(
op=OP.GRPC_CLIENT,
name="unary unary call to %s" % method,
origin=SPAN_ORIGIN,
) as span:
span.set_data("type", "unary unary")
span.set_data("method", method)
client_call_details = self._update_client_call_details_metadata_from_scope(
client_call_details
)
response = continuation(client_call_details, request)
span.set_data("code", response.code().name)
return response
def intercept_unary_stream(self, continuation, client_call_details, request):
# type: (ClientInterceptor, Callable[[ClientCallDetails, Message], Union[Iterable[Any], UnaryStreamCall]], ClientCallDetails, Message) -> Union[Iterator[Message], Call]
method = client_call_details.method
with sentry_sdk.start_span(
op=OP.GRPC_CLIENT,
name="unary stream call to %s" % method,
origin=SPAN_ORIGIN,
) as span:
span.set_data("type", "unary stream")
span.set_data("method", method)
client_call_details = self._update_client_call_details_metadata_from_scope(
client_call_details
)
response = continuation(
client_call_details, request
) # type: UnaryStreamCall
# Setting code on unary-stream leads to execution getting stuck
# span.set_data("code", response.code().name)
return response
@staticmethod
def _update_client_call_details_metadata_from_scope(client_call_details):
# type: (ClientCallDetails) -> ClientCallDetails
metadata = (
list(client_call_details.metadata) if client_call_details.metadata else []
)
for (
key,
value,
) in sentry_sdk.get_current_scope().iter_trace_propagation_headers():
metadata.append((key, value))
client_call_details = grpc._interceptor._ClientCallDetails(
method=client_call_details.method,
timeout=client_call_details.timeout,
metadata=metadata,
credentials=client_call_details.credentials,
wait_for_ready=client_call_details.wait_for_ready,
compression=client_call_details.compression,
)
return client_call_details
@@ -0,0 +1 @@
SPAN_ORIGIN = "auto.grpc.grpc"
@@ -0,0 +1,66 @@
import sentry_sdk
from sentry_sdk.consts import OP
from sentry_sdk.integrations import DidNotEnable
from sentry_sdk.integrations.grpc.consts import SPAN_ORIGIN
from sentry_sdk.tracing import Transaction, TRANSACTION_SOURCE_CUSTOM
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from typing import Callable, Optional
from google.protobuf.message import Message
try:
import grpc
from grpc import ServicerContext, HandlerCallDetails, RpcMethodHandler
except ImportError:
raise DidNotEnable("grpcio is not installed")
class ServerInterceptor(grpc.ServerInterceptor): # type: ignore
def __init__(self, find_name=None):
# type: (ServerInterceptor, Optional[Callable[[ServicerContext], str]]) -> None
self._find_method_name = find_name or ServerInterceptor._find_name
super().__init__()
def intercept_service(self, continuation, handler_call_details):
# type: (ServerInterceptor, Callable[[HandlerCallDetails], RpcMethodHandler], HandlerCallDetails) -> RpcMethodHandler
handler = continuation(handler_call_details)
if not handler or not handler.unary_unary:
return handler
def behavior(request, context):
# type: (Message, ServicerContext) -> Message
with sentry_sdk.isolation_scope():
name = self._find_method_name(context)
if name:
metadata = dict(context.invocation_metadata())
transaction = Transaction.continue_from_headers(
metadata,
op=OP.GRPC_SERVER,
name=name,
source=TRANSACTION_SOURCE_CUSTOM,
origin=SPAN_ORIGIN,
)
with sentry_sdk.start_transaction(transaction=transaction):
try:
return handler.unary_unary(request, context)
except BaseException as e:
raise e
else:
return handler.unary_unary(request, context)
return grpc.unary_unary_rpc_method_handler(
behavior,
request_deserializer=handler.request_deserializer,
response_serializer=handler.response_serializer,
)
@staticmethod
def _find_name(context):
# type: (ServicerContext) -> str
return context._rpc_event.call_details.method.decode()
@@ -0,0 +1,167 @@
import sentry_sdk
from sentry_sdk.consts import OP, SPANDATA
from sentry_sdk.integrations import Integration, DidNotEnable
from sentry_sdk.tracing import BAGGAGE_HEADER_NAME
from sentry_sdk.tracing_utils import Baggage, should_propagate_trace
from sentry_sdk.utils import (
SENSITIVE_DATA_SUBSTITUTE,
capture_internal_exceptions,
ensure_integration_enabled,
logger,
parse_url,
)
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from collections.abc import MutableMapping
from typing import Any
try:
from httpx import AsyncClient, Client, Request, Response # type: ignore
except ImportError:
raise DidNotEnable("httpx is not installed")
__all__ = ["HttpxIntegration"]
class HttpxIntegration(Integration):
identifier = "httpx"
origin = f"auto.http.{identifier}"
@staticmethod
def setup_once():
# type: () -> None
"""
httpx has its own transport layer and can be customized when needed,
so patch Client.send and AsyncClient.send to support both synchronous and async interfaces.
"""
_install_httpx_client()
_install_httpx_async_client()
def _install_httpx_client():
# type: () -> None
real_send = Client.send
@ensure_integration_enabled(HttpxIntegration, real_send)
def send(self, request, **kwargs):
# type: (Client, Request, **Any) -> Response
parsed_url = None
with capture_internal_exceptions():
parsed_url = parse_url(str(request.url), sanitize=False)
with sentry_sdk.start_span(
op=OP.HTTP_CLIENT,
name="%s %s"
% (
request.method,
parsed_url.url if parsed_url else SENSITIVE_DATA_SUBSTITUTE,
),
origin=HttpxIntegration.origin,
) as span:
span.set_data(SPANDATA.HTTP_METHOD, request.method)
if parsed_url is not None:
span.set_data("url", parsed_url.url)
span.set_data(SPANDATA.HTTP_QUERY, parsed_url.query)
span.set_data(SPANDATA.HTTP_FRAGMENT, parsed_url.fragment)
if should_propagate_trace(sentry_sdk.get_client(), str(request.url)):
for (
key,
value,
) in sentry_sdk.get_current_scope().iter_trace_propagation_headers():
logger.debug(
"[Tracing] Adding `{key}` header {value} to outgoing request to {url}.".format(
key=key, value=value, url=request.url
)
)
if key == BAGGAGE_HEADER_NAME:
_add_sentry_baggage_to_headers(request.headers, value)
else:
request.headers[key] = value
rv = real_send(self, request, **kwargs)
span.set_http_status(rv.status_code)
span.set_data("reason", rv.reason_phrase)
return rv
Client.send = send
def _install_httpx_async_client():
# type: () -> None
real_send = AsyncClient.send
async def send(self, request, **kwargs):
# type: (AsyncClient, Request, **Any) -> Response
if sentry_sdk.get_client().get_integration(HttpxIntegration) is None:
return await real_send(self, request, **kwargs)
parsed_url = None
with capture_internal_exceptions():
parsed_url = parse_url(str(request.url), sanitize=False)
with sentry_sdk.start_span(
op=OP.HTTP_CLIENT,
name="%s %s"
% (
request.method,
parsed_url.url if parsed_url else SENSITIVE_DATA_SUBSTITUTE,
),
origin=HttpxIntegration.origin,
) as span:
span.set_data(SPANDATA.HTTP_METHOD, request.method)
if parsed_url is not None:
span.set_data("url", parsed_url.url)
span.set_data(SPANDATA.HTTP_QUERY, parsed_url.query)
span.set_data(SPANDATA.HTTP_FRAGMENT, parsed_url.fragment)
if should_propagate_trace(sentry_sdk.get_client(), str(request.url)):
for (
key,
value,
) in sentry_sdk.get_current_scope().iter_trace_propagation_headers():
logger.debug(
"[Tracing] Adding `{key}` header {value} to outgoing request to {url}.".format(
key=key, value=value, url=request.url
)
)
if key == BAGGAGE_HEADER_NAME and request.headers.get(
BAGGAGE_HEADER_NAME
):
# do not overwrite any existing baggage, just append to it
request.headers[key] += "," + value
else:
request.headers[key] = value
rv = await real_send(self, request, **kwargs)
span.set_http_status(rv.status_code)
span.set_data("reason", rv.reason_phrase)
return rv
AsyncClient.send = send
def _add_sentry_baggage_to_headers(headers, sentry_baggage):
# type: (MutableMapping[str, str], str) -> None
"""Add the Sentry baggage to the headers.
This function directly mutates the provided headers. The provided sentry_baggage
is appended to the existing baggage. If the baggage already contains Sentry items,
they are stripped out first.
"""
existing_baggage = headers.get(BAGGAGE_HEADER_NAME, "")
stripped_existing_baggage = Baggage.strip_sentry_baggage(existing_baggage)
separator = "," if len(stripped_existing_baggage) > 0 else ""
headers[BAGGAGE_HEADER_NAME] = (
stripped_existing_baggage + separator + sentry_baggage
)
@@ -0,0 +1,174 @@
import sys
from datetime import datetime
import sentry_sdk
from sentry_sdk.api import continue_trace, get_baggage, get_traceparent
from sentry_sdk.consts import OP, SPANSTATUS
from sentry_sdk.integrations import DidNotEnable, Integration
from sentry_sdk.scope import should_send_default_pii
from sentry_sdk.tracing import (
BAGGAGE_HEADER_NAME,
SENTRY_TRACE_HEADER_NAME,
TRANSACTION_SOURCE_TASK,
)
from sentry_sdk.utils import (
capture_internal_exceptions,
ensure_integration_enabled,
event_from_exception,
SENSITIVE_DATA_SUBSTITUTE,
reraise,
)
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from typing import Any, Callable, Optional, Union, TypeVar
from sentry_sdk._types import EventProcessor, Event, Hint
from sentry_sdk.utils import ExcInfo
F = TypeVar("F", bound=Callable[..., Any])
try:
from huey.api import Huey, Result, ResultGroup, Task, PeriodicTask
from huey.exceptions import CancelExecution, RetryTask, TaskLockedException
except ImportError:
raise DidNotEnable("Huey is not installed")
HUEY_CONTROL_FLOW_EXCEPTIONS = (CancelExecution, RetryTask, TaskLockedException)
class HueyIntegration(Integration):
identifier = "huey"
origin = f"auto.queue.{identifier}"
@staticmethod
def setup_once():
# type: () -> None
patch_enqueue()
patch_execute()
def patch_enqueue():
# type: () -> None
old_enqueue = Huey.enqueue
@ensure_integration_enabled(HueyIntegration, old_enqueue)
def _sentry_enqueue(self, task):
# type: (Huey, Task) -> Optional[Union[Result, ResultGroup]]
with sentry_sdk.start_span(
op=OP.QUEUE_SUBMIT_HUEY,
name=task.name,
origin=HueyIntegration.origin,
):
if not isinstance(task, PeriodicTask):
# Attach trace propagation data to task kwargs. We do
# not do this for periodic tasks, as these don't
# really have an originating transaction.
task.kwargs["sentry_headers"] = {
BAGGAGE_HEADER_NAME: get_baggage(),
SENTRY_TRACE_HEADER_NAME: get_traceparent(),
}
return old_enqueue(self, task)
Huey.enqueue = _sentry_enqueue
def _make_event_processor(task):
# type: (Any) -> EventProcessor
def event_processor(event, hint):
# type: (Event, Hint) -> Optional[Event]
with capture_internal_exceptions():
tags = event.setdefault("tags", {})
tags["huey_task_id"] = task.id
tags["huey_task_retry"] = task.default_retries > task.retries
extra = event.setdefault("extra", {})
extra["huey-job"] = {
"task": task.name,
"args": (
task.args
if should_send_default_pii()
else SENSITIVE_DATA_SUBSTITUTE
),
"kwargs": (
task.kwargs
if should_send_default_pii()
else SENSITIVE_DATA_SUBSTITUTE
),
"retry": (task.default_retries or 0) - task.retries,
}
return event
return event_processor
def _capture_exception(exc_info):
# type: (ExcInfo) -> None
scope = sentry_sdk.get_current_scope()
if exc_info[0] in HUEY_CONTROL_FLOW_EXCEPTIONS:
scope.transaction.set_status(SPANSTATUS.ABORTED)
return
scope.transaction.set_status(SPANSTATUS.INTERNAL_ERROR)
event, hint = event_from_exception(
exc_info,
client_options=sentry_sdk.get_client().options,
mechanism={"type": HueyIntegration.identifier, "handled": False},
)
scope.capture_event(event, hint=hint)
def _wrap_task_execute(func):
# type: (F) -> F
@ensure_integration_enabled(HueyIntegration, func)
def _sentry_execute(*args, **kwargs):
# type: (*Any, **Any) -> Any
try:
result = func(*args, **kwargs)
except Exception:
exc_info = sys.exc_info()
_capture_exception(exc_info)
reraise(*exc_info)
return result
return _sentry_execute # type: ignore
def patch_execute():
# type: () -> None
old_execute = Huey._execute
@ensure_integration_enabled(HueyIntegration, old_execute)
def _sentry_execute(self, task, timestamp=None):
# type: (Huey, Task, Optional[datetime]) -> Any
with sentry_sdk.isolation_scope() as scope:
with capture_internal_exceptions():
scope._name = "huey"
scope.clear_breadcrumbs()
scope.add_event_processor(_make_event_processor(task))
sentry_headers = task.kwargs.pop("sentry_headers", None)
transaction = continue_trace(
sentry_headers or {},
name=task.name,
op=OP.QUEUE_TASK_HUEY,
source=TRANSACTION_SOURCE_TASK,
origin=HueyIntegration.origin,
)
transaction.set_status(SPANSTATUS.OK)
if not getattr(task, "_sentry_is_patched", False):
task.execute = _wrap_task_execute(task.execute)
task._sentry_is_patched = True
with sentry_sdk.start_transaction(transaction):
return old_execute(self, task, timestamp)
Huey._execute = _sentry_execute
@@ -0,0 +1,175 @@
from functools import wraps
from sentry_sdk import consts
from sentry_sdk.ai.monitoring import record_token_usage
from sentry_sdk.ai.utils import set_data_normalized
from sentry_sdk.consts import SPANDATA
from typing import Any, Iterable, Callable
import sentry_sdk
from sentry_sdk.scope import should_send_default_pii
from sentry_sdk.integrations import DidNotEnable, Integration
from sentry_sdk.utils import (
capture_internal_exceptions,
event_from_exception,
)
try:
import huggingface_hub.inference._client
from huggingface_hub import ChatCompletionStreamOutput, TextGenerationOutput
except ImportError:
raise DidNotEnable("Huggingface not installed")
class HuggingfaceHubIntegration(Integration):
identifier = "huggingface_hub"
origin = f"auto.ai.{identifier}"
def __init__(self, include_prompts=True):
# type: (HuggingfaceHubIntegration, bool) -> None
self.include_prompts = include_prompts
@staticmethod
def setup_once():
# type: () -> None
huggingface_hub.inference._client.InferenceClient.text_generation = (
_wrap_text_generation(
huggingface_hub.inference._client.InferenceClient.text_generation
)
)
def _capture_exception(exc):
# type: (Any) -> None
event, hint = event_from_exception(
exc,
client_options=sentry_sdk.get_client().options,
mechanism={"type": "huggingface_hub", "handled": False},
)
sentry_sdk.capture_event(event, hint=hint)
def _wrap_text_generation(f):
# type: (Callable[..., Any]) -> Callable[..., Any]
@wraps(f)
def new_text_generation(*args, **kwargs):
# type: (*Any, **Any) -> Any
integration = sentry_sdk.get_client().get_integration(HuggingfaceHubIntegration)
if integration is None:
return f(*args, **kwargs)
if "prompt" in kwargs:
prompt = kwargs["prompt"]
elif len(args) >= 2:
kwargs["prompt"] = args[1]
prompt = kwargs["prompt"]
args = (args[0],) + args[2:]
else:
# invalid call, let it return error
return f(*args, **kwargs)
model = kwargs.get("model")
streaming = kwargs.get("stream")
span = sentry_sdk.start_span(
op=consts.OP.HUGGINGFACE_HUB_CHAT_COMPLETIONS_CREATE,
name="Text Generation",
origin=HuggingfaceHubIntegration.origin,
)
span.__enter__()
try:
res = f(*args, **kwargs)
except Exception as e:
_capture_exception(e)
span.__exit__(None, None, None)
raise e from None
with capture_internal_exceptions():
if should_send_default_pii() and integration.include_prompts:
set_data_normalized(span, SPANDATA.AI_INPUT_MESSAGES, prompt)
set_data_normalized(span, SPANDATA.AI_MODEL_ID, model)
set_data_normalized(span, SPANDATA.AI_STREAMING, streaming)
if isinstance(res, str):
if should_send_default_pii() and integration.include_prompts:
set_data_normalized(
span,
"ai.responses",
[res],
)
span.__exit__(None, None, None)
return res
if isinstance(res, TextGenerationOutput):
if should_send_default_pii() and integration.include_prompts:
set_data_normalized(
span,
"ai.responses",
[res.generated_text],
)
if res.details is not None and res.details.generated_tokens > 0:
record_token_usage(span, total_tokens=res.details.generated_tokens)
span.__exit__(None, None, None)
return res
if not isinstance(res, Iterable):
# we only know how to deal with strings and iterables, ignore
set_data_normalized(span, "unknown_response", True)
span.__exit__(None, None, None)
return res
if kwargs.get("details", False):
# res is Iterable[TextGenerationStreamOutput]
def new_details_iterator():
# type: () -> Iterable[ChatCompletionStreamOutput]
with capture_internal_exceptions():
tokens_used = 0
data_buf: list[str] = []
for x in res:
if hasattr(x, "token") and hasattr(x.token, "text"):
data_buf.append(x.token.text)
if hasattr(x, "details") and hasattr(
x.details, "generated_tokens"
):
tokens_used = x.details.generated_tokens
yield x
if (
len(data_buf) > 0
and should_send_default_pii()
and integration.include_prompts
):
set_data_normalized(
span, SPANDATA.AI_RESPONSES, "".join(data_buf)
)
if tokens_used > 0:
record_token_usage(span, total_tokens=tokens_used)
span.__exit__(None, None, None)
return new_details_iterator()
else:
# res is Iterable[str]
def new_iterator():
# type: () -> Iterable[str]
data_buf: list[str] = []
with capture_internal_exceptions():
for s in res:
if isinstance(s, str):
data_buf.append(s)
yield s
if (
len(data_buf) > 0
and should_send_default_pii()
and integration.include_prompts
):
set_data_normalized(
span, SPANDATA.AI_RESPONSES, "".join(data_buf)
)
span.__exit__(None, None, None)
return new_iterator()
return new_text_generation
@@ -0,0 +1,465 @@
from collections import OrderedDict
from functools import wraps
import sentry_sdk
from sentry_sdk.ai.monitoring import set_ai_pipeline_name, record_token_usage
from sentry_sdk.consts import OP, SPANDATA
from sentry_sdk.ai.utils import set_data_normalized
from sentry_sdk.scope import should_send_default_pii
from sentry_sdk.tracing import Span
from sentry_sdk.integrations import DidNotEnable, Integration
from sentry_sdk.utils import logger, capture_internal_exceptions
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from typing import Any, List, Callable, Dict, Union, Optional
from uuid import UUID
try:
from langchain_core.messages import BaseMessage
from langchain_core.outputs import LLMResult
from langchain_core.callbacks import (
manager,
BaseCallbackHandler,
)
from langchain_core.agents import AgentAction, AgentFinish
except ImportError:
raise DidNotEnable("langchain not installed")
DATA_FIELDS = {
"temperature": SPANDATA.AI_TEMPERATURE,
"top_p": SPANDATA.AI_TOP_P,
"top_k": SPANDATA.AI_TOP_K,
"function_call": SPANDATA.AI_FUNCTION_CALL,
"tool_calls": SPANDATA.AI_TOOL_CALLS,
"tools": SPANDATA.AI_TOOLS,
"response_format": SPANDATA.AI_RESPONSE_FORMAT,
"logit_bias": SPANDATA.AI_LOGIT_BIAS,
"tags": SPANDATA.AI_TAGS,
}
# To avoid double collecting tokens, we do *not* measure
# token counts for models for which we have an explicit integration
NO_COLLECT_TOKEN_MODELS = [
"openai-chat",
"anthropic-chat",
"cohere-chat",
"huggingface_endpoint",
]
class LangchainIntegration(Integration):
identifier = "langchain"
origin = f"auto.ai.{identifier}"
# The most number of spans (e.g., LLM calls) that can be processed at the same time.
max_spans = 1024
def __init__(
self, include_prompts=True, max_spans=1024, tiktoken_encoding_name=None
):
# type: (LangchainIntegration, bool, int, Optional[str]) -> None
self.include_prompts = include_prompts
self.max_spans = max_spans
self.tiktoken_encoding_name = tiktoken_encoding_name
@staticmethod
def setup_once():
# type: () -> None
manager._configure = _wrap_configure(manager._configure)
class WatchedSpan:
span = None # type: Span
num_completion_tokens = 0 # type: int
num_prompt_tokens = 0 # type: int
no_collect_tokens = False # type: bool
children = [] # type: List[WatchedSpan]
is_pipeline = False # type: bool
def __init__(self, span):
# type: (Span) -> None
self.span = span
class SentryLangchainCallback(BaseCallbackHandler): # type: ignore[misc]
"""Base callback handler that can be used to handle callbacks from langchain."""
span_map = OrderedDict() # type: OrderedDict[UUID, WatchedSpan]
max_span_map_size = 0
def __init__(self, max_span_map_size, include_prompts, tiktoken_encoding_name=None):
# type: (int, bool, Optional[str]) -> None
self.max_span_map_size = max_span_map_size
self.include_prompts = include_prompts
self.tiktoken_encoding = None
if tiktoken_encoding_name is not None:
import tiktoken # type: ignore
self.tiktoken_encoding = tiktoken.get_encoding(tiktoken_encoding_name)
def count_tokens(self, s):
# type: (str) -> int
if self.tiktoken_encoding is not None:
return len(self.tiktoken_encoding.encode_ordinary(s))
return 0
def gc_span_map(self):
# type: () -> None
while len(self.span_map) > self.max_span_map_size:
run_id, watched_span = self.span_map.popitem(last=False)
self._exit_span(watched_span, run_id)
def _handle_error(self, run_id, error):
# type: (UUID, Any) -> None
if not run_id or run_id not in self.span_map:
return
span_data = self.span_map[run_id]
if not span_data:
return
sentry_sdk.capture_exception(error, span_data.span.scope)
span_data.span.__exit__(None, None, None)
del self.span_map[run_id]
def _normalize_langchain_message(self, message):
# type: (BaseMessage) -> Any
parsed = {"content": message.content, "role": message.type}
parsed.update(message.additional_kwargs)
return parsed
def _create_span(self, run_id, parent_id, **kwargs):
# type: (SentryLangchainCallback, UUID, Optional[Any], Any) -> WatchedSpan
watched_span = None # type: Optional[WatchedSpan]
if parent_id:
parent_span = self.span_map.get(parent_id) # type: Optional[WatchedSpan]
if parent_span:
watched_span = WatchedSpan(parent_span.span.start_child(**kwargs))
parent_span.children.append(watched_span)
if watched_span is None:
watched_span = WatchedSpan(sentry_sdk.start_span(**kwargs))
if kwargs.get("op", "").startswith("ai.pipeline."):
if kwargs.get("name"):
set_ai_pipeline_name(kwargs.get("name"))
watched_span.is_pipeline = True
watched_span.span.__enter__()
self.span_map[run_id] = watched_span
self.gc_span_map()
return watched_span
def _exit_span(self, span_data, run_id):
# type: (SentryLangchainCallback, WatchedSpan, UUID) -> None
if span_data.is_pipeline:
set_ai_pipeline_name(None)
span_data.span.__exit__(None, None, None)
del self.span_map[run_id]
def on_llm_start(
self,
serialized,
prompts,
*,
run_id,
tags=None,
parent_run_id=None,
metadata=None,
**kwargs,
):
# type: (SentryLangchainCallback, Dict[str, Any], List[str], UUID, Optional[List[str]], Optional[UUID], Optional[Dict[str, Any]], Any) -> Any
"""Run when LLM starts running."""
with capture_internal_exceptions():
if not run_id:
return
all_params = kwargs.get("invocation_params", {})
all_params.update(serialized.get("kwargs", {}))
watched_span = self._create_span(
run_id,
kwargs.get("parent_run_id"),
op=OP.LANGCHAIN_RUN,
name=kwargs.get("name") or "Langchain LLM call",
origin=LangchainIntegration.origin,
)
span = watched_span.span
if should_send_default_pii() and self.include_prompts:
set_data_normalized(span, SPANDATA.AI_INPUT_MESSAGES, prompts)
for k, v in DATA_FIELDS.items():
if k in all_params:
set_data_normalized(span, v, all_params[k])
def on_chat_model_start(self, serialized, messages, *, run_id, **kwargs):
# type: (SentryLangchainCallback, Dict[str, Any], List[List[BaseMessage]], UUID, Any) -> Any
"""Run when Chat Model starts running."""
with capture_internal_exceptions():
if not run_id:
return
all_params = kwargs.get("invocation_params", {})
all_params.update(serialized.get("kwargs", {}))
watched_span = self._create_span(
run_id,
kwargs.get("parent_run_id"),
op=OP.LANGCHAIN_CHAT_COMPLETIONS_CREATE,
name=kwargs.get("name") or "Langchain Chat Model",
origin=LangchainIntegration.origin,
)
span = watched_span.span
model = all_params.get(
"model", all_params.get("model_name", all_params.get("model_id"))
)
watched_span.no_collect_tokens = any(
x in all_params.get("_type", "") for x in NO_COLLECT_TOKEN_MODELS
)
if not model and "anthropic" in all_params.get("_type"):
model = "claude-2"
if model:
span.set_data(SPANDATA.AI_MODEL_ID, model)
if should_send_default_pii() and self.include_prompts:
set_data_normalized(
span,
SPANDATA.AI_INPUT_MESSAGES,
[
[self._normalize_langchain_message(x) for x in list_]
for list_ in messages
],
)
for k, v in DATA_FIELDS.items():
if k in all_params:
set_data_normalized(span, v, all_params[k])
if not watched_span.no_collect_tokens:
for list_ in messages:
for message in list_:
self.span_map[run_id].num_prompt_tokens += self.count_tokens(
message.content
) + self.count_tokens(message.type)
def on_llm_new_token(self, token, *, run_id, **kwargs):
# type: (SentryLangchainCallback, str, UUID, Any) -> Any
"""Run on new LLM token. Only available when streaming is enabled."""
with capture_internal_exceptions():
if not run_id or run_id not in self.span_map:
return
span_data = self.span_map[run_id]
if not span_data or span_data.no_collect_tokens:
return
span_data.num_completion_tokens += self.count_tokens(token)
def on_llm_end(self, response, *, run_id, **kwargs):
# type: (SentryLangchainCallback, LLMResult, UUID, Any) -> Any
"""Run when LLM ends running."""
with capture_internal_exceptions():
if not run_id:
return
token_usage = (
response.llm_output.get("token_usage") if response.llm_output else None
)
span_data = self.span_map[run_id]
if not span_data:
return
if should_send_default_pii() and self.include_prompts:
set_data_normalized(
span_data.span,
SPANDATA.AI_RESPONSES,
[[x.text for x in list_] for list_ in response.generations],
)
if not span_data.no_collect_tokens:
if token_usage:
record_token_usage(
span_data.span,
token_usage.get("prompt_tokens"),
token_usage.get("completion_tokens"),
token_usage.get("total_tokens"),
)
else:
record_token_usage(
span_data.span,
span_data.num_prompt_tokens,
span_data.num_completion_tokens,
)
self._exit_span(span_data, run_id)
def on_llm_error(self, error, *, run_id, **kwargs):
# type: (SentryLangchainCallback, Union[Exception, KeyboardInterrupt], UUID, Any) -> Any
"""Run when LLM errors."""
with capture_internal_exceptions():
self._handle_error(run_id, error)
def on_chain_start(self, serialized, inputs, *, run_id, **kwargs):
# type: (SentryLangchainCallback, Dict[str, Any], Dict[str, Any], UUID, Any) -> Any
"""Run when chain starts running."""
with capture_internal_exceptions():
if not run_id:
return
watched_span = self._create_span(
run_id,
kwargs.get("parent_run_id"),
op=(
OP.LANGCHAIN_RUN
if kwargs.get("parent_run_id") is not None
else OP.LANGCHAIN_PIPELINE
),
name=kwargs.get("name") or "Chain execution",
origin=LangchainIntegration.origin,
)
metadata = kwargs.get("metadata")
if metadata:
set_data_normalized(watched_span.span, SPANDATA.AI_METADATA, metadata)
def on_chain_end(self, outputs, *, run_id, **kwargs):
# type: (SentryLangchainCallback, Dict[str, Any], UUID, Any) -> Any
"""Run when chain ends running."""
with capture_internal_exceptions():
if not run_id or run_id not in self.span_map:
return
span_data = self.span_map[run_id]
if not span_data:
return
self._exit_span(span_data, run_id)
def on_chain_error(self, error, *, run_id, **kwargs):
# type: (SentryLangchainCallback, Union[Exception, KeyboardInterrupt], UUID, Any) -> Any
"""Run when chain errors."""
self._handle_error(run_id, error)
def on_agent_action(self, action, *, run_id, **kwargs):
# type: (SentryLangchainCallback, AgentAction, UUID, Any) -> Any
with capture_internal_exceptions():
if not run_id:
return
watched_span = self._create_span(
run_id,
kwargs.get("parent_run_id"),
op=OP.LANGCHAIN_AGENT,
name=action.tool or "AI tool usage",
origin=LangchainIntegration.origin,
)
if action.tool_input and should_send_default_pii() and self.include_prompts:
set_data_normalized(
watched_span.span, SPANDATA.AI_INPUT_MESSAGES, action.tool_input
)
def on_agent_finish(self, finish, *, run_id, **kwargs):
# type: (SentryLangchainCallback, AgentFinish, UUID, Any) -> Any
with capture_internal_exceptions():
if not run_id:
return
span_data = self.span_map[run_id]
if not span_data:
return
if should_send_default_pii() and self.include_prompts:
set_data_normalized(
span_data.span, SPANDATA.AI_RESPONSES, finish.return_values.items()
)
self._exit_span(span_data, run_id)
def on_tool_start(self, serialized, input_str, *, run_id, **kwargs):
# type: (SentryLangchainCallback, Dict[str, Any], str, UUID, Any) -> Any
"""Run when tool starts running."""
with capture_internal_exceptions():
if not run_id:
return
watched_span = self._create_span(
run_id,
kwargs.get("parent_run_id"),
op=OP.LANGCHAIN_TOOL,
name=serialized.get("name") or kwargs.get("name") or "AI tool usage",
origin=LangchainIntegration.origin,
)
if should_send_default_pii() and self.include_prompts:
set_data_normalized(
watched_span.span,
SPANDATA.AI_INPUT_MESSAGES,
kwargs.get("inputs", [input_str]),
)
if kwargs.get("metadata"):
set_data_normalized(
watched_span.span, SPANDATA.AI_METADATA, kwargs.get("metadata")
)
def on_tool_end(self, output, *, run_id, **kwargs):
# type: (SentryLangchainCallback, str, UUID, Any) -> Any
"""Run when tool ends running."""
with capture_internal_exceptions():
if not run_id or run_id not in self.span_map:
return
span_data = self.span_map[run_id]
if not span_data:
return
if should_send_default_pii() and self.include_prompts:
set_data_normalized(span_data.span, SPANDATA.AI_RESPONSES, output)
self._exit_span(span_data, run_id)
def on_tool_error(self, error, *args, run_id, **kwargs):
# type: (SentryLangchainCallback, Union[Exception, KeyboardInterrupt], UUID, Any) -> Any
"""Run when tool errors."""
self._handle_error(run_id, error)
def _wrap_configure(f):
# type: (Callable[..., Any]) -> Callable[..., Any]
@wraps(f)
def new_configure(*args, **kwargs):
# type: (Any, Any) -> Any
integration = sentry_sdk.get_client().get_integration(LangchainIntegration)
if integration is None:
return f(*args, **kwargs)
with capture_internal_exceptions():
new_callbacks = [] # type: List[BaseCallbackHandler]
if "local_callbacks" in kwargs:
existing_callbacks = kwargs["local_callbacks"]
kwargs["local_callbacks"] = new_callbacks
elif len(args) > 2:
existing_callbacks = args[2]
args = (
args[0],
args[1],
new_callbacks,
) + args[3:]
else:
existing_callbacks = []
if existing_callbacks:
if isinstance(existing_callbacks, list):
for cb in existing_callbacks:
new_callbacks.append(cb)
elif isinstance(existing_callbacks, BaseCallbackHandler):
new_callbacks.append(existing_callbacks)
else:
logger.debug("Unknown callback type: %s", existing_callbacks)
already_added = False
for callback in new_callbacks:
if isinstance(callback, SentryLangchainCallback):
already_added = True
if not already_added:
new_callbacks.append(
SentryLangchainCallback(
integration.max_spans,
integration.include_prompts,
integration.tiktoken_encoding_name,
)
)
return f(*args, **kwargs)
return new_configure
@@ -0,0 +1,62 @@
from typing import TYPE_CHECKING
import sentry_sdk
from sentry_sdk.integrations import DidNotEnable, Integration
try:
import ldclient
from ldclient.hook import Hook, Metadata
if TYPE_CHECKING:
from ldclient import LDClient
from ldclient.hook import EvaluationSeriesContext
from ldclient.evaluation import EvaluationDetail
from typing import Any
except ImportError:
raise DidNotEnable("LaunchDarkly is not installed")
class LaunchDarklyIntegration(Integration):
identifier = "launchdarkly"
def __init__(self, ld_client=None):
# type: (LDClient | None) -> None
"""
:param client: An initialized LDClient instance. If a client is not provided, this
integration will attempt to use the shared global instance.
"""
try:
client = ld_client or ldclient.get()
except Exception as exc:
raise DidNotEnable("Error getting LaunchDarkly client. " + repr(exc))
if not client.is_initialized():
raise DidNotEnable("LaunchDarkly client is not initialized.")
# Register the flag collection hook with the LD client.
client.add_hook(LaunchDarklyHook())
@staticmethod
def setup_once():
# type: () -> None
pass
class LaunchDarklyHook(Hook):
@property
def metadata(self):
# type: () -> Metadata
return Metadata(name="sentry-flag-auditor")
def after_evaluation(self, series_context, data, detail):
# type: (EvaluationSeriesContext, dict[Any, Any], EvaluationDetail) -> dict[Any, Any]
if isinstance(detail.value, bool):
flags = sentry_sdk.get_current_scope().flags
flags.set(series_context.key, detail.value)
return data
def before_evaluation(self, series_context, data):
# type: (EvaluationSeriesContext, dict[Any, Any]) -> dict[Any, Any]
return data # No-op.
@@ -0,0 +1,306 @@
from collections.abc import Set
import sentry_sdk
from sentry_sdk.consts import OP
from sentry_sdk.integrations import (
_DEFAULT_FAILED_REQUEST_STATUS_CODES,
DidNotEnable,
Integration,
)
from sentry_sdk.integrations.asgi import SentryAsgiMiddleware
from sentry_sdk.integrations.logging import ignore_logger
from sentry_sdk.scope import should_send_default_pii
from sentry_sdk.tracing import SOURCE_FOR_STYLE, TRANSACTION_SOURCE_ROUTE
from sentry_sdk.utils import (
ensure_integration_enabled,
event_from_exception,
transaction_from_function,
)
try:
from litestar import Request, Litestar # type: ignore
from litestar.handlers.base import BaseRouteHandler # type: ignore
from litestar.middleware import DefineMiddleware # type: ignore
from litestar.routes.http import HTTPRoute # type: ignore
from litestar.data_extractors import ConnectionDataExtractor # type: ignore
from litestar.exceptions import HTTPException # type: ignore
except ImportError:
raise DidNotEnable("Litestar is not installed")
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from typing import Any, Optional, Union
from litestar.types.asgi_types import ASGIApp # type: ignore
from litestar.types import ( # type: ignore
HTTPReceiveMessage,
HTTPScope,
Message,
Middleware,
Receive,
Scope as LitestarScope,
Send,
WebSocketReceiveMessage,
)
from litestar.middleware import MiddlewareProtocol
from sentry_sdk._types import Event, Hint
_DEFAULT_TRANSACTION_NAME = "generic Litestar request"
class LitestarIntegration(Integration):
identifier = "litestar"
origin = f"auto.http.{identifier}"
def __init__(
self,
failed_request_status_codes=_DEFAULT_FAILED_REQUEST_STATUS_CODES, # type: Set[int]
) -> None:
self.failed_request_status_codes = failed_request_status_codes
@staticmethod
def setup_once():
# type: () -> None
patch_app_init()
patch_middlewares()
patch_http_route_handle()
# The following line follows the pattern found in other integrations such as `DjangoIntegration.setup_once`.
# The Litestar `ExceptionHandlerMiddleware.__call__` catches exceptions and does the following
# (among other things):
# 1. Logs them, some at least (such as 500s) as errors
# 2. Calls after_exception hooks
# The `LitestarIntegration`` provides an after_exception hook (see `patch_app_init` below) to create a Sentry event
# from an exception, which ends up being called during step 2 above. However, the Sentry `LoggingIntegration` will
# by default create a Sentry event from error logs made in step 1 if we do not prevent it from doing so.
ignore_logger("litestar")
class SentryLitestarASGIMiddleware(SentryAsgiMiddleware):
def __init__(self, app, span_origin=LitestarIntegration.origin):
# type: (ASGIApp, str) -> None
super().__init__(
app=app,
unsafe_context_data=False,
transaction_style="endpoint",
mechanism_type="asgi",
span_origin=span_origin,
)
def patch_app_init():
# type: () -> None
"""
Replaces the Litestar class's `__init__` function in order to inject `after_exception` handlers and set the
`SentryLitestarASGIMiddleware` as the outmost middleware in the stack.
See:
- https://docs.litestar.dev/2/usage/applications.html#after-exception
- https://docs.litestar.dev/2/usage/middleware/using-middleware.html
"""
old__init__ = Litestar.__init__
@ensure_integration_enabled(LitestarIntegration, old__init__)
def injection_wrapper(self, *args, **kwargs):
# type: (Litestar, *Any, **Any) -> None
kwargs["after_exception"] = [
exception_handler,
*(kwargs.get("after_exception") or []),
]
SentryLitestarASGIMiddleware.__call__ = SentryLitestarASGIMiddleware._run_asgi3 # type: ignore
middleware = kwargs.get("middleware") or []
kwargs["middleware"] = [SentryLitestarASGIMiddleware, *middleware]
old__init__(self, *args, **kwargs)
Litestar.__init__ = injection_wrapper
def patch_middlewares():
# type: () -> None
old_resolve_middleware_stack = BaseRouteHandler.resolve_middleware
@ensure_integration_enabled(LitestarIntegration, old_resolve_middleware_stack)
def resolve_middleware_wrapper(self):
# type: (BaseRouteHandler) -> list[Middleware]
return [
enable_span_for_middleware(middleware)
for middleware in old_resolve_middleware_stack(self)
]
BaseRouteHandler.resolve_middleware = resolve_middleware_wrapper
def enable_span_for_middleware(middleware):
# type: (Middleware) -> Middleware
if (
not hasattr(middleware, "__call__") # noqa: B004
or middleware is SentryLitestarASGIMiddleware
):
return middleware
if isinstance(middleware, DefineMiddleware):
old_call = middleware.middleware.__call__ # type: ASGIApp
else:
old_call = middleware.__call__
async def _create_span_call(self, scope, receive, send):
# type: (MiddlewareProtocol, LitestarScope, Receive, Send) -> None
if sentry_sdk.get_client().get_integration(LitestarIntegration) is None:
return await old_call(self, scope, receive, send)
middleware_name = self.__class__.__name__
with sentry_sdk.start_span(
op=OP.MIDDLEWARE_LITESTAR,
name=middleware_name,
origin=LitestarIntegration.origin,
) as middleware_span:
middleware_span.set_tag("litestar.middleware_name", middleware_name)
# Creating spans for the "receive" callback
async def _sentry_receive(*args, **kwargs):
# type: (*Any, **Any) -> Union[HTTPReceiveMessage, WebSocketReceiveMessage]
if sentry_sdk.get_client().get_integration(LitestarIntegration) is None:
return await receive(*args, **kwargs)
with sentry_sdk.start_span(
op=OP.MIDDLEWARE_LITESTAR_RECEIVE,
name=getattr(receive, "__qualname__", str(receive)),
origin=LitestarIntegration.origin,
) as span:
span.set_tag("litestar.middleware_name", middleware_name)
return await receive(*args, **kwargs)
receive_name = getattr(receive, "__name__", str(receive))
receive_patched = receive_name == "_sentry_receive"
new_receive = _sentry_receive if not receive_patched else receive
# Creating spans for the "send" callback
async def _sentry_send(message):
# type: (Message) -> None
if sentry_sdk.get_client().get_integration(LitestarIntegration) is None:
return await send(message)
with sentry_sdk.start_span(
op=OP.MIDDLEWARE_LITESTAR_SEND,
name=getattr(send, "__qualname__", str(send)),
origin=LitestarIntegration.origin,
) as span:
span.set_tag("litestar.middleware_name", middleware_name)
return await send(message)
send_name = getattr(send, "__name__", str(send))
send_patched = send_name == "_sentry_send"
new_send = _sentry_send if not send_patched else send
return await old_call(self, scope, new_receive, new_send)
not_yet_patched = old_call.__name__ not in ["_create_span_call"]
if not_yet_patched:
if isinstance(middleware, DefineMiddleware):
middleware.middleware.__call__ = _create_span_call
else:
middleware.__call__ = _create_span_call
return middleware
def patch_http_route_handle():
# type: () -> None
old_handle = HTTPRoute.handle
async def handle_wrapper(self, scope, receive, send):
# type: (HTTPRoute, HTTPScope, Receive, Send) -> None
if sentry_sdk.get_client().get_integration(LitestarIntegration) is None:
return await old_handle(self, scope, receive, send)
sentry_scope = sentry_sdk.get_isolation_scope()
request = scope["app"].request_class(
scope=scope, receive=receive, send=send
) # type: Request[Any, Any]
extracted_request_data = ConnectionDataExtractor(
parse_body=True, parse_query=True
)(request)
body = extracted_request_data.pop("body")
request_data = await body
def event_processor(event, _):
# type: (Event, Hint) -> Event
route_handler = scope.get("route_handler")
request_info = event.get("request", {})
request_info["content_length"] = len(scope.get("_body", b""))
if should_send_default_pii():
request_info["cookies"] = extracted_request_data["cookies"]
if request_data is not None:
request_info["data"] = request_data
func = None
if route_handler.name is not None:
tx_name = route_handler.name
# Accounts for use of type `Ref` in earlier versions of litestar without the need to reference it as a type
elif hasattr(route_handler.fn, "value"):
func = route_handler.fn.value
else:
func = route_handler.fn
if func is not None:
tx_name = transaction_from_function(func)
tx_info = {"source": SOURCE_FOR_STYLE["endpoint"]}
if not tx_name:
tx_name = _DEFAULT_TRANSACTION_NAME
tx_info = {"source": TRANSACTION_SOURCE_ROUTE}
event.update(
{
"request": request_info,
"transaction": tx_name,
"transaction_info": tx_info,
}
)
return event
sentry_scope._name = LitestarIntegration.identifier
sentry_scope.add_event_processor(event_processor)
return await old_handle(self, scope, receive, send)
HTTPRoute.handle = handle_wrapper
def retrieve_user_from_scope(scope):
# type: (LitestarScope) -> Optional[dict[str, Any]]
scope_user = scope.get("user")
if isinstance(scope_user, dict):
return scope_user
if hasattr(scope_user, "asdict"): # dataclasses
return scope_user.asdict()
return None
@ensure_integration_enabled(LitestarIntegration)
def exception_handler(exc, scope):
# type: (Exception, LitestarScope) -> None
user_info = None # type: Optional[dict[str, Any]]
if should_send_default_pii():
user_info = retrieve_user_from_scope(scope)
if user_info and isinstance(user_info, dict):
sentry_scope = sentry_sdk.get_isolation_scope()
sentry_scope.set_user(user_info)
if isinstance(exc, HTTPException):
integration = sentry_sdk.get_client().get_integration(LitestarIntegration)
if (
integration is not None
and exc.status_code not in integration.failed_request_status_codes
):
return
event, hint = event_from_exception(
exc,
client_options=sentry_sdk.get_client().options,
mechanism={"type": LitestarIntegration.identifier, "handled": False},
)
sentry_sdk.capture_event(event, hint=hint)
@@ -0,0 +1,294 @@
import logging
from datetime import datetime, timezone
from fnmatch import fnmatch
import sentry_sdk
from sentry_sdk.utils import (
to_string,
event_from_exception,
current_stacktrace,
capture_internal_exceptions,
)
from sentry_sdk.integrations import Integration
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from collections.abc import MutableMapping
from logging import LogRecord
from typing import Any
from typing import Dict
from typing import Optional
DEFAULT_LEVEL = logging.INFO
DEFAULT_EVENT_LEVEL = logging.ERROR
LOGGING_TO_EVENT_LEVEL = {
logging.NOTSET: "notset",
logging.DEBUG: "debug",
logging.INFO: "info",
logging.WARN: "warning", # WARN is same a WARNING
logging.WARNING: "warning",
logging.ERROR: "error",
logging.FATAL: "fatal",
logging.CRITICAL: "fatal", # CRITICAL is same as FATAL
}
# Capturing events from those loggers causes recursion errors. We cannot allow
# the user to unconditionally create events from those loggers under any
# circumstances.
#
# Note: Ignoring by logger name here is better than mucking with thread-locals.
# We do not necessarily know whether thread-locals work 100% correctly in the user's environment.
_IGNORED_LOGGERS = set(
["sentry_sdk.errors", "urllib3.connectionpool", "urllib3.connection"]
)
def ignore_logger(
name, # type: str
):
# type: (...) -> None
"""This disables recording (both in breadcrumbs and as events) calls to
a logger of a specific name. Among other uses, many of our integrations
use this to prevent their actions being recorded as breadcrumbs. Exposed
to users as a way to quiet spammy loggers.
:param name: The name of the logger to ignore (same string you would pass to ``logging.getLogger``).
"""
_IGNORED_LOGGERS.add(name)
class LoggingIntegration(Integration):
identifier = "logging"
def __init__(self, level=DEFAULT_LEVEL, event_level=DEFAULT_EVENT_LEVEL):
# type: (Optional[int], Optional[int]) -> None
self._handler = None
self._breadcrumb_handler = None
if level is not None:
self._breadcrumb_handler = BreadcrumbHandler(level=level)
if event_level is not None:
self._handler = EventHandler(level=event_level)
def _handle_record(self, record):
# type: (LogRecord) -> None
if self._handler is not None and record.levelno >= self._handler.level:
self._handler.handle(record)
if (
self._breadcrumb_handler is not None
and record.levelno >= self._breadcrumb_handler.level
):
self._breadcrumb_handler.handle(record)
@staticmethod
def setup_once():
# type: () -> None
old_callhandlers = logging.Logger.callHandlers
def sentry_patched_callhandlers(self, record):
# type: (Any, LogRecord) -> Any
# keeping a local reference because the
# global might be discarded on shutdown
ignored_loggers = _IGNORED_LOGGERS
try:
return old_callhandlers(self, record)
finally:
# This check is done twice, once also here before we even get
# the integration. Otherwise we have a high chance of getting
# into a recursion error when the integration is resolved
# (this also is slower).
if ignored_loggers is not None and record.name not in ignored_loggers:
integration = sentry_sdk.get_client().get_integration(
LoggingIntegration
)
if integration is not None:
integration._handle_record(record)
logging.Logger.callHandlers = sentry_patched_callhandlers # type: ignore
class _BaseHandler(logging.Handler):
COMMON_RECORD_ATTRS = frozenset(
(
"args",
"created",
"exc_info",
"exc_text",
"filename",
"funcName",
"levelname",
"levelno",
"linenno",
"lineno",
"message",
"module",
"msecs",
"msg",
"name",
"pathname",
"process",
"processName",
"relativeCreated",
"stack",
"tags",
"taskName",
"thread",
"threadName",
"stack_info",
)
)
def _can_record(self, record):
# type: (LogRecord) -> bool
"""Prevents ignored loggers from recording"""
for logger in _IGNORED_LOGGERS:
if fnmatch(record.name, logger):
return False
return True
def _logging_to_event_level(self, record):
# type: (LogRecord) -> str
return LOGGING_TO_EVENT_LEVEL.get(
record.levelno, record.levelname.lower() if record.levelname else ""
)
def _extra_from_record(self, record):
# type: (LogRecord) -> MutableMapping[str, object]
return {
k: v
for k, v in vars(record).items()
if k not in self.COMMON_RECORD_ATTRS
and (not isinstance(k, str) or not k.startswith("_"))
}
class EventHandler(_BaseHandler):
"""
A logging handler that emits Sentry events for each log record
Note that you do not have to use this class if the logging integration is enabled, which it is by default.
"""
def emit(self, record):
# type: (LogRecord) -> Any
with capture_internal_exceptions():
self.format(record)
return self._emit(record)
def _emit(self, record):
# type: (LogRecord) -> None
if not self._can_record(record):
return
client = sentry_sdk.get_client()
if not client.is_active():
return
client_options = client.options
# exc_info might be None or (None, None, None)
#
# exc_info may also be any falsy value due to Python stdlib being
# liberal with what it receives and Celery's billiard being "liberal"
# with what it sends. See
# https://github.com/getsentry/sentry-python/issues/904
if record.exc_info and record.exc_info[0] is not None:
event, hint = event_from_exception(
record.exc_info,
client_options=client_options,
mechanism={"type": "logging", "handled": True},
)
elif (record.exc_info and record.exc_info[0] is None) or record.stack_info:
event = {}
hint = {}
with capture_internal_exceptions():
event["threads"] = {
"values": [
{
"stacktrace": current_stacktrace(
include_local_variables=client_options[
"include_local_variables"
],
max_value_length=client_options["max_value_length"],
),
"crashed": False,
"current": True,
}
]
}
else:
event = {}
hint = {}
hint["log_record"] = record
level = self._logging_to_event_level(record)
if level in {"debug", "info", "warning", "error", "critical", "fatal"}:
event["level"] = level # type: ignore[typeddict-item]
event["logger"] = record.name
# Log records from `warnings` module as separate issues
record_caputured_from_warnings_module = (
record.name == "py.warnings" and record.msg == "%s"
)
if record_caputured_from_warnings_module:
# use the actual message and not "%s" as the message
# this prevents grouping all warnings under one "%s" issue
msg = record.args[0] # type: ignore
event["logentry"] = {
"message": msg,
"params": (),
}
else:
event["logentry"] = {
"message": to_string(record.msg),
"params": record.args,
}
event["extra"] = self._extra_from_record(record)
sentry_sdk.capture_event(event, hint=hint)
# Legacy name
SentryHandler = EventHandler
class BreadcrumbHandler(_BaseHandler):
"""
A logging handler that records breadcrumbs for each log record.
Note that you do not have to use this class if the logging integration is enabled, which it is by default.
"""
def emit(self, record):
# type: (LogRecord) -> Any
with capture_internal_exceptions():
self.format(record)
return self._emit(record)
def _emit(self, record):
# type: (LogRecord) -> None
if not self._can_record(record):
return
sentry_sdk.add_breadcrumb(
self._breadcrumb_from_record(record), hint={"log_record": record}
)
def _breadcrumb_from_record(self, record):
# type: (LogRecord) -> Dict[str, Any]
return {
"type": "log",
"level": self._logging_to_event_level(record),
"category": record.name,
"message": record.message,
"timestamp": datetime.fromtimestamp(record.created, timezone.utc),
"data": self._extra_from_record(record),
}
@@ -0,0 +1,100 @@
import enum
from sentry_sdk.integrations import Integration, DidNotEnable
from sentry_sdk.integrations.logging import (
BreadcrumbHandler,
EventHandler,
_BaseHandler,
)
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from logging import LogRecord
from typing import Optional, Tuple
try:
import loguru
from loguru import logger
from loguru._defaults import LOGURU_FORMAT as DEFAULT_FORMAT
except ImportError:
raise DidNotEnable("LOGURU is not installed")
class LoggingLevels(enum.IntEnum):
TRACE = 5
DEBUG = 10
INFO = 20
SUCCESS = 25
WARNING = 30
ERROR = 40
CRITICAL = 50
DEFAULT_LEVEL = LoggingLevels.INFO.value
DEFAULT_EVENT_LEVEL = LoggingLevels.ERROR.value
# We need to save the handlers to be able to remove them later
# in tests (they call `LoguruIntegration.__init__` multiple times,
# and we can't use `setup_once` because it's called before
# than we get configuration).
_ADDED_HANDLERS = (None, None) # type: Tuple[Optional[int], Optional[int]]
class LoguruIntegration(Integration):
identifier = "loguru"
def __init__(
self,
level=DEFAULT_LEVEL,
event_level=DEFAULT_EVENT_LEVEL,
breadcrumb_format=DEFAULT_FORMAT,
event_format=DEFAULT_FORMAT,
):
# type: (Optional[int], Optional[int], str | loguru.FormatFunction, str | loguru.FormatFunction) -> None
global _ADDED_HANDLERS
breadcrumb_handler, event_handler = _ADDED_HANDLERS
if breadcrumb_handler is not None:
logger.remove(breadcrumb_handler)
breadcrumb_handler = None
if event_handler is not None:
logger.remove(event_handler)
event_handler = None
if level is not None:
breadcrumb_handler = logger.add(
LoguruBreadcrumbHandler(level=level),
level=level,
format=breadcrumb_format,
)
if event_level is not None:
event_handler = logger.add(
LoguruEventHandler(level=event_level),
level=event_level,
format=event_format,
)
_ADDED_HANDLERS = (breadcrumb_handler, event_handler)
@staticmethod
def setup_once():
# type: () -> None
pass # we do everything in __init__
class _LoguruBaseHandler(_BaseHandler):
def _logging_to_event_level(self, record):
# type: (LogRecord) -> str
try:
return LoggingLevels(record.levelno).name.lower()
except ValueError:
return record.levelname.lower() if record.levelname else ""
class LoguruEventHandler(_LoguruBaseHandler, EventHandler):
"""Modified version of :class:`sentry_sdk.integrations.logging.EventHandler` to use loguru's level names."""
class LoguruBreadcrumbHandler(_LoguruBaseHandler, BreadcrumbHandler):
"""Modified version of :class:`sentry_sdk.integrations.logging.BreadcrumbHandler` to use loguru's level names."""
@@ -0,0 +1,29 @@
import sentry_sdk
from sentry_sdk.integrations import Integration
from sentry_sdk.scope import add_global_event_processor
from sentry_sdk.utils import _get_installed_modules
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from typing import Any
from sentry_sdk._types import Event
class ModulesIntegration(Integration):
identifier = "modules"
@staticmethod
def setup_once():
# type: () -> None
@add_global_event_processor
def processor(event, hint):
# type: (Event, Any) -> Event
if event.get("type") == "transaction":
return event
if sentry_sdk.get_client().get_integration(ModulesIntegration) is None:
return event
event["modules"] = _get_installed_modules()
return event
@@ -0,0 +1,429 @@
from functools import wraps
import sentry_sdk
from sentry_sdk import consts
from sentry_sdk.ai.monitoring import record_token_usage
from sentry_sdk.ai.utils import set_data_normalized
from sentry_sdk.consts import SPANDATA
from sentry_sdk.integrations import DidNotEnable, Integration
from sentry_sdk.scope import should_send_default_pii
from sentry_sdk.utils import (
capture_internal_exceptions,
event_from_exception,
)
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from typing import Any, Iterable, List, Optional, Callable, AsyncIterator, Iterator
from sentry_sdk.tracing import Span
try:
from openai.resources.chat.completions import Completions, AsyncCompletions
from openai.resources import Embeddings, AsyncEmbeddings
if TYPE_CHECKING:
from openai.types.chat import ChatCompletionMessageParam, ChatCompletionChunk
except ImportError:
raise DidNotEnable("OpenAI not installed")
class OpenAIIntegration(Integration):
identifier = "openai"
origin = f"auto.ai.{identifier}"
def __init__(self, include_prompts=True, tiktoken_encoding_name=None):
# type: (OpenAIIntegration, bool, Optional[str]) -> None
self.include_prompts = include_prompts
self.tiktoken_encoding = None
if tiktoken_encoding_name is not None:
import tiktoken # type: ignore
self.tiktoken_encoding = tiktoken.get_encoding(tiktoken_encoding_name)
@staticmethod
def setup_once():
# type: () -> None
Completions.create = _wrap_chat_completion_create(Completions.create)
Embeddings.create = _wrap_embeddings_create(Embeddings.create)
AsyncCompletions.create = _wrap_async_chat_completion_create(
AsyncCompletions.create
)
AsyncEmbeddings.create = _wrap_async_embeddings_create(AsyncEmbeddings.create)
def count_tokens(self, s):
# type: (OpenAIIntegration, str) -> int
if self.tiktoken_encoding is not None:
return len(self.tiktoken_encoding.encode_ordinary(s))
return 0
def _capture_exception(exc):
# type: (Any) -> None
event, hint = event_from_exception(
exc,
client_options=sentry_sdk.get_client().options,
mechanism={"type": "openai", "handled": False},
)
sentry_sdk.capture_event(event, hint=hint)
def _calculate_chat_completion_usage(
messages, response, span, streaming_message_responses, count_tokens
):
# type: (Iterable[ChatCompletionMessageParam], Any, Span, Optional[List[str]], Callable[..., Any]) -> None
completion_tokens = 0 # type: Optional[int]
prompt_tokens = 0 # type: Optional[int]
total_tokens = 0 # type: Optional[int]
if hasattr(response, "usage"):
if hasattr(response.usage, "completion_tokens") and isinstance(
response.usage.completion_tokens, int
):
completion_tokens = response.usage.completion_tokens
if hasattr(response.usage, "prompt_tokens") and isinstance(
response.usage.prompt_tokens, int
):
prompt_tokens = response.usage.prompt_tokens
if hasattr(response.usage, "total_tokens") and isinstance(
response.usage.total_tokens, int
):
total_tokens = response.usage.total_tokens
if prompt_tokens == 0:
for message in messages:
if "content" in message:
prompt_tokens += count_tokens(message["content"])
if completion_tokens == 0:
if streaming_message_responses is not None:
for message in streaming_message_responses:
completion_tokens += count_tokens(message)
elif hasattr(response, "choices"):
for choice in response.choices:
if hasattr(choice, "message"):
completion_tokens += count_tokens(choice.message)
if prompt_tokens == 0:
prompt_tokens = None
if completion_tokens == 0:
completion_tokens = None
if total_tokens == 0:
total_tokens = None
record_token_usage(span, prompt_tokens, completion_tokens, total_tokens)
def _new_chat_completion_common(f, *args, **kwargs):
# type: (Any, *Any, **Any) -> Any
integration = sentry_sdk.get_client().get_integration(OpenAIIntegration)
if integration is None:
return f(*args, **kwargs)
if "messages" not in kwargs:
# invalid call (in all versions of openai), let it return error
return f(*args, **kwargs)
try:
iter(kwargs["messages"])
except TypeError:
# invalid call (in all versions), messages must be iterable
return f(*args, **kwargs)
kwargs["messages"] = list(kwargs["messages"])
messages = kwargs["messages"]
model = kwargs.get("model")
streaming = kwargs.get("stream")
span = sentry_sdk.start_span(
op=consts.OP.OPENAI_CHAT_COMPLETIONS_CREATE,
name="Chat Completion",
origin=OpenAIIntegration.origin,
)
span.__enter__()
res = yield f, args, kwargs
with capture_internal_exceptions():
if should_send_default_pii() and integration.include_prompts:
set_data_normalized(span, SPANDATA.AI_INPUT_MESSAGES, messages)
set_data_normalized(span, SPANDATA.AI_MODEL_ID, model)
set_data_normalized(span, SPANDATA.AI_STREAMING, streaming)
if hasattr(res, "choices"):
if should_send_default_pii() and integration.include_prompts:
set_data_normalized(
span,
"ai.responses",
list(map(lambda x: x.message, res.choices)),
)
_calculate_chat_completion_usage(
messages, res, span, None, integration.count_tokens
)
span.__exit__(None, None, None)
elif hasattr(res, "_iterator"):
data_buf: list[list[str]] = [] # one for each choice
old_iterator = res._iterator
def new_iterator():
# type: () -> Iterator[ChatCompletionChunk]
with capture_internal_exceptions():
for x in old_iterator:
if hasattr(x, "choices"):
choice_index = 0
for choice in x.choices:
if hasattr(choice, "delta") and hasattr(
choice.delta, "content"
):
content = choice.delta.content
if len(data_buf) <= choice_index:
data_buf.append([])
data_buf[choice_index].append(content or "")
choice_index += 1
yield x
if len(data_buf) > 0:
all_responses = list(
map(lambda chunk: "".join(chunk), data_buf)
)
if should_send_default_pii() and integration.include_prompts:
set_data_normalized(
span, SPANDATA.AI_RESPONSES, all_responses
)
_calculate_chat_completion_usage(
messages,
res,
span,
all_responses,
integration.count_tokens,
)
span.__exit__(None, None, None)
async def new_iterator_async():
# type: () -> AsyncIterator[ChatCompletionChunk]
with capture_internal_exceptions():
async for x in old_iterator:
if hasattr(x, "choices"):
choice_index = 0
for choice in x.choices:
if hasattr(choice, "delta") and hasattr(
choice.delta, "content"
):
content = choice.delta.content
if len(data_buf) <= choice_index:
data_buf.append([])
data_buf[choice_index].append(content or "")
choice_index += 1
yield x
if len(data_buf) > 0:
all_responses = list(
map(lambda chunk: "".join(chunk), data_buf)
)
if should_send_default_pii() and integration.include_prompts:
set_data_normalized(
span, SPANDATA.AI_RESPONSES, all_responses
)
_calculate_chat_completion_usage(
messages,
res,
span,
all_responses,
integration.count_tokens,
)
span.__exit__(None, None, None)
if str(type(res._iterator)) == "<class 'async_generator'>":
res._iterator = new_iterator_async()
else:
res._iterator = new_iterator()
else:
set_data_normalized(span, "unknown_response", True)
span.__exit__(None, None, None)
return res
def _wrap_chat_completion_create(f):
# type: (Callable[..., Any]) -> Callable[..., Any]
def _execute_sync(f, *args, **kwargs):
# type: (Any, *Any, **Any) -> Any
gen = _new_chat_completion_common(f, *args, **kwargs)
try:
f, args, kwargs = next(gen)
except StopIteration as e:
return e.value
try:
try:
result = f(*args, **kwargs)
except Exception as e:
_capture_exception(e)
raise e from None
return gen.send(result)
except StopIteration as e:
return e.value
@wraps(f)
def _sentry_patched_create_sync(*args, **kwargs):
# type: (*Any, **Any) -> Any
integration = sentry_sdk.get_client().get_integration(OpenAIIntegration)
if integration is None or "messages" not in kwargs:
# no "messages" means invalid call (in all versions of openai), let it return error
return f(*args, **kwargs)
return _execute_sync(f, *args, **kwargs)
return _sentry_patched_create_sync
def _wrap_async_chat_completion_create(f):
# type: (Callable[..., Any]) -> Callable[..., Any]
async def _execute_async(f, *args, **kwargs):
# type: (Any, *Any, **Any) -> Any
gen = _new_chat_completion_common(f, *args, **kwargs)
try:
f, args, kwargs = next(gen)
except StopIteration as e:
return await e.value
try:
try:
result = await f(*args, **kwargs)
except Exception as e:
_capture_exception(e)
raise e from None
return gen.send(result)
except StopIteration as e:
return e.value
@wraps(f)
async def _sentry_patched_create_async(*args, **kwargs):
# type: (*Any, **Any) -> Any
integration = sentry_sdk.get_client().get_integration(OpenAIIntegration)
if integration is None or "messages" not in kwargs:
# no "messages" means invalid call (in all versions of openai), let it return error
return await f(*args, **kwargs)
return await _execute_async(f, *args, **kwargs)
return _sentry_patched_create_async
def _new_embeddings_create_common(f, *args, **kwargs):
# type: (Any, *Any, **Any) -> Any
integration = sentry_sdk.get_client().get_integration(OpenAIIntegration)
if integration is None:
return f(*args, **kwargs)
with sentry_sdk.start_span(
op=consts.OP.OPENAI_EMBEDDINGS_CREATE,
description="OpenAI Embedding Creation",
origin=OpenAIIntegration.origin,
) as span:
if "input" in kwargs and (
should_send_default_pii() and integration.include_prompts
):
if isinstance(kwargs["input"], str):
set_data_normalized(span, "ai.input_messages", [kwargs["input"]])
elif (
isinstance(kwargs["input"], list)
and len(kwargs["input"]) > 0
and isinstance(kwargs["input"][0], str)
):
set_data_normalized(span, "ai.input_messages", kwargs["input"])
if "model" in kwargs:
set_data_normalized(span, "ai.model_id", kwargs["model"])
response = yield f, args, kwargs
prompt_tokens = 0
total_tokens = 0
if hasattr(response, "usage"):
if hasattr(response.usage, "prompt_tokens") and isinstance(
response.usage.prompt_tokens, int
):
prompt_tokens = response.usage.prompt_tokens
if hasattr(response.usage, "total_tokens") and isinstance(
response.usage.total_tokens, int
):
total_tokens = response.usage.total_tokens
if prompt_tokens == 0:
prompt_tokens = integration.count_tokens(kwargs["input"] or "")
record_token_usage(span, prompt_tokens, None, total_tokens or prompt_tokens)
return response
def _wrap_embeddings_create(f):
# type: (Any) -> Any
def _execute_sync(f, *args, **kwargs):
# type: (Any, *Any, **Any) -> Any
gen = _new_embeddings_create_common(f, *args, **kwargs)
try:
f, args, kwargs = next(gen)
except StopIteration as e:
return e.value
try:
try:
result = f(*args, **kwargs)
except Exception as e:
_capture_exception(e)
raise e from None
return gen.send(result)
except StopIteration as e:
return e.value
@wraps(f)
def _sentry_patched_create_sync(*args, **kwargs):
# type: (*Any, **Any) -> Any
integration = sentry_sdk.get_client().get_integration(OpenAIIntegration)
if integration is None:
return f(*args, **kwargs)
return _execute_sync(f, *args, **kwargs)
return _sentry_patched_create_sync
def _wrap_async_embeddings_create(f):
# type: (Any) -> Any
async def _execute_async(f, *args, **kwargs):
# type: (Any, *Any, **Any) -> Any
gen = _new_embeddings_create_common(f, *args, **kwargs)
try:
f, args, kwargs = next(gen)
except StopIteration as e:
return await e.value
try:
try:
result = await f(*args, **kwargs)
except Exception as e:
_capture_exception(e)
raise e from None
return gen.send(result)
except StopIteration as e:
return e.value
@wraps(f)
async def _sentry_patched_create_async(*args, **kwargs):
# type: (*Any, **Any) -> Any
integration = sentry_sdk.get_client().get_integration(OpenAIIntegration)
if integration is None:
return await f(*args, **kwargs)
return await _execute_async(f, *args, **kwargs)
return _sentry_patched_create_async
@@ -0,0 +1,39 @@
from typing import TYPE_CHECKING
import sentry_sdk
from sentry_sdk.integrations import DidNotEnable, Integration
try:
from openfeature import api
from openfeature.hook import Hook
if TYPE_CHECKING:
from openfeature.flag_evaluation import FlagEvaluationDetails
from openfeature.hook import HookContext, HookHints
except ImportError:
raise DidNotEnable("OpenFeature is not installed")
class OpenFeatureIntegration(Integration):
identifier = "openfeature"
@staticmethod
def setup_once():
# type: () -> None
# Register the hook within the global openfeature hooks list.
api.add_hooks(hooks=[OpenFeatureHook()])
class OpenFeatureHook(Hook):
def after(self, hook_context, details, hints):
# type: (HookContext, FlagEvaluationDetails[bool], HookHints) -> None
if isinstance(details.value, bool):
flags = sentry_sdk.get_current_scope().flags
flags.set(details.flag_key, details.value)
def error(self, hook_context, exception, hints):
# type: (HookContext, Exception, HookHints) -> None
if isinstance(hook_context.default_value, bool):
flags = sentry_sdk.get_current_scope().flags
flags.set(hook_context.flag_key, hook_context.default_value)
@@ -0,0 +1,7 @@
from sentry_sdk.integrations.opentelemetry.span_processor import SentrySpanProcessor
from sentry_sdk.integrations.opentelemetry.propagator import SentryPropagator
__all__ = [
"SentryPropagator",
"SentrySpanProcessor",
]
@@ -0,0 +1,5 @@
from opentelemetry.context import create_key
SENTRY_TRACE_KEY = create_key("sentry-trace")
SENTRY_BAGGAGE_KEY = create_key("sentry-baggage")
@@ -0,0 +1,58 @@
"""
IMPORTANT: The contents of this file are part of a proof of concept and as such
are experimental and not suitable for production use. They may be changed or
removed at any time without prior notice.
"""
from sentry_sdk.integrations import DidNotEnable, Integration
from sentry_sdk.integrations.opentelemetry.propagator import SentryPropagator
from sentry_sdk.integrations.opentelemetry.span_processor import SentrySpanProcessor
from sentry_sdk.utils import logger
try:
from opentelemetry import trace
from opentelemetry.propagate import set_global_textmap
from opentelemetry.sdk.trace import TracerProvider
except ImportError:
raise DidNotEnable("opentelemetry not installed")
try:
from opentelemetry.instrumentation.django import DjangoInstrumentor # type: ignore[import-not-found]
except ImportError:
DjangoInstrumentor = None
CONFIGURABLE_INSTRUMENTATIONS = {
DjangoInstrumentor: {"is_sql_commentor_enabled": True},
}
class OpenTelemetryIntegration(Integration):
identifier = "opentelemetry"
@staticmethod
def setup_once():
# type: () -> None
logger.warning(
"[OTel] Initializing highly experimental OpenTelemetry support. "
"Use at your own risk."
)
_setup_sentry_tracing()
# _setup_instrumentors()
logger.debug("[OTel] Finished setting up OpenTelemetry integration")
def _setup_sentry_tracing():
# type: () -> None
provider = TracerProvider()
provider.add_span_processor(SentrySpanProcessor())
trace.set_tracer_provider(provider)
set_global_textmap(SentryPropagator())
def _setup_instrumentors():
# type: () -> None
for instrumentor, kwargs in CONFIGURABLE_INSTRUMENTATIONS.items():
instrumentor().instrument(**kwargs)
@@ -0,0 +1,117 @@
from opentelemetry import trace
from opentelemetry.context import (
Context,
get_current,
set_value,
)
from opentelemetry.propagators.textmap import (
CarrierT,
Getter,
Setter,
TextMapPropagator,
default_getter,
default_setter,
)
from opentelemetry.trace import (
NonRecordingSpan,
SpanContext,
TraceFlags,
)
from sentry_sdk.integrations.opentelemetry.consts import (
SENTRY_BAGGAGE_KEY,
SENTRY_TRACE_KEY,
)
from sentry_sdk.integrations.opentelemetry.span_processor import (
SentrySpanProcessor,
)
from sentry_sdk.tracing import (
BAGGAGE_HEADER_NAME,
SENTRY_TRACE_HEADER_NAME,
)
from sentry_sdk.tracing_utils import Baggage, extract_sentrytrace_data
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from typing import Optional, Set
class SentryPropagator(TextMapPropagator):
"""
Propagates tracing headers for Sentry's tracing system in a way OTel understands.
"""
def extract(self, carrier, context=None, getter=default_getter):
# type: (CarrierT, Optional[Context], Getter[CarrierT]) -> Context
if context is None:
context = get_current()
sentry_trace = getter.get(carrier, SENTRY_TRACE_HEADER_NAME)
if not sentry_trace:
return context
sentrytrace = extract_sentrytrace_data(sentry_trace[0])
if not sentrytrace:
return context
context = set_value(SENTRY_TRACE_KEY, sentrytrace, context)
trace_id, span_id = sentrytrace["trace_id"], sentrytrace["parent_span_id"]
span_context = SpanContext(
trace_id=int(trace_id, 16), # type: ignore
span_id=int(span_id, 16), # type: ignore
# we simulate a sampled trace on the otel side and leave the sampling to sentry
trace_flags=TraceFlags(TraceFlags.SAMPLED),
is_remote=True,
)
baggage_header = getter.get(carrier, BAGGAGE_HEADER_NAME)
if baggage_header:
baggage = Baggage.from_incoming_header(baggage_header[0])
else:
# If there's an incoming sentry-trace but no incoming baggage header,
# for instance in traces coming from older SDKs,
# baggage will be empty and frozen and won't be populated as head SDK.
baggage = Baggage(sentry_items={})
baggage.freeze()
context = set_value(SENTRY_BAGGAGE_KEY, baggage, context)
span = NonRecordingSpan(span_context)
modified_context = trace.set_span_in_context(span, context)
return modified_context
def inject(self, carrier, context=None, setter=default_setter):
# type: (CarrierT, Optional[Context], Setter[CarrierT]) -> None
if context is None:
context = get_current()
current_span = trace.get_current_span(context)
current_span_context = current_span.get_span_context()
if not current_span_context.is_valid:
return
span_id = trace.format_span_id(current_span_context.span_id)
span_map = SentrySpanProcessor().otel_span_map
sentry_span = span_map.get(span_id, None)
if not sentry_span:
return
setter.set(carrier, SENTRY_TRACE_HEADER_NAME, sentry_span.to_traceparent())
if sentry_span.containing_transaction:
baggage = sentry_span.containing_transaction.get_baggage()
if baggage:
baggage_data = baggage.serialize()
if baggage_data:
setter.set(carrier, BAGGAGE_HEADER_NAME, baggage_data)
@property
def fields(self):
# type: () -> Set[str]
return {SENTRY_TRACE_HEADER_NAME, BAGGAGE_HEADER_NAME}
@@ -0,0 +1,391 @@
from datetime import datetime, timezone
from time import time
from typing import TYPE_CHECKING, cast
from opentelemetry.context import get_value
from opentelemetry.sdk.trace import SpanProcessor, ReadableSpan as OTelSpan
from opentelemetry.semconv.trace import SpanAttributes
from opentelemetry.trace import (
format_span_id,
format_trace_id,
get_current_span,
SpanKind,
)
from opentelemetry.trace.span import (
INVALID_SPAN_ID,
INVALID_TRACE_ID,
)
from sentry_sdk import get_client, start_transaction
from sentry_sdk.consts import INSTRUMENTER, SPANSTATUS
from sentry_sdk.integrations.opentelemetry.consts import (
SENTRY_BAGGAGE_KEY,
SENTRY_TRACE_KEY,
)
from sentry_sdk.scope import add_global_event_processor
from sentry_sdk.tracing import Transaction, Span as SentrySpan
from sentry_sdk.utils import Dsn
from urllib3.util import parse_url as urlparse
if TYPE_CHECKING:
from typing import Any, Optional, Union
from opentelemetry import context as context_api
from sentry_sdk._types import Event, Hint
OPEN_TELEMETRY_CONTEXT = "otel"
SPAN_MAX_TIME_OPEN_MINUTES = 10
SPAN_ORIGIN = "auto.otel"
def link_trace_context_to_error_event(event, otel_span_map):
# type: (Event, dict[str, Union[Transaction, SentrySpan]]) -> Event
client = get_client()
if client.options["instrumenter"] != INSTRUMENTER.OTEL:
return event
if hasattr(event, "type") and event["type"] == "transaction":
return event
otel_span = get_current_span()
if not otel_span:
return event
ctx = otel_span.get_span_context()
if ctx.trace_id == INVALID_TRACE_ID or ctx.span_id == INVALID_SPAN_ID:
return event
sentry_span = otel_span_map.get(format_span_id(ctx.span_id), None)
if not sentry_span:
return event
contexts = event.setdefault("contexts", {})
contexts.setdefault("trace", {}).update(sentry_span.get_trace_context())
return event
class SentrySpanProcessor(SpanProcessor):
"""
Converts OTel spans into Sentry spans so they can be sent to the Sentry backend.
"""
# The mapping from otel span ids to sentry spans
otel_span_map = {} # type: dict[str, Union[Transaction, SentrySpan]]
# The currently open spans. Elements will be discarded after SPAN_MAX_TIME_OPEN_MINUTES
open_spans = {} # type: dict[int, set[str]]
def __new__(cls):
# type: () -> SentrySpanProcessor
if not hasattr(cls, "instance"):
cls.instance = super().__new__(cls)
return cls.instance
def __init__(self):
# type: () -> None
@add_global_event_processor
def global_event_processor(event, hint):
# type: (Event, Hint) -> Event
return link_trace_context_to_error_event(event, self.otel_span_map)
def _prune_old_spans(self):
# type: (SentrySpanProcessor) -> None
"""
Prune spans that have been open for too long.
"""
current_time_minutes = int(time() / 60)
for span_start_minutes in list(
self.open_spans.keys()
): # making a list because we change the dict
# prune empty open spans buckets
if self.open_spans[span_start_minutes] == set():
self.open_spans.pop(span_start_minutes)
# prune old buckets
elif current_time_minutes - span_start_minutes > SPAN_MAX_TIME_OPEN_MINUTES:
for span_id in self.open_spans.pop(span_start_minutes):
self.otel_span_map.pop(span_id, None)
def on_start(self, otel_span, parent_context=None):
# type: (OTelSpan, Optional[context_api.Context]) -> None
client = get_client()
if not client.dsn:
return
try:
_ = Dsn(client.dsn)
except Exception:
return
if client.options["instrumenter"] != INSTRUMENTER.OTEL:
return
if not otel_span.get_span_context().is_valid:
return
if self._is_sentry_span(otel_span):
return
trace_data = self._get_trace_data(otel_span, parent_context)
parent_span_id = trace_data["parent_span_id"]
sentry_parent_span = (
self.otel_span_map.get(parent_span_id) if parent_span_id else None
)
start_timestamp = None
if otel_span.start_time is not None:
start_timestamp = datetime.fromtimestamp(
otel_span.start_time / 1e9, timezone.utc
) # OTel spans have nanosecond precision
sentry_span = None
if sentry_parent_span:
sentry_span = sentry_parent_span.start_child(
span_id=trace_data["span_id"],
name=otel_span.name,
start_timestamp=start_timestamp,
instrumenter=INSTRUMENTER.OTEL,
origin=SPAN_ORIGIN,
)
else:
sentry_span = start_transaction(
name=otel_span.name,
span_id=trace_data["span_id"],
parent_span_id=parent_span_id,
trace_id=trace_data["trace_id"],
baggage=trace_data["baggage"],
start_timestamp=start_timestamp,
instrumenter=INSTRUMENTER.OTEL,
origin=SPAN_ORIGIN,
)
self.otel_span_map[trace_data["span_id"]] = sentry_span
if otel_span.start_time is not None:
span_start_in_minutes = int(
otel_span.start_time / 1e9 / 60
) # OTel spans have nanosecond precision
self.open_spans.setdefault(span_start_in_minutes, set()).add(
trace_data["span_id"]
)
self._prune_old_spans()
def on_end(self, otel_span):
# type: (OTelSpan) -> None
client = get_client()
if client.options["instrumenter"] != INSTRUMENTER.OTEL:
return
span_context = otel_span.get_span_context()
if not span_context.is_valid:
return
span_id = format_span_id(span_context.span_id)
sentry_span = self.otel_span_map.pop(span_id, None)
if not sentry_span:
return
sentry_span.op = otel_span.name
self._update_span_with_otel_status(sentry_span, otel_span)
if isinstance(sentry_span, Transaction):
sentry_span.name = otel_span.name
sentry_span.set_context(
OPEN_TELEMETRY_CONTEXT, self._get_otel_context(otel_span)
)
self._update_transaction_with_otel_data(sentry_span, otel_span)
else:
self._update_span_with_otel_data(sentry_span, otel_span)
end_timestamp = None
if otel_span.end_time is not None:
end_timestamp = datetime.fromtimestamp(
otel_span.end_time / 1e9, timezone.utc
) # OTel spans have nanosecond precision
sentry_span.finish(end_timestamp=end_timestamp)
if otel_span.start_time is not None:
span_start_in_minutes = int(
otel_span.start_time / 1e9 / 60
) # OTel spans have nanosecond precision
self.open_spans.setdefault(span_start_in_minutes, set()).discard(span_id)
self._prune_old_spans()
def _is_sentry_span(self, otel_span):
# type: (OTelSpan) -> bool
"""
Break infinite loop:
HTTP requests to Sentry are caught by OTel and send again to Sentry.
"""
otel_span_url = None
if otel_span.attributes is not None:
otel_span_url = otel_span.attributes.get(SpanAttributes.HTTP_URL)
otel_span_url = cast("Optional[str]", otel_span_url)
dsn_url = None
client = get_client()
if client.dsn:
dsn_url = Dsn(client.dsn).netloc
if otel_span_url and dsn_url and dsn_url in otel_span_url:
return True
return False
def _get_otel_context(self, otel_span):
# type: (OTelSpan) -> dict[str, Any]
"""
Returns the OTel context for Sentry.
See: https://develop.sentry.dev/sdk/performance/opentelemetry/#step-5-add-opentelemetry-context
"""
ctx = {}
if otel_span.attributes:
ctx["attributes"] = dict(otel_span.attributes)
if otel_span.resource.attributes:
ctx["resource"] = dict(otel_span.resource.attributes)
return ctx
def _get_trace_data(self, otel_span, parent_context):
# type: (OTelSpan, Optional[context_api.Context]) -> dict[str, Any]
"""
Extracts tracing information from one OTel span and its parent OTel context.
"""
trace_data = {} # type: dict[str, Any]
span_context = otel_span.get_span_context()
span_id = format_span_id(span_context.span_id)
trace_data["span_id"] = span_id
trace_id = format_trace_id(span_context.trace_id)
trace_data["trace_id"] = trace_id
parent_span_id = (
format_span_id(otel_span.parent.span_id) if otel_span.parent else None
)
trace_data["parent_span_id"] = parent_span_id
sentry_trace_data = get_value(SENTRY_TRACE_KEY, parent_context)
sentry_trace_data = cast("dict[str, Union[str, bool, None]]", sentry_trace_data)
trace_data["parent_sampled"] = (
sentry_trace_data["parent_sampled"] if sentry_trace_data else None
)
baggage = get_value(SENTRY_BAGGAGE_KEY, parent_context)
trace_data["baggage"] = baggage
return trace_data
def _update_span_with_otel_status(self, sentry_span, otel_span):
# type: (SentrySpan, OTelSpan) -> None
"""
Set the Sentry span status from the OTel span
"""
if otel_span.status.is_unset:
return
if otel_span.status.is_ok:
sentry_span.set_status(SPANSTATUS.OK)
return
sentry_span.set_status(SPANSTATUS.INTERNAL_ERROR)
def _update_span_with_otel_data(self, sentry_span, otel_span):
# type: (SentrySpan, OTelSpan) -> None
"""
Convert OTel span data and update the Sentry span with it.
This should eventually happen on the server when ingesting the spans.
"""
sentry_span.set_data("otel.kind", otel_span.kind)
op = otel_span.name
description = otel_span.name
if otel_span.attributes is not None:
for key, val in otel_span.attributes.items():
sentry_span.set_data(key, val)
http_method = otel_span.attributes.get(SpanAttributes.HTTP_METHOD)
http_method = cast("Optional[str]", http_method)
db_query = otel_span.attributes.get(SpanAttributes.DB_SYSTEM)
if http_method:
op = "http"
if otel_span.kind == SpanKind.SERVER:
op += ".server"
elif otel_span.kind == SpanKind.CLIENT:
op += ".client"
description = http_method
peer_name = otel_span.attributes.get(SpanAttributes.NET_PEER_NAME, None)
if peer_name:
description += " {}".format(peer_name)
target = otel_span.attributes.get(SpanAttributes.HTTP_TARGET, None)
if target:
description += " {}".format(target)
if not peer_name and not target:
url = otel_span.attributes.get(SpanAttributes.HTTP_URL, None)
url = cast("Optional[str]", url)
if url:
parsed_url = urlparse(url)
url = "{}://{}{}".format(
parsed_url.scheme, parsed_url.netloc, parsed_url.path
)
description += " {}".format(url)
status_code = otel_span.attributes.get(
SpanAttributes.HTTP_STATUS_CODE, None
)
status_code = cast("Optional[int]", status_code)
if status_code:
sentry_span.set_http_status(status_code)
elif db_query:
op = "db"
statement = otel_span.attributes.get(SpanAttributes.DB_STATEMENT, None)
statement = cast("Optional[str]", statement)
if statement:
description = statement
sentry_span.op = op
sentry_span.description = description
def _update_transaction_with_otel_data(self, sentry_span, otel_span):
# type: (SentrySpan, OTelSpan) -> None
if otel_span.attributes is None:
return
http_method = otel_span.attributes.get(SpanAttributes.HTTP_METHOD)
if http_method:
status_code = otel_span.attributes.get(SpanAttributes.HTTP_STATUS_CODE)
status_code = cast("Optional[int]", status_code)
if status_code:
sentry_span.set_http_status(status_code)
op = "http"
if otel_span.kind == SpanKind.SERVER:
op += ".server"
elif otel_span.kind == SpanKind.CLIENT:
op += ".client"
sentry_span.op = op
@@ -0,0 +1,139 @@
import ast
import sentry_sdk
from sentry_sdk import serializer
from sentry_sdk.integrations import Integration, DidNotEnable
from sentry_sdk.scope import add_global_event_processor
from sentry_sdk.utils import walk_exception_chain, iter_stacks
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from typing import Optional, Dict, Any, Tuple, List
from types import FrameType
from sentry_sdk._types import Event, Hint
try:
import executing
except ImportError:
raise DidNotEnable("executing is not installed")
try:
import pure_eval
except ImportError:
raise DidNotEnable("pure_eval is not installed")
try:
# Used implicitly, just testing it's available
import asttokens # noqa
except ImportError:
raise DidNotEnable("asttokens is not installed")
class PureEvalIntegration(Integration):
identifier = "pure_eval"
@staticmethod
def setup_once():
# type: () -> None
@add_global_event_processor
def add_executing_info(event, hint):
# type: (Event, Optional[Hint]) -> Optional[Event]
if sentry_sdk.get_client().get_integration(PureEvalIntegration) is None:
return event
if hint is None:
return event
exc_info = hint.get("exc_info", None)
if exc_info is None:
return event
exception = event.get("exception", None)
if exception is None:
return event
values = exception.get("values", None)
if values is None:
return event
for exception, (_exc_type, _exc_value, exc_tb) in zip(
reversed(values), walk_exception_chain(exc_info)
):
sentry_frames = [
frame
for frame in exception.get("stacktrace", {}).get("frames", [])
if frame.get("function")
]
tbs = list(iter_stacks(exc_tb))
if len(sentry_frames) != len(tbs):
continue
for sentry_frame, tb in zip(sentry_frames, tbs):
sentry_frame["vars"] = (
pure_eval_frame(tb.tb_frame) or sentry_frame["vars"]
)
return event
def pure_eval_frame(frame):
# type: (FrameType) -> Dict[str, Any]
source = executing.Source.for_frame(frame)
if not source.tree:
return {}
statements = source.statements_at_line(frame.f_lineno)
if not statements:
return {}
scope = stmt = list(statements)[0]
while True:
# Get the parent first in case the original statement is already
# a function definition, e.g. if we're calling a decorator
# In that case we still want the surrounding scope, not that function
scope = scope.parent
if isinstance(scope, (ast.FunctionDef, ast.ClassDef, ast.Module)):
break
evaluator = pure_eval.Evaluator.from_frame(frame)
expressions = evaluator.interesting_expressions_grouped(scope)
def closeness(expression):
# type: (Tuple[List[Any], Any]) -> Tuple[int, int]
# Prioritise expressions with a node closer to the statement executed
# without being after that statement
# A higher return value is better - the expression will appear
# earlier in the list of values and is less likely to be trimmed
nodes, _value = expression
def start(n):
# type: (ast.expr) -> Tuple[int, int]
return (n.lineno, n.col_offset)
nodes_before_stmt = [
node for node in nodes if start(node) < stmt.last_token.end # type: ignore
]
if nodes_before_stmt:
# The position of the last node before or in the statement
return max(start(node) for node in nodes_before_stmt)
else:
# The position of the first node after the statement
# Negative means it's always lower priority than nodes that come before
# Less negative means closer to the statement and higher priority
lineno, col_offset = min(start(node) for node in nodes)
return (-lineno, -col_offset)
# This adds the first_token and last_token attributes to nodes
atok = source.asttokens()
expressions.sort(key=closeness, reverse=True)
vars = {
atok.get_text(nodes[0]): value
for nodes, value in expressions[: serializer.MAX_DATABAG_BREADTH]
}
return serializer.serialize(vars, is_vars=True)
@@ -0,0 +1,214 @@
import copy
import json
import sentry_sdk
from sentry_sdk.consts import SPANSTATUS, SPANDATA, OP
from sentry_sdk.integrations import DidNotEnable, Integration
from sentry_sdk.scope import should_send_default_pii
from sentry_sdk.tracing import Span
from sentry_sdk.utils import capture_internal_exceptions
try:
from pymongo import monitoring
except ImportError:
raise DidNotEnable("Pymongo not installed")
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from typing import Any, Dict, Union
from pymongo.monitoring import (
CommandFailedEvent,
CommandStartedEvent,
CommandSucceededEvent,
)
SAFE_COMMAND_ATTRIBUTES = [
"insert",
"ordered",
"find",
"limit",
"singleBatch",
"aggregate",
"createIndexes",
"indexes",
"delete",
"findAndModify",
"renameCollection",
"to",
"drop",
]
def _strip_pii(command):
# type: (Dict[str, Any]) -> Dict[str, Any]
for key in command:
is_safe_field = key in SAFE_COMMAND_ATTRIBUTES
if is_safe_field:
# Skip if safe key
continue
update_db_command = key == "update" and "findAndModify" not in command
if update_db_command:
# Also skip "update" db command because it is save.
# There is also an "update" key in the "findAndModify" command, which is NOT safe!
continue
# Special stripping for documents
is_document = key == "documents"
if is_document:
for doc in command[key]:
for doc_key in doc:
doc[doc_key] = "%s"
continue
# Special stripping for dict style fields
is_dict_field = key in ["filter", "query", "update"]
if is_dict_field:
for item_key in command[key]:
command[key][item_key] = "%s"
continue
# For pipeline fields strip the `$match` dict
is_pipeline_field = key == "pipeline"
if is_pipeline_field:
for pipeline in command[key]:
for match_key in pipeline["$match"] if "$match" in pipeline else []:
pipeline["$match"][match_key] = "%s"
continue
# Default stripping
command[key] = "%s"
return command
def _get_db_data(event):
# type: (Any) -> Dict[str, Any]
data = {}
data[SPANDATA.DB_SYSTEM] = "mongodb"
db_name = event.database_name
if db_name is not None:
data[SPANDATA.DB_NAME] = db_name
server_address = event.connection_id[0]
if server_address is not None:
data[SPANDATA.SERVER_ADDRESS] = server_address
server_port = event.connection_id[1]
if server_port is not None:
data[SPANDATA.SERVER_PORT] = server_port
return data
class CommandTracer(monitoring.CommandListener):
def __init__(self):
# type: () -> None
self._ongoing_operations = {} # type: Dict[int, Span]
def _operation_key(self, event):
# type: (Union[CommandFailedEvent, CommandStartedEvent, CommandSucceededEvent]) -> int
return event.request_id
def started(self, event):
# type: (CommandStartedEvent) -> None
if sentry_sdk.get_client().get_integration(PyMongoIntegration) is None:
return
with capture_internal_exceptions():
command = dict(copy.deepcopy(event.command))
command.pop("$db", None)
command.pop("$clusterTime", None)
command.pop("$signature", None)
tags = {
"db.name": event.database_name,
SPANDATA.DB_SYSTEM: "mongodb",
SPANDATA.DB_OPERATION: event.command_name,
SPANDATA.DB_MONGODB_COLLECTION: command.get(event.command_name),
}
try:
tags["net.peer.name"] = event.connection_id[0]
tags["net.peer.port"] = str(event.connection_id[1])
except TypeError:
pass
data = {"operation_ids": {}} # type: Dict[str, Any]
data["operation_ids"]["operation"] = event.operation_id
data["operation_ids"]["request"] = event.request_id
data.update(_get_db_data(event))
try:
lsid = command.pop("lsid")["id"]
data["operation_ids"]["session"] = str(lsid)
except KeyError:
pass
if not should_send_default_pii():
command = _strip_pii(command)
query = json.dumps(command, default=str)
span = sentry_sdk.start_span(
op=OP.DB,
name=query,
origin=PyMongoIntegration.origin,
)
for tag, value in tags.items():
# set the tag for backwards-compatibility.
# TODO: remove the set_tag call in the next major release!
span.set_tag(tag, value)
span.set_data(tag, value)
for key, value in data.items():
span.set_data(key, value)
with capture_internal_exceptions():
sentry_sdk.add_breadcrumb(
message=query, category="query", type=OP.DB, data=tags
)
self._ongoing_operations[self._operation_key(event)] = span.__enter__()
def failed(self, event):
# type: (CommandFailedEvent) -> None
if sentry_sdk.get_client().get_integration(PyMongoIntegration) is None:
return
try:
span = self._ongoing_operations.pop(self._operation_key(event))
span.set_status(SPANSTATUS.INTERNAL_ERROR)
span.__exit__(None, None, None)
except KeyError:
return
def succeeded(self, event):
# type: (CommandSucceededEvent) -> None
if sentry_sdk.get_client().get_integration(PyMongoIntegration) is None:
return
try:
span = self._ongoing_operations.pop(self._operation_key(event))
span.set_status(SPANSTATUS.OK)
span.__exit__(None, None, None)
except KeyError:
pass
class PyMongoIntegration(Integration):
identifier = "pymongo"
origin = f"auto.db.{identifier}"
@staticmethod
def setup_once():
# type: () -> None
monitoring.register(CommandTracer())
@@ -0,0 +1,229 @@
import functools
import os
import sys
import weakref
import sentry_sdk
from sentry_sdk.integrations import Integration, DidNotEnable
from sentry_sdk.integrations._wsgi_common import RequestExtractor
from sentry_sdk.integrations.wsgi import SentryWsgiMiddleware
from sentry_sdk.scope import should_send_default_pii
from sentry_sdk.tracing import SOURCE_FOR_STYLE
from sentry_sdk.utils import (
capture_internal_exceptions,
ensure_integration_enabled,
event_from_exception,
reraise,
)
try:
from pyramid.httpexceptions import HTTPException
from pyramid.request import Request
except ImportError:
raise DidNotEnable("Pyramid not installed")
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from pyramid.response import Response
from typing import Any
from sentry_sdk.integrations.wsgi import _ScopedResponse
from typing import Callable
from typing import Dict
from typing import Optional
from webob.cookies import RequestCookies
from webob.request import _FieldStorageWithFile
from sentry_sdk.utils import ExcInfo
from sentry_sdk._types import Event, EventProcessor
if getattr(Request, "authenticated_userid", None):
def authenticated_userid(request):
# type: (Request) -> Optional[Any]
return request.authenticated_userid
else:
# bw-compat for pyramid < 1.5
from pyramid.security import authenticated_userid # type: ignore
TRANSACTION_STYLE_VALUES = ("route_name", "route_pattern")
class PyramidIntegration(Integration):
identifier = "pyramid"
origin = f"auto.http.{identifier}"
transaction_style = ""
def __init__(self, transaction_style="route_name"):
# type: (str) -> None
if transaction_style not in TRANSACTION_STYLE_VALUES:
raise ValueError(
"Invalid value for transaction_style: %s (must be in %s)"
% (transaction_style, TRANSACTION_STYLE_VALUES)
)
self.transaction_style = transaction_style
@staticmethod
def setup_once():
# type: () -> None
from pyramid import router
old_call_view = router._call_view
@functools.wraps(old_call_view)
def sentry_patched_call_view(registry, request, *args, **kwargs):
# type: (Any, Request, *Any, **Any) -> Response
integration = sentry_sdk.get_client().get_integration(PyramidIntegration)
if integration is None:
return old_call_view(registry, request, *args, **kwargs)
_set_transaction_name_and_source(
sentry_sdk.get_current_scope(), integration.transaction_style, request
)
scope = sentry_sdk.get_isolation_scope()
scope.add_event_processor(
_make_event_processor(weakref.ref(request), integration)
)
return old_call_view(registry, request, *args, **kwargs)
router._call_view = sentry_patched_call_view
if hasattr(Request, "invoke_exception_view"):
old_invoke_exception_view = Request.invoke_exception_view
def sentry_patched_invoke_exception_view(self, *args, **kwargs):
# type: (Request, *Any, **Any) -> Any
rv = old_invoke_exception_view(self, *args, **kwargs)
if (
self.exc_info
and all(self.exc_info)
and rv.status_int == 500
and sentry_sdk.get_client().get_integration(PyramidIntegration)
is not None
):
_capture_exception(self.exc_info)
return rv
Request.invoke_exception_view = sentry_patched_invoke_exception_view
old_wsgi_call = router.Router.__call__
@ensure_integration_enabled(PyramidIntegration, old_wsgi_call)
def sentry_patched_wsgi_call(self, environ, start_response):
# type: (Any, Dict[str, str], Callable[..., Any]) -> _ScopedResponse
def sentry_patched_inner_wsgi_call(environ, start_response):
# type: (Dict[str, Any], Callable[..., Any]) -> Any
try:
return old_wsgi_call(self, environ, start_response)
except Exception:
einfo = sys.exc_info()
_capture_exception(einfo)
reraise(*einfo)
middleware = SentryWsgiMiddleware(
sentry_patched_inner_wsgi_call,
span_origin=PyramidIntegration.origin,
)
return middleware(environ, start_response)
router.Router.__call__ = sentry_patched_wsgi_call
@ensure_integration_enabled(PyramidIntegration)
def _capture_exception(exc_info):
# type: (ExcInfo) -> None
if exc_info[0] is None or issubclass(exc_info[0], HTTPException):
return
event, hint = event_from_exception(
exc_info,
client_options=sentry_sdk.get_client().options,
mechanism={"type": "pyramid", "handled": False},
)
sentry_sdk.capture_event(event, hint=hint)
def _set_transaction_name_and_source(scope, transaction_style, request):
# type: (sentry_sdk.Scope, str, Request) -> None
try:
name_for_style = {
"route_name": request.matched_route.name,
"route_pattern": request.matched_route.pattern,
}
scope.set_transaction_name(
name_for_style[transaction_style],
source=SOURCE_FOR_STYLE[transaction_style],
)
except Exception:
pass
class PyramidRequestExtractor(RequestExtractor):
def url(self):
# type: () -> str
return self.request.path_url
def env(self):
# type: () -> Dict[str, str]
return self.request.environ
def cookies(self):
# type: () -> RequestCookies
return self.request.cookies
def raw_data(self):
# type: () -> str
return self.request.text
def form(self):
# type: () -> Dict[str, str]
return {
key: value
for key, value in self.request.POST.items()
if not getattr(value, "filename", None)
}
def files(self):
# type: () -> Dict[str, _FieldStorageWithFile]
return {
key: value
for key, value in self.request.POST.items()
if getattr(value, "filename", None)
}
def size_of_file(self, postdata):
# type: (_FieldStorageWithFile) -> int
file = postdata.file
try:
return os.fstat(file.fileno()).st_size
except Exception:
return 0
def _make_event_processor(weak_request, integration):
# type: (Callable[[], Request], PyramidIntegration) -> EventProcessor
def pyramid_event_processor(event, hint):
# type: (Event, Dict[str, Any]) -> Event
request = weak_request()
if request is None:
return event
with capture_internal_exceptions():
PyramidRequestExtractor(request).extract_into_event(event)
if should_send_default_pii():
with capture_internal_exceptions():
user_info = event.setdefault("user", {})
user_info.setdefault("id", authenticated_userid(request))
return event
return pyramid_event_processor
@@ -0,0 +1,237 @@
import asyncio
import inspect
from functools import wraps
import sentry_sdk
from sentry_sdk.integrations import DidNotEnable, Integration
from sentry_sdk.integrations._wsgi_common import _filter_headers
from sentry_sdk.integrations.asgi import SentryAsgiMiddleware
from sentry_sdk.scope import should_send_default_pii
from sentry_sdk.tracing import SOURCE_FOR_STYLE
from sentry_sdk.utils import (
capture_internal_exceptions,
ensure_integration_enabled,
event_from_exception,
)
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from typing import Any
from typing import Union
from sentry_sdk._types import Event, EventProcessor
try:
import quart_auth # type: ignore
except ImportError:
quart_auth = None
try:
from quart import ( # type: ignore
has_request_context,
has_websocket_context,
Request,
Quart,
request,
websocket,
)
from quart.signals import ( # type: ignore
got_background_exception,
got_request_exception,
got_websocket_exception,
request_started,
websocket_started,
)
except ImportError:
raise DidNotEnable("Quart is not installed")
else:
# Quart 0.19 is based on Flask and hence no longer has a Scaffold
try:
from quart.scaffold import Scaffold # type: ignore
except ImportError:
from flask.sansio.scaffold import Scaffold # type: ignore
TRANSACTION_STYLE_VALUES = ("endpoint", "url")
class QuartIntegration(Integration):
identifier = "quart"
origin = f"auto.http.{identifier}"
transaction_style = ""
def __init__(self, transaction_style="endpoint"):
# type: (str) -> None
if transaction_style not in TRANSACTION_STYLE_VALUES:
raise ValueError(
"Invalid value for transaction_style: %s (must be in %s)"
% (transaction_style, TRANSACTION_STYLE_VALUES)
)
self.transaction_style = transaction_style
@staticmethod
def setup_once():
# type: () -> None
request_started.connect(_request_websocket_started)
websocket_started.connect(_request_websocket_started)
got_background_exception.connect(_capture_exception)
got_request_exception.connect(_capture_exception)
got_websocket_exception.connect(_capture_exception)
patch_asgi_app()
patch_scaffold_route()
def patch_asgi_app():
# type: () -> None
old_app = Quart.__call__
async def sentry_patched_asgi_app(self, scope, receive, send):
# type: (Any, Any, Any, Any) -> Any
if sentry_sdk.get_client().get_integration(QuartIntegration) is None:
return await old_app(self, scope, receive, send)
middleware = SentryAsgiMiddleware(
lambda *a, **kw: old_app(self, *a, **kw),
span_origin=QuartIntegration.origin,
)
middleware.__call__ = middleware._run_asgi3
return await middleware(scope, receive, send)
Quart.__call__ = sentry_patched_asgi_app
def patch_scaffold_route():
# type: () -> None
old_route = Scaffold.route
def _sentry_route(*args, **kwargs):
# type: (*Any, **Any) -> Any
old_decorator = old_route(*args, **kwargs)
def decorator(old_func):
# type: (Any) -> Any
if inspect.isfunction(old_func) and not asyncio.iscoroutinefunction(
old_func
):
@wraps(old_func)
@ensure_integration_enabled(QuartIntegration, old_func)
def _sentry_func(*args, **kwargs):
# type: (*Any, **Any) -> Any
current_scope = sentry_sdk.get_current_scope()
if current_scope.transaction is not None:
current_scope.transaction.update_active_thread()
sentry_scope = sentry_sdk.get_isolation_scope()
if sentry_scope.profile is not None:
sentry_scope.profile.update_active_thread_id()
return old_func(*args, **kwargs)
return old_decorator(_sentry_func)
return old_decorator(old_func)
return decorator
Scaffold.route = _sentry_route
def _set_transaction_name_and_source(scope, transaction_style, request):
# type: (sentry_sdk.Scope, str, Request) -> None
try:
name_for_style = {
"url": request.url_rule.rule,
"endpoint": request.url_rule.endpoint,
}
scope.set_transaction_name(
name_for_style[transaction_style],
source=SOURCE_FOR_STYLE[transaction_style],
)
except Exception:
pass
async def _request_websocket_started(app, **kwargs):
# type: (Quart, **Any) -> None
integration = sentry_sdk.get_client().get_integration(QuartIntegration)
if integration is None:
return
if has_request_context():
request_websocket = request._get_current_object()
if has_websocket_context():
request_websocket = websocket._get_current_object()
# Set the transaction name here, but rely on ASGI middleware
# to actually start the transaction
_set_transaction_name_and_source(
sentry_sdk.get_current_scope(), integration.transaction_style, request_websocket
)
scope = sentry_sdk.get_isolation_scope()
evt_processor = _make_request_event_processor(app, request_websocket, integration)
scope.add_event_processor(evt_processor)
def _make_request_event_processor(app, request, integration):
# type: (Quart, Request, QuartIntegration) -> EventProcessor
def inner(event, hint):
# type: (Event, dict[str, Any]) -> Event
# if the request is gone we are fine not logging the data from
# it. This might happen if the processor is pushed away to
# another thread.
if request is None:
return event
with capture_internal_exceptions():
# TODO: Figure out what to do with request body. Methods on request
# are async, but event processors are not.
request_info = event.setdefault("request", {})
request_info["url"] = request.url
request_info["query_string"] = request.query_string
request_info["method"] = request.method
request_info["headers"] = _filter_headers(dict(request.headers))
if should_send_default_pii():
request_info["env"] = {"REMOTE_ADDR": request.access_route[0]}
_add_user_to_event(event)
return event
return inner
async def _capture_exception(sender, exception, **kwargs):
# type: (Quart, Union[ValueError, BaseException], **Any) -> None
integration = sentry_sdk.get_client().get_integration(QuartIntegration)
if integration is None:
return
event, hint = event_from_exception(
exception,
client_options=sentry_sdk.get_client().options,
mechanism={"type": "quart", "handled": False},
)
sentry_sdk.capture_event(event, hint=hint)
def _add_user_to_event(event):
# type: (Event) -> None
if quart_auth is None:
return
user = quart_auth.current_user
if user is None:
return
with capture_internal_exceptions():
user_info = event.setdefault("user", {})
user_info["id"] = quart_auth.current_user._auth_id
@@ -0,0 +1,141 @@
import inspect
import sys
import sentry_sdk
from sentry_sdk.consts import OP, SPANSTATUS
from sentry_sdk.integrations import _check_minimum_version, DidNotEnable, Integration
from sentry_sdk.tracing import TRANSACTION_SOURCE_TASK
from sentry_sdk.utils import (
event_from_exception,
logger,
package_version,
qualname_from_function,
reraise,
)
try:
import ray # type: ignore[import-not-found]
except ImportError:
raise DidNotEnable("Ray not installed.")
import functools
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from collections.abc import Callable
from typing import Any, Optional
from sentry_sdk.utils import ExcInfo
def _check_sentry_initialized():
# type: () -> None
if sentry_sdk.get_client().is_active():
return
logger.debug(
"[Tracing] Sentry not initialized in ray cluster worker, performance data will be discarded."
)
def _patch_ray_remote():
# type: () -> None
old_remote = ray.remote
@functools.wraps(old_remote)
def new_remote(f, *args, **kwargs):
# type: (Callable[..., Any], *Any, **Any) -> Callable[..., Any]
if inspect.isclass(f):
# Ray Actors
# (https://docs.ray.io/en/latest/ray-core/actors.html)
# are not supported
# (Only Ray Tasks are supported)
return old_remote(f, *args, *kwargs)
def _f(*f_args, _tracing=None, **f_kwargs):
# type: (Any, Optional[dict[str, Any]], Any) -> Any
"""
Ray Worker
"""
_check_sentry_initialized()
transaction = sentry_sdk.continue_trace(
_tracing or {},
op=OP.QUEUE_TASK_RAY,
name=qualname_from_function(f),
origin=RayIntegration.origin,
source=TRANSACTION_SOURCE_TASK,
)
with sentry_sdk.start_transaction(transaction) as transaction:
try:
result = f(*f_args, **f_kwargs)
transaction.set_status(SPANSTATUS.OK)
except Exception:
transaction.set_status(SPANSTATUS.INTERNAL_ERROR)
exc_info = sys.exc_info()
_capture_exception(exc_info)
reraise(*exc_info)
return result
rv = old_remote(_f, *args, *kwargs)
old_remote_method = rv.remote
def _remote_method_with_header_propagation(*args, **kwargs):
# type: (*Any, **Any) -> Any
"""
Ray Client
"""
with sentry_sdk.start_span(
op=OP.QUEUE_SUBMIT_RAY,
name=qualname_from_function(f),
origin=RayIntegration.origin,
) as span:
tracing = {
k: v
for k, v in sentry_sdk.get_current_scope().iter_trace_propagation_headers()
}
try:
result = old_remote_method(*args, **kwargs, _tracing=tracing)
span.set_status(SPANSTATUS.OK)
except Exception:
span.set_status(SPANSTATUS.INTERNAL_ERROR)
exc_info = sys.exc_info()
_capture_exception(exc_info)
reraise(*exc_info)
return result
rv.remote = _remote_method_with_header_propagation
return rv
ray.remote = new_remote
def _capture_exception(exc_info, **kwargs):
# type: (ExcInfo, **Any) -> None
client = sentry_sdk.get_client()
event, hint = event_from_exception(
exc_info,
client_options=client.options,
mechanism={
"handled": False,
"type": RayIntegration.identifier,
},
)
sentry_sdk.capture_event(event, hint=hint)
class RayIntegration(Integration):
identifier = "ray"
origin = f"auto.queue.{identifier}"
@staticmethod
def setup_once():
# type: () -> None
version = package_version("ray")
_check_minimum_version(RayIntegration, version)
_patch_ray_remote()
@@ -0,0 +1,38 @@
from sentry_sdk.integrations import Integration, DidNotEnable
from sentry_sdk.integrations.redis.consts import _DEFAULT_MAX_DATA_SIZE
from sentry_sdk.integrations.redis.rb import _patch_rb
from sentry_sdk.integrations.redis.redis import _patch_redis
from sentry_sdk.integrations.redis.redis_cluster import _patch_redis_cluster
from sentry_sdk.integrations.redis.redis_py_cluster_legacy import _patch_rediscluster
from sentry_sdk.utils import logger
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from typing import Optional
class RedisIntegration(Integration):
identifier = "redis"
def __init__(self, max_data_size=_DEFAULT_MAX_DATA_SIZE, cache_prefixes=None):
# type: (int, Optional[list[str]]) -> None
self.max_data_size = max_data_size
self.cache_prefixes = cache_prefixes if cache_prefixes is not None else []
@staticmethod
def setup_once():
# type: () -> None
try:
from redis import StrictRedis, client
except ImportError:
raise DidNotEnable("Redis client not installed")
_patch_redis(StrictRedis, client)
_patch_redis_cluster()
_patch_rb()
try:
_patch_rediscluster()
except Exception:
logger.exception("Error occurred while patching `rediscluster` library")
@@ -0,0 +1,108 @@
import sentry_sdk
from sentry_sdk.consts import OP
from sentry_sdk.integrations.redis.consts import SPAN_ORIGIN
from sentry_sdk.integrations.redis.modules.caches import (
_compile_cache_span_properties,
_set_cache_data,
)
from sentry_sdk.integrations.redis.modules.queries import _compile_db_span_properties
from sentry_sdk.integrations.redis.utils import (
_set_client_data,
_set_pipeline_data,
)
from sentry_sdk.tracing import Span
from sentry_sdk.utils import capture_internal_exceptions
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from collections.abc import Callable
from typing import Any, Union
from redis.asyncio.client import Pipeline, StrictRedis
from redis.asyncio.cluster import ClusterPipeline, RedisCluster
def patch_redis_async_pipeline(
pipeline_cls, is_cluster, get_command_args_fn, set_db_data_fn
):
# type: (Union[type[Pipeline[Any]], type[ClusterPipeline[Any]]], bool, Any, Callable[[Span, Any], None]) -> None
old_execute = pipeline_cls.execute
from sentry_sdk.integrations.redis import RedisIntegration
async def _sentry_execute(self, *args, **kwargs):
# type: (Any, *Any, **Any) -> Any
if sentry_sdk.get_client().get_integration(RedisIntegration) is None:
return await old_execute(self, *args, **kwargs)
with sentry_sdk.start_span(
op=OP.DB_REDIS,
name="redis.pipeline.execute",
origin=SPAN_ORIGIN,
) as span:
with capture_internal_exceptions():
set_db_data_fn(span, self)
_set_pipeline_data(
span,
is_cluster,
get_command_args_fn,
False if is_cluster else self.is_transaction,
self._command_stack if is_cluster else self.command_stack,
)
return await old_execute(self, *args, **kwargs)
pipeline_cls.execute = _sentry_execute # type: ignore
def patch_redis_async_client(cls, is_cluster, set_db_data_fn):
# type: (Union[type[StrictRedis[Any]], type[RedisCluster[Any]]], bool, Callable[[Span, Any], None]) -> None
old_execute_command = cls.execute_command
from sentry_sdk.integrations.redis import RedisIntegration
async def _sentry_execute_command(self, name, *args, **kwargs):
# type: (Any, str, *Any, **Any) -> Any
integration = sentry_sdk.get_client().get_integration(RedisIntegration)
if integration is None:
return await old_execute_command(self, name, *args, **kwargs)
cache_properties = _compile_cache_span_properties(
name,
args,
kwargs,
integration,
)
cache_span = None
if cache_properties["is_cache_key"] and cache_properties["op"] is not None:
cache_span = sentry_sdk.start_span(
op=cache_properties["op"],
name=cache_properties["description"],
origin=SPAN_ORIGIN,
)
cache_span.__enter__()
db_properties = _compile_db_span_properties(integration, name, args)
db_span = sentry_sdk.start_span(
op=db_properties["op"],
name=db_properties["description"],
origin=SPAN_ORIGIN,
)
db_span.__enter__()
set_db_data_fn(db_span, self)
_set_client_data(db_span, is_cluster, name, *args)
value = await old_execute_command(self, name, *args, **kwargs)
db_span.__exit__(None, None, None)
if cache_span:
_set_cache_data(cache_span, self, cache_properties, value)
cache_span.__exit__(None, None, None)
return value
cls.execute_command = _sentry_execute_command # type: ignore
@@ -0,0 +1,113 @@
import sentry_sdk
from sentry_sdk.consts import OP
from sentry_sdk.integrations.redis.consts import SPAN_ORIGIN
from sentry_sdk.integrations.redis.modules.caches import (
_compile_cache_span_properties,
_set_cache_data,
)
from sentry_sdk.integrations.redis.modules.queries import _compile_db_span_properties
from sentry_sdk.integrations.redis.utils import (
_set_client_data,
_set_pipeline_data,
)
from sentry_sdk.tracing import Span
from sentry_sdk.utils import capture_internal_exceptions
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from collections.abc import Callable
from typing import Any
def patch_redis_pipeline(
pipeline_cls,
is_cluster,
get_command_args_fn,
set_db_data_fn,
):
# type: (Any, bool, Any, Callable[[Span, Any], None]) -> None
old_execute = pipeline_cls.execute
from sentry_sdk.integrations.redis import RedisIntegration
def sentry_patched_execute(self, *args, **kwargs):
# type: (Any, *Any, **Any) -> Any
if sentry_sdk.get_client().get_integration(RedisIntegration) is None:
return old_execute(self, *args, **kwargs)
with sentry_sdk.start_span(
op=OP.DB_REDIS,
name="redis.pipeline.execute",
origin=SPAN_ORIGIN,
) as span:
with capture_internal_exceptions():
set_db_data_fn(span, self)
_set_pipeline_data(
span,
is_cluster,
get_command_args_fn,
False if is_cluster else self.transaction,
self.command_stack,
)
return old_execute(self, *args, **kwargs)
pipeline_cls.execute = sentry_patched_execute
def patch_redis_client(cls, is_cluster, set_db_data_fn):
# type: (Any, bool, Callable[[Span, Any], None]) -> None
"""
This function can be used to instrument custom redis client classes or
subclasses.
"""
old_execute_command = cls.execute_command
from sentry_sdk.integrations.redis import RedisIntegration
def sentry_patched_execute_command(self, name, *args, **kwargs):
# type: (Any, str, *Any, **Any) -> Any
integration = sentry_sdk.get_client().get_integration(RedisIntegration)
if integration is None:
return old_execute_command(self, name, *args, **kwargs)
cache_properties = _compile_cache_span_properties(
name,
args,
kwargs,
integration,
)
cache_span = None
if cache_properties["is_cache_key"] and cache_properties["op"] is not None:
cache_span = sentry_sdk.start_span(
op=cache_properties["op"],
name=cache_properties["description"],
origin=SPAN_ORIGIN,
)
cache_span.__enter__()
db_properties = _compile_db_span_properties(integration, name, args)
db_span = sentry_sdk.start_span(
op=db_properties["op"],
name=db_properties["description"],
origin=SPAN_ORIGIN,
)
db_span.__enter__()
set_db_data_fn(db_span, self)
_set_client_data(db_span, is_cluster, name, *args)
value = old_execute_command(self, name, *args, **kwargs)
db_span.__exit__(None, None, None)
if cache_span:
_set_cache_data(cache_span, self, cache_properties, value)
cache_span.__exit__(None, None, None)
return value
cls.execute_command = sentry_patched_execute_command
@@ -0,0 +1,19 @@
SPAN_ORIGIN = "auto.db.redis"
_SINGLE_KEY_COMMANDS = frozenset(
["decr", "decrby", "get", "incr", "incrby", "pttl", "set", "setex", "setnx", "ttl"],
)
_MULTI_KEY_COMMANDS = frozenset(
[
"del",
"touch",
"unlink",
"mget",
],
)
_COMMANDS_INCLUDING_SENSITIVE_DATA = [
"auth",
]
_MAX_NUM_ARGS = 10 # Trim argument lists to this many values
_MAX_NUM_COMMANDS = 10 # Trim command lists to this many values
_DEFAULT_MAX_DATA_SIZE = 1024
@@ -0,0 +1,121 @@
"""
Code used for the Caches module in Sentry
"""
from sentry_sdk.consts import OP, SPANDATA
from sentry_sdk.integrations.redis.utils import _get_safe_key, _key_as_string
from sentry_sdk.utils import capture_internal_exceptions
GET_COMMANDS = ("get", "mget")
SET_COMMANDS = ("set", "setex")
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from sentry_sdk.integrations.redis import RedisIntegration
from sentry_sdk.tracing import Span
from typing import Any, Optional
def _get_op(name):
# type: (str) -> Optional[str]
op = None
if name.lower() in GET_COMMANDS:
op = OP.CACHE_GET
elif name.lower() in SET_COMMANDS:
op = OP.CACHE_PUT
return op
def _compile_cache_span_properties(redis_command, args, kwargs, integration):
# type: (str, tuple[Any, ...], dict[str, Any], RedisIntegration) -> dict[str, Any]
key = _get_safe_key(redis_command, args, kwargs)
key_as_string = _key_as_string(key)
keys_as_string = key_as_string.split(", ")
is_cache_key = False
for prefix in integration.cache_prefixes:
for kee in keys_as_string:
if kee.startswith(prefix):
is_cache_key = True
break
if is_cache_key:
break
value = None
if redis_command.lower() in SET_COMMANDS:
value = args[-1]
properties = {
"op": _get_op(redis_command),
"description": _get_cache_span_description(
redis_command, args, kwargs, integration
),
"key": key,
"key_as_string": key_as_string,
"redis_command": redis_command.lower(),
"is_cache_key": is_cache_key,
"value": value,
}
return properties
def _get_cache_span_description(redis_command, args, kwargs, integration):
# type: (str, tuple[Any, ...], dict[str, Any], RedisIntegration) -> str
description = _key_as_string(_get_safe_key(redis_command, args, kwargs))
data_should_be_truncated = (
integration.max_data_size and len(description) > integration.max_data_size
)
if data_should_be_truncated:
description = description[: integration.max_data_size - len("...")] + "..."
return description
def _set_cache_data(span, redis_client, properties, return_value):
# type: (Span, Any, dict[str, Any], Optional[Any]) -> None
with capture_internal_exceptions():
span.set_data(SPANDATA.CACHE_KEY, properties["key"])
if properties["redis_command"] in GET_COMMANDS:
if return_value is not None:
span.set_data(SPANDATA.CACHE_HIT, True)
size = (
len(str(return_value).encode("utf-8"))
if not isinstance(return_value, bytes)
else len(return_value)
)
span.set_data(SPANDATA.CACHE_ITEM_SIZE, size)
else:
span.set_data(SPANDATA.CACHE_HIT, False)
elif properties["redis_command"] in SET_COMMANDS:
if properties["value"] is not None:
size = (
len(properties["value"].encode("utf-8"))
if not isinstance(properties["value"], bytes)
else len(properties["value"])
)
span.set_data(SPANDATA.CACHE_ITEM_SIZE, size)
try:
connection_params = redis_client.connection_pool.connection_kwargs
except AttributeError:
# If it is a cluster, there is no connection_pool attribute so we
# need to get the default node from the cluster instance
default_node = redis_client.get_default_node()
connection_params = {
"host": default_node.host,
"port": default_node.port,
}
host = connection_params.get("host")
if host is not None:
span.set_data(SPANDATA.NETWORK_PEER_ADDRESS, host)
port = connection_params.get("port")
if port is not None:
span.set_data(SPANDATA.NETWORK_PEER_PORT, port)
@@ -0,0 +1,68 @@
"""
Code used for the Queries module in Sentry
"""
from sentry_sdk.consts import OP, SPANDATA
from sentry_sdk.integrations.redis.utils import _get_safe_command
from sentry_sdk.utils import capture_internal_exceptions
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from redis import Redis
from sentry_sdk.integrations.redis import RedisIntegration
from sentry_sdk.tracing import Span
from typing import Any
def _compile_db_span_properties(integration, redis_command, args):
# type: (RedisIntegration, str, tuple[Any, ...]) -> dict[str, Any]
description = _get_db_span_description(integration, redis_command, args)
properties = {
"op": OP.DB_REDIS,
"description": description,
}
return properties
def _get_db_span_description(integration, command_name, args):
# type: (RedisIntegration, str, tuple[Any, ...]) -> str
description = command_name
with capture_internal_exceptions():
description = _get_safe_command(command_name, args)
data_should_be_truncated = (
integration.max_data_size and len(description) > integration.max_data_size
)
if data_should_be_truncated:
description = description[: integration.max_data_size - len("...")] + "..."
return description
def _set_db_data_on_span(span, connection_params):
# type: (Span, dict[str, Any]) -> None
span.set_data(SPANDATA.DB_SYSTEM, "redis")
db = connection_params.get("db")
if db is not None:
span.set_data(SPANDATA.DB_NAME, str(db))
host = connection_params.get("host")
if host is not None:
span.set_data(SPANDATA.SERVER_ADDRESS, host)
port = connection_params.get("port")
if port is not None:
span.set_data(SPANDATA.SERVER_PORT, port)
def _set_db_data(span, redis_instance):
# type: (Span, Redis[Any]) -> None
try:
_set_db_data_on_span(span, redis_instance.connection_pool.connection_kwargs)
except AttributeError:
pass # connections_kwargs may be missing in some cases
@@ -0,0 +1,32 @@
"""
Instrumentation for Redis Blaster (rb)
https://github.com/getsentry/rb
"""
from sentry_sdk.integrations.redis._sync_common import patch_redis_client
from sentry_sdk.integrations.redis.modules.queries import _set_db_data
def _patch_rb():
# type: () -> None
try:
import rb.clients # type: ignore
except ImportError:
pass
else:
patch_redis_client(
rb.clients.FanoutClient,
is_cluster=False,
set_db_data_fn=_set_db_data,
)
patch_redis_client(
rb.clients.MappingClient,
is_cluster=False,
set_db_data_fn=_set_db_data,
)
patch_redis_client(
rb.clients.RoutingClient,
is_cluster=False,
set_db_data_fn=_set_db_data,
)
@@ -0,0 +1,69 @@
"""
Instrumentation for Redis
https://github.com/redis/redis-py
"""
from sentry_sdk.integrations.redis._sync_common import (
patch_redis_client,
patch_redis_pipeline,
)
from sentry_sdk.integrations.redis.modules.queries import _set_db_data
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from typing import Any, Sequence
def _get_redis_command_args(command):
# type: (Any) -> Sequence[Any]
return command[0]
def _patch_redis(StrictRedis, client): # noqa: N803
# type: (Any, Any) -> None
patch_redis_client(
StrictRedis,
is_cluster=False,
set_db_data_fn=_set_db_data,
)
patch_redis_pipeline(
client.Pipeline,
is_cluster=False,
get_command_args_fn=_get_redis_command_args,
set_db_data_fn=_set_db_data,
)
try:
strict_pipeline = client.StrictPipeline
except AttributeError:
pass
else:
patch_redis_pipeline(
strict_pipeline,
is_cluster=False,
get_command_args_fn=_get_redis_command_args,
set_db_data_fn=_set_db_data,
)
try:
import redis.asyncio
except ImportError:
pass
else:
from sentry_sdk.integrations.redis._async_common import (
patch_redis_async_client,
patch_redis_async_pipeline,
)
patch_redis_async_client(
redis.asyncio.client.StrictRedis,
is_cluster=False,
set_db_data_fn=_set_db_data,
)
patch_redis_async_pipeline(
redis.asyncio.client.Pipeline,
False,
_get_redis_command_args,
set_db_data_fn=_set_db_data,
)
@@ -0,0 +1,99 @@
"""
Instrumentation for RedisCluster
This is part of the main redis-py client.
https://github.com/redis/redis-py/blob/master/redis/cluster.py
"""
from sentry_sdk.integrations.redis._sync_common import (
patch_redis_client,
patch_redis_pipeline,
)
from sentry_sdk.integrations.redis.modules.queries import _set_db_data_on_span
from sentry_sdk.integrations.redis.utils import _parse_rediscluster_command
from sentry_sdk.utils import capture_internal_exceptions
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from typing import Any
from redis import RedisCluster
from redis.asyncio.cluster import (
RedisCluster as AsyncRedisCluster,
ClusterPipeline as AsyncClusterPipeline,
)
from sentry_sdk.tracing import Span
def _set_async_cluster_db_data(span, async_redis_cluster_instance):
# type: (Span, AsyncRedisCluster[Any]) -> None
default_node = async_redis_cluster_instance.get_default_node()
if default_node is not None and default_node.connection_kwargs is not None:
_set_db_data_on_span(span, default_node.connection_kwargs)
def _set_async_cluster_pipeline_db_data(span, async_redis_cluster_pipeline_instance):
# type: (Span, AsyncClusterPipeline[Any]) -> None
with capture_internal_exceptions():
_set_async_cluster_db_data(
span,
# the AsyncClusterPipeline has always had a `_client` attr but it is private so potentially problematic and mypy
# does not recognize it - see https://github.com/redis/redis-py/blame/v5.0.0/redis/asyncio/cluster.py#L1386
async_redis_cluster_pipeline_instance._client, # type: ignore[attr-defined]
)
def _set_cluster_db_data(span, redis_cluster_instance):
# type: (Span, RedisCluster[Any]) -> None
default_node = redis_cluster_instance.get_default_node()
if default_node is not None:
connection_params = {
"host": default_node.host,
"port": default_node.port,
}
_set_db_data_on_span(span, connection_params)
def _patch_redis_cluster():
# type: () -> None
"""Patches the cluster module on redis SDK (as opposed to rediscluster library)"""
try:
from redis import RedisCluster, cluster
except ImportError:
pass
else:
patch_redis_client(
RedisCluster,
is_cluster=True,
set_db_data_fn=_set_cluster_db_data,
)
patch_redis_pipeline(
cluster.ClusterPipeline,
is_cluster=True,
get_command_args_fn=_parse_rediscluster_command,
set_db_data_fn=_set_cluster_db_data,
)
try:
from redis.asyncio import cluster as async_cluster
except ImportError:
pass
else:
from sentry_sdk.integrations.redis._async_common import (
patch_redis_async_client,
patch_redis_async_pipeline,
)
patch_redis_async_client(
async_cluster.RedisCluster,
is_cluster=True,
set_db_data_fn=_set_async_cluster_db_data,
)
patch_redis_async_pipeline(
async_cluster.ClusterPipeline,
is_cluster=True,
get_command_args_fn=_parse_rediscluster_command,
set_db_data_fn=_set_async_cluster_pipeline_db_data,
)
@@ -0,0 +1,50 @@
"""
Instrumentation for redis-py-cluster
The project redis-py-cluster is EOL and was integrated into redis-py starting from version 4.1.0 (Dec 26, 2021).
https://github.com/grokzen/redis-py-cluster
"""
from sentry_sdk.integrations.redis._sync_common import (
patch_redis_client,
patch_redis_pipeline,
)
from sentry_sdk.integrations.redis.modules.queries import _set_db_data
from sentry_sdk.integrations.redis.utils import _parse_rediscluster_command
def _patch_rediscluster():
# type: () -> None
try:
import rediscluster # type: ignore
except ImportError:
return
patch_redis_client(
rediscluster.RedisCluster,
is_cluster=True,
set_db_data_fn=_set_db_data,
)
# up to v1.3.6, __version__ attribute is a tuple
# from v2.0.0, __version__ is a string and VERSION a tuple
version = getattr(rediscluster, "VERSION", rediscluster.__version__)
# StrictRedisCluster was introduced in v0.2.0 and removed in v2.0.0
# https://github.com/Grokzen/redis-py-cluster/blob/master/docs/release-notes.rst
if (0, 2, 0) < version < (2, 0, 0):
pipeline_cls = rediscluster.pipeline.StrictClusterPipeline
patch_redis_client(
rediscluster.StrictRedisCluster,
is_cluster=True,
set_db_data_fn=_set_db_data,
)
else:
pipeline_cls = rediscluster.pipeline.ClusterPipeline
patch_redis_pipeline(
pipeline_cls,
is_cluster=True,
get_command_args_fn=_parse_rediscluster_command,
set_db_data_fn=_set_db_data,
)
@@ -0,0 +1,144 @@
from sentry_sdk.consts import SPANDATA
from sentry_sdk.integrations.redis.consts import (
_COMMANDS_INCLUDING_SENSITIVE_DATA,
_MAX_NUM_ARGS,
_MAX_NUM_COMMANDS,
_MULTI_KEY_COMMANDS,
_SINGLE_KEY_COMMANDS,
)
from sentry_sdk.scope import should_send_default_pii
from sentry_sdk.utils import SENSITIVE_DATA_SUBSTITUTE
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from typing import Any, Optional, Sequence
from sentry_sdk.tracing import Span
def _get_safe_command(name, args):
# type: (str, Sequence[Any]) -> str
command_parts = [name]
for i, arg in enumerate(args):
if i > _MAX_NUM_ARGS:
break
name_low = name.lower()
if name_low in _COMMANDS_INCLUDING_SENSITIVE_DATA:
command_parts.append(SENSITIVE_DATA_SUBSTITUTE)
continue
arg_is_the_key = i == 0
if arg_is_the_key:
command_parts.append(repr(arg))
else:
if should_send_default_pii():
command_parts.append(repr(arg))
else:
command_parts.append(SENSITIVE_DATA_SUBSTITUTE)
command = " ".join(command_parts)
return command
def _safe_decode(key):
# type: (Any) -> str
if isinstance(key, bytes):
try:
return key.decode()
except UnicodeDecodeError:
return ""
return str(key)
def _key_as_string(key):
# type: (Any) -> str
if isinstance(key, (dict, list, tuple)):
key = ", ".join(_safe_decode(x) for x in key)
elif isinstance(key, bytes):
key = _safe_decode(key)
elif key is None:
key = ""
else:
key = str(key)
return key
def _get_safe_key(method_name, args, kwargs):
# type: (str, Optional[tuple[Any, ...]], Optional[dict[str, Any]]) -> Optional[tuple[str, ...]]
"""
Gets the key (or keys) from the given method_name.
The method_name could be a redis command or a django caching command
"""
key = None
if args is not None and method_name.lower() in _MULTI_KEY_COMMANDS:
# for example redis "mget"
key = tuple(args)
elif args is not None and len(args) >= 1:
# for example django "set_many/get_many" or redis "get"
if isinstance(args[0], (dict, list, tuple)):
key = tuple(args[0])
else:
key = (args[0],)
elif kwargs is not None and "key" in kwargs:
# this is a legacy case for older versions of Django
if isinstance(kwargs["key"], (list, tuple)):
if len(kwargs["key"]) > 0:
key = tuple(kwargs["key"])
else:
if kwargs["key"] is not None:
key = (kwargs["key"],)
return key
def _parse_rediscluster_command(command):
# type: (Any) -> Sequence[Any]
return command.args
def _set_pipeline_data(
span, is_cluster, get_command_args_fn, is_transaction, command_stack
):
# type: (Span, bool, Any, bool, Sequence[Any]) -> None
span.set_tag("redis.is_cluster", is_cluster)
span.set_tag("redis.transaction", is_transaction)
commands = []
for i, arg in enumerate(command_stack):
if i >= _MAX_NUM_COMMANDS:
break
command = get_command_args_fn(arg)
commands.append(_get_safe_command(command[0], command[1:]))
span.set_data(
"redis.commands",
{
"count": len(command_stack),
"first_ten": commands,
},
)
def _set_client_data(span, is_cluster, name, *args):
# type: (Span, bool, str, *Any) -> None
span.set_tag("redis.is_cluster", is_cluster)
if name:
span.set_tag("redis.command", name)
span.set_tag(SPANDATA.DB_OPERATION, name)
if name and args:
name_low = name.lower()
if (name_low in _SINGLE_KEY_COMMANDS) or (
name_low in _MULTI_KEY_COMMANDS and len(args) == 1
):
span.set_tag("redis.key", args[0])
@@ -0,0 +1,161 @@
import weakref
import sentry_sdk
from sentry_sdk.consts import OP
from sentry_sdk.api import continue_trace
from sentry_sdk.integrations import _check_minimum_version, DidNotEnable, Integration
from sentry_sdk.integrations.logging import ignore_logger
from sentry_sdk.tracing import TRANSACTION_SOURCE_TASK
from sentry_sdk.utils import (
capture_internal_exceptions,
ensure_integration_enabled,
event_from_exception,
format_timestamp,
parse_version,
)
try:
from rq.queue import Queue
from rq.timeouts import JobTimeoutException
from rq.version import VERSION as RQ_VERSION
from rq.worker import Worker
from rq.job import JobStatus
except ImportError:
raise DidNotEnable("RQ not installed")
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from typing import Any, Callable
from sentry_sdk._types import Event, EventProcessor
from sentry_sdk.utils import ExcInfo
from rq.job import Job
class RqIntegration(Integration):
identifier = "rq"
origin = f"auto.queue.{identifier}"
@staticmethod
def setup_once():
# type: () -> None
version = parse_version(RQ_VERSION)
_check_minimum_version(RqIntegration, version)
old_perform_job = Worker.perform_job
@ensure_integration_enabled(RqIntegration, old_perform_job)
def sentry_patched_perform_job(self, job, *args, **kwargs):
# type: (Any, Job, *Queue, **Any) -> bool
with sentry_sdk.new_scope() as scope:
scope.clear_breadcrumbs()
scope.add_event_processor(_make_event_processor(weakref.ref(job)))
transaction = continue_trace(
job.meta.get("_sentry_trace_headers") or {},
op=OP.QUEUE_TASK_RQ,
name="unknown RQ task",
source=TRANSACTION_SOURCE_TASK,
origin=RqIntegration.origin,
)
with capture_internal_exceptions():
transaction.name = job.func_name
with sentry_sdk.start_transaction(
transaction,
custom_sampling_context={"rq_job": job},
):
rv = old_perform_job(self, job, *args, **kwargs)
if self.is_horse:
# We're inside of a forked process and RQ is
# about to call `os._exit`. Make sure that our
# events get sent out.
sentry_sdk.get_client().flush()
return rv
Worker.perform_job = sentry_patched_perform_job
old_handle_exception = Worker.handle_exception
def sentry_patched_handle_exception(self, job, *exc_info, **kwargs):
# type: (Worker, Any, *Any, **Any) -> Any
retry = (
hasattr(job, "retries_left")
and job.retries_left
and job.retries_left > 0
)
failed = job._status == JobStatus.FAILED or job.is_failed
if failed and not retry:
_capture_exception(exc_info)
return old_handle_exception(self, job, *exc_info, **kwargs)
Worker.handle_exception = sentry_patched_handle_exception
old_enqueue_job = Queue.enqueue_job
@ensure_integration_enabled(RqIntegration, old_enqueue_job)
def sentry_patched_enqueue_job(self, job, **kwargs):
# type: (Queue, Any, **Any) -> Any
scope = sentry_sdk.get_current_scope()
if scope.span is not None:
job.meta["_sentry_trace_headers"] = dict(
scope.iter_trace_propagation_headers()
)
return old_enqueue_job(self, job, **kwargs)
Queue.enqueue_job = sentry_patched_enqueue_job
ignore_logger("rq.worker")
def _make_event_processor(weak_job):
# type: (Callable[[], Job]) -> EventProcessor
def event_processor(event, hint):
# type: (Event, dict[str, Any]) -> Event
job = weak_job()
if job is not None:
with capture_internal_exceptions():
extra = event.setdefault("extra", {})
rq_job = {
"job_id": job.id,
"func": job.func_name,
"args": job.args,
"kwargs": job.kwargs,
"description": job.description,
}
if job.enqueued_at:
rq_job["enqueued_at"] = format_timestamp(job.enqueued_at)
if job.started_at:
rq_job["started_at"] = format_timestamp(job.started_at)
extra["rq-job"] = rq_job
if "exc_info" in hint:
with capture_internal_exceptions():
if issubclass(hint["exc_info"][0], JobTimeoutException):
event["fingerprint"] = ["rq", "JobTimeoutException", job.func_name]
return event
return event_processor
def _capture_exception(exc_info, **kwargs):
# type: (ExcInfo, **Any) -> None
client = sentry_sdk.get_client()
event, hint = event_from_exception(
exc_info,
client_options=client.options,
mechanism={"type": "rq", "handled": False},
)
sentry_sdk.capture_event(event, hint=hint)
@@ -0,0 +1,284 @@
"""
This integration ingests tracing data from native extensions written in Rust.
Using it requires additional setup on the Rust side to accept a
`RustTracingLayer` Python object and register it with the `tracing-subscriber`
using an adapter from the `pyo3-python-tracing-subscriber` crate. For example:
```rust
#[pyfunction]
pub fn initialize_tracing(py_impl: Bound<'_, PyAny>) {
tracing_subscriber::registry()
.with(pyo3_python_tracing_subscriber::PythonCallbackLayerBridge::new(py_impl))
.init();
}
```
Usage in Python would then look like:
```
sentry_sdk.init(
dsn=sentry_dsn,
integrations=[
RustTracingIntegration(
"demo_rust_extension",
demo_rust_extension.initialize_tracing,
event_type_mapping=event_type_mapping,
)
],
)
```
Each native extension requires its own integration.
"""
import json
from enum import Enum, auto
from typing import Any, Callable, Dict, Tuple, Optional
import sentry_sdk
from sentry_sdk.integrations import Integration
from sentry_sdk.scope import should_send_default_pii
from sentry_sdk.tracing import Span as SentrySpan
from sentry_sdk.utils import SENSITIVE_DATA_SUBSTITUTE
TraceState = Optional[Tuple[Optional[SentrySpan], SentrySpan]]
class RustTracingLevel(Enum):
Trace = "TRACE"
Debug = "DEBUG"
Info = "INFO"
Warn = "WARN"
Error = "ERROR"
class EventTypeMapping(Enum):
Ignore = auto()
Exc = auto()
Breadcrumb = auto()
Event = auto()
def tracing_level_to_sentry_level(level):
# type: (str) -> sentry_sdk._types.LogLevelStr
level = RustTracingLevel(level)
if level in (RustTracingLevel.Trace, RustTracingLevel.Debug):
return "debug"
elif level == RustTracingLevel.Info:
return "info"
elif level == RustTracingLevel.Warn:
return "warning"
elif level == RustTracingLevel.Error:
return "error"
else:
# Better this than crashing
return "info"
def extract_contexts(event: Dict[str, Any]) -> Dict[str, Any]:
metadata = event.get("metadata", {})
contexts = {}
location = {}
for field in ["module_path", "file", "line"]:
if field in metadata:
location[field] = metadata[field]
if len(location) > 0:
contexts["rust_tracing_location"] = location
fields = {}
for field in metadata.get("fields", []):
fields[field] = event.get(field)
if len(fields) > 0:
contexts["rust_tracing_fields"] = fields
return contexts
def process_event(event: Dict[str, Any]) -> None:
metadata = event.get("metadata", {})
logger = metadata.get("target")
level = tracing_level_to_sentry_level(metadata.get("level"))
message = event.get("message") # type: sentry_sdk._types.Any
contexts = extract_contexts(event)
sentry_event = {
"logger": logger,
"level": level,
"message": message,
"contexts": contexts,
} # type: sentry_sdk._types.Event
sentry_sdk.capture_event(sentry_event)
def process_exception(event: Dict[str, Any]) -> None:
process_event(event)
def process_breadcrumb(event: Dict[str, Any]) -> None:
level = tracing_level_to_sentry_level(event.get("metadata", {}).get("level"))
message = event.get("message")
sentry_sdk.add_breadcrumb(level=level, message=message)
def default_span_filter(metadata: Dict[str, Any]) -> bool:
return RustTracingLevel(metadata.get("level")) in (
RustTracingLevel.Error,
RustTracingLevel.Warn,
RustTracingLevel.Info,
)
def default_event_type_mapping(metadata: Dict[str, Any]) -> EventTypeMapping:
level = RustTracingLevel(metadata.get("level"))
if level == RustTracingLevel.Error:
return EventTypeMapping.Exc
elif level in (RustTracingLevel.Warn, RustTracingLevel.Info):
return EventTypeMapping.Breadcrumb
elif level in (RustTracingLevel.Debug, RustTracingLevel.Trace):
return EventTypeMapping.Ignore
else:
return EventTypeMapping.Ignore
class RustTracingLayer:
def __init__(
self,
origin: str,
event_type_mapping: Callable[
[Dict[str, Any]], EventTypeMapping
] = default_event_type_mapping,
span_filter: Callable[[Dict[str, Any]], bool] = default_span_filter,
include_tracing_fields: Optional[bool] = None,
):
self.origin = origin
self.event_type_mapping = event_type_mapping
self.span_filter = span_filter
self.include_tracing_fields = include_tracing_fields
def _include_tracing_fields(self) -> bool:
"""
By default, the values of tracing fields are not included in case they
contain PII. A user may override that by passing `True` for the
`include_tracing_fields` keyword argument of this integration or by
setting `send_default_pii` to `True` in their Sentry client options.
"""
return (
should_send_default_pii()
if self.include_tracing_fields is None
else self.include_tracing_fields
)
def on_event(self, event: str, _span_state: TraceState) -> None:
deserialized_event = json.loads(event)
metadata = deserialized_event.get("metadata", {})
event_type = self.event_type_mapping(metadata)
if event_type == EventTypeMapping.Ignore:
return
elif event_type == EventTypeMapping.Exc:
process_exception(deserialized_event)
elif event_type == EventTypeMapping.Breadcrumb:
process_breadcrumb(deserialized_event)
elif event_type == EventTypeMapping.Event:
process_event(deserialized_event)
def on_new_span(self, attrs: str, span_id: str) -> TraceState:
attrs = json.loads(attrs)
metadata = attrs.get("metadata", {})
if not self.span_filter(metadata):
return None
module_path = metadata.get("module_path")
name = metadata.get("name")
message = attrs.get("message")
if message is not None:
sentry_span_name = message
elif module_path is not None and name is not None:
sentry_span_name = f"{module_path}::{name}" # noqa: E231
elif name is not None:
sentry_span_name = name
else:
sentry_span_name = "<unknown>"
kwargs = {
"op": "function",
"name": sentry_span_name,
"origin": self.origin,
}
scope = sentry_sdk.get_current_scope()
parent_sentry_span = scope.span
if parent_sentry_span:
sentry_span = parent_sentry_span.start_child(**kwargs)
else:
sentry_span = scope.start_span(**kwargs)
fields = metadata.get("fields", [])
for field in fields:
if self._include_tracing_fields():
sentry_span.set_data(field, attrs.get(field))
else:
sentry_span.set_data(field, SENSITIVE_DATA_SUBSTITUTE)
scope.span = sentry_span
return (parent_sentry_span, sentry_span)
def on_close(self, span_id: str, span_state: TraceState) -> None:
if span_state is None:
return
parent_sentry_span, sentry_span = span_state
sentry_span.finish()
sentry_sdk.get_current_scope().span = parent_sentry_span
def on_record(self, span_id: str, values: str, span_state: TraceState) -> None:
if span_state is None:
return
_parent_sentry_span, sentry_span = span_state
deserialized_values = json.loads(values)
for key, value in deserialized_values.items():
if self._include_tracing_fields():
sentry_span.set_data(key, value)
else:
sentry_span.set_data(key, SENSITIVE_DATA_SUBSTITUTE)
class RustTracingIntegration(Integration):
"""
Ingests tracing data from a Rust native extension's `tracing` instrumentation.
If a project uses more than one Rust native extension, each one will need
its own instance of `RustTracingIntegration` with an initializer function
specific to that extension.
Since all of the setup for this integration requires instance-specific state
which is not available in `setup_once()`, setup instead happens in `__init__()`.
"""
def __init__(
self,
identifier: str,
initializer: Callable[[RustTracingLayer], None],
event_type_mapping: Callable[
[Dict[str, Any]], EventTypeMapping
] = default_event_type_mapping,
span_filter: Callable[[Dict[str, Any]], bool] = default_span_filter,
include_tracing_fields: Optional[bool] = None,
):
self.identifier = identifier
origin = f"auto.function.rust_tracing.{identifier}"
self.tracing_layer = RustTracingLayer(
origin, event_type_mapping, span_filter, include_tracing_fields
)
initializer(self.tracing_layer)
@staticmethod
def setup_once() -> None:
pass
@@ -0,0 +1,368 @@
import sys
import weakref
from inspect import isawaitable
from urllib.parse import urlsplit
import sentry_sdk
from sentry_sdk import continue_trace
from sentry_sdk.consts import OP
from sentry_sdk.integrations import _check_minimum_version, Integration, DidNotEnable
from sentry_sdk.integrations._wsgi_common import RequestExtractor, _filter_headers
from sentry_sdk.integrations.logging import ignore_logger
from sentry_sdk.tracing import TRANSACTION_SOURCE_COMPONENT, TRANSACTION_SOURCE_URL
from sentry_sdk.utils import (
capture_internal_exceptions,
ensure_integration_enabled,
event_from_exception,
HAS_REAL_CONTEXTVARS,
CONTEXTVARS_ERROR_MESSAGE,
parse_version,
reraise,
)
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from collections.abc import Container
from typing import Any
from typing import Callable
from typing import Optional
from typing import Union
from typing import Dict
from sanic.request import Request, RequestParameters
from sanic.response import BaseHTTPResponse
from sentry_sdk._types import Event, EventProcessor, ExcInfo, Hint
from sanic.router import Route
try:
from sanic import Sanic, __version__ as SANIC_VERSION
from sanic.exceptions import SanicException
from sanic.router import Router
from sanic.handlers import ErrorHandler
except ImportError:
raise DidNotEnable("Sanic not installed")
old_error_handler_lookup = ErrorHandler.lookup
old_handle_request = Sanic.handle_request
old_router_get = Router.get
try:
# This method was introduced in Sanic v21.9
old_startup = Sanic._startup
except AttributeError:
pass
class SanicIntegration(Integration):
identifier = "sanic"
origin = f"auto.http.{identifier}"
version = None
def __init__(self, unsampled_statuses=frozenset({404})):
# type: (Optional[Container[int]]) -> None
"""
The unsampled_statuses parameter can be used to specify for which HTTP statuses the
transactions should not be sent to Sentry. By default, transactions are sent for all
HTTP statuses, except 404. Set unsampled_statuses to None to send transactions for all
HTTP statuses, including 404.
"""
self._unsampled_statuses = unsampled_statuses or set()
@staticmethod
def setup_once():
# type: () -> None
SanicIntegration.version = parse_version(SANIC_VERSION)
_check_minimum_version(SanicIntegration, SanicIntegration.version)
if not HAS_REAL_CONTEXTVARS:
# We better have contextvars or we're going to leak state between
# requests.
raise DidNotEnable(
"The sanic integration for Sentry requires Python 3.7+ "
" or the aiocontextvars package." + CONTEXTVARS_ERROR_MESSAGE
)
if SANIC_VERSION.startswith("0.8."):
# Sanic 0.8 and older creates a logger named "root" and puts a
# stringified version of every exception in there (without exc_info),
# which our error deduplication can't detect.
#
# We explicitly check the version here because it is a very
# invasive step to ignore this logger and not necessary in newer
# versions at all.
#
# https://github.com/huge-success/sanic/issues/1332
ignore_logger("root")
if SanicIntegration.version is not None and SanicIntegration.version < (21, 9):
_setup_legacy_sanic()
return
_setup_sanic()
class SanicRequestExtractor(RequestExtractor):
def content_length(self):
# type: () -> int
if self.request.body is None:
return 0
return len(self.request.body)
def cookies(self):
# type: () -> Dict[str, str]
return dict(self.request.cookies)
def raw_data(self):
# type: () -> bytes
return self.request.body
def form(self):
# type: () -> RequestParameters
return self.request.form
def is_json(self):
# type: () -> bool
raise NotImplementedError()
def json(self):
# type: () -> Optional[Any]
return self.request.json
def files(self):
# type: () -> RequestParameters
return self.request.files
def size_of_file(self, file):
# type: (Any) -> int
return len(file.body or ())
def _setup_sanic():
# type: () -> None
Sanic._startup = _startup
ErrorHandler.lookup = _sentry_error_handler_lookup
def _setup_legacy_sanic():
# type: () -> None
Sanic.handle_request = _legacy_handle_request
Router.get = _legacy_router_get
ErrorHandler.lookup = _sentry_error_handler_lookup
async def _startup(self):
# type: (Sanic) -> None
# This happens about as early in the lifecycle as possible, just after the
# Request object is created. The body has not yet been consumed.
self.signal("http.lifecycle.request")(_context_enter)
# This happens after the handler is complete. In v21.9 this signal is not
# dispatched when there is an exception. Therefore we need to close out
# and call _context_exit from the custom exception handler as well.
# See https://github.com/sanic-org/sanic/issues/2297
self.signal("http.lifecycle.response")(_context_exit)
# This happens inside of request handling immediately after the route
# has been identified by the router.
self.signal("http.routing.after")(_set_transaction)
# The above signals need to be declared before this can be called.
await old_startup(self)
async def _context_enter(request):
# type: (Request) -> None
request.ctx._sentry_do_integration = (
sentry_sdk.get_client().get_integration(SanicIntegration) is not None
)
if not request.ctx._sentry_do_integration:
return
weak_request = weakref.ref(request)
request.ctx._sentry_scope = sentry_sdk.isolation_scope()
scope = request.ctx._sentry_scope.__enter__()
scope.clear_breadcrumbs()
scope.add_event_processor(_make_request_processor(weak_request))
transaction = continue_trace(
dict(request.headers),
op=OP.HTTP_SERVER,
# Unless the request results in a 404 error, the name and source will get overwritten in _set_transaction
name=request.path,
source=TRANSACTION_SOURCE_URL,
origin=SanicIntegration.origin,
)
request.ctx._sentry_transaction = sentry_sdk.start_transaction(
transaction
).__enter__()
async def _context_exit(request, response=None):
# type: (Request, Optional[BaseHTTPResponse]) -> None
with capture_internal_exceptions():
if not request.ctx._sentry_do_integration:
return
integration = sentry_sdk.get_client().get_integration(SanicIntegration)
response_status = None if response is None else response.status
# This capture_internal_exceptions block has been intentionally nested here, so that in case an exception
# happens while trying to end the transaction, we still attempt to exit the hub.
with capture_internal_exceptions():
request.ctx._sentry_transaction.set_http_status(response_status)
request.ctx._sentry_transaction.sampled &= (
isinstance(integration, SanicIntegration)
and response_status not in integration._unsampled_statuses
)
request.ctx._sentry_transaction.__exit__(None, None, None)
request.ctx._sentry_scope.__exit__(None, None, None)
async def _set_transaction(request, route, **_):
# type: (Request, Route, **Any) -> None
if request.ctx._sentry_do_integration:
with capture_internal_exceptions():
scope = sentry_sdk.get_current_scope()
route_name = route.name.replace(request.app.name, "").strip(".")
scope.set_transaction_name(route_name, source=TRANSACTION_SOURCE_COMPONENT)
def _sentry_error_handler_lookup(self, exception, *args, **kwargs):
# type: (Any, Exception, *Any, **Any) -> Optional[object]
_capture_exception(exception)
old_error_handler = old_error_handler_lookup(self, exception, *args, **kwargs)
if old_error_handler is None:
return None
if sentry_sdk.get_client().get_integration(SanicIntegration) is None:
return old_error_handler
async def sentry_wrapped_error_handler(request, exception):
# type: (Request, Exception) -> Any
try:
response = old_error_handler(request, exception)
if isawaitable(response):
response = await response
return response
except Exception:
# Report errors that occur in Sanic error handler. These
# exceptions will not even show up in Sanic's
# `sanic.exceptions` logger.
exc_info = sys.exc_info()
_capture_exception(exc_info)
reraise(*exc_info)
finally:
# As mentioned in previous comment in _startup, this can be removed
# after https://github.com/sanic-org/sanic/issues/2297 is resolved
if SanicIntegration.version and SanicIntegration.version == (21, 9):
await _context_exit(request)
return sentry_wrapped_error_handler
async def _legacy_handle_request(self, request, *args, **kwargs):
# type: (Any, Request, *Any, **Any) -> Any
if sentry_sdk.get_client().get_integration(SanicIntegration) is None:
return await old_handle_request(self, request, *args, **kwargs)
weak_request = weakref.ref(request)
with sentry_sdk.isolation_scope() as scope:
scope.clear_breadcrumbs()
scope.add_event_processor(_make_request_processor(weak_request))
response = old_handle_request(self, request, *args, **kwargs)
if isawaitable(response):
response = await response
return response
def _legacy_router_get(self, *args):
# type: (Any, Union[Any, Request]) -> Any
rv = old_router_get(self, *args)
if sentry_sdk.get_client().get_integration(SanicIntegration) is not None:
with capture_internal_exceptions():
scope = sentry_sdk.get_isolation_scope()
if SanicIntegration.version and SanicIntegration.version >= (21, 3):
# Sanic versions above and including 21.3 append the app name to the
# route name, and so we need to remove it from Route name so the
# transaction name is consistent across all versions
sanic_app_name = self.ctx.app.name
sanic_route = rv[0].name
if sanic_route.startswith("%s." % sanic_app_name):
# We add a 1 to the len of the sanic_app_name because there is a dot
# that joins app name and the route name
# Format: app_name.route_name
sanic_route = sanic_route[len(sanic_app_name) + 1 :]
scope.set_transaction_name(
sanic_route, source=TRANSACTION_SOURCE_COMPONENT
)
else:
scope.set_transaction_name(
rv[0].__name__, source=TRANSACTION_SOURCE_COMPONENT
)
return rv
@ensure_integration_enabled(SanicIntegration)
def _capture_exception(exception):
# type: (Union[ExcInfo, BaseException]) -> None
with capture_internal_exceptions():
event, hint = event_from_exception(
exception,
client_options=sentry_sdk.get_client().options,
mechanism={"type": "sanic", "handled": False},
)
if hint and hasattr(hint["exc_info"][0], "quiet") and hint["exc_info"][0].quiet:
return
sentry_sdk.capture_event(event, hint=hint)
def _make_request_processor(weak_request):
# type: (Callable[[], Request]) -> EventProcessor
def sanic_processor(event, hint):
# type: (Event, Optional[Hint]) -> Optional[Event]
try:
if hint and issubclass(hint["exc_info"][0], SanicException):
return None
except KeyError:
pass
request = weak_request()
if request is None:
return event
with capture_internal_exceptions():
extractor = SanicRequestExtractor(request)
extractor.extract_into_event(event)
request_info = event["request"]
urlparts = urlsplit(request.url)
request_info["url"] = "%s://%s%s" % (
urlparts.scheme,
urlparts.netloc,
urlparts.path,
)
request_info["query_string"] = urlparts.query
request_info["method"] = request.method
request_info["env"] = {"REMOTE_ADDR": request.remote_addr}
request_info["headers"] = _filter_headers(dict(request.headers))
return event
return sanic_processor
@@ -0,0 +1,76 @@
import sys
from functools import wraps
import sentry_sdk
from sentry_sdk.utils import event_from_exception, reraise
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from typing import Any
from typing import Callable
from typing import TypeVar
from typing import Union
from typing import Optional
from typing import overload
F = TypeVar("F", bound=Callable[..., Any])
else:
def overload(x):
# type: (F) -> F
return x
@overload
def serverless_function(f, flush=True):
# type: (F, bool) -> F
pass
@overload
def serverless_function(f=None, flush=True): # noqa: F811
# type: (None, bool) -> Callable[[F], F]
pass
def serverless_function(f=None, flush=True): # noqa
# type: (Optional[F], bool) -> Union[F, Callable[[F], F]]
def wrapper(f):
# type: (F) -> F
@wraps(f)
def inner(*args, **kwargs):
# type: (*Any, **Any) -> Any
with sentry_sdk.isolation_scope() as scope:
scope.clear_breadcrumbs()
try:
return f(*args, **kwargs)
except Exception:
_capture_and_reraise()
finally:
if flush:
sentry_sdk.flush()
return inner # type: ignore
if f is None:
return wrapper
else:
return wrapper(f)
def _capture_and_reraise():
# type: () -> None
exc_info = sys.exc_info()
client = sentry_sdk.get_client()
if client.is_active():
event, hint = event_from_exception(
exc_info,
client_options=client.options,
mechanism={"type": "serverless", "handled": False},
)
sentry_sdk.capture_event(event, hint=hint)
reraise(*exc_info)
@@ -0,0 +1,96 @@
import socket
import sentry_sdk
from sentry_sdk._types import MYPY
from sentry_sdk.consts import OP
from sentry_sdk.integrations import Integration
if MYPY:
from socket import AddressFamily, SocketKind
from typing import Tuple, Optional, Union, List
__all__ = ["SocketIntegration"]
class SocketIntegration(Integration):
identifier = "socket"
origin = f"auto.socket.{identifier}"
@staticmethod
def setup_once():
# type: () -> None
"""
patches two of the most used functions of socket: create_connection and getaddrinfo(dns resolver)
"""
_patch_create_connection()
_patch_getaddrinfo()
def _get_span_description(host, port):
# type: (Union[bytes, str, None], Union[bytes, str, int, None]) -> str
try:
host = host.decode() # type: ignore
except (UnicodeDecodeError, AttributeError):
pass
try:
port = port.decode() # type: ignore
except (UnicodeDecodeError, AttributeError):
pass
description = "%s:%s" % (host, port) # type: ignore
return description
def _patch_create_connection():
# type: () -> None
real_create_connection = socket.create_connection
def create_connection(
address,
timeout=socket._GLOBAL_DEFAULT_TIMEOUT, # type: ignore
source_address=None,
):
# type: (Tuple[Optional[str], int], Optional[float], Optional[Tuple[Union[bytearray, bytes, str], int]])-> socket.socket
integration = sentry_sdk.get_client().get_integration(SocketIntegration)
if integration is None:
return real_create_connection(address, timeout, source_address)
with sentry_sdk.start_span(
op=OP.SOCKET_CONNECTION,
name=_get_span_description(address[0], address[1]),
origin=SocketIntegration.origin,
) as span:
span.set_data("address", address)
span.set_data("timeout", timeout)
span.set_data("source_address", source_address)
return real_create_connection(
address=address, timeout=timeout, source_address=source_address
)
socket.create_connection = create_connection # type: ignore
def _patch_getaddrinfo():
# type: () -> None
real_getaddrinfo = socket.getaddrinfo
def getaddrinfo(host, port, family=0, type=0, proto=0, flags=0):
# type: (Union[bytes, str, None], Union[bytes, str, int, None], int, int, int, int) -> List[Tuple[AddressFamily, SocketKind, int, str, Union[Tuple[str, int], Tuple[str, int, int, int], Tuple[int, bytes]]]]
integration = sentry_sdk.get_client().get_integration(SocketIntegration)
if integration is None:
return real_getaddrinfo(host, port, family, type, proto, flags)
with sentry_sdk.start_span(
op=OP.SOCKET_DNS,
name=_get_span_description(host, port),
origin=SocketIntegration.origin,
) as span:
span.set_data("host", host)
span.set_data("port", port)
return real_getaddrinfo(host, port, family, type, proto, flags)
socket.getaddrinfo = getaddrinfo
@@ -0,0 +1,4 @@
from sentry_sdk.integrations.spark.spark_driver import SparkIntegration
from sentry_sdk.integrations.spark.spark_worker import SparkWorkerIntegration
__all__ = ["SparkIntegration", "SparkWorkerIntegration"]
@@ -0,0 +1,285 @@
import sentry_sdk
from sentry_sdk.integrations import Integration
from sentry_sdk.utils import capture_internal_exceptions, ensure_integration_enabled
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from typing import Any
from typing import Optional
from sentry_sdk._types import Event, Hint
from pyspark import SparkContext
class SparkIntegration(Integration):
identifier = "spark"
@staticmethod
def setup_once():
# type: () -> None
_setup_sentry_tracing()
def _set_app_properties():
# type: () -> None
"""
Set properties in driver that propagate to worker processes, allowing for workers to have access to those properties.
This allows worker integration to have access to app_name and application_id.
"""
from pyspark import SparkContext
spark_context = SparkContext._active_spark_context
if spark_context:
spark_context.setLocalProperty("sentry_app_name", spark_context.appName)
spark_context.setLocalProperty(
"sentry_application_id", spark_context.applicationId
)
def _start_sentry_listener(sc):
# type: (SparkContext) -> None
"""
Start java gateway server to add custom `SparkListener`
"""
from pyspark.java_gateway import ensure_callback_server_started
gw = sc._gateway
ensure_callback_server_started(gw)
listener = SentryListener()
sc._jsc.sc().addSparkListener(listener)
def _add_event_processor(sc):
# type: (SparkContext) -> None
scope = sentry_sdk.get_isolation_scope()
@scope.add_event_processor
def process_event(event, hint):
# type: (Event, Hint) -> Optional[Event]
with capture_internal_exceptions():
if sentry_sdk.get_client().get_integration(SparkIntegration) is None:
return event
if sc._active_spark_context is None:
return event
event.setdefault("user", {}).setdefault("id", sc.sparkUser())
event.setdefault("tags", {}).setdefault(
"executor.id", sc._conf.get("spark.executor.id")
)
event["tags"].setdefault(
"spark-submit.deployMode",
sc._conf.get("spark.submit.deployMode"),
)
event["tags"].setdefault("driver.host", sc._conf.get("spark.driver.host"))
event["tags"].setdefault("driver.port", sc._conf.get("spark.driver.port"))
event["tags"].setdefault("spark_version", sc.version)
event["tags"].setdefault("app_name", sc.appName)
event["tags"].setdefault("application_id", sc.applicationId)
event["tags"].setdefault("master", sc.master)
event["tags"].setdefault("spark_home", sc.sparkHome)
event.setdefault("extra", {}).setdefault("web_url", sc.uiWebUrl)
return event
def _activate_integration(sc):
# type: (SparkContext) -> None
_start_sentry_listener(sc)
_set_app_properties()
_add_event_processor(sc)
def _patch_spark_context_init():
# type: () -> None
from pyspark import SparkContext
spark_context_init = SparkContext._do_init
@ensure_integration_enabled(SparkIntegration, spark_context_init)
def _sentry_patched_spark_context_init(self, *args, **kwargs):
# type: (SparkContext, *Any, **Any) -> Optional[Any]
rv = spark_context_init(self, *args, **kwargs)
_activate_integration(self)
return rv
SparkContext._do_init = _sentry_patched_spark_context_init
def _setup_sentry_tracing():
# type: () -> None
from pyspark import SparkContext
if SparkContext._active_spark_context is not None:
_activate_integration(SparkContext._active_spark_context)
return
_patch_spark_context_init()
class SparkListener:
def onApplicationEnd(self, applicationEnd): # noqa: N802,N803
# type: (Any) -> None
pass
def onApplicationStart(self, applicationStart): # noqa: N802,N803
# type: (Any) -> None
pass
def onBlockManagerAdded(self, blockManagerAdded): # noqa: N802,N803
# type: (Any) -> None
pass
def onBlockManagerRemoved(self, blockManagerRemoved): # noqa: N802,N803
# type: (Any) -> None
pass
def onBlockUpdated(self, blockUpdated): # noqa: N802,N803
# type: (Any) -> None
pass
def onEnvironmentUpdate(self, environmentUpdate): # noqa: N802,N803
# type: (Any) -> None
pass
def onExecutorAdded(self, executorAdded): # noqa: N802,N803
# type: (Any) -> None
pass
def onExecutorBlacklisted(self, executorBlacklisted): # noqa: N802,N803
# type: (Any) -> None
pass
def onExecutorBlacklistedForStage( # noqa: N802
self, executorBlacklistedForStage # noqa: N803
):
# type: (Any) -> None
pass
def onExecutorMetricsUpdate(self, executorMetricsUpdate): # noqa: N802,N803
# type: (Any) -> None
pass
def onExecutorRemoved(self, executorRemoved): # noqa: N802,N803
# type: (Any) -> None
pass
def onJobEnd(self, jobEnd): # noqa: N802,N803
# type: (Any) -> None
pass
def onJobStart(self, jobStart): # noqa: N802,N803
# type: (Any) -> None
pass
def onNodeBlacklisted(self, nodeBlacklisted): # noqa: N802,N803
# type: (Any) -> None
pass
def onNodeBlacklistedForStage(self, nodeBlacklistedForStage): # noqa: N802,N803
# type: (Any) -> None
pass
def onNodeUnblacklisted(self, nodeUnblacklisted): # noqa: N802,N803
# type: (Any) -> None
pass
def onOtherEvent(self, event): # noqa: N802,N803
# type: (Any) -> None
pass
def onSpeculativeTaskSubmitted(self, speculativeTask): # noqa: N802,N803
# type: (Any) -> None
pass
def onStageCompleted(self, stageCompleted): # noqa: N802,N803
# type: (Any) -> None
pass
def onStageSubmitted(self, stageSubmitted): # noqa: N802,N803
# type: (Any) -> None
pass
def onTaskEnd(self, taskEnd): # noqa: N802,N803
# type: (Any) -> None
pass
def onTaskGettingResult(self, taskGettingResult): # noqa: N802,N803
# type: (Any) -> None
pass
def onTaskStart(self, taskStart): # noqa: N802,N803
# type: (Any) -> None
pass
def onUnpersistRDD(self, unpersistRDD): # noqa: N802,N803
# type: (Any) -> None
pass
class Java:
implements = ["org.apache.spark.scheduler.SparkListenerInterface"]
class SentryListener(SparkListener):
def _add_breadcrumb(
self,
level, # type: str
message, # type: str
data=None, # type: Optional[dict[str, Any]]
):
# type: (...) -> None
sentry_sdk.get_global_scope().add_breadcrumb(
level=level, message=message, data=data
)
def onJobStart(self, jobStart): # noqa: N802,N803
# type: (Any) -> None
message = "Job {} Started".format(jobStart.jobId())
self._add_breadcrumb(level="info", message=message)
_set_app_properties()
def onJobEnd(self, jobEnd): # noqa: N802,N803
# type: (Any) -> None
level = ""
message = ""
data = {"result": jobEnd.jobResult().toString()}
if jobEnd.jobResult().toString() == "JobSucceeded":
level = "info"
message = "Job {} Ended".format(jobEnd.jobId())
else:
level = "warning"
message = "Job {} Failed".format(jobEnd.jobId())
self._add_breadcrumb(level=level, message=message, data=data)
def onStageSubmitted(self, stageSubmitted): # noqa: N802,N803
# type: (Any) -> None
stage_info = stageSubmitted.stageInfo()
message = "Stage {} Submitted".format(stage_info.stageId())
data = {"attemptId": stage_info.attemptId(), "name": stage_info.name()}
self._add_breadcrumb(level="info", message=message, data=data)
_set_app_properties()
def onStageCompleted(self, stageCompleted): # noqa: N802,N803
# type: (Any) -> None
from py4j.protocol import Py4JJavaError # type: ignore
stage_info = stageCompleted.stageInfo()
message = ""
level = ""
data = {"attemptId": stage_info.attemptId(), "name": stage_info.name()}
# Have to Try Except because stageInfo.failureReason() is typed with Scala Option
try:
data["reason"] = stage_info.failureReason().get()
message = "Stage {} Failed".format(stage_info.stageId())
level = "warning"
except Py4JJavaError:
message = "Stage {} Completed".format(stage_info.stageId())
level = "info"
self._add_breadcrumb(level=level, message=message, data=data)
@@ -0,0 +1,116 @@
import sys
import sentry_sdk
from sentry_sdk.integrations import Integration
from sentry_sdk.utils import (
capture_internal_exceptions,
exc_info_from_error,
single_exception_from_error_tuple,
walk_exception_chain,
event_hint_with_exc_info,
)
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from typing import Any
from typing import Optional
from sentry_sdk._types import ExcInfo, Event, Hint
class SparkWorkerIntegration(Integration):
identifier = "spark_worker"
@staticmethod
def setup_once():
# type: () -> None
import pyspark.daemon as original_daemon
original_daemon.worker_main = _sentry_worker_main
def _capture_exception(exc_info):
# type: (ExcInfo) -> None
client = sentry_sdk.get_client()
mechanism = {"type": "spark", "handled": False}
exc_info = exc_info_from_error(exc_info)
exc_type, exc_value, tb = exc_info
rv = []
# On Exception worker will call sys.exit(-1), so we can ignore SystemExit and similar errors
for exc_type, exc_value, tb in walk_exception_chain(exc_info):
if exc_type not in (SystemExit, EOFError, ConnectionResetError):
rv.append(
single_exception_from_error_tuple(
exc_type, exc_value, tb, client.options, mechanism
)
)
if rv:
rv.reverse()
hint = event_hint_with_exc_info(exc_info)
event = {"level": "error", "exception": {"values": rv}} # type: Event
_tag_task_context()
sentry_sdk.capture_event(event, hint=hint)
def _tag_task_context():
# type: () -> None
from pyspark.taskcontext import TaskContext
scope = sentry_sdk.get_isolation_scope()
@scope.add_event_processor
def process_event(event, hint):
# type: (Event, Hint) -> Optional[Event]
with capture_internal_exceptions():
integration = sentry_sdk.get_client().get_integration(
SparkWorkerIntegration
)
task_context = TaskContext.get()
if integration is None or task_context is None:
return event
event.setdefault("tags", {}).setdefault(
"stageId", str(task_context.stageId())
)
event["tags"].setdefault("partitionId", str(task_context.partitionId()))
event["tags"].setdefault("attemptNumber", str(task_context.attemptNumber()))
event["tags"].setdefault("taskAttemptId", str(task_context.taskAttemptId()))
if task_context._localProperties:
if "sentry_app_name" in task_context._localProperties:
event["tags"].setdefault(
"app_name", task_context._localProperties["sentry_app_name"]
)
event["tags"].setdefault(
"application_id",
task_context._localProperties["sentry_application_id"],
)
if "callSite.short" in task_context._localProperties:
event.setdefault("extra", {}).setdefault(
"callSite", task_context._localProperties["callSite.short"]
)
return event
def _sentry_worker_main(*args, **kwargs):
# type: (*Optional[Any], **Optional[Any]) -> None
import pyspark.worker as original_worker
try:
original_worker.main(*args, **kwargs)
except SystemExit:
if sentry_sdk.get_client().get_integration(SparkWorkerIntegration) is not None:
exc_info = sys.exc_info()
with capture_internal_exceptions():
_capture_exception(exc_info)
@@ -0,0 +1,146 @@
from sentry_sdk.consts import SPANSTATUS, SPANDATA
from sentry_sdk.integrations import _check_minimum_version, Integration, DidNotEnable
from sentry_sdk.tracing_utils import add_query_source, record_sql_queries
from sentry_sdk.utils import (
capture_internal_exceptions,
ensure_integration_enabled,
parse_version,
)
try:
from sqlalchemy.engine import Engine # type: ignore
from sqlalchemy.event import listen # type: ignore
from sqlalchemy import __version__ as SQLALCHEMY_VERSION # type: ignore
except ImportError:
raise DidNotEnable("SQLAlchemy not installed.")
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from typing import Any
from typing import ContextManager
from typing import Optional
from sentry_sdk.tracing import Span
class SqlalchemyIntegration(Integration):
identifier = "sqlalchemy"
origin = f"auto.db.{identifier}"
@staticmethod
def setup_once():
# type: () -> None
version = parse_version(SQLALCHEMY_VERSION)
_check_minimum_version(SqlalchemyIntegration, version)
listen(Engine, "before_cursor_execute", _before_cursor_execute)
listen(Engine, "after_cursor_execute", _after_cursor_execute)
listen(Engine, "handle_error", _handle_error)
@ensure_integration_enabled(SqlalchemyIntegration)
def _before_cursor_execute(
conn, cursor, statement, parameters, context, executemany, *args
):
# type: (Any, Any, Any, Any, Any, bool, *Any) -> None
ctx_mgr = record_sql_queries(
cursor,
statement,
parameters,
paramstyle=context and context.dialect and context.dialect.paramstyle or None,
executemany=executemany,
span_origin=SqlalchemyIntegration.origin,
)
context._sentry_sql_span_manager = ctx_mgr
span = ctx_mgr.__enter__()
if span is not None:
_set_db_data(span, conn)
context._sentry_sql_span = span
@ensure_integration_enabled(SqlalchemyIntegration)
def _after_cursor_execute(conn, cursor, statement, parameters, context, *args):
# type: (Any, Any, Any, Any, Any, *Any) -> None
ctx_mgr = getattr(
context, "_sentry_sql_span_manager", None
) # type: Optional[ContextManager[Any]]
if ctx_mgr is not None:
context._sentry_sql_span_manager = None
ctx_mgr.__exit__(None, None, None)
span = getattr(context, "_sentry_sql_span", None) # type: Optional[Span]
if span is not None:
with capture_internal_exceptions():
add_query_source(span)
def _handle_error(context, *args):
# type: (Any, *Any) -> None
execution_context = context.execution_context
if execution_context is None:
return
span = getattr(execution_context, "_sentry_sql_span", None) # type: Optional[Span]
if span is not None:
span.set_status(SPANSTATUS.INTERNAL_ERROR)
# _after_cursor_execute does not get called for crashing SQL stmts. Judging
# from SQLAlchemy codebase it does seem like any error coming into this
# handler is going to be fatal.
ctx_mgr = getattr(
execution_context, "_sentry_sql_span_manager", None
) # type: Optional[ContextManager[Any]]
if ctx_mgr is not None:
execution_context._sentry_sql_span_manager = None
ctx_mgr.__exit__(None, None, None)
# See: https://docs.sqlalchemy.org/en/20/dialects/index.html
def _get_db_system(name):
# type: (str) -> Optional[str]
name = str(name)
if "sqlite" in name:
return "sqlite"
if "postgres" in name:
return "postgresql"
if "mariadb" in name:
return "mariadb"
if "mysql" in name:
return "mysql"
if "oracle" in name:
return "oracle"
return None
def _set_db_data(span, conn):
# type: (Span, Any) -> None
db_system = _get_db_system(conn.engine.name)
if db_system is not None:
span.set_data(SPANDATA.DB_SYSTEM, db_system)
if conn.engine.url is None:
return
db_name = conn.engine.url.database
if db_name is not None:
span.set_data(SPANDATA.DB_NAME, db_name)
server_address = conn.engine.url.host
if server_address is not None:
span.set_data(SPANDATA.SERVER_ADDRESS, server_address)
server_port = conn.engine.url.port
if server_port is not None:
span.set_data(SPANDATA.SERVER_PORT, server_port)
@@ -0,0 +1,737 @@
import asyncio
import functools
import warnings
from collections.abc import Set
from copy import deepcopy
import sentry_sdk
from sentry_sdk.consts import OP
from sentry_sdk.integrations import (
DidNotEnable,
Integration,
_DEFAULT_FAILED_REQUEST_STATUS_CODES,
)
from sentry_sdk.integrations._wsgi_common import (
DEFAULT_HTTP_METHODS_TO_CAPTURE,
HttpCodeRangeContainer,
_is_json_content_type,
request_body_within_bounds,
)
from sentry_sdk.integrations.asgi import SentryAsgiMiddleware
from sentry_sdk.scope import should_send_default_pii
from sentry_sdk.tracing import (
SOURCE_FOR_STYLE,
TRANSACTION_SOURCE_COMPONENT,
TRANSACTION_SOURCE_ROUTE,
)
from sentry_sdk.utils import (
AnnotatedValue,
capture_internal_exceptions,
ensure_integration_enabled,
event_from_exception,
logger,
parse_version,
transaction_from_function,
)
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from typing import Any, Awaitable, Callable, Container, Dict, Optional, Tuple, Union
from sentry_sdk._types import Event, HttpStatusCodeRange
try:
import starlette # type: ignore
from starlette import __version__ as STARLETTE_VERSION
from starlette.applications import Starlette # type: ignore
from starlette.datastructures import UploadFile # type: ignore
from starlette.middleware import Middleware # type: ignore
from starlette.middleware.authentication import ( # type: ignore
AuthenticationMiddleware,
)
from starlette.requests import Request # type: ignore
from starlette.routing import Match # type: ignore
from starlette.types import ASGIApp, Receive, Scope as StarletteScope, Send # type: ignore
except ImportError:
raise DidNotEnable("Starlette is not installed")
try:
# Starlette 0.20
from starlette.middleware.exceptions import ExceptionMiddleware # type: ignore
except ImportError:
# Startlette 0.19.1
from starlette.exceptions import ExceptionMiddleware # type: ignore
try:
# Optional dependency of Starlette to parse form data.
try:
# python-multipart 0.0.13 and later
import python_multipart as multipart # type: ignore
except ImportError:
# python-multipart 0.0.12 and earlier
import multipart # type: ignore
except ImportError:
multipart = None
_DEFAULT_TRANSACTION_NAME = "generic Starlette request"
TRANSACTION_STYLE_VALUES = ("endpoint", "url")
class StarletteIntegration(Integration):
identifier = "starlette"
origin = f"auto.http.{identifier}"
transaction_style = ""
def __init__(
self,
transaction_style="url", # type: str
failed_request_status_codes=_DEFAULT_FAILED_REQUEST_STATUS_CODES, # type: Union[Set[int], list[HttpStatusCodeRange], None]
middleware_spans=True, # type: bool
http_methods_to_capture=DEFAULT_HTTP_METHODS_TO_CAPTURE, # type: tuple[str, ...]
):
# type: (...) -> None
if transaction_style not in TRANSACTION_STYLE_VALUES:
raise ValueError(
"Invalid value for transaction_style: %s (must be in %s)"
% (transaction_style, TRANSACTION_STYLE_VALUES)
)
self.transaction_style = transaction_style
self.middleware_spans = middleware_spans
self.http_methods_to_capture = tuple(map(str.upper, http_methods_to_capture))
if isinstance(failed_request_status_codes, Set):
self.failed_request_status_codes = (
failed_request_status_codes
) # type: Container[int]
else:
warnings.warn(
"Passing a list or None for failed_request_status_codes is deprecated. "
"Please pass a set of int instead.",
DeprecationWarning,
stacklevel=2,
)
if failed_request_status_codes is None:
self.failed_request_status_codes = _DEFAULT_FAILED_REQUEST_STATUS_CODES
else:
self.failed_request_status_codes = HttpCodeRangeContainer(
failed_request_status_codes
)
@staticmethod
def setup_once():
# type: () -> None
version = parse_version(STARLETTE_VERSION)
if version is None:
raise DidNotEnable(
"Unparsable Starlette version: {}".format(STARLETTE_VERSION)
)
patch_middlewares()
patch_asgi_app()
patch_request_response()
if version >= (0, 24):
patch_templates()
def _enable_span_for_middleware(middleware_class):
# type: (Any) -> type
old_call = middleware_class.__call__
async def _create_span_call(app, scope, receive, send, **kwargs):
# type: (Any, Dict[str, Any], Callable[[], Awaitable[Dict[str, Any]]], Callable[[Dict[str, Any]], Awaitable[None]], Any) -> None
integration = sentry_sdk.get_client().get_integration(StarletteIntegration)
if integration is None or not integration.middleware_spans:
return await old_call(app, scope, receive, send, **kwargs)
middleware_name = app.__class__.__name__
# Update transaction name with middleware name
name, source = _get_transaction_from_middleware(app, scope, integration)
if name is not None:
sentry_sdk.get_current_scope().set_transaction_name(
name,
source=source,
)
with sentry_sdk.start_span(
op=OP.MIDDLEWARE_STARLETTE,
name=middleware_name,
origin=StarletteIntegration.origin,
) as middleware_span:
middleware_span.set_tag("starlette.middleware_name", middleware_name)
# Creating spans for the "receive" callback
async def _sentry_receive(*args, **kwargs):
# type: (*Any, **Any) -> Any
with sentry_sdk.start_span(
op=OP.MIDDLEWARE_STARLETTE_RECEIVE,
name=getattr(receive, "__qualname__", str(receive)),
origin=StarletteIntegration.origin,
) as span:
span.set_tag("starlette.middleware_name", middleware_name)
return await receive(*args, **kwargs)
receive_name = getattr(receive, "__name__", str(receive))
receive_patched = receive_name == "_sentry_receive"
new_receive = _sentry_receive if not receive_patched else receive
# Creating spans for the "send" callback
async def _sentry_send(*args, **kwargs):
# type: (*Any, **Any) -> Any
with sentry_sdk.start_span(
op=OP.MIDDLEWARE_STARLETTE_SEND,
name=getattr(send, "__qualname__", str(send)),
origin=StarletteIntegration.origin,
) as span:
span.set_tag("starlette.middleware_name", middleware_name)
return await send(*args, **kwargs)
send_name = getattr(send, "__name__", str(send))
send_patched = send_name == "_sentry_send"
new_send = _sentry_send if not send_patched else send
return await old_call(app, scope, new_receive, new_send, **kwargs)
not_yet_patched = old_call.__name__ not in [
"_create_span_call",
"_sentry_authenticationmiddleware_call",
"_sentry_exceptionmiddleware_call",
]
if not_yet_patched:
middleware_class.__call__ = _create_span_call
return middleware_class
@ensure_integration_enabled(StarletteIntegration)
def _capture_exception(exception, handled=False):
# type: (BaseException, **Any) -> None
event, hint = event_from_exception(
exception,
client_options=sentry_sdk.get_client().options,
mechanism={"type": StarletteIntegration.identifier, "handled": handled},
)
sentry_sdk.capture_event(event, hint=hint)
def patch_exception_middleware(middleware_class):
# type: (Any) -> None
"""
Capture all exceptions in Starlette app and
also extract user information.
"""
old_middleware_init = middleware_class.__init__
not_yet_patched = "_sentry_middleware_init" not in str(old_middleware_init)
if not_yet_patched:
def _sentry_middleware_init(self, *args, **kwargs):
# type: (Any, Any, Any) -> None
old_middleware_init(self, *args, **kwargs)
# Patch existing exception handlers
old_handlers = self._exception_handlers.copy()
async def _sentry_patched_exception_handler(self, *args, **kwargs):
# type: (Any, Any, Any) -> None
integration = sentry_sdk.get_client().get_integration(
StarletteIntegration
)
exp = args[0]
if integration is not None:
is_http_server_error = (
hasattr(exp, "status_code")
and isinstance(exp.status_code, int)
and exp.status_code in integration.failed_request_status_codes
)
if is_http_server_error:
_capture_exception(exp, handled=True)
# Find a matching handler
old_handler = None
for cls in type(exp).__mro__:
if cls in old_handlers:
old_handler = old_handlers[cls]
break
if old_handler is None:
return
if _is_async_callable(old_handler):
return await old_handler(self, *args, **kwargs)
else:
return old_handler(self, *args, **kwargs)
for key in self._exception_handlers.keys():
self._exception_handlers[key] = _sentry_patched_exception_handler
middleware_class.__init__ = _sentry_middleware_init
old_call = middleware_class.__call__
async def _sentry_exceptionmiddleware_call(self, scope, receive, send):
# type: (Dict[str, Any], Dict[str, Any], Callable[[], Awaitable[Dict[str, Any]]], Callable[[Dict[str, Any]], Awaitable[None]]) -> None
# Also add the user (that was eventually set by be Authentication middle
# that was called before this middleware). This is done because the authentication
# middleware sets the user in the scope and then (in the same function)
# calls this exception middelware. In case there is no exception (or no handler
# for the type of exception occuring) then the exception bubbles up and setting the
# user information into the sentry scope is done in auth middleware and the
# ASGI middleware will then send everything to Sentry and this is fine.
# But if there is an exception happening that the exception middleware here
# has a handler for, it will send the exception directly to Sentry, so we need
# the user information right now.
# This is why we do it here.
_add_user_to_sentry_scope(scope)
await old_call(self, scope, receive, send)
middleware_class.__call__ = _sentry_exceptionmiddleware_call
@ensure_integration_enabled(StarletteIntegration)
def _add_user_to_sentry_scope(scope):
# type: (Dict[str, Any]) -> None
"""
Extracts user information from the ASGI scope and
adds it to Sentry's scope.
"""
if "user" not in scope:
return
if not should_send_default_pii():
return
user_info = {} # type: Dict[str, Any]
starlette_user = scope["user"]
username = getattr(starlette_user, "username", None)
if username:
user_info.setdefault("username", starlette_user.username)
user_id = getattr(starlette_user, "id", None)
if user_id:
user_info.setdefault("id", starlette_user.id)
email = getattr(starlette_user, "email", None)
if email:
user_info.setdefault("email", starlette_user.email)
sentry_scope = sentry_sdk.get_isolation_scope()
sentry_scope.user = user_info
def patch_authentication_middleware(middleware_class):
# type: (Any) -> None
"""
Add user information to Sentry scope.
"""
old_call = middleware_class.__call__
not_yet_patched = "_sentry_authenticationmiddleware_call" not in str(old_call)
if not_yet_patched:
async def _sentry_authenticationmiddleware_call(self, scope, receive, send):
# type: (Dict[str, Any], Dict[str, Any], Callable[[], Awaitable[Dict[str, Any]]], Callable[[Dict[str, Any]], Awaitable[None]]) -> None
await old_call(self, scope, receive, send)
_add_user_to_sentry_scope(scope)
middleware_class.__call__ = _sentry_authenticationmiddleware_call
def patch_middlewares():
# type: () -> None
"""
Patches Starlettes `Middleware` class to record
spans for every middleware invoked.
"""
old_middleware_init = Middleware.__init__
not_yet_patched = "_sentry_middleware_init" not in str(old_middleware_init)
if not_yet_patched:
def _sentry_middleware_init(self, cls, **options):
# type: (Any, Any, Any) -> None
if cls == SentryAsgiMiddleware:
return old_middleware_init(self, cls, **options)
span_enabled_cls = _enable_span_for_middleware(cls)
old_middleware_init(self, span_enabled_cls, **options)
if cls == AuthenticationMiddleware:
patch_authentication_middleware(cls)
if cls == ExceptionMiddleware:
patch_exception_middleware(cls)
Middleware.__init__ = _sentry_middleware_init
def patch_asgi_app():
# type: () -> None
"""
Instrument Starlette ASGI app using the SentryAsgiMiddleware.
"""
old_app = Starlette.__call__
async def _sentry_patched_asgi_app(self, scope, receive, send):
# type: (Starlette, StarletteScope, Receive, Send) -> None
integration = sentry_sdk.get_client().get_integration(StarletteIntegration)
if integration is None:
return await old_app(self, scope, receive, send)
middleware = SentryAsgiMiddleware(
lambda *a, **kw: old_app(self, *a, **kw),
mechanism_type=StarletteIntegration.identifier,
transaction_style=integration.transaction_style,
span_origin=StarletteIntegration.origin,
http_methods_to_capture=(
integration.http_methods_to_capture
if integration
else DEFAULT_HTTP_METHODS_TO_CAPTURE
),
)
middleware.__call__ = middleware._run_asgi3
return await middleware(scope, receive, send)
Starlette.__call__ = _sentry_patched_asgi_app
# This was vendored in from Starlette to support Starlette 0.19.1 because
# this function was only introduced in 0.20.x
def _is_async_callable(obj):
# type: (Any) -> bool
while isinstance(obj, functools.partial):
obj = obj.func
return asyncio.iscoroutinefunction(obj) or (
callable(obj) and asyncio.iscoroutinefunction(obj.__call__)
)
def patch_request_response():
# type: () -> None
old_request_response = starlette.routing.request_response
def _sentry_request_response(func):
# type: (Callable[[Any], Any]) -> ASGIApp
old_func = func
is_coroutine = _is_async_callable(old_func)
if is_coroutine:
async def _sentry_async_func(*args, **kwargs):
# type: (*Any, **Any) -> Any
integration = sentry_sdk.get_client().get_integration(
StarletteIntegration
)
if integration is None:
return await old_func(*args, **kwargs)
request = args[0]
_set_transaction_name_and_source(
sentry_sdk.get_current_scope(),
integration.transaction_style,
request,
)
sentry_scope = sentry_sdk.get_isolation_scope()
extractor = StarletteRequestExtractor(request)
info = await extractor.extract_request_info()
def _make_request_event_processor(req, integration):
# type: (Any, Any) -> Callable[[Event, dict[str, Any]], Event]
def event_processor(event, hint):
# type: (Event, Dict[str, Any]) -> Event
# Add info from request to event
request_info = event.get("request", {})
if info:
if "cookies" in info:
request_info["cookies"] = info["cookies"]
if "data" in info:
request_info["data"] = info["data"]
event["request"] = deepcopy(request_info)
return event
return event_processor
sentry_scope._name = StarletteIntegration.identifier
sentry_scope.add_event_processor(
_make_request_event_processor(request, integration)
)
return await old_func(*args, **kwargs)
func = _sentry_async_func
else:
@functools.wraps(old_func)
def _sentry_sync_func(*args, **kwargs):
# type: (*Any, **Any) -> Any
integration = sentry_sdk.get_client().get_integration(
StarletteIntegration
)
if integration is None:
return old_func(*args, **kwargs)
current_scope = sentry_sdk.get_current_scope()
if current_scope.transaction is not None:
current_scope.transaction.update_active_thread()
sentry_scope = sentry_sdk.get_isolation_scope()
if sentry_scope.profile is not None:
sentry_scope.profile.update_active_thread_id()
request = args[0]
_set_transaction_name_and_source(
sentry_scope, integration.transaction_style, request
)
extractor = StarletteRequestExtractor(request)
cookies = extractor.extract_cookies_from_request()
def _make_request_event_processor(req, integration):
# type: (Any, Any) -> Callable[[Event, dict[str, Any]], Event]
def event_processor(event, hint):
# type: (Event, dict[str, Any]) -> Event
# Extract information from request
request_info = event.get("request", {})
if cookies:
request_info["cookies"] = cookies
event["request"] = deepcopy(request_info)
return event
return event_processor
sentry_scope._name = StarletteIntegration.identifier
sentry_scope.add_event_processor(
_make_request_event_processor(request, integration)
)
return old_func(*args, **kwargs)
func = _sentry_sync_func
return old_request_response(func)
starlette.routing.request_response = _sentry_request_response
def patch_templates():
# type: () -> None
# If markupsafe is not installed, then Jinja2 is not installed
# (markupsafe is a dependency of Jinja2)
# In this case we do not need to patch the Jinja2Templates class
try:
from markupsafe import Markup
except ImportError:
return # Nothing to do
from starlette.templating import Jinja2Templates # type: ignore
old_jinja2templates_init = Jinja2Templates.__init__
not_yet_patched = "_sentry_jinja2templates_init" not in str(
old_jinja2templates_init
)
if not_yet_patched:
def _sentry_jinja2templates_init(self, *args, **kwargs):
# type: (Jinja2Templates, *Any, **Any) -> None
def add_sentry_trace_meta(request):
# type: (Request) -> Dict[str, Any]
trace_meta = Markup(
sentry_sdk.get_current_scope().trace_propagation_meta()
)
return {
"sentry_trace_meta": trace_meta,
}
kwargs.setdefault("context_processors", [])
if add_sentry_trace_meta not in kwargs["context_processors"]:
kwargs["context_processors"].append(add_sentry_trace_meta)
return old_jinja2templates_init(self, *args, **kwargs)
Jinja2Templates.__init__ = _sentry_jinja2templates_init
class StarletteRequestExtractor:
"""
Extracts useful information from the Starlette request
(like form data or cookies) and adds it to the Sentry event.
"""
request = None # type: Request
def __init__(self, request):
# type: (StarletteRequestExtractor, Request) -> None
self.request = request
def extract_cookies_from_request(self):
# type: (StarletteRequestExtractor) -> Optional[Dict[str, Any]]
cookies = None # type: Optional[Dict[str, Any]]
if should_send_default_pii():
cookies = self.cookies()
return cookies
async def extract_request_info(self):
# type: (StarletteRequestExtractor) -> Optional[Dict[str, Any]]
client = sentry_sdk.get_client()
request_info = {} # type: Dict[str, Any]
with capture_internal_exceptions():
# Add cookies
if should_send_default_pii():
request_info["cookies"] = self.cookies()
# If there is no body, just return the cookies
content_length = await self.content_length()
if not content_length:
return request_info
# Add annotation if body is too big
if content_length and not request_body_within_bounds(
client, content_length
):
request_info["data"] = AnnotatedValue.removed_because_over_size_limit()
return request_info
# Add JSON body, if it is a JSON request
json = await self.json()
if json:
request_info["data"] = json
return request_info
# Add form as key/value pairs, if request has form data
form = await self.form()
if form:
form_data = {}
for key, val in form.items():
is_file = isinstance(val, UploadFile)
form_data[key] = (
val
if not is_file
else AnnotatedValue.removed_because_raw_data()
)
request_info["data"] = form_data
return request_info
# Raw data, do not add body just an annotation
request_info["data"] = AnnotatedValue.removed_because_raw_data()
return request_info
async def content_length(self):
# type: (StarletteRequestExtractor) -> Optional[int]
if "content-length" in self.request.headers:
return int(self.request.headers["content-length"])
return None
def cookies(self):
# type: (StarletteRequestExtractor) -> Dict[str, Any]
return self.request.cookies
async def form(self):
# type: (StarletteRequestExtractor) -> Any
if multipart is None:
return None
# Parse the body first to get it cached, as Starlette does not cache form() as it
# does with body() and json() https://github.com/encode/starlette/discussions/1933
# Calling `.form()` without calling `.body()` first will
# potentially break the users project.
await self.request.body()
return await self.request.form()
def is_json(self):
# type: (StarletteRequestExtractor) -> bool
return _is_json_content_type(self.request.headers.get("content-type"))
async def json(self):
# type: (StarletteRequestExtractor) -> Optional[Dict[str, Any]]
if not self.is_json():
return None
return await self.request.json()
def _transaction_name_from_router(scope):
# type: (StarletteScope) -> Optional[str]
router = scope.get("router")
if not router:
return None
for route in router.routes:
match = route.matches(scope)
if match[0] == Match.FULL:
return route.path
return None
def _set_transaction_name_and_source(scope, transaction_style, request):
# type: (sentry_sdk.Scope, str, Any) -> None
name = None
source = SOURCE_FOR_STYLE[transaction_style]
if transaction_style == "endpoint":
endpoint = request.scope.get("endpoint")
if endpoint:
name = transaction_from_function(endpoint) or None
elif transaction_style == "url":
name = _transaction_name_from_router(request.scope)
if name is None:
name = _DEFAULT_TRANSACTION_NAME
source = TRANSACTION_SOURCE_ROUTE
scope.set_transaction_name(name, source=source)
logger.debug(
"[Starlette] Set transaction name and source on scope: %s / %s", name, source
)
def _get_transaction_from_middleware(app, asgi_scope, integration):
# type: (Any, Dict[str, Any], StarletteIntegration) -> Tuple[Optional[str], Optional[str]]
name = None
source = None
if integration.transaction_style == "endpoint":
name = transaction_from_function(app.__class__)
source = TRANSACTION_SOURCE_COMPONENT
elif integration.transaction_style == "url":
name = _transaction_name_from_router(asgi_scope)
source = TRANSACTION_SOURCE_ROUTE
return name, source
@@ -0,0 +1,292 @@
import sentry_sdk
from sentry_sdk.consts import OP
from sentry_sdk.integrations import DidNotEnable, Integration
from sentry_sdk.integrations.asgi import SentryAsgiMiddleware
from sentry_sdk.scope import should_send_default_pii
from sentry_sdk.tracing import SOURCE_FOR_STYLE, TRANSACTION_SOURCE_ROUTE
from sentry_sdk.utils import (
ensure_integration_enabled,
event_from_exception,
transaction_from_function,
)
try:
from starlite import Request, Starlite, State # type: ignore
from starlite.handlers.base import BaseRouteHandler # type: ignore
from starlite.middleware import DefineMiddleware # type: ignore
from starlite.plugins.base import get_plugin_for_value # type: ignore
from starlite.routes.http import HTTPRoute # type: ignore
from starlite.utils import ConnectionDataExtractor, is_async_callable, Ref # type: ignore
from pydantic import BaseModel # type: ignore
except ImportError:
raise DidNotEnable("Starlite is not installed")
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from typing import Any, Optional, Union
from starlite.types import ( # type: ignore
ASGIApp,
Hint,
HTTPReceiveMessage,
HTTPScope,
Message,
Middleware,
Receive,
Scope as StarliteScope,
Send,
WebSocketReceiveMessage,
)
from starlite import MiddlewareProtocol
from sentry_sdk._types import Event
_DEFAULT_TRANSACTION_NAME = "generic Starlite request"
class StarliteIntegration(Integration):
identifier = "starlite"
origin = f"auto.http.{identifier}"
@staticmethod
def setup_once():
# type: () -> None
patch_app_init()
patch_middlewares()
patch_http_route_handle()
class SentryStarliteASGIMiddleware(SentryAsgiMiddleware):
def __init__(self, app, span_origin=StarliteIntegration.origin):
# type: (ASGIApp, str) -> None
super().__init__(
app=app,
unsafe_context_data=False,
transaction_style="endpoint",
mechanism_type="asgi",
span_origin=span_origin,
)
def patch_app_init():
# type: () -> None
"""
Replaces the Starlite class's `__init__` function in order to inject `after_exception` handlers and set the
`SentryStarliteASGIMiddleware` as the outmost middleware in the stack.
See:
- https://starlite-api.github.io/starlite/usage/0-the-starlite-app/5-application-hooks/#after-exception
- https://starlite-api.github.io/starlite/usage/7-middleware/0-middleware-intro/
"""
old__init__ = Starlite.__init__
@ensure_integration_enabled(StarliteIntegration, old__init__)
def injection_wrapper(self, *args, **kwargs):
# type: (Starlite, *Any, **Any) -> None
after_exception = kwargs.pop("after_exception", [])
kwargs.update(
after_exception=[
exception_handler,
*(
after_exception
if isinstance(after_exception, list)
else [after_exception]
),
]
)
SentryStarliteASGIMiddleware.__call__ = SentryStarliteASGIMiddleware._run_asgi3 # type: ignore
middleware = kwargs.get("middleware") or []
kwargs["middleware"] = [SentryStarliteASGIMiddleware, *middleware]
old__init__(self, *args, **kwargs)
Starlite.__init__ = injection_wrapper
def patch_middlewares():
# type: () -> None
old_resolve_middleware_stack = BaseRouteHandler.resolve_middleware
@ensure_integration_enabled(StarliteIntegration, old_resolve_middleware_stack)
def resolve_middleware_wrapper(self):
# type: (BaseRouteHandler) -> list[Middleware]
return [
enable_span_for_middleware(middleware)
for middleware in old_resolve_middleware_stack(self)
]
BaseRouteHandler.resolve_middleware = resolve_middleware_wrapper
def enable_span_for_middleware(middleware):
# type: (Middleware) -> Middleware
if (
not hasattr(middleware, "__call__") # noqa: B004
or middleware is SentryStarliteASGIMiddleware
):
return middleware
if isinstance(middleware, DefineMiddleware):
old_call = middleware.middleware.__call__ # type: ASGIApp
else:
old_call = middleware.__call__
async def _create_span_call(self, scope, receive, send):
# type: (MiddlewareProtocol, StarliteScope, Receive, Send) -> None
if sentry_sdk.get_client().get_integration(StarliteIntegration) is None:
return await old_call(self, scope, receive, send)
middleware_name = self.__class__.__name__
with sentry_sdk.start_span(
op=OP.MIDDLEWARE_STARLITE,
name=middleware_name,
origin=StarliteIntegration.origin,
) as middleware_span:
middleware_span.set_tag("starlite.middleware_name", middleware_name)
# Creating spans for the "receive" callback
async def _sentry_receive(*args, **kwargs):
# type: (*Any, **Any) -> Union[HTTPReceiveMessage, WebSocketReceiveMessage]
if sentry_sdk.get_client().get_integration(StarliteIntegration) is None:
return await receive(*args, **kwargs)
with sentry_sdk.start_span(
op=OP.MIDDLEWARE_STARLITE_RECEIVE,
name=getattr(receive, "__qualname__", str(receive)),
origin=StarliteIntegration.origin,
) as span:
span.set_tag("starlite.middleware_name", middleware_name)
return await receive(*args, **kwargs)
receive_name = getattr(receive, "__name__", str(receive))
receive_patched = receive_name == "_sentry_receive"
new_receive = _sentry_receive if not receive_patched else receive
# Creating spans for the "send" callback
async def _sentry_send(message):
# type: (Message) -> None
if sentry_sdk.get_client().get_integration(StarliteIntegration) is None:
return await send(message)
with sentry_sdk.start_span(
op=OP.MIDDLEWARE_STARLITE_SEND,
name=getattr(send, "__qualname__", str(send)),
origin=StarliteIntegration.origin,
) as span:
span.set_tag("starlite.middleware_name", middleware_name)
return await send(message)
send_name = getattr(send, "__name__", str(send))
send_patched = send_name == "_sentry_send"
new_send = _sentry_send if not send_patched else send
return await old_call(self, scope, new_receive, new_send)
not_yet_patched = old_call.__name__ not in ["_create_span_call"]
if not_yet_patched:
if isinstance(middleware, DefineMiddleware):
middleware.middleware.__call__ = _create_span_call
else:
middleware.__call__ = _create_span_call
return middleware
def patch_http_route_handle():
# type: () -> None
old_handle = HTTPRoute.handle
async def handle_wrapper(self, scope, receive, send):
# type: (HTTPRoute, HTTPScope, Receive, Send) -> None
if sentry_sdk.get_client().get_integration(StarliteIntegration) is None:
return await old_handle(self, scope, receive, send)
sentry_scope = sentry_sdk.get_isolation_scope()
request = scope["app"].request_class(
scope=scope, receive=receive, send=send
) # type: Request[Any, Any]
extracted_request_data = ConnectionDataExtractor(
parse_body=True, parse_query=True
)(request)
body = extracted_request_data.pop("body")
request_data = await body
def event_processor(event, _):
# type: (Event, Hint) -> Event
route_handler = scope.get("route_handler")
request_info = event.get("request", {})
request_info["content_length"] = len(scope.get("_body", b""))
if should_send_default_pii():
request_info["cookies"] = extracted_request_data["cookies"]
if request_data is not None:
request_info["data"] = request_data
func = None
if route_handler.name is not None:
tx_name = route_handler.name
elif isinstance(route_handler.fn, Ref):
func = route_handler.fn.value
else:
func = route_handler.fn
if func is not None:
tx_name = transaction_from_function(func)
tx_info = {"source": SOURCE_FOR_STYLE["endpoint"]}
if not tx_name:
tx_name = _DEFAULT_TRANSACTION_NAME
tx_info = {"source": TRANSACTION_SOURCE_ROUTE}
event.update(
{
"request": request_info,
"transaction": tx_name,
"transaction_info": tx_info,
}
)
return event
sentry_scope._name = StarliteIntegration.identifier
sentry_scope.add_event_processor(event_processor)
return await old_handle(self, scope, receive, send)
HTTPRoute.handle = handle_wrapper
def retrieve_user_from_scope(scope):
# type: (StarliteScope) -> Optional[dict[str, Any]]
scope_user = scope.get("user")
if not scope_user:
return None
if isinstance(scope_user, dict):
return scope_user
if isinstance(scope_user, BaseModel):
return scope_user.dict()
if hasattr(scope_user, "asdict"): # dataclasses
return scope_user.asdict()
plugin = get_plugin_for_value(scope_user)
if plugin and not is_async_callable(plugin.to_dict):
return plugin.to_dict(scope_user)
return None
@ensure_integration_enabled(StarliteIntegration)
def exception_handler(exc, scope, _):
# type: (Exception, StarliteScope, State) -> None
user_info = None # type: Optional[dict[str, Any]]
if should_send_default_pii():
user_info = retrieve_user_from_scope(scope)
if user_info and isinstance(user_info, dict):
sentry_scope = sentry_sdk.get_isolation_scope()
sentry_scope.set_user(user_info)
event, hint = event_from_exception(
exc,
client_options=sentry_sdk.get_client().options,
mechanism={"type": StarliteIntegration.identifier, "handled": False},
)
sentry_sdk.capture_event(event, hint=hint)
@@ -0,0 +1,37 @@
from functools import wraps
from typing import Any, TYPE_CHECKING
from sentry_sdk.feature_flags import add_feature_flag
from sentry_sdk.integrations import Integration, DidNotEnable, _check_minimum_version
from sentry_sdk.utils import parse_version
try:
from statsig import statsig as statsig_module
from statsig.version import __version__ as STATSIG_VERSION
except ImportError:
raise DidNotEnable("statsig is not installed")
if TYPE_CHECKING:
from statsig.statsig_user import StatsigUser
class StatsigIntegration(Integration):
identifier = "statsig"
@staticmethod
def setup_once():
# type: () -> None
version = parse_version(STATSIG_VERSION)
_check_minimum_version(StatsigIntegration, version, "statsig")
# Wrap and patch evaluation method(s) in the statsig module
old_check_gate = statsig_module.check_gate
@wraps(old_check_gate)
def sentry_check_gate(user, gate, *args, **kwargs):
# type: (StatsigUser, str, *Any, **Any) -> Any
enabled = old_check_gate(user, gate, *args, **kwargs)
add_feature_flag(gate, enabled)
return enabled
statsig_module.check_gate = sentry_check_gate
@@ -0,0 +1,265 @@
import os
import subprocess
import sys
import platform
from http.client import HTTPConnection
import sentry_sdk
from sentry_sdk.consts import OP, SPANDATA
from sentry_sdk.integrations import Integration
from sentry_sdk.scope import add_global_event_processor
from sentry_sdk.tracing_utils import EnvironHeaders, should_propagate_trace
from sentry_sdk.utils import (
SENSITIVE_DATA_SUBSTITUTE,
capture_internal_exceptions,
ensure_integration_enabled,
is_sentry_url,
logger,
safe_repr,
parse_url,
)
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from typing import Any
from typing import Callable
from typing import Dict
from typing import Optional
from typing import List
from sentry_sdk._types import Event, Hint
_RUNTIME_CONTEXT = {
"name": platform.python_implementation(),
"version": "%s.%s.%s" % (sys.version_info[:3]),
"build": sys.version,
} # type: dict[str, object]
class StdlibIntegration(Integration):
identifier = "stdlib"
@staticmethod
def setup_once():
# type: () -> None
_install_httplib()
_install_subprocess()
@add_global_event_processor
def add_python_runtime_context(event, hint):
# type: (Event, Hint) -> Optional[Event]
if sentry_sdk.get_client().get_integration(StdlibIntegration) is not None:
contexts = event.setdefault("contexts", {})
if isinstance(contexts, dict) and "runtime" not in contexts:
contexts["runtime"] = _RUNTIME_CONTEXT
return event
def _install_httplib():
# type: () -> None
real_putrequest = HTTPConnection.putrequest
real_getresponse = HTTPConnection.getresponse
def putrequest(self, method, url, *args, **kwargs):
# type: (HTTPConnection, str, str, *Any, **Any) -> Any
host = self.host
port = self.port
default_port = self.default_port
client = sentry_sdk.get_client()
if client.get_integration(StdlibIntegration) is None or is_sentry_url(
client, host
):
return real_putrequest(self, method, url, *args, **kwargs)
real_url = url
if real_url is None or not real_url.startswith(("http://", "https://")):
real_url = "%s://%s%s%s" % (
default_port == 443 and "https" or "http",
host,
port != default_port and ":%s" % port or "",
url,
)
parsed_url = None
with capture_internal_exceptions():
parsed_url = parse_url(real_url, sanitize=False)
span = sentry_sdk.start_span(
op=OP.HTTP_CLIENT,
name="%s %s"
% (method, parsed_url.url if parsed_url else SENSITIVE_DATA_SUBSTITUTE),
origin="auto.http.stdlib.httplib",
)
span.set_data(SPANDATA.HTTP_METHOD, method)
if parsed_url is not None:
span.set_data("url", parsed_url.url)
span.set_data(SPANDATA.HTTP_QUERY, parsed_url.query)
span.set_data(SPANDATA.HTTP_FRAGMENT, parsed_url.fragment)
rv = real_putrequest(self, method, url, *args, **kwargs)
if should_propagate_trace(client, real_url):
for (
key,
value,
) in sentry_sdk.get_current_scope().iter_trace_propagation_headers(
span=span
):
logger.debug(
"[Tracing] Adding `{key}` header {value} to outgoing request to {real_url}.".format(
key=key, value=value, real_url=real_url
)
)
self.putheader(key, value)
self._sentrysdk_span = span # type: ignore[attr-defined]
return rv
def getresponse(self, *args, **kwargs):
# type: (HTTPConnection, *Any, **Any) -> Any
span = getattr(self, "_sentrysdk_span", None)
if span is None:
return real_getresponse(self, *args, **kwargs)
try:
rv = real_getresponse(self, *args, **kwargs)
span.set_http_status(int(rv.status))
span.set_data("reason", rv.reason)
finally:
span.finish()
return rv
HTTPConnection.putrequest = putrequest # type: ignore[method-assign]
HTTPConnection.getresponse = getresponse # type: ignore[method-assign]
def _init_argument(args, kwargs, name, position, setdefault_callback=None):
# type: (List[Any], Dict[Any, Any], str, int, Optional[Callable[[Any], Any]]) -> Any
"""
given (*args, **kwargs) of a function call, retrieve (and optionally set a
default for) an argument by either name or position.
This is useful for wrapping functions with complex type signatures and
extracting a few arguments without needing to redefine that function's
entire type signature.
"""
if name in kwargs:
rv = kwargs[name]
if setdefault_callback is not None:
rv = setdefault_callback(rv)
if rv is not None:
kwargs[name] = rv
elif position < len(args):
rv = args[position]
if setdefault_callback is not None:
rv = setdefault_callback(rv)
if rv is not None:
args[position] = rv
else:
rv = setdefault_callback and setdefault_callback(None)
if rv is not None:
kwargs[name] = rv
return rv
def _install_subprocess():
# type: () -> None
old_popen_init = subprocess.Popen.__init__
@ensure_integration_enabled(StdlibIntegration, old_popen_init)
def sentry_patched_popen_init(self, *a, **kw):
# type: (subprocess.Popen[Any], *Any, **Any) -> None
# Convert from tuple to list to be able to set values.
a = list(a)
args = _init_argument(a, kw, "args", 0) or []
cwd = _init_argument(a, kw, "cwd", 9)
# if args is not a list or tuple (and e.g. some iterator instead),
# let's not use it at all. There are too many things that can go wrong
# when trying to collect an iterator into a list and setting that list
# into `a` again.
#
# Also invocations where `args` is not a sequence are not actually
# legal. They just happen to work under CPython.
description = None
if isinstance(args, (list, tuple)) and len(args) < 100:
with capture_internal_exceptions():
description = " ".join(map(str, args))
if description is None:
description = safe_repr(args)
env = None
with sentry_sdk.start_span(
op=OP.SUBPROCESS,
name=description,
origin="auto.subprocess.stdlib.subprocess",
) as span:
for k, v in sentry_sdk.get_current_scope().iter_trace_propagation_headers(
span=span
):
if env is None:
env = _init_argument(
a,
kw,
"env",
10,
lambda x: dict(x if x is not None else os.environ),
)
env["SUBPROCESS_" + k.upper().replace("-", "_")] = v
if cwd:
span.set_data("subprocess.cwd", cwd)
rv = old_popen_init(self, *a, **kw)
span.set_tag("subprocess.pid", self.pid)
return rv
subprocess.Popen.__init__ = sentry_patched_popen_init # type: ignore
old_popen_wait = subprocess.Popen.wait
@ensure_integration_enabled(StdlibIntegration, old_popen_wait)
def sentry_patched_popen_wait(self, *a, **kw):
# type: (subprocess.Popen[Any], *Any, **Any) -> Any
with sentry_sdk.start_span(
op=OP.SUBPROCESS_WAIT,
origin="auto.subprocess.stdlib.subprocess",
) as span:
span.set_tag("subprocess.pid", self.pid)
return old_popen_wait(self, *a, **kw)
subprocess.Popen.wait = sentry_patched_popen_wait # type: ignore
old_popen_communicate = subprocess.Popen.communicate
@ensure_integration_enabled(StdlibIntegration, old_popen_communicate)
def sentry_patched_popen_communicate(self, *a, **kw):
# type: (subprocess.Popen[Any], *Any, **Any) -> Any
with sentry_sdk.start_span(
op=OP.SUBPROCESS_COMMUNICATE,
origin="auto.subprocess.stdlib.subprocess",
) as span:
span.set_tag("subprocess.pid", self.pid)
return old_popen_communicate(self, *a, **kw)
subprocess.Popen.communicate = sentry_patched_popen_communicate # type: ignore
def get_subprocess_traceparent_headers():
# type: () -> EnvironHeaders
return EnvironHeaders(os.environ, prefix="SUBPROCESS_")
@@ -0,0 +1,393 @@
import functools
import hashlib
from inspect import isawaitable
import sentry_sdk
from sentry_sdk.consts import OP
from sentry_sdk.integrations import _check_minimum_version, Integration, DidNotEnable
from sentry_sdk.integrations.logging import ignore_logger
from sentry_sdk.scope import should_send_default_pii
from sentry_sdk.tracing import TRANSACTION_SOURCE_COMPONENT
from sentry_sdk.utils import (
capture_internal_exceptions,
ensure_integration_enabled,
event_from_exception,
logger,
package_version,
_get_installed_modules,
)
try:
from functools import cached_property
except ImportError:
# The strawberry integration requires Python 3.8+. functools.cached_property
# was added in 3.8, so this check is technically not needed, but since this
# is an auto-enabling integration, we might get to executing this import in
# lower Python versions, so we need to deal with it.
raise DidNotEnable("strawberry-graphql integration requires Python 3.8 or newer")
try:
from strawberry import Schema
from strawberry.extensions import SchemaExtension
from strawberry.extensions.tracing.utils import (
should_skip_tracing as strawberry_should_skip_tracing,
)
from strawberry.http import async_base_view, sync_base_view
except ImportError:
raise DidNotEnable("strawberry-graphql is not installed")
try:
from strawberry.extensions.tracing import (
SentryTracingExtension as StrawberrySentryAsyncExtension,
SentryTracingExtensionSync as StrawberrySentrySyncExtension,
)
except ImportError:
StrawberrySentryAsyncExtension = None
StrawberrySentrySyncExtension = None
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from typing import Any, Callable, Generator, List, Optional
from graphql import GraphQLError, GraphQLResolveInfo
from strawberry.http import GraphQLHTTPResponse
from strawberry.types import ExecutionContext
from sentry_sdk._types import Event, EventProcessor
ignore_logger("strawberry.execution")
class StrawberryIntegration(Integration):
identifier = "strawberry"
origin = f"auto.graphql.{identifier}"
def __init__(self, async_execution=None):
# type: (Optional[bool]) -> None
if async_execution not in (None, False, True):
raise ValueError(
'Invalid value for async_execution: "{}" (must be bool)'.format(
async_execution
)
)
self.async_execution = async_execution
@staticmethod
def setup_once():
# type: () -> None
version = package_version("strawberry-graphql")
_check_minimum_version(StrawberryIntegration, version, "strawberry-graphql")
_patch_schema_init()
_patch_views()
def _patch_schema_init():
# type: () -> None
old_schema_init = Schema.__init__
@functools.wraps(old_schema_init)
def _sentry_patched_schema_init(self, *args, **kwargs):
# type: (Schema, Any, Any) -> None
integration = sentry_sdk.get_client().get_integration(StrawberryIntegration)
if integration is None:
return old_schema_init(self, *args, **kwargs)
extensions = kwargs.get("extensions") or []
if integration.async_execution is not None:
should_use_async_extension = integration.async_execution
else:
# try to figure it out ourselves
should_use_async_extension = _guess_if_using_async(extensions)
logger.info(
"Assuming strawberry is running %s. If not, initialize it as StrawberryIntegration(async_execution=%s).",
"async" if should_use_async_extension else "sync",
"False" if should_use_async_extension else "True",
)
# remove the built in strawberry sentry extension, if present
extensions = [
extension
for extension in extensions
if extension
not in (StrawberrySentryAsyncExtension, StrawberrySentrySyncExtension)
]
# add our extension
extensions.append(
SentryAsyncExtension if should_use_async_extension else SentrySyncExtension
)
kwargs["extensions"] = extensions
return old_schema_init(self, *args, **kwargs)
Schema.__init__ = _sentry_patched_schema_init # type: ignore[method-assign]
class SentryAsyncExtension(SchemaExtension):
def __init__(
self,
*,
execution_context=None,
):
# type: (Any, Optional[ExecutionContext]) -> None
if execution_context:
self.execution_context = execution_context
@cached_property
def _resource_name(self):
# type: () -> str
query_hash = self.hash_query(self.execution_context.query) # type: ignore
if self.execution_context.operation_name:
return "{}:{}".format(self.execution_context.operation_name, query_hash)
return query_hash
def hash_query(self, query):
# type: (str) -> str
return hashlib.md5(query.encode("utf-8")).hexdigest()
def on_operation(self):
# type: () -> Generator[None, None, None]
self._operation_name = self.execution_context.operation_name
operation_type = "query"
op = OP.GRAPHQL_QUERY
if self.execution_context.query is None:
self.execution_context.query = ""
if self.execution_context.query.strip().startswith("mutation"):
operation_type = "mutation"
op = OP.GRAPHQL_MUTATION
elif self.execution_context.query.strip().startswith("subscription"):
operation_type = "subscription"
op = OP.GRAPHQL_SUBSCRIPTION
description = operation_type
if self._operation_name:
description += " {}".format(self._operation_name)
sentry_sdk.add_breadcrumb(
category="graphql.operation",
data={
"operation_name": self._operation_name,
"operation_type": operation_type,
},
)
scope = sentry_sdk.get_isolation_scope()
event_processor = _make_request_event_processor(self.execution_context)
scope.add_event_processor(event_processor)
span = sentry_sdk.get_current_span()
if span:
self.graphql_span = span.start_child(
op=op,
name=description,
origin=StrawberryIntegration.origin,
)
else:
self.graphql_span = sentry_sdk.start_span(
op=op,
name=description,
origin=StrawberryIntegration.origin,
)
self.graphql_span.set_data("graphql.operation.type", operation_type)
self.graphql_span.set_data("graphql.operation.name", self._operation_name)
self.graphql_span.set_data("graphql.document", self.execution_context.query)
self.graphql_span.set_data("graphql.resource_name", self._resource_name)
yield
transaction = self.graphql_span.containing_transaction
if transaction and self.execution_context.operation_name:
transaction.name = self.execution_context.operation_name
transaction.source = TRANSACTION_SOURCE_COMPONENT
transaction.op = op
self.graphql_span.finish()
def on_validate(self):
# type: () -> Generator[None, None, None]
self.validation_span = self.graphql_span.start_child(
op=OP.GRAPHQL_VALIDATE,
name="validation",
origin=StrawberryIntegration.origin,
)
yield
self.validation_span.finish()
def on_parse(self):
# type: () -> Generator[None, None, None]
self.parsing_span = self.graphql_span.start_child(
op=OP.GRAPHQL_PARSE,
name="parsing",
origin=StrawberryIntegration.origin,
)
yield
self.parsing_span.finish()
def should_skip_tracing(self, _next, info):
# type: (Callable[[Any, GraphQLResolveInfo, Any, Any], Any], GraphQLResolveInfo) -> bool
return strawberry_should_skip_tracing(_next, info)
async def _resolve(self, _next, root, info, *args, **kwargs):
# type: (Callable[[Any, GraphQLResolveInfo, Any, Any], Any], Any, GraphQLResolveInfo, str, Any) -> Any
result = _next(root, info, *args, **kwargs)
if isawaitable(result):
result = await result
return result
async def resolve(self, _next, root, info, *args, **kwargs):
# type: (Callable[[Any, GraphQLResolveInfo, Any, Any], Any], Any, GraphQLResolveInfo, str, Any) -> Any
if self.should_skip_tracing(_next, info):
return await self._resolve(_next, root, info, *args, **kwargs)
field_path = "{}.{}".format(info.parent_type, info.field_name)
with self.graphql_span.start_child(
op=OP.GRAPHQL_RESOLVE,
name="resolving {}".format(field_path),
origin=StrawberryIntegration.origin,
) as span:
span.set_data("graphql.field_name", info.field_name)
span.set_data("graphql.parent_type", info.parent_type.name)
span.set_data("graphql.field_path", field_path)
span.set_data("graphql.path", ".".join(map(str, info.path.as_list())))
return await self._resolve(_next, root, info, *args, **kwargs)
class SentrySyncExtension(SentryAsyncExtension):
def resolve(self, _next, root, info, *args, **kwargs):
# type: (Callable[[Any, Any, Any, Any], Any], Any, GraphQLResolveInfo, str, Any) -> Any
if self.should_skip_tracing(_next, info):
return _next(root, info, *args, **kwargs)
field_path = "{}.{}".format(info.parent_type, info.field_name)
with self.graphql_span.start_child(
op=OP.GRAPHQL_RESOLVE,
name="resolving {}".format(field_path),
origin=StrawberryIntegration.origin,
) as span:
span.set_data("graphql.field_name", info.field_name)
span.set_data("graphql.parent_type", info.parent_type.name)
span.set_data("graphql.field_path", field_path)
span.set_data("graphql.path", ".".join(map(str, info.path.as_list())))
return _next(root, info, *args, **kwargs)
def _patch_views():
# type: () -> None
old_async_view_handle_errors = async_base_view.AsyncBaseHTTPView._handle_errors
old_sync_view_handle_errors = sync_base_view.SyncBaseHTTPView._handle_errors
def _sentry_patched_async_view_handle_errors(self, errors, response_data):
# type: (Any, List[GraphQLError], GraphQLHTTPResponse) -> None
old_async_view_handle_errors(self, errors, response_data)
_sentry_patched_handle_errors(self, errors, response_data)
def _sentry_patched_sync_view_handle_errors(self, errors, response_data):
# type: (Any, List[GraphQLError], GraphQLHTTPResponse) -> None
old_sync_view_handle_errors(self, errors, response_data)
_sentry_patched_handle_errors(self, errors, response_data)
@ensure_integration_enabled(StrawberryIntegration)
def _sentry_patched_handle_errors(self, errors, response_data):
# type: (Any, List[GraphQLError], GraphQLHTTPResponse) -> None
if not errors:
return
scope = sentry_sdk.get_isolation_scope()
event_processor = _make_response_event_processor(response_data)
scope.add_event_processor(event_processor)
with capture_internal_exceptions():
for error in errors:
event, hint = event_from_exception(
error,
client_options=sentry_sdk.get_client().options,
mechanism={
"type": StrawberryIntegration.identifier,
"handled": False,
},
)
sentry_sdk.capture_event(event, hint=hint)
async_base_view.AsyncBaseHTTPView._handle_errors = ( # type: ignore[method-assign]
_sentry_patched_async_view_handle_errors
)
sync_base_view.SyncBaseHTTPView._handle_errors = ( # type: ignore[method-assign]
_sentry_patched_sync_view_handle_errors
)
def _make_request_event_processor(execution_context):
# type: (ExecutionContext) -> EventProcessor
def inner(event, hint):
# type: (Event, dict[str, Any]) -> Event
with capture_internal_exceptions():
if should_send_default_pii():
request_data = event.setdefault("request", {})
request_data["api_target"] = "graphql"
if not request_data.get("data"):
data = {"query": execution_context.query} # type: dict[str, Any]
if execution_context.variables:
data["variables"] = execution_context.variables
if execution_context.operation_name:
data["operationName"] = execution_context.operation_name
request_data["data"] = data
else:
try:
del event["request"]["data"]
except (KeyError, TypeError):
pass
return event
return inner
def _make_response_event_processor(response_data):
# type: (GraphQLHTTPResponse) -> EventProcessor
def inner(event, hint):
# type: (Event, dict[str, Any]) -> Event
with capture_internal_exceptions():
if should_send_default_pii():
contexts = event.setdefault("contexts", {})
contexts["response"] = {"data": response_data}
return event
return inner
def _guess_if_using_async(extensions):
# type: (List[SchemaExtension]) -> bool
if StrawberrySentryAsyncExtension in extensions:
return True
elif StrawberrySentrySyncExtension in extensions:
return False
return bool(
{"starlette", "starlite", "litestar", "fastapi"} & set(_get_installed_modules())
)
@@ -0,0 +1,70 @@
import functools
import sys
import sentry_sdk
from sentry_sdk.utils import capture_internal_exceptions, event_from_exception
from sentry_sdk.integrations import Integration
from sentry_sdk._types import TYPE_CHECKING
if TYPE_CHECKING:
from collections.abc import Callable
from typing import NoReturn, Union
class SysExitIntegration(Integration):
"""Captures sys.exit calls and sends them as events to Sentry.
By default, SystemExit exceptions are not captured by the SDK. Enabling this integration will capture SystemExit
exceptions generated by sys.exit calls and send them to Sentry.
This integration, in its default configuration, only captures the sys.exit call if the exit code is a non-zero and
non-None value (unsuccessful exits). Pass `capture_successful_exits=True` to capture successful exits as well.
Note that the integration does not capture SystemExit exceptions raised outside a call to sys.exit.
"""
identifier = "sys_exit"
def __init__(self, *, capture_successful_exits=False):
# type: (bool) -> None
self._capture_successful_exits = capture_successful_exits
@staticmethod
def setup_once():
# type: () -> None
SysExitIntegration._patch_sys_exit()
@staticmethod
def _patch_sys_exit():
# type: () -> None
old_exit = sys.exit # type: Callable[[Union[str, int, None]], NoReturn]
@functools.wraps(old_exit)
def sentry_patched_exit(__status=0):
# type: (Union[str, int, None]) -> NoReturn
# @ensure_integration_enabled ensures that this is non-None
integration = sentry_sdk.get_client().get_integration(SysExitIntegration)
if integration is None:
old_exit(__status)
try:
old_exit(__status)
except SystemExit as e:
with capture_internal_exceptions():
if integration._capture_successful_exits or __status not in (
0,
None,
):
_capture_exception(e)
raise e
sys.exit = sentry_patched_exit
def _capture_exception(exc):
# type: (SystemExit) -> None
event, hint = event_from_exception(
exc,
client_options=sentry_sdk.get_client().options,
mechanism={"type": SysExitIntegration.identifier, "handled": False},
)
sentry_sdk.capture_event(event, hint=hint)
@@ -0,0 +1,121 @@
import sys
from functools import wraps
from threading import Thread, current_thread
import sentry_sdk
from sentry_sdk.integrations import Integration
from sentry_sdk.scope import use_isolation_scope, use_scope
from sentry_sdk.utils import (
event_from_exception,
capture_internal_exceptions,
logger,
reraise,
)
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from typing import Any
from typing import TypeVar
from typing import Callable
from typing import Optional
from sentry_sdk._types import ExcInfo
F = TypeVar("F", bound=Callable[..., Any])
class ThreadingIntegration(Integration):
identifier = "threading"
def __init__(self, propagate_hub=None, propagate_scope=True):
# type: (Optional[bool], bool) -> None
if propagate_hub is not None:
logger.warning(
"Deprecated: propagate_hub is deprecated. This will be removed in the future."
)
# Note: propagate_hub did not have any effect on propagation of scope data
# scope data was always propagated no matter what the value of propagate_hub was
# This is why the default for propagate_scope is True
self.propagate_scope = propagate_scope
if propagate_hub is not None:
self.propagate_scope = propagate_hub
@staticmethod
def setup_once():
# type: () -> None
old_start = Thread.start
@wraps(old_start)
def sentry_start(self, *a, **kw):
# type: (Thread, *Any, **Any) -> Any
integration = sentry_sdk.get_client().get_integration(ThreadingIntegration)
if integration is None:
return old_start(self, *a, **kw)
if integration.propagate_scope:
isolation_scope = sentry_sdk.get_isolation_scope()
current_scope = sentry_sdk.get_current_scope()
else:
isolation_scope = None
current_scope = None
# Patching instance methods in `start()` creates a reference cycle if
# done in a naive way. See
# https://github.com/getsentry/sentry-python/pull/434
#
# In threading module, using current_thread API will access current thread instance
# without holding it to avoid a reference cycle in an easier way.
with capture_internal_exceptions():
new_run = _wrap_run(
isolation_scope,
current_scope,
getattr(self.run, "__func__", self.run),
)
self.run = new_run # type: ignore
return old_start(self, *a, **kw)
Thread.start = sentry_start # type: ignore
def _wrap_run(isolation_scope_to_use, current_scope_to_use, old_run_func):
# type: (Optional[sentry_sdk.Scope], Optional[sentry_sdk.Scope], F) -> F
@wraps(old_run_func)
def run(*a, **kw):
# type: (*Any, **Any) -> Any
def _run_old_run_func():
# type: () -> Any
try:
self = current_thread()
return old_run_func(self, *a, **kw)
except Exception:
reraise(*_capture_exception())
if isolation_scope_to_use is not None and current_scope_to_use is not None:
with use_isolation_scope(isolation_scope_to_use):
with use_scope(current_scope_to_use):
return _run_old_run_func()
else:
return _run_old_run_func()
return run # type: ignore
def _capture_exception():
# type: () -> ExcInfo
exc_info = sys.exc_info()
client = sentry_sdk.get_client()
if client.get_integration(ThreadingIntegration) is not None:
event, hint = event_from_exception(
exc_info,
client_options=client.options,
mechanism={"type": "threading", "handled": False},
)
sentry_sdk.capture_event(event, hint=hint)
return exc_info
@@ -0,0 +1,223 @@
import weakref
import contextlib
from inspect import iscoroutinefunction
import sentry_sdk
from sentry_sdk.api import continue_trace
from sentry_sdk.consts import OP
from sentry_sdk.scope import should_send_default_pii
from sentry_sdk.tracing import (
TRANSACTION_SOURCE_COMPONENT,
TRANSACTION_SOURCE_ROUTE,
)
from sentry_sdk.utils import (
HAS_REAL_CONTEXTVARS,
CONTEXTVARS_ERROR_MESSAGE,
ensure_integration_enabled,
event_from_exception,
capture_internal_exceptions,
transaction_from_function,
)
from sentry_sdk.integrations import _check_minimum_version, Integration, DidNotEnable
from sentry_sdk.integrations._wsgi_common import (
RequestExtractor,
_filter_headers,
_is_json_content_type,
)
from sentry_sdk.integrations.logging import ignore_logger
try:
from tornado import version_info as TORNADO_VERSION
from tornado.web import RequestHandler, HTTPError
from tornado.gen import coroutine
except ImportError:
raise DidNotEnable("Tornado not installed")
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from typing import Any
from typing import Optional
from typing import Dict
from typing import Callable
from typing import Generator
from sentry_sdk._types import Event, EventProcessor
class TornadoIntegration(Integration):
identifier = "tornado"
origin = f"auto.http.{identifier}"
@staticmethod
def setup_once():
# type: () -> None
_check_minimum_version(TornadoIntegration, TORNADO_VERSION)
if not HAS_REAL_CONTEXTVARS:
# Tornado is async. We better have contextvars or we're going to leak
# state between requests.
raise DidNotEnable(
"The tornado integration for Sentry requires Python 3.7+ or the aiocontextvars package"
+ CONTEXTVARS_ERROR_MESSAGE
)
ignore_logger("tornado.access")
old_execute = RequestHandler._execute
awaitable = iscoroutinefunction(old_execute)
if awaitable:
# Starting Tornado 6 RequestHandler._execute method is a standard Python coroutine (async/await)
# In that case our method should be a coroutine function too
async def sentry_execute_request_handler(self, *args, **kwargs):
# type: (RequestHandler, *Any, **Any) -> Any
with _handle_request_impl(self):
return await old_execute(self, *args, **kwargs)
else:
@coroutine # type: ignore
def sentry_execute_request_handler(self, *args, **kwargs): # type: ignore
# type: (RequestHandler, *Any, **Any) -> Any
with _handle_request_impl(self):
result = yield from old_execute(self, *args, **kwargs)
return result
RequestHandler._execute = sentry_execute_request_handler
old_log_exception = RequestHandler.log_exception
def sentry_log_exception(self, ty, value, tb, *args, **kwargs):
# type: (Any, type, BaseException, Any, *Any, **Any) -> Optional[Any]
_capture_exception(ty, value, tb)
return old_log_exception(self, ty, value, tb, *args, **kwargs)
RequestHandler.log_exception = sentry_log_exception
@contextlib.contextmanager
def _handle_request_impl(self):
# type: (RequestHandler) -> Generator[None, None, None]
integration = sentry_sdk.get_client().get_integration(TornadoIntegration)
if integration is None:
yield
weak_handler = weakref.ref(self)
with sentry_sdk.isolation_scope() as scope:
headers = self.request.headers
scope.clear_breadcrumbs()
processor = _make_event_processor(weak_handler)
scope.add_event_processor(processor)
transaction = continue_trace(
headers,
op=OP.HTTP_SERVER,
# Like with all other integrations, this is our
# fallback transaction in case there is no route.
# sentry_urldispatcher_resolve is responsible for
# setting a transaction name later.
name="generic Tornado request",
source=TRANSACTION_SOURCE_ROUTE,
origin=TornadoIntegration.origin,
)
with sentry_sdk.start_transaction(
transaction, custom_sampling_context={"tornado_request": self.request}
):
yield
@ensure_integration_enabled(TornadoIntegration)
def _capture_exception(ty, value, tb):
# type: (type, BaseException, Any) -> None
if isinstance(value, HTTPError):
return
event, hint = event_from_exception(
(ty, value, tb),
client_options=sentry_sdk.get_client().options,
mechanism={"type": "tornado", "handled": False},
)
sentry_sdk.capture_event(event, hint=hint)
def _make_event_processor(weak_handler):
# type: (Callable[[], RequestHandler]) -> EventProcessor
def tornado_processor(event, hint):
# type: (Event, dict[str, Any]) -> Event
handler = weak_handler()
if handler is None:
return event
request = handler.request
with capture_internal_exceptions():
method = getattr(handler, handler.request.method.lower())
event["transaction"] = transaction_from_function(method) or ""
event["transaction_info"] = {"source": TRANSACTION_SOURCE_COMPONENT}
with capture_internal_exceptions():
extractor = TornadoRequestExtractor(request)
extractor.extract_into_event(event)
request_info = event["request"]
request_info["url"] = "%s://%s%s" % (
request.protocol,
request.host,
request.path,
)
request_info["query_string"] = request.query
request_info["method"] = request.method
request_info["env"] = {"REMOTE_ADDR": request.remote_ip}
request_info["headers"] = _filter_headers(dict(request.headers))
with capture_internal_exceptions():
if handler.current_user and should_send_default_pii():
event.setdefault("user", {}).setdefault("is_authenticated", True)
return event
return tornado_processor
class TornadoRequestExtractor(RequestExtractor):
def content_length(self):
# type: () -> int
if self.request.body is None:
return 0
return len(self.request.body)
def cookies(self):
# type: () -> Dict[str, str]
return {k: v.value for k, v in self.request.cookies.items()}
def raw_data(self):
# type: () -> bytes
return self.request.body
def form(self):
# type: () -> Dict[str, Any]
return {
k: [v.decode("latin1", "replace") for v in vs]
for k, vs in self.request.body_arguments.items()
}
def is_json(self):
# type: () -> bool
return _is_json_content_type(self.request.headers.get("content-type"))
def files(self):
# type: () -> Dict[str, Any]
return {k: v[0] for k, v in self.request.files.items() if v}
def size_of_file(self, file):
# type: (Any) -> int
return len(file.body or ())
@@ -0,0 +1,50 @@
import sentry_sdk
from sentry_sdk.integrations import Integration
from sentry_sdk.integrations.wsgi import SentryWsgiMiddleware
from sentry_sdk.utils import ensure_integration_enabled, event_from_exception
from trytond.exceptions import TrytonException # type: ignore
from trytond.wsgi import app # type: ignore
# TODO: trytond-worker, trytond-cron and trytond-admin intergations
class TrytondWSGIIntegration(Integration):
identifier = "trytond_wsgi"
origin = f"auto.http.{identifier}"
def __init__(self): # type: () -> None
pass
@staticmethod
def setup_once(): # type: () -> None
app.wsgi_app = SentryWsgiMiddleware(
app.wsgi_app,
span_origin=TrytondWSGIIntegration.origin,
)
@ensure_integration_enabled(TrytondWSGIIntegration)
def error_handler(e): # type: (Exception) -> None
if isinstance(e, TrytonException):
return
else:
client = sentry_sdk.get_client()
event, hint = event_from_exception(
e,
client_options=client.options,
mechanism={"type": "trytond", "handled": False},
)
sentry_sdk.capture_event(event, hint=hint)
# Expected error handlers signature was changed
# when the error_handler decorator was introduced
# in Tryton-5.4
if hasattr(app, "error_handler"):
@app.error_handler
def _(app, request, e): # type: ignore
error_handler(e)
else:
app.error_handlers.append(error_handler)

Some files were not shown because too many files have changed in this diff Show More