Skip to content

Commit 8a2b74f

Browse files
Add loguru integration (#1994)
* Add `loguru` integration Actually, this is the solution in comments under #653 adapted to codebase and tested as well. #653 (comment) I also changed `logging` integration to use methods instead of functions in handlers, as in that way we can easily overwrite parts that are different in `loguru` integration. It shouldn't be a problem, as those methods are private and used only in that file. --------- Co-authored-by: Anton Pirker <[email protected]>
1 parent 4b6a381 commit 8a2b74f

File tree

8 files changed

+326
-71
lines changed

8 files changed

+326
-71
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
name: Test loguru
2+
3+
on:
4+
push:
5+
branches:
6+
- master
7+
- release/**
8+
9+
pull_request:
10+
11+
# Cancel in progress workflows on pull_requests.
12+
# https://docs.github.com/en/actions/using-jobs/using-concurrency#example-using-a-fallback-value
13+
concurrency:
14+
group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }}
15+
cancel-in-progress: true
16+
17+
permissions:
18+
contents: read
19+
20+
env:
21+
BUILD_CACHE_KEY: ${{ github.sha }}
22+
CACHED_BUILD_PATHS: |
23+
${{ github.workspace }}/dist-serverless
24+
25+
jobs:
26+
test:
27+
name: loguru, python ${{ matrix.python-version }}, ${{ matrix.os }}
28+
runs-on: ${{ matrix.os }}
29+
timeout-minutes: 45
30+
31+
strategy:
32+
fail-fast: false
33+
matrix:
34+
python-version: ["3.5","3.6","3.7","3.8","3.9","3.10","3.11"]
35+
# python3.6 reached EOL and is no longer being supported on
36+
# new versions of hosted runners on Github Actions
37+
# ubuntu-20.04 is the last version that supported python3.6
38+
# see https://github.com/actions/setup-python/issues/544#issuecomment-1332535877
39+
os: [ubuntu-20.04]
40+
41+
steps:
42+
- uses: actions/checkout@v3
43+
- uses: actions/setup-python@v4
44+
with:
45+
python-version: ${{ matrix.python-version }}
46+
47+
- name: Setup Test Env
48+
run: |
49+
pip install coverage "tox>=3,<4"
50+
51+
- name: Test loguru
52+
timeout-minutes: 45
53+
shell: bash
54+
run: |
55+
set -x # print commands that are executed
56+
coverage erase
57+
58+
# Run tests
59+
./scripts/runtox.sh "py${{ matrix.python-version }}-loguru" --cov=tests --cov=sentry_sdk --cov-report= --cov-branch
60+
coverage combine .coverage*
61+
coverage xml -i
62+
63+
- uses: codecov/codecov-action@v3
64+
with:
65+
token: ${{ secrets.CODECOV_TOKEN }}
66+
files: coverage.xml
67+
68+
check_required_tests:
69+
name: All loguru tests passed or skipped
70+
needs: test
71+
# Always run this, even if a dependent job failed
72+
if: always()
73+
runs-on: ubuntu-20.04
74+
steps:
75+
- name: Check for failures
76+
if: contains(needs.test.result, 'failure')
77+
run: |
78+
echo "One of the dependent jobs have failed. You may need to re-run it." && exit 1

linter-requirements.txt

+1
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ types-certifi
55
types-redis
66
types-setuptools
77
pymongo # There is no separate types module.
8+
loguru # There is no separate types module.
89
flake8-bugbear==22.12.6
910
pep8-naming==0.13.2
1011
pre-commit # local linting

sentry_sdk/integrations/logging.py

+67-70
Original file line numberDiff line numberDiff line change
@@ -107,75 +107,61 @@ def sentry_patched_callhandlers(self, record):
107107
logging.Logger.callHandlers = sentry_patched_callhandlers # type: ignore
108108

109109

110-
def _can_record(record):
111-
# type: (LogRecord) -> bool
112-
"""Prevents ignored loggers from recording"""
113-
for logger in _IGNORED_LOGGERS:
114-
if fnmatch(record.name, logger):
115-
return False
116-
return True
117-
118-
119-
def _breadcrumb_from_record(record):
120-
# type: (LogRecord) -> Dict[str, Any]
121-
return {
122-
"type": "log",
123-
"level": _logging_to_event_level(record),
124-
"category": record.name,
125-
"message": record.message,
126-
"timestamp": datetime.datetime.utcfromtimestamp(record.created),
127-
"data": _extra_from_record(record),
128-
}
129-
130-
131-
def _logging_to_event_level(record):
132-
# type: (LogRecord) -> str
133-
return LOGGING_TO_EVENT_LEVEL.get(
134-
record.levelno, record.levelname.lower() if record.levelname else ""
110+
class _BaseHandler(logging.Handler, object):
111+
COMMON_RECORD_ATTRS = frozenset(
112+
(
113+
"args",
114+
"created",
115+
"exc_info",
116+
"exc_text",
117+
"filename",
118+
"funcName",
119+
"levelname",
120+
"levelno",
121+
"linenno",
122+
"lineno",
123+
"message",
124+
"module",
125+
"msecs",
126+
"msg",
127+
"name",
128+
"pathname",
129+
"process",
130+
"processName",
131+
"relativeCreated",
132+
"stack",
133+
"tags",
134+
"thread",
135+
"threadName",
136+
"stack_info",
137+
)
135138
)
136139

140+
def _can_record(self, record):
141+
# type: (LogRecord) -> bool
142+
"""Prevents ignored loggers from recording"""
143+
for logger in _IGNORED_LOGGERS:
144+
if fnmatch(record.name, logger):
145+
return False
146+
return True
147+
148+
def _logging_to_event_level(self, record):
149+
# type: (LogRecord) -> str
150+
return LOGGING_TO_EVENT_LEVEL.get(
151+
record.levelno, record.levelname.lower() if record.levelname else ""
152+
)
137153

138-
COMMON_RECORD_ATTRS = frozenset(
139-
(
140-
"args",
141-
"created",
142-
"exc_info",
143-
"exc_text",
144-
"filename",
145-
"funcName",
146-
"levelname",
147-
"levelno",
148-
"linenno",
149-
"lineno",
150-
"message",
151-
"module",
152-
"msecs",
153-
"msg",
154-
"name",
155-
"pathname",
156-
"process",
157-
"processName",
158-
"relativeCreated",
159-
"stack",
160-
"tags",
161-
"thread",
162-
"threadName",
163-
"stack_info",
164-
)
165-
)
166-
167-
168-
def _extra_from_record(record):
169-
# type: (LogRecord) -> Dict[str, None]
170-
return {
171-
k: v
172-
for k, v in iteritems(vars(record))
173-
if k not in COMMON_RECORD_ATTRS
174-
and (not isinstance(k, str) or not k.startswith("_"))
175-
}
154+
def _extra_from_record(self, record):
155+
# type: (LogRecord) -> Dict[str, None]
156+
return {
157+
k: v
158+
for k, v in iteritems(vars(record))
159+
if k not in self.COMMON_RECORD_ATTRS
160+
and (not isinstance(k, str) or not k.startswith("_"))
161+
}
176162

177163

178-
class EventHandler(logging.Handler, object):
164+
class EventHandler(_BaseHandler):
179165
"""
180166
A logging handler that emits Sentry events for each log record
181167
@@ -190,7 +176,7 @@ def emit(self, record):
190176

191177
def _emit(self, record):
192178
# type: (LogRecord) -> None
193-
if not _can_record(record):
179+
if not self._can_record(record):
194180
return
195181

196182
hub = Hub.current
@@ -232,7 +218,7 @@ def _emit(self, record):
232218

233219
hint["log_record"] = record
234220

235-
event["level"] = _logging_to_event_level(record)
221+
event["level"] = self._logging_to_event_level(record)
236222
event["logger"] = record.name
237223

238224
# Log records from `warnings` module as separate issues
@@ -255,7 +241,7 @@ def _emit(self, record):
255241
"params": record.args,
256242
}
257243

258-
event["extra"] = _extra_from_record(record)
244+
event["extra"] = self._extra_from_record(record)
259245

260246
hub.capture_event(event, hint=hint)
261247

@@ -264,7 +250,7 @@ def _emit(self, record):
264250
SentryHandler = EventHandler
265251

266252

267-
class BreadcrumbHandler(logging.Handler, object):
253+
class BreadcrumbHandler(_BaseHandler):
268254
"""
269255
A logging handler that records breadcrumbs for each log record.
270256
@@ -279,9 +265,20 @@ def emit(self, record):
279265

280266
def _emit(self, record):
281267
# type: (LogRecord) -> None
282-
if not _can_record(record):
268+
if not self._can_record(record):
283269
return
284270

285271
Hub.current.add_breadcrumb(
286-
_breadcrumb_from_record(record), hint={"log_record": record}
272+
self._breadcrumb_from_record(record), hint={"log_record": record}
287273
)
274+
275+
def _breadcrumb_from_record(self, record):
276+
# type: (LogRecord) -> Dict[str, Any]
277+
return {
278+
"type": "log",
279+
"level": self._logging_to_event_level(record),
280+
"category": record.name,
281+
"message": record.message,
282+
"timestamp": datetime.datetime.utcfromtimestamp(record.created),
283+
"data": self._extra_from_record(record),
284+
}

sentry_sdk/integrations/loguru.py

+89
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
from __future__ import absolute_import
2+
3+
import enum
4+
5+
from sentry_sdk._types import TYPE_CHECKING
6+
from sentry_sdk.integrations import Integration, DidNotEnable
7+
from sentry_sdk.integrations.logging import (
8+
BreadcrumbHandler,
9+
EventHandler,
10+
_BaseHandler,
11+
)
12+
13+
if TYPE_CHECKING:
14+
from logging import LogRecord
15+
from typing import Optional, Tuple
16+
17+
try:
18+
from loguru import logger
19+
except ImportError:
20+
raise DidNotEnable("LOGURU is not installed")
21+
22+
23+
class LoggingLevels(enum.IntEnum):
24+
TRACE = 5
25+
DEBUG = 10
26+
INFO = 20
27+
SUCCESS = 25
28+
WARNING = 30
29+
ERROR = 40
30+
CRITICAL = 50
31+
32+
33+
DEFAULT_LEVEL = LoggingLevels.INFO.value
34+
DEFAULT_EVENT_LEVEL = LoggingLevels.ERROR.value
35+
# We need to save the handlers to be able to remove them later
36+
# in tests (they call `LoguruIntegration.__init__` multiple times,
37+
# and we can't use `setup_once` because it's called before
38+
# than we get configuration).
39+
_ADDED_HANDLERS = (None, None) # type: Tuple[Optional[int], Optional[int]]
40+
41+
42+
class LoguruIntegration(Integration):
43+
identifier = "loguru"
44+
45+
def __init__(self, level=DEFAULT_LEVEL, event_level=DEFAULT_EVENT_LEVEL):
46+
# type: (Optional[int], Optional[int]) -> None
47+
global _ADDED_HANDLERS
48+
breadcrumb_handler, event_handler = _ADDED_HANDLERS
49+
50+
if breadcrumb_handler is not None:
51+
logger.remove(breadcrumb_handler)
52+
breadcrumb_handler = None
53+
if event_handler is not None:
54+
logger.remove(event_handler)
55+
event_handler = None
56+
57+
if level is not None:
58+
breadcrumb_handler = logger.add(
59+
LoguruBreadcrumbHandler(level=level), level=level
60+
)
61+
62+
if event_level is not None:
63+
event_handler = logger.add(
64+
LoguruEventHandler(level=event_level), level=event_level
65+
)
66+
67+
_ADDED_HANDLERS = (breadcrumb_handler, event_handler)
68+
69+
@staticmethod
70+
def setup_once():
71+
# type: () -> None
72+
pass # we do everything in __init__
73+
74+
75+
class _LoguruBaseHandler(_BaseHandler):
76+
def _logging_to_event_level(self, record):
77+
# type: (LogRecord) -> str
78+
try:
79+
return LoggingLevels(record.levelno).name.lower()
80+
except ValueError:
81+
return record.levelname.lower() if record.levelname else ""
82+
83+
84+
class LoguruEventHandler(_LoguruBaseHandler, EventHandler):
85+
"""Modified version of :class:`sentry_sdk.integrations.logging.EventHandler` to use loguru's level names."""
86+
87+
88+
class LoguruBreadcrumbHandler(_LoguruBaseHandler, BreadcrumbHandler):
89+
"""Modified version of :class:`sentry_sdk.integrations.logging.BreadcrumbHandler` to use loguru's level names."""

setup.py

+2-1
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,8 @@ def get_file_text(file_name):
6868
"fastapi": ["fastapi>=0.79.0"],
6969
"pymongo": ["pymongo>=3.1"],
7070
"opentelemetry": ["opentelemetry-distro>=0.35b0"],
71-
"grpcio": ["grpcio>=1.21.1"]
71+
"grpcio": ["grpcio>=1.21.1"],
72+
"loguru": ["loguru>=0.5"],
7273
},
7374
classifiers=[
7475
"Development Status :: 5 - Production/Stable",

tests/integrations/loguru/__init__.py

+3
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
import pytest
2+
3+
pytest.importorskip("loguru")

0 commit comments

Comments
 (0)