Skip to content

Skip Queryset serialization for any querysets embedded in the stack frame f_locals. #4606

@jvineet

Description

@jvineet

Problem Statement

Sentry tried to serialize a queryset (as part of stack frame locals) which resulted in multiple expensive queries to the db over and over while serializing stack frames that almost knocked over one of our production databases. Here is what happened:

Django ModelAdmin adds a QuerySet to the django log table as part of the request context (or template context, definitely one of the two) in the _changeform_view method

https://github.com/django/django/blob/5.1.6/django/contrib/admin/options.py#L1928
https://github.com/django/django/blob/5.1.6/django/contrib/admin/sites.py#L341

This context then ends up in sentry as part of the frame locals for each frame that captured this context. Sentry then ends up calling this queryset for every frame serialization step, which makes a new call to the admin log table with multiple joins (one inner and one outer).

SELECT django_admin_log.id, django_admin_log.action_time, django_admin_log.user_id,
  django_admin_log.content_type_id, django_admin_log.object_id, django_admin_log.object_repr,
  django_admin_log.action_flag, django_admin_log.change_message, auth_user.id, auth_user.password,
  auth_user.last_login, auth_user.is_superuser, auth_user.username, auth_user.first_name,
  auth_user.last_name, auth_user.email, auth_user.is_staff, auth_user.is_active, auth_user.date_joined,
   django_content_type.id, django_content_type.app_label, django_content_type.model
FROM django_admin_log
INNER JOIN auth_user ON (django_admin_log.user_id = auth_user.id)
LEFT OUTER JOIN django_content_type ON (django_admin_log.content_type_id = django_content_type.id)
ORDER BY django_admin_log.action_time DESC
LIMIT 21

The stack traces are ginormous, over 70 frames deep, so it ends up making ~70+ of these expensive queries to the db, adding a lot of load to it. And then 2x this value, since we had reported recently to the sentry team that sentry logging.exception serialization often happens twice, so we were probably making ~140 expensive db calls!! This is what almost knocked over our database.

https://github.com/getsentry/sentry-python/blob/2.33.1/sentry_sdk/utils.py#L615-L617

I should mention that this queryset is not at the top level in the context dict, but buried at least 3 levels down (frame.f_locals["context"].dicts[3]["log_entries"]). These db calls happen because QuerySets get evaluated whenever repr(some_queryset) is called on them as part of stack frame serialization. We also couldn't patch safe_repr in sentry as that calls repr() and then all dict objects are recursively called with repr() and I could not think of a way to have safe_repr be called recursively on all dict elements, without something invasive like patching builtin.repr() or QuerySet.__repr__.

https://docs.djangoproject.com/en/5.2/ref/models/querysets/#when-querysets-are-evaluated

For reference here is sentry trace when this happened. Notice how it took us over 6 mins, just to serialization all of the stack frames (twice) because of all of the expensive db calls.

Image

here are some of the db spans from this trace for the 140+ db calls where each one took ~2.5s.
Image

Solution Brainstorm

For the time being, to get around this particular issue, we ended up creating a simple sentry integration, which patches the sentry serializer.serializefunction and adds a wrapper that strips out the the location which holds this particularQuerySetthat DjangoModelAdmin` had added to the context.

class SafeQuerySetIntegration(integrations.Integration):
    identifier = "safe_queryset_integration"

    @staticmethod
    def setup_once() -> None:
        """
        This method is called once by Sentry when the integration is initialized.
        """
        original_serialize = serializer.serialize

        def patched_serialize(event: dict[str, Any], **kwargs: Any) -> dict[str, Any]:
            # strip context dicts, if they are present, since that's where admin adds the
            # django_admin_log table QuerySets.
            if "context" in event and hasattr(event["context"], "dicts"):
                event["context"].dicts = ["<stripped>"]
            return original_serialize(event, **kwargs)

        serializer.serialize = patched_serialize
        logging.info("Sentry Integration: serialize has been patched via SafeQuerySetIntegration.")

While this works for now, what we are hoping for is a global way to tell sentry to skip any QuerySet serializations that it encounters while serializing the stack frames. I understand if there isn't any way to do this since django evaluating QuerySets as part of repr() definitely came as a surprise to us, and wasn't something we were expecting. I'd also be happy to find out if there is already a way to do this that I just couldn't quite figure out.

At one point I had thought about patching safe_repr with a wrapper and traverse through the entire data structure of the value passed over and replace and QuerySet instance with <QuerySet> str before passing the value over to the wrapped safe_repr function. But I am not sure if that is a good idea, because traversing the whole value data requires additional complexity and the data in theory could be deeply nested (maybe not an issue if sentry only serializes 3 or 4 levels of depth).

Metadata

Metadata

Assignees

Type

No type

Projects

Status

No status

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions