Skip to content

feat(loguru): Sentry logs for Loguru #4445

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 27 commits into from
Jun 10, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,7 @@
sentry_sdk.init(
dsn="...",
_experiments={
"enable_sentry_logs": True
"enable_logs": True
}
integrations=[
LoggingIntegration(sentry_logs_level=logging.ERROR),
Expand Down
37 changes: 16 additions & 21 deletions sentry_sdk/integrations/logging.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@

import sentry_sdk
from sentry_sdk.client import BaseClient
from sentry_sdk.logger import _log_level_to_otel
from sentry_sdk.utils import (
safe_repr,
to_string,
Expand All @@ -14,7 +15,7 @@
)
from sentry_sdk.integrations import Integration

from typing import TYPE_CHECKING, Tuple
from typing import TYPE_CHECKING

if TYPE_CHECKING:
from collections.abc import MutableMapping
Expand All @@ -36,6 +37,16 @@
logging.CRITICAL: "fatal", # CRITICAL is same as FATAL
}

# Map logging level numbers to corresponding OTel level numbers
SEVERITY_TO_OTEL_SEVERITY = {
logging.CRITICAL: 21, # fatal
logging.ERROR: 17, # error
logging.WARNING: 13, # warn
logging.INFO: 9, # info
logging.DEBUG: 5, # debug
}


# Capturing events from those loggers causes recursion errors. We cannot allow
# the user to unconditionally create events from those loggers under any
# circumstances.
Expand Down Expand Up @@ -312,21 +323,6 @@ def _breadcrumb_from_record(self, record):
}


def _python_level_to_otel(record_level):
# type: (int) -> Tuple[int, str]
for py_level, otel_severity_number, otel_severity_text in [
(50, 21, "fatal"),
(40, 17, "error"),
(30, 13, "warn"),
(20, 9, "info"),
(10, 5, "debug"),
(5, 1, "trace"),
Copy link
Contributor Author

@sentrivana sentrivana Jun 10, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This (5, 1, "trace") is now missing from the new mapping in SEVERITY_TO_OTEL_SEVERITY. There is no level 5 in the stdlib logging library

]:
if record_level >= py_level:
return otel_severity_number, otel_severity_text
return 0, "default"


class SentryLogsHandler(_BaseHandler):
"""
A logging handler that records Sentry logs for each Python log record.
Expand All @@ -352,7 +348,9 @@ def emit(self, record):

def _capture_log_from_record(self, client, record):
# type: (BaseClient, LogRecord) -> None
otel_severity_number, otel_severity_text = _python_level_to_otel(record.levelno)
otel_severity_number, otel_severity_text = _log_level_to_otel(
record.levelno, SEVERITY_TO_OTEL_SEVERITY
)
project_root = client.options["project_root"]
attrs = self._extra_from_record(record) # type: Any
attrs["sentry.origin"] = "auto.logger.log"
Expand All @@ -363,10 +361,7 @@ def _capture_log_from_record(self, client, record):
for i, arg in enumerate(record.args):
attrs[f"sentry.message.parameter.{i}"] = (
arg
if isinstance(arg, str)
or isinstance(arg, float)
or isinstance(arg, int)
or isinstance(arg, bool)
if isinstance(arg, (str, float, int, bool))
else safe_repr(arg)
)
if record.lineno:
Expand Down
121 changes: 102 additions & 19 deletions sentry_sdk/integrations/loguru.py
Original file line number Diff line number Diff line change
@@ -1,22 +1,27 @@
import enum

import sentry_sdk
from sentry_sdk.integrations import Integration, DidNotEnable
from sentry_sdk.integrations.logging import (
BreadcrumbHandler,
EventHandler,
_BaseHandler,
)
from sentry_sdk.logger import _log_level_to_otel

from typing import TYPE_CHECKING

if TYPE_CHECKING:
from logging import LogRecord
from typing import Optional, Any
from typing import Any, Optional

try:
import loguru
from loguru import logger
from loguru._defaults import LOGURU_FORMAT as DEFAULT_FORMAT

if TYPE_CHECKING:
from loguru import Message
except ImportError:
raise DidNotEnable("LOGURU is not installed")

Expand All @@ -31,6 +36,10 @@ class LoggingLevels(enum.IntEnum):
CRITICAL = 50


DEFAULT_LEVEL = LoggingLevels.INFO.value
DEFAULT_EVENT_LEVEL = LoggingLevels.ERROR.value


SENTRY_LEVEL_FROM_LOGURU_LEVEL = {
"TRACE": "DEBUG",
"DEBUG": "DEBUG",
Expand All @@ -41,8 +50,16 @@ class LoggingLevels(enum.IntEnum):
"CRITICAL": "CRITICAL",
}

DEFAULT_LEVEL = LoggingLevels.INFO.value
DEFAULT_EVENT_LEVEL = LoggingLevels.ERROR.value
# Map Loguru level numbers to corresponding OTel level numbers
SEVERITY_TO_OTEL_SEVERITY = {
LoggingLevels.CRITICAL: 21, # fatal
LoggingLevels.ERROR: 17, # error
LoggingLevels.WARNING: 13, # warn
LoggingLevels.SUCCESS: 11, # info
LoggingLevels.INFO: 9, # info
LoggingLevels.DEBUG: 5, # debug
LoggingLevels.TRACE: 1, # trace
}


class LoguruIntegration(Integration):
Expand All @@ -52,19 +69,22 @@ class LoguruIntegration(Integration):
event_level = DEFAULT_EVENT_LEVEL # type: Optional[int]
breadcrumb_format = DEFAULT_FORMAT
event_format = DEFAULT_FORMAT
sentry_logs_level = DEFAULT_LEVEL # type: Optional[int]

def __init__(
self,
level=DEFAULT_LEVEL,
event_level=DEFAULT_EVENT_LEVEL,
breadcrumb_format=DEFAULT_FORMAT,
event_format=DEFAULT_FORMAT,
sentry_logs_level=DEFAULT_LEVEL,
):
# type: (Optional[int], Optional[int], str | loguru.FormatFunction, str | loguru.FormatFunction) -> None
# type: (Optional[int], Optional[int], str | loguru.FormatFunction, str | loguru.FormatFunction, Optional[int]) -> None
LoguruIntegration.level = level
LoguruIntegration.event_level = event_level
LoguruIntegration.breadcrumb_format = breadcrumb_format
LoguruIntegration.event_format = event_format
LoguruIntegration.sentry_logs_level = sentry_logs_level

@staticmethod
def setup_once():
Expand All @@ -83,8 +103,23 @@ def setup_once():
format=LoguruIntegration.event_format,
)

if LoguruIntegration.sentry_logs_level is not None:
logger.add(
loguru_sentry_logs_handler,
level=LoguruIntegration.sentry_logs_level,
)


class _LoguruBaseHandler(_BaseHandler):
def __init__(self, *args, **kwargs):
# type: (*Any, **Any) -> None
if kwargs.get("level"):
kwargs["level"] = SENTRY_LEVEL_FROM_LOGURU_LEVEL.get(
kwargs.get("level", ""), DEFAULT_LEVEL
)

super().__init__(*args, **kwargs)

def _logging_to_event_level(self, record):
# type: (LogRecord) -> str
try:
Expand All @@ -98,24 +133,72 @@ def _logging_to_event_level(self, record):
class LoguruEventHandler(_LoguruBaseHandler, EventHandler):
"""Modified version of :class:`sentry_sdk.integrations.logging.EventHandler` to use loguru's level names."""

def __init__(self, *args, **kwargs):
# type: (*Any, **Any) -> None
if kwargs.get("level"):
kwargs["level"] = SENTRY_LEVEL_FROM_LOGURU_LEVEL.get(
kwargs.get("level", ""), DEFAULT_LEVEL
)

super().__init__(*args, **kwargs)
pass


class LoguruBreadcrumbHandler(_LoguruBaseHandler, BreadcrumbHandler):
"""Modified version of :class:`sentry_sdk.integrations.logging.BreadcrumbHandler` to use loguru's level names."""

def __init__(self, *args, **kwargs):
# type: (*Any, **Any) -> None
if kwargs.get("level"):
kwargs["level"] = SENTRY_LEVEL_FROM_LOGURU_LEVEL.get(
kwargs.get("level", ""), DEFAULT_LEVEL
)
pass

super().__init__(*args, **kwargs)

def loguru_sentry_logs_handler(message):
# type: (Message) -> None
# This is intentionally a callable sink instead of a standard logging handler
# since otherwise we wouldn't get direct access to message.record
client = sentry_sdk.get_client()

if not client.is_active():
return

if not client.options["_experiments"].get("enable_logs", False):
return

record = message.record

if (
LoguruIntegration.sentry_logs_level is None
or record["level"].no < LoguruIntegration.sentry_logs_level
):
return

otel_severity_number, otel_severity_text = _log_level_to_otel(
record["level"].no, SEVERITY_TO_OTEL_SEVERITY
)

attrs = {"sentry.origin": "auto.logger.loguru"} # type: dict[str, Any]

project_root = client.options["project_root"]
if record.get("file"):
if project_root is not None and record["file"].path.startswith(project_root):
attrs["code.file.path"] = record["file"].path[len(project_root) + 1 :]
else:
attrs["code.file.path"] = record["file"].path

if record.get("line") is not None:
attrs["code.line.number"] = record["line"]

if record.get("function"):
attrs["code.function.name"] = record["function"]

if record.get("thread"):
attrs["thread.name"] = record["thread"].name
attrs["thread.id"] = record["thread"].id

if record.get("process"):
attrs["process.pid"] = record["process"].id
attrs["process.executable.name"] = record["process"].name

if record.get("name"):
attrs["logger.name"] = record["name"]

client._capture_experimental_log(
{
"severity_text": otel_severity_text,
"severity_number": otel_severity_number,
"body": record["message"],
"attributes": attrs,
"time_unix_nano": int(record["time"].timestamp() * 1e9),
"trace_id": None,
}
)
29 changes: 29 additions & 0 deletions sentry_sdk/logger.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,17 @@
from sentry_sdk import get_client
from sentry_sdk.utils import safe_repr

OTEL_RANGES = [
# ((severity level range), severity text)
# https://opentelemetry.io/docs/specs/otel/logs/data-model
((1, 4), "trace"),
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[optional suggestion] This is completely fine as is, but if you want, you could make all of these into ranges, then rather than manually checking if the severity number is in between the bounds, you would just check if it is in the range.

Suggested change
((1, 4), "trace"),
(range(1, 5), "trace"),

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd prefer to keep as is, since that way the ranges correspond to OTel levels exactly as they're written in code (i.e., you don't need to make the extra effort in your head to decrement the last number of each range by one to arrive at the actual range)

((5, 8), "debug"),
((9, 12), "info"),
((13, 16), "warn"),
((17, 20), "error"),
((21, 24), "fatal"),
]


def _capture_log(severity_text, severity_number, template, **kwargs):
# type: (str, int, str, **Any) -> None
Expand Down Expand Up @@ -52,3 +63,21 @@ def _capture_log(severity_text, severity_number, template, **kwargs):
warning = functools.partial(_capture_log, "warn", 13)
error = functools.partial(_capture_log, "error", 17)
fatal = functools.partial(_capture_log, "fatal", 21)


def _otel_severity_text(otel_severity_number):
# type: (int) -> str
for (lower, upper), severity in OTEL_RANGES:
if lower <= otel_severity_number <= upper:
return severity
Comment on lines +70 to +72
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If you take my suggestion about ranges

Suggested change
for (lower, upper), severity in OTEL_RANGES:
if lower <= otel_severity_number <= upper:
return severity
for severity_range, severity in OTEL_RANGES:
if otel_severity_number in severity_range:
return severity


return "default"


def _log_level_to_otel(level, mapping):
# type: (int, dict[Any, int]) -> tuple[int, str]
for py_level, otel_severity_number in sorted(mapping.items(), reverse=True):
if level >= py_level:
return otel_severity_number, _otel_severity_text(otel_severity_number)

return 0, "default"
Loading
Loading