Repository: getsentry/sentry-python

Issue:
Add support of loguru logger.
Please add support for [loguru](https://github.com/Delgan/loguru). I can make pull request but I don't know how to correctly integrate structure loggers.


Discussion:
https://github.com/getsentry/sentry-python/issues/228 is very relevant for this, it's possible you will run into similar issues here. We are happy to accept and merge PRs for those things, generally speaking
Hi! I just wanted to chime in with my solution here. 
I solved this by following advice given in #228 as @untitaker pointed to :) ( Thanks @untitaker ! )

Basically, all I did was this:

```python
from loguru import logger
from sentry_sdk.integrations.logging import (
    LoggingIntegration,
    BreadcrumbHandler,
    EventHandler,
)


.
.
.


if settings.sentry_dsn:
    _ = logger.add(
        BreadcrumbHandler(level=logging.DEBUG),
            diagnose=settings.loguru_diagnose,
            level=logging.DEBUG,
    )

    _ = logger.add(
        EventHandler(level=logging.ERROR),
            diagnose=settings.loguru_diagnose,
            level=logging.ERROR,
    )

    integrations = [
        LoggingIntegration(level=None, event_level=None),
    ]
```

I also work with a lib where I don't have much control over their use of the built-in logger, and so I have this snippet as well to intercept the built-in logger and route everything through loguru ( from the excellent loguru docs! ):

```python
class InterceptHandler(logging.Handler):
    """Intercepts builtin logging messages and routes them to Loguru"""

    def emit(self, record):
        # Get corresponding Loguru level if it exists
        try:
            level = logger.level(record.levelname).name
        except ValueError:
            level = record.levelno

        # Find caller from where originated the logged message
        frame, depth = logging.currentframe(), 2
        while frame.f_code.co_filename == logging.__file__:
            frame = frame.f_back
            depth += 1
        logger.opt(depth=depth, exception=record.exc_info).log(
            level, record.getMessage()
        )


# Enable interceptor
logging.basicConfig(handlers=[InterceptHandler()], level=0)
``` 
Hello guys, we been using loguru for a while and I believe it would be a good option to integrate with Sentry
 @jtfidje where the `settings ` are coming from in your example ?
> @jtfidje where the `settings ` are coming from in your example ?

settings is an instance of a Settings-class that inherits from `pydantic.BaseSettings`:

```python
from pydantic import BaseSettings
class Settings(BaseSettings):
    """My settings!"""
    .
    .
    .
```

I use this a lot to configure applications

This issue has gone three weeks without activity. In another week, I will close it.

But! If you comment or otherwise update it, I will reset the clock, and if you label it `Status: Backlog` or `Status: In Progress`, I will leave it alone ... forever!

----

"A weed is but an unloved flower." ― _Ella Wheeler Wilcox_ 🥀
> I also work with a lib where I don't have much control over their use of the built-in logger, and so I have this snippet as well to intercept the built-in logger and route everything through loguru ( from the excellent loguru docs! ):

@jtfidje I'm seeing some issues with what I believe how InterceptHandler is working - I posted a description here (https://github.com/Delgan/loguru/issues/344#issuecomment-1013900054). In short, I'm seeing `sentry_sdk.integrations.logging:86 (logging) | Test info msg` insted of `__main__:42 (t) | Test info msg`. 
Hey guys!

Sorry for not getting back to you. This is amazing work so far:
- So we have two PR on logurus Repo by @TheZavitaev and @Delgan
- And we have code here in this thread from @jtfidje 
- And there is also discussions about JSON logs that are related over here: https://github.com/getsentry/sentry-python/issues/228

My question now is, how do we get this into a PR of the sentry-python repo?
If we have a PR I am happy to test it and help bringing it into a shape we can merge.

We do hat a checklist on how an Integration could/should look like:
https://github.com/getsentry/sentry-python/blob/master/CONTRIBUTING.md#adding-a-new-integration-checklist

And also one could use the `logging` integration as kind of a template:
https://github.com/getsentry/sentry-python/blob/master/sentry_sdk/integrations/logging.py

So anyone motivated? (I think if we pull this off, I can organize Sentry swag for everyone)
@antonpirker Sounds fun! I'll try to help where I can :)

Swag sounds very nice! O:-)
Hey @jtfidje !
Great to here you are in to help! 

Could you look at all the information we have in this thread and see if the PRs and your code here can be merged into one thing that addresses all the problems?
As the Loguru developer, I would like Loguru and Sentry to be seamlessly compatible! :+1: 

Basically the `InterceptHandler` is supposed to be attached to some standard `logger` object (possibly using `logging.basicConfig()`). It works fine most of the time, but there are some difficulties with Sentry. As discussed, it requires playing with `default_integrations=False`, `BreadcrumbHandler` and `EventHandler` which is not very intuitive. I don't know how it could be improved, but let me know if you need details about logging integration with Loguru. ;)
Hey @Delgan!

Thanks for the info and the offer to help. 

So you already created an sort of Integration for Loguru with Sentry? Is this anything that you could put into a pull request?

I have put this issue on the low prio internal backlog and also into a milestone. I don't yet know when we (Sentry) will have time to help because we are currently packed with other stuff. 
This issue has gone three weeks without activity. In another week, I will close it.

But! If you comment or otherwise update it, I will reset the clock, and if you label it `Status: Backlog` or `Status: In Progress`, I will leave it alone ... forever!

----

"A weed is but an unloved flower." ― _Ella Wheeler Wilcox_ 🥀
Hey @antonpirker, sorry for the late answer. Time flies...

Honestly I would not know which PR to open. I'm not a Sentry user myself, so I'm not in the best position to know how to improve integration with Loguru. I've seen that for now, it requires touching different Sentry configuration (`default_integrations=False`, `BreadcrumbHandler` and `EventHandler`) but I do not know the logic behind it.

I'm still willing to help if anyone have interest in investigating this. :+1:
Ok, thanks for the feedback.
I will put it on the internal backlog in low prio (so it will take some time until we get a chance to look at this)

Hi, any update? We just deployed sentry in our project and thinking about using loguru as logger.

Resolving patch:
diff --git a/sentry_sdk/integrations/logging.py b/sentry_sdk/integrations/logging.py
--- a/sentry_sdk/integrations/logging.py
+++ b/sentry_sdk/integrations/logging.py
@@ -107,75 +107,61 @@ def sentry_patched_callhandlers(self, record):
         logging.Logger.callHandlers = sentry_patched_callhandlers  # type: ignore
 
 
-def _can_record(record):
-    # type: (LogRecord) -> bool
-    """Prevents ignored loggers from recording"""
-    for logger in _IGNORED_LOGGERS:
-        if fnmatch(record.name, logger):
-            return False
-    return True
-
-
-def _breadcrumb_from_record(record):
-    # type: (LogRecord) -> Dict[str, Any]
-    return {
-        "type": "log",
-        "level": _logging_to_event_level(record),
-        "category": record.name,
-        "message": record.message,
-        "timestamp": datetime.datetime.utcfromtimestamp(record.created),
-        "data": _extra_from_record(record),
-    }
-
-
-def _logging_to_event_level(record):
-    # type: (LogRecord) -> str
-    return LOGGING_TO_EVENT_LEVEL.get(
-        record.levelno, record.levelname.lower() if record.levelname else ""
+class _BaseHandler(logging.Handler, object):
+    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",
+            "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 ""
+        )
 
-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",
-        "thread",
-        "threadName",
-        "stack_info",
-    )
-)
-
-
-def _extra_from_record(record):
-    # type: (LogRecord) -> Dict[str, None]
-    return {
-        k: v
-        for k, v in iteritems(vars(record))
-        if k not in COMMON_RECORD_ATTRS
-        and (not isinstance(k, str) or not k.startswith("_"))
-    }
+    def _extra_from_record(self, record):
+        # type: (LogRecord) -> Dict[str, None]
+        return {
+            k: v
+            for k, v in iteritems(vars(record))
+            if k not in self.COMMON_RECORD_ATTRS
+            and (not isinstance(k, str) or not k.startswith("_"))
+        }
 
 
-class EventHandler(logging.Handler, object):
+class EventHandler(_BaseHandler):
     """
     A logging handler that emits Sentry events for each log record
 
@@ -190,7 +176,7 @@ def emit(self, record):
 
     def _emit(self, record):
         # type: (LogRecord) -> None
-        if not _can_record(record):
+        if not self._can_record(record):
             return
 
         hub = Hub.current
@@ -232,7 +218,7 @@ def _emit(self, record):
 
         hint["log_record"] = record
 
-        event["level"] = _logging_to_event_level(record)
+        event["level"] = self._logging_to_event_level(record)
         event["logger"] = record.name
 
         # Log records from `warnings` module as separate issues
@@ -255,7 +241,7 @@ def _emit(self, record):
                 "params": record.args,
             }
 
-        event["extra"] = _extra_from_record(record)
+        event["extra"] = self._extra_from_record(record)
 
         hub.capture_event(event, hint=hint)
 
@@ -264,7 +250,7 @@ def _emit(self, record):
 SentryHandler = EventHandler
 
 
-class BreadcrumbHandler(logging.Handler, object):
+class BreadcrumbHandler(_BaseHandler):
     """
     A logging handler that records breadcrumbs for each log record.
 
@@ -279,9 +265,20 @@ def emit(self, record):
 
     def _emit(self, record):
         # type: (LogRecord) -> None
-        if not _can_record(record):
+        if not self._can_record(record):
             return
 
         Hub.current.add_breadcrumb(
-            _breadcrumb_from_record(record), hint={"log_record": record}
+            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.datetime.utcfromtimestamp(record.created),
+            "data": self._extra_from_record(record),
+        }
diff --git a/sentry_sdk/integrations/loguru.py b/sentry_sdk/integrations/loguru.py
new file mode 100644
--- /dev/null
+++ b/sentry_sdk/integrations/loguru.py
@@ -0,0 +1,89 @@
+from __future__ import absolute_import
+
+import enum
+
+from sentry_sdk._types import TYPE_CHECKING
+from sentry_sdk.integrations import Integration, DidNotEnable
+from sentry_sdk.integrations.logging import (
+    BreadcrumbHandler,
+    EventHandler,
+    _BaseHandler,
+)
+
+if TYPE_CHECKING:
+    from logging import LogRecord
+    from typing import Optional, Tuple
+
+try:
+    from loguru import logger
+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):
+        # type: (Optional[int], Optional[int]) -> 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
+            )
+
+        if event_level is not None:
+            event_handler = logger.add(
+                LoguruEventHandler(level=event_level), level=event_level
+            )
+
+        _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."""
diff --git a/setup.py b/setup.py
--- a/setup.py
+++ b/setup.py
@@ -68,7 +68,8 @@ def get_file_text(file_name):
         "fastapi": ["fastapi>=0.79.0"],
         "pymongo": ["pymongo>=3.1"],
         "opentelemetry": ["opentelemetry-distro>=0.35b0"],
-        "grpcio": ["grpcio>=1.21.1"]
+        "grpcio": ["grpcio>=1.21.1"],
+        "loguru": ["loguru>=0.5"],
     },
     classifiers=[
         "Development Status :: 5 - Production/Stable",


Test patch:
diff --git a/tests/integrations/loguru/__init__.py b/tests/integrations/loguru/__init__.py
new file mode 100644
--- /dev/null
+++ b/tests/integrations/loguru/__init__.py
@@ -0,0 +1,3 @@
+import pytest
+
+pytest.importorskip("loguru")
diff --git a/tests/integrations/loguru/test_loguru.py b/tests/integrations/loguru/test_loguru.py
new file mode 100644
--- /dev/null
+++ b/tests/integrations/loguru/test_loguru.py
@@ -0,0 +1,77 @@
+import pytest
+from loguru import logger
+
+import sentry_sdk
+from sentry_sdk.integrations.loguru import LoguruIntegration, LoggingLevels
+
+logger.remove(0)  # don't print to console
+
+
+@pytest.mark.parametrize(
+    "level,created_event",
+    [
+        # None - no breadcrumb
+        # False - no event
+        # True - event created
+        (LoggingLevels.TRACE, None),
+        (LoggingLevels.DEBUG, None),
+        (LoggingLevels.INFO, False),
+        (LoggingLevels.SUCCESS, False),
+        (LoggingLevels.WARNING, False),
+        (LoggingLevels.ERROR, True),
+        (LoggingLevels.CRITICAL, True),
+    ],
+)
+@pytest.mark.parametrize("disable_breadcrumbs", [True, False])
+@pytest.mark.parametrize("disable_events", [True, False])
+def test_just_log(
+    sentry_init,
+    capture_events,
+    level,
+    created_event,
+    disable_breadcrumbs,
+    disable_events,
+):
+    sentry_init(
+        integrations=[
+            LoguruIntegration(
+                level=None if disable_breadcrumbs else LoggingLevels.INFO.value,
+                event_level=None if disable_events else LoggingLevels.ERROR.value,
+            )
+        ],
+        default_integrations=False,
+    )
+    events = capture_events()
+
+    getattr(logger, level.name.lower())("test")
+
+    formatted_message = (
+        " | "
+        + "{:9}".format(level.name.upper())
+        + "| tests.integrations.loguru.test_loguru:test_just_log:46 - test"
+    )
+
+    if not created_event:
+        assert not events
+
+        breadcrumbs = sentry_sdk.Hub.current.scope._breadcrumbs
+        if (
+            not disable_breadcrumbs and created_event is not None
+        ):  # not None == not TRACE or DEBUG level
+            (breadcrumb,) = breadcrumbs
+            assert breadcrumb["level"] == level.name.lower()
+            assert breadcrumb["category"] == "tests.integrations.loguru.test_loguru"
+            assert breadcrumb["message"][23:] == formatted_message
+        else:
+            assert not breadcrumbs
+
+        return
+
+    if disable_events:
+        assert not events
+        return
+
+    (event,) = events
+    assert event["level"] == (level.name.lower())
+    assert event["logger"] == "tests.integrations.loguru.test_loguru"
+    assert event["logentry"]["message"][23:] == formatted_message