sentry-python icon indicating copy to clipboard operation
sentry-python copied to clipboard

Make the `structlog` integration for Sentry logs

Open sector119 opened this issue 6 months ago • 4 comments

Problem Statement

structlog logs can be sent to Sentry via the existing LoggingIntegration; however, this does not preserve the structuring of the logs (e.g. if the logs have custom attributes set, these are included in the log message). See this example

Solution Brainstorm

Instead, create a dedicated structlog integration, which would add these logs to the log event as custom attributes.

sector119 avatar May 26 '25 13:05 sector119

Hi @sector119, thank you for raising this issue.

While we would be open to considering adding a structlog integration, it is already possible to get structlog to work by calling structlog.stdlib.recreate_defaults() near the beginning of your application's lifecycle, ideally right after the sentry_sdk.init() call.

Calling structlog.stdlib.recreate_defaults() causes all of your structlog logs to be routed through Python's default logging library. That way, the Sentry SDK's logging integration can pick them up.

Here is an example of how you can get your structlog logs to show up in Sentry.

import sentry_sdk
import structlog


def main():
    sentry_sdk.init(
        dsn="your dsn here",
        _experiments={
            "enable_logs": True,
        },
    )
    structlog.stdlib.recreate_defaults()  # Add this call
    logger = structlog.get_logger()
    logger.info("Hello, world!")  # This gets logged in Sentry


if __name__ == "__main__":
    main()

Does this solution work for you? If yes, please close the issue. If not, please explain why this does not work, and we will use that information to inform our decision on whether to create a dedicated structlog integration.

szokeasaurusrex avatar May 26 '25 13:05 szokeasaurusrex

Does this solution work for you? If yes, please close the issue. If not, please explain why this does not work, and we will use that information to inform our decision on whether to create a dedicated structlog integration.

Thanks a lot, it works for me, but that is not what I exactly want to get.

for example I perform

logger.error("Account not found", person_id=person_id, street_id=street_id, building=building, apartment=apartment)

with structlog json formatter I get message like this, so I have message and arguments logged separately

{"person_id":2036273,"street_id":null,"building":null,"apartment":null,"event":"Account not found","level":"error","logger":"epsilon-xmlrpc","timestamp":"2025-05-27T00:05:19.820515","sentry_id":"f54adb5462254e0fb42111e3d3149452"}

I thought that with sentry logging I would get something similar, so I would be able to search by all that args

Right now I just have message with all args in the same line (

2025-05-27 00:01:20 [error ] Account not found [epsilon-xmlrpc] apartment=None building=None person_id=2036273 street_id=None

sector119 avatar May 26 '25 21:05 sector119

Ah okay, I understand now! Basically, you would like us to attach this information as attributes onto the log that we send to Sentry.

I believe this is a completely valid request and should be possible for us to implement, so I will add this to our backlog as a feature request.

szokeasaurusrex avatar May 27 '25 08:05 szokeasaurusrex

Ah okay, I understand now! Basically, you would like us to attach this information as attributes onto the log that we send to Sentry.

Yes, as attributes, hints or something similar that we can search by

Thank you

sector119 avatar May 27 '25 09:05 sector119

The integration would work differently to integrations for logging or loguru because structlog is a framework for transforming structured logs before they are handled by a wrapped logger. The wrapped logger could be provided by logging, loguru, be one of structlog's own implementations or come from anywhere else.

Structlog calls the wrapper class a bound logger. It does not seem straightforward to hook Sentry up in the bound logger, because the available logging methods ultimately depend on the wrapped logger. See https://www.structlog.org/en/stable/typing.html.

Logs can be transformed by structlog by a chain of processors. The logging severity is only indirectly available in the processors via the method name invoked on the wrapped logger. See https://www.structlog.org/en/stable/processors.html

The last processor is referred to as a renderer in the structlog docs. The structlog.processors.JSONRenderer returns a string. The attributes are therefore lost before reaching the wrapped logger.

One option is providing a SentryProcessor users can inject at a sensible place in their processing chain. The best place in the chain is likely the processor immediately before the structlog's event dictionary is turned into a string by one of the user's processors. The problem with the approach is that the severity is not available in the processing chain, and Sentry logs require a severity field.

alexander-alderman-webb avatar Sep 04 '25 11:09 alexander-alderman-webb

If it helps, I ended up writing my own integration for Sentry logging for Structlog. I initially tried processors but that ended up having issues, I think it was also reading the logs in from stdout and duplicating them in Sentry. I ended up using a handler and that seems to be working pretty well for me.

import logging

from sentry_sdk.logger import _capture_log

SEVERITY_NUMBER = {
    "trace": 1,
    "debug": 5,
    "info": 9,
    "warning": 13,
    "error": 17,
    "fatal": 21,
}


class SentryHandler(logging.Handler):
    def emit(self, record: logging.LogRecord) -> None:
        if not isinstance(record.msg, dict):
            return

        kwargs = dict(record.msg)
        level = kwargs.pop("level")
        # _capture_log will try to .format the string so escape any curly braces
        event = str(kwargs.pop("event")).replace("{", "{{").replace("}", "}}") 
        _capture_log(level, SEVERITY_NUMBER[level], event, **kwargs)

This was in Django so I then configured my logs something like this

LOGGING = {
    "version": 1,
    "disable_existing_loggers": False,
    "filters": {
        "filter_uptime": {
            "()": "my_project.logging.ExcludeRequestPathFilter",
            "exclude_request_path": "/uptime",
        },
    },
    "formatters": {
        "json_formatter": {
            "()": structlog.stdlib.ProcessorFormatter,
            "processor": structlog.processors.JSONRenderer(),
            "foreign_pre_chain": [
                structlog.processors.TimeStamper(fmt="iso"),
                structlog.processors.StackInfoRenderer(),
                structlog.processors.format_exc_info,  # Capture full traceback
            ],
        },
    },
    "handlers": {
        "console": {
            "class": "logging.StreamHandler",
            "formatter": "json_formatter",
            "filters": ["filter_uptime"]
        },
        "sentry": {
            "class": "my_project.sentry_logging.SentryHandler",
            "filters": ["filter_uptime"]
        },
    },
    "loggers": {
        "django_structlog": {
            "handlers": ["console", "sentry"],
            "level": "INFO",
            "filters": ["filter_uptime"]
        },
        "django": {
            "handlers": ["console", "sentry"],
            "level": "INFO",
            "filters": ["filter_uptime"]
        },
        ...
    },
}

This had the added benefit of working with any filters we applied to the logging too, so we could filter out logs from things like our uptime probe or Prometheus integration

pmdevita avatar Sep 04 '25 14:09 pmdevita