Skip to content

Commit 51db87c

Browse files
feat(loguru): Sentry logs for Loguru (#4445)
Allow to send Loguru logs to Sentry. We can't parametrize them nicely, but this is a good first step. Also: * Move some tests around. Tests specific to the stdlib logging integration were moved from the generic sentry logs tests to the logging integration tests. * Remove `@minimum_python_37` from some tests that don't need 3.7+. * Dedupe some code by moving it to a superclass (`_LoguruBaseHandler`) Closes #4151 --------- Co-authored-by: Daniel Szoke <[email protected]>
1 parent 1433ec2 commit 51db87c

File tree

7 files changed

+685
-240
lines changed

7 files changed

+685
-240
lines changed

CHANGELOG.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -122,7 +122,7 @@
122122
sentry_sdk.init(
123123
dsn="...",
124124
_experiments={
125-
"enable_sentry_logs": True
125+
"enable_logs": True
126126
}
127127
integrations=[
128128
LoggingIntegration(sentry_logs_level=logging.ERROR),

sentry_sdk/integrations/logging.py

Lines changed: 16 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55

66
import sentry_sdk
77
from sentry_sdk.client import BaseClient
8+
from sentry_sdk.logger import _log_level_to_otel
89
from sentry_sdk.utils import (
910
safe_repr,
1011
to_string,
@@ -14,7 +15,7 @@
1415
)
1516
from sentry_sdk.integrations import Integration
1617

17-
from typing import TYPE_CHECKING, Tuple
18+
from typing import TYPE_CHECKING
1819

1920
if TYPE_CHECKING:
2021
from collections.abc import MutableMapping
@@ -36,6 +37,16 @@
3637
logging.CRITICAL: "fatal", # CRITICAL is same as FATAL
3738
}
3839

40+
# Map logging level numbers to corresponding OTel level numbers
41+
SEVERITY_TO_OTEL_SEVERITY = {
42+
logging.CRITICAL: 21, # fatal
43+
logging.ERROR: 17, # error
44+
logging.WARNING: 13, # warn
45+
logging.INFO: 9, # info
46+
logging.DEBUG: 5, # debug
47+
}
48+
49+
3950
# Capturing events from those loggers causes recursion errors. We cannot allow
4051
# the user to unconditionally create events from those loggers under any
4152
# circumstances.
@@ -315,21 +326,6 @@ def _breadcrumb_from_record(self, record):
315326
}
316327

317328

318-
def _python_level_to_otel(record_level):
319-
# type: (int) -> Tuple[int, str]
320-
for py_level, otel_severity_number, otel_severity_text in [
321-
(50, 21, "fatal"),
322-
(40, 17, "error"),
323-
(30, 13, "warn"),
324-
(20, 9, "info"),
325-
(10, 5, "debug"),
326-
(5, 1, "trace"),
327-
]:
328-
if record_level >= py_level:
329-
return otel_severity_number, otel_severity_text
330-
return 0, "default"
331-
332-
333329
class SentryLogsHandler(_BaseHandler):
334330
"""
335331
A logging handler that records Sentry logs for each Python log record.
@@ -355,7 +351,9 @@ def emit(self, record):
355351

356352
def _capture_log_from_record(self, client, record):
357353
# type: (BaseClient, LogRecord) -> None
358-
otel_severity_number, otel_severity_text = _python_level_to_otel(record.levelno)
354+
otel_severity_number, otel_severity_text = _log_level_to_otel(
355+
record.levelno, SEVERITY_TO_OTEL_SEVERITY
356+
)
359357
project_root = client.options["project_root"]
360358
attrs = self._extra_from_record(record) # type: Any
361359
attrs["sentry.origin"] = "auto.logger.log"
@@ -366,10 +364,7 @@ def _capture_log_from_record(self, client, record):
366364
for i, arg in enumerate(record.args):
367365
attrs[f"sentry.message.parameter.{i}"] = (
368366
arg
369-
if isinstance(arg, str)
370-
or isinstance(arg, float)
371-
or isinstance(arg, int)
372-
or isinstance(arg, bool)
367+
if isinstance(arg, (str, float, int, bool))
373368
else safe_repr(arg)
374369
)
375370
if record.lineno:

sentry_sdk/integrations/loguru.py

Lines changed: 102 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,27 @@
11
import enum
22

3+
import sentry_sdk
34
from sentry_sdk.integrations import Integration, DidNotEnable
45
from sentry_sdk.integrations.logging import (
56
BreadcrumbHandler,
67
EventHandler,
78
_BaseHandler,
89
)
10+
from sentry_sdk.logger import _log_level_to_otel
911

1012
from typing import TYPE_CHECKING
1113

1214
if TYPE_CHECKING:
1315
from logging import LogRecord
14-
from typing import Optional, Any
16+
from typing import Any, Optional
1517

1618
try:
1719
import loguru
1820
from loguru import logger
1921
from loguru._defaults import LOGURU_FORMAT as DEFAULT_FORMAT
22+
23+
if TYPE_CHECKING:
24+
from loguru import Message
2025
except ImportError:
2126
raise DidNotEnable("LOGURU is not installed")
2227

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

3338

39+
DEFAULT_LEVEL = LoggingLevels.INFO.value
40+
DEFAULT_EVENT_LEVEL = LoggingLevels.ERROR.value
41+
42+
3443
SENTRY_LEVEL_FROM_LOGURU_LEVEL = {
3544
"TRACE": "DEBUG",
3645
"DEBUG": "DEBUG",
@@ -41,8 +50,16 @@ class LoggingLevels(enum.IntEnum):
4150
"CRITICAL": "CRITICAL",
4251
}
4352

44-
DEFAULT_LEVEL = LoggingLevels.INFO.value
45-
DEFAULT_EVENT_LEVEL = LoggingLevels.ERROR.value
53+
# Map Loguru level numbers to corresponding OTel level numbers
54+
SEVERITY_TO_OTEL_SEVERITY = {
55+
LoggingLevels.CRITICAL: 21, # fatal
56+
LoggingLevels.ERROR: 17, # error
57+
LoggingLevels.WARNING: 13, # warn
58+
LoggingLevels.SUCCESS: 11, # info
59+
LoggingLevels.INFO: 9, # info
60+
LoggingLevels.DEBUG: 5, # debug
61+
LoggingLevels.TRACE: 1, # trace
62+
}
4663

4764

4865
class LoguruIntegration(Integration):
@@ -52,19 +69,22 @@ class LoguruIntegration(Integration):
5269
event_level = DEFAULT_EVENT_LEVEL # type: Optional[int]
5370
breadcrumb_format = DEFAULT_FORMAT
5471
event_format = DEFAULT_FORMAT
72+
sentry_logs_level = DEFAULT_LEVEL # type: Optional[int]
5573

5674
def __init__(
5775
self,
5876
level=DEFAULT_LEVEL,
5977
event_level=DEFAULT_EVENT_LEVEL,
6078
breadcrumb_format=DEFAULT_FORMAT,
6179
event_format=DEFAULT_FORMAT,
80+
sentry_logs_level=DEFAULT_LEVEL,
6281
):
63-
# type: (Optional[int], Optional[int], str | loguru.FormatFunction, str | loguru.FormatFunction) -> None
82+
# type: (Optional[int], Optional[int], str | loguru.FormatFunction, str | loguru.FormatFunction, Optional[int]) -> None
6483
LoguruIntegration.level = level
6584
LoguruIntegration.event_level = event_level
6685
LoguruIntegration.breadcrumb_format = breadcrumb_format
6786
LoguruIntegration.event_format = event_format
87+
LoguruIntegration.sentry_logs_level = sentry_logs_level
6888

6989
@staticmethod
7090
def setup_once():
@@ -83,8 +103,23 @@ def setup_once():
83103
format=LoguruIntegration.event_format,
84104
)
85105

106+
if LoguruIntegration.sentry_logs_level is not None:
107+
logger.add(
108+
loguru_sentry_logs_handler,
109+
level=LoguruIntegration.sentry_logs_level,
110+
)
111+
86112

87113
class _LoguruBaseHandler(_BaseHandler):
114+
def __init__(self, *args, **kwargs):
115+
# type: (*Any, **Any) -> None
116+
if kwargs.get("level"):
117+
kwargs["level"] = SENTRY_LEVEL_FROM_LOGURU_LEVEL.get(
118+
kwargs.get("level", ""), DEFAULT_LEVEL
119+
)
120+
121+
super().__init__(*args, **kwargs)
122+
88123
def _logging_to_event_level(self, record):
89124
# type: (LogRecord) -> str
90125
try:
@@ -98,24 +133,72 @@ def _logging_to_event_level(self, record):
98133
class LoguruEventHandler(_LoguruBaseHandler, EventHandler):
99134
"""Modified version of :class:`sentry_sdk.integrations.logging.EventHandler` to use loguru's level names."""
100135

101-
def __init__(self, *args, **kwargs):
102-
# type: (*Any, **Any) -> None
103-
if kwargs.get("level"):
104-
kwargs["level"] = SENTRY_LEVEL_FROM_LOGURU_LEVEL.get(
105-
kwargs.get("level", ""), DEFAULT_LEVEL
106-
)
107-
108-
super().__init__(*args, **kwargs)
136+
pass
109137

110138

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

114-
def __init__(self, *args, **kwargs):
115-
# type: (*Any, **Any) -> None
116-
if kwargs.get("level"):
117-
kwargs["level"] = SENTRY_LEVEL_FROM_LOGURU_LEVEL.get(
118-
kwargs.get("level", ""), DEFAULT_LEVEL
119-
)
142+
pass
120143

121-
super().__init__(*args, **kwargs)
144+
145+
def loguru_sentry_logs_handler(message):
146+
# type: (Message) -> None
147+
# This is intentionally a callable sink instead of a standard logging handler
148+
# since otherwise we wouldn't get direct access to message.record
149+
client = sentry_sdk.get_client()
150+
151+
if not client.is_active():
152+
return
153+
154+
if not client.options["_experiments"].get("enable_logs", False):
155+
return
156+
157+
record = message.record
158+
159+
if (
160+
LoguruIntegration.sentry_logs_level is None
161+
or record["level"].no < LoguruIntegration.sentry_logs_level
162+
):
163+
return
164+
165+
otel_severity_number, otel_severity_text = _log_level_to_otel(
166+
record["level"].no, SEVERITY_TO_OTEL_SEVERITY
167+
)
168+
169+
attrs = {"sentry.origin": "auto.logger.loguru"} # type: dict[str, Any]
170+
171+
project_root = client.options["project_root"]
172+
if record.get("file"):
173+
if project_root is not None and record["file"].path.startswith(project_root):
174+
attrs["code.file.path"] = record["file"].path[len(project_root) + 1 :]
175+
else:
176+
attrs["code.file.path"] = record["file"].path
177+
178+
if record.get("line") is not None:
179+
attrs["code.line.number"] = record["line"]
180+
181+
if record.get("function"):
182+
attrs["code.function.name"] = record["function"]
183+
184+
if record.get("thread"):
185+
attrs["thread.name"] = record["thread"].name
186+
attrs["thread.id"] = record["thread"].id
187+
188+
if record.get("process"):
189+
attrs["process.pid"] = record["process"].id
190+
attrs["process.executable.name"] = record["process"].name
191+
192+
if record.get("name"):
193+
attrs["logger.name"] = record["name"]
194+
195+
client._capture_experimental_log(
196+
{
197+
"severity_text": otel_severity_text,
198+
"severity_number": otel_severity_number,
199+
"body": record["message"],
200+
"attributes": attrs,
201+
"time_unix_nano": int(record["time"].timestamp() * 1e9),
202+
"trace_id": None,
203+
}
204+
)

sentry_sdk/logger.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,17 @@
66
from sentry_sdk import get_client
77
from sentry_sdk.utils import safe_repr
88

9+
OTEL_RANGES = [
10+
# ((severity level range), severity text)
11+
# https://opentelemetry.io/docs/specs/otel/logs/data-model
12+
((1, 4), "trace"),
13+
((5, 8), "debug"),
14+
((9, 12), "info"),
15+
((13, 16), "warn"),
16+
((17, 20), "error"),
17+
((21, 24), "fatal"),
18+
]
19+
920

1021
def _capture_log(severity_text, severity_number, template, **kwargs):
1122
# type: (str, int, str, **Any) -> None
@@ -52,3 +63,21 @@ def _capture_log(severity_text, severity_number, template, **kwargs):
5263
warning = functools.partial(_capture_log, "warn", 13)
5364
error = functools.partial(_capture_log, "error", 17)
5465
fatal = functools.partial(_capture_log, "fatal", 21)
66+
67+
68+
def _otel_severity_text(otel_severity_number):
69+
# type: (int) -> str
70+
for (lower, upper), severity in OTEL_RANGES:
71+
if lower <= otel_severity_number <= upper:
72+
return severity
73+
74+
return "default"
75+
76+
77+
def _log_level_to_otel(level, mapping):
78+
# type: (int, dict[Any, int]) -> tuple[int, str]
79+
for py_level, otel_severity_number in sorted(mapping.items(), reverse=True):
80+
if level >= py_level:
81+
return otel_severity_number, _otel_severity_text(otel_severity_number)
82+
83+
return 0, "default"

0 commit comments

Comments
 (0)