Decorators to configure Sentry error and trace rates?

Question:

I am using Sentry and sentry_sdk to monitor errors and traces in my Python application. I want to configure the error and trace rates for different routes in my FastAPI API. To do this, I want to write two decorators called sentry_error_rate and sentry_trace_rate that will allow me to set the sample rates for errors and traces, respectively.

The sentry_error_rate decorator should take a single argument errors_sample_rate (a float between 0 and 1) and apply it to a specific route.
The sentry_trace_rate decorator should take a single argument traces_sample_rate (also a float between 0 and 1) and apply it to a specific route.

def sentry_trace_rate(traces_sample_rate: float = 0.0) -> callable:
    """ Decorator to set the traces_sample_rate for a specific route.
    This is useful for routes that are called very frequently, but we
    want to sample them to reduce the amount of data we send to Sentry.

    Args:
        traces_sample_rate (float): The sample rate to use for this route.
    """
    def decorator(func):
        @wraps(func)
        async def wrapper(*args, **kwargs):
            # Do something here ?
            return await func(*args, **kwargs)
        return wrapper
    return decorator


def sentry_error_rate(errors_sample_rate: float = 0.0) -> callable:
    """ Decorator to set the errors_sample_rate for a specific route.
    This is useful for routes that are called very frequently, but we
    want to sample them to reduce the amount of data we send to Sentry.

    Args:
        errors_sample_rate (float): The sample rate to use for this route.
    """
    def decorator(func):
        @wraps(func)
        async def wrapper(*args, **kwargs):
            # Do something here ?
            return await func(*args, **kwargs)
        return wrapper
    return decorator

Does someone have an idea if this is possible and how it could be done ?

Asked By: Yohann L.

||

Answers:

I finally managed to do it using a registry mechanism. Each route with a decorator are registred in a dictionary with their trace/error rate. I then used a trace_sampler/before_send function as indicated here:

Here’s my sentry_wrapper.py:

import asyncio
import random
from functools import wraps
from typing import Callable, Union

from fastapi import APIRouter


_route_traces_entrypoints = {}
_route_errors_entrypoints = {}
_fn_traces_entrypoints = {}
_fn_errors_entrypoints = {}
_fn_to_route_entrypoints = {}


def sentry_trace_rate(trace_sample_rate: float = 0.0) -> Callable:
    """Decorator to set the sentry trace rate for a specific endpoint.
    This is useful for endpoints that are called very frequently,
    and we don't want to report all traces.
    Args:
         trace_sample_rate (float): The rate to sample traces. 0.0 to disable traces.
    """

    def decorator(fn: Callable) -> Callable:
        # Assert there is not twice function with the same nam
        if fn.__name__ in _fn_traces_entrypoints:
            raise ValueError(f"Two function have the same name: {fn.__name__} | {fn.__file__}")

        # Add fn entrypoint
        _fn_traces_entrypoints[fn.__name__] = trace_sample_rate

        # Check for coroutines and return the right wrapper
        if asyncio.iscoroutinefunction(fn):

            @wraps(fn)
            async def wrapper(*args, **kwargs) -> Callable:
                return await fn(*args, **kwargs)

            return wrapper
        else:

            @wraps(fn)
            def wrapper(*args, **kwargs) -> Callable:
                return fn(*args, **kwargs)

            return wrapper

    return decorator


def sentry_error_rate(error_sample_rate: float = 0.0) -> Callable:
    """Decorator to set the sentry error rate for a specific endpoint.
    This is useful for endpoints that are called very frequently,
    and we don't want to report all errors.
    Args:
         error_sample_rate (float): The rate to sample errors. 0.0 to disable errors.
    """

    def decorator(fn: Callable) -> Callable:
        # Assert there is not twice function with the same nam
        if fn.__name__ in _fn_errors_entrypoints:
            raise ValueError(f"Two function have the same name: {fn.__name__} | {fn.__file__}")

        # Add fn entrypoint
        _fn_errors_entrypoints[fn.__name__] = error_sample_rate

        # Check for coroutines and return the right wrapper
        if asyncio.iscoroutinefunction(fn):

            @wraps(fn)
            async def wrapper(*args, **kwargs) -> Callable:
                return await fn(*args, **kwargs)

            return wrapper
        else:

            @wraps(fn)
            def wrapper(*args, **kwargs) -> Callable:
                return fn(*args, **kwargs)

            return wrapper

    return decorator


def register_traces_disabler(router: APIRouter) -> None:
    """Register all the entrypoints for the traces disabler
    Args:
        router (APIRouter): The router to register
    """
    for route in router.routes:
        if route.name in _fn_traces_entrypoints:
            _route_traces_entrypoints[route.path] = _fn_traces_entrypoints[route.name]


def register_errors_disabler(router: APIRouter) -> None:
    """Register all the entrypoints for the errors disabler
    Args:
        router (APIRouter): The router to register
    """
    for route in router.routes:
        if route.name in _fn_errors_entrypoints:
            _route_errors_entrypoints[route.path] = _fn_errors_entrypoints[route.name]


class TracesSampler:
    """Class to sample traces for sentry
    Args:
        default_traces_sample_rate (float, optional): The default sample rate for traces.
            Defaults to 1.0.
    """

    def __init__(self, default_traces_sample_rate: float = 1.0) -> None:
        self.default_traces_sample_rate = default_traces_sample_rate

    def __call__(self, sampling_context) -> float:
        return _route_traces_entrypoints.get(sampling_context["asgi_scope"]["path"], self.default_traces_sample_rate)


class BeforeSend:
    """Class to sample event before sending them to sentry
    Args:
        default_errors_sample_rate (float, optional): The default sample rate for errors.
            Defaults to 1.0.
    """

    def __init__(self, default_errors_sample_rate: float = 1.0) -> None:
        self.default_errors_sample_rate = default_errors_sample_rate

    def __call__(self, event: dict, hint: dict) -> Union[dict, None]:
        # Get the sample rate for this route, or use the default if it's not defined
        sample_rate = _route_errors_entrypoints.get(event["transaction"], self.default_errors_sample_rate)

        # Generate a random number between 0 and 1, and discard the event if it's greater than the sample rate
        if random.random() > sample_rate:
            return None

        # Return the event if it should be captured
        return event

I have then ro register some routes:

@router.get("/route")
@sentry_wrapper.sentry_trace_rate(trace_sample_rate=0.5) # limit traces to 50%
@sentry_wrapper.sentry_error_rate(error_sample_rate=0.25) # limit error to 25%
def route_fn():
    pass

And don’t forget to register each route at the end of the file:

from app.services.sentry_wrapper import register_errors_disabler, register_traces_disabler

register_traces_disabler(router)
register_errors_disabler(router)
Answered By: Yohann L.
Categories: questions Tags: , ,
Answers are sorted by their score. The answer accepted by the question owner as the best is marked with
at the top-right corner.