django-resilient-logger 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- django_resilient_logger-0.1.0/.gitignore +172 -0
- django_resilient_logger-0.1.0/LICENSE +21 -0
- django_resilient_logger-0.1.0/PKG-INFO +33 -0
- django_resilient_logger-0.1.0/README.md +0 -0
- django_resilient_logger-0.1.0/pyproject.toml +83 -0
- django_resilient_logger-0.1.0/resilient_logger/__init__.py +1 -0
- django_resilient_logger-0.1.0/resilient_logger/abstract_log_facade.py +46 -0
- django_resilient_logger-0.1.0/resilient_logger/abstract_submitter.py +144 -0
- django_resilient_logger-0.1.0/resilient_logger/admin.py +38 -0
- django_resilient_logger-0.1.0/resilient_logger/apps.py +5 -0
- django_resilient_logger-0.1.0/resilient_logger/elasticsearch_submitter.py +55 -0
- django_resilient_logger-0.1.0/resilient_logger/handlers.py +48 -0
- django_resilient_logger-0.1.0/resilient_logger/migrations/0001_initial.py +28 -0
- django_resilient_logger-0.1.0/resilient_logger/migrations/__init__.py +0 -0
- django_resilient_logger-0.1.0/resilient_logger/models.py +14 -0
- django_resilient_logger-0.1.0/resilient_logger/proxy_submitter.py +25 -0
- django_resilient_logger-0.1.0/resilient_logger/resilient_log_facade.py +67 -0
- django_resilient_logger-0.1.0/resilient_logger/tests/__init__.py +0 -0
- django_resilient_logger-0.1.0/resilient_logger/tests/test_admin.py +31 -0
- django_resilient_logger-0.1.0/resilient_logger/utils.py +55 -0
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
# Byte-compiled / optimized / DLL files
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[cod]
|
|
4
|
+
*$py.class
|
|
5
|
+
|
|
6
|
+
# C extensions
|
|
7
|
+
*.so
|
|
8
|
+
|
|
9
|
+
# Distribution / packaging
|
|
10
|
+
.Python
|
|
11
|
+
build/
|
|
12
|
+
develop-eggs/
|
|
13
|
+
dist/
|
|
14
|
+
downloads/
|
|
15
|
+
eggs/
|
|
16
|
+
.eggs/
|
|
17
|
+
lib/
|
|
18
|
+
lib64/
|
|
19
|
+
parts/
|
|
20
|
+
sdist/
|
|
21
|
+
var/
|
|
22
|
+
wheels/
|
|
23
|
+
share/python-wheels/
|
|
24
|
+
*.egg-info/
|
|
25
|
+
.installed.cfg
|
|
26
|
+
*.egg
|
|
27
|
+
MANIFEST
|
|
28
|
+
|
|
29
|
+
# PyInstaller
|
|
30
|
+
# Usually these files are written by a python script from a template
|
|
31
|
+
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
|
32
|
+
*.manifest
|
|
33
|
+
*.spec
|
|
34
|
+
|
|
35
|
+
# Installer logs
|
|
36
|
+
pip-log.txt
|
|
37
|
+
pip-delete-this-directory.txt
|
|
38
|
+
|
|
39
|
+
# Unit test / coverage reports
|
|
40
|
+
htmlcov/
|
|
41
|
+
.tox/
|
|
42
|
+
.nox/
|
|
43
|
+
.coverage
|
|
44
|
+
.coverage.*
|
|
45
|
+
.cache
|
|
46
|
+
nosetests.xml
|
|
47
|
+
coverage.xml
|
|
48
|
+
*.cover
|
|
49
|
+
*.py,cover
|
|
50
|
+
.hypothesis/
|
|
51
|
+
.pytest_cache/
|
|
52
|
+
cover/
|
|
53
|
+
|
|
54
|
+
# Translations
|
|
55
|
+
*.mo
|
|
56
|
+
*.pot
|
|
57
|
+
|
|
58
|
+
# Django stuff:
|
|
59
|
+
*.log
|
|
60
|
+
local_settings.py
|
|
61
|
+
db.sqlite3
|
|
62
|
+
db.sqlite3-journal
|
|
63
|
+
|
|
64
|
+
# Flask stuff:
|
|
65
|
+
instance/
|
|
66
|
+
.webassets-cache
|
|
67
|
+
|
|
68
|
+
# Scrapy stuff:
|
|
69
|
+
.scrapy
|
|
70
|
+
|
|
71
|
+
# Sphinx documentation
|
|
72
|
+
docs/_build/
|
|
73
|
+
|
|
74
|
+
# PyBuilder
|
|
75
|
+
.pybuilder/
|
|
76
|
+
target/
|
|
77
|
+
|
|
78
|
+
# Jupyter Notebook
|
|
79
|
+
.ipynb_checkpoints
|
|
80
|
+
|
|
81
|
+
# IPython
|
|
82
|
+
profile_default/
|
|
83
|
+
ipython_config.py
|
|
84
|
+
|
|
85
|
+
# pyenv
|
|
86
|
+
# For a library or package, you might want to ignore these files since the code is
|
|
87
|
+
# intended to run in multiple environments; otherwise, check them in:
|
|
88
|
+
# .python-version
|
|
89
|
+
|
|
90
|
+
# pipenv
|
|
91
|
+
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
|
|
92
|
+
# However, in case of collaboration, if having platform-specific dependencies or dependencies
|
|
93
|
+
# having no cross-platform support, pipenv may install dependencies that don't work, or not
|
|
94
|
+
# install all needed dependencies.
|
|
95
|
+
#Pipfile.lock
|
|
96
|
+
|
|
97
|
+
# UV
|
|
98
|
+
# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
|
|
99
|
+
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
|
100
|
+
# commonly ignored for libraries.
|
|
101
|
+
#uv.lock
|
|
102
|
+
|
|
103
|
+
# poetry
|
|
104
|
+
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
|
|
105
|
+
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
|
106
|
+
# commonly ignored for libraries.
|
|
107
|
+
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
|
|
108
|
+
#poetry.lock
|
|
109
|
+
|
|
110
|
+
# pdm
|
|
111
|
+
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
|
|
112
|
+
#pdm.lock
|
|
113
|
+
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
|
|
114
|
+
# in version control.
|
|
115
|
+
# https://pdm.fming.dev/latest/usage/project/#working-with-version-control
|
|
116
|
+
.pdm.toml
|
|
117
|
+
.pdm-python
|
|
118
|
+
.pdm-build/
|
|
119
|
+
|
|
120
|
+
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
|
|
121
|
+
__pypackages__/
|
|
122
|
+
|
|
123
|
+
# Celery stuff
|
|
124
|
+
celerybeat-schedule
|
|
125
|
+
celerybeat.pid
|
|
126
|
+
|
|
127
|
+
# SageMath parsed files
|
|
128
|
+
*.sage.py
|
|
129
|
+
|
|
130
|
+
# Environments
|
|
131
|
+
.env
|
|
132
|
+
.venv
|
|
133
|
+
env/
|
|
134
|
+
venv/
|
|
135
|
+
ENV/
|
|
136
|
+
env.bak/
|
|
137
|
+
venv.bak/
|
|
138
|
+
|
|
139
|
+
# Spyder project settings
|
|
140
|
+
.spyderproject
|
|
141
|
+
.spyproject
|
|
142
|
+
|
|
143
|
+
# Rope project settings
|
|
144
|
+
.ropeproject
|
|
145
|
+
|
|
146
|
+
# mkdocs documentation
|
|
147
|
+
/site
|
|
148
|
+
|
|
149
|
+
# mypy
|
|
150
|
+
.mypy_cache/
|
|
151
|
+
.dmypy.json
|
|
152
|
+
dmypy.json
|
|
153
|
+
|
|
154
|
+
# Pyre type checker
|
|
155
|
+
.pyre/
|
|
156
|
+
|
|
157
|
+
# pytype static type analyzer
|
|
158
|
+
.pytype/
|
|
159
|
+
|
|
160
|
+
# Cython debug symbols
|
|
161
|
+
cython_debug/
|
|
162
|
+
|
|
163
|
+
# PyCharm
|
|
164
|
+
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
|
|
165
|
+
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
|
|
166
|
+
# and can be added to the global gitignore or merged into this file. For a more nuclear
|
|
167
|
+
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
|
168
|
+
#.idea/
|
|
169
|
+
|
|
170
|
+
# PyPI configuration file
|
|
171
|
+
.pypirc
|
|
172
|
+
.DS_Store
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 City of Helsinki
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: django-resilient-logger
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A module that fixes provides django-specific resilient logger module.
|
|
5
|
+
Project-URL: Homepage, https://github.com/City-of-Helsinki/django-resilient-logger
|
|
6
|
+
Project-URL: Issues, https://github.com/City-of-Helsinki/django-resilient-logger/issues
|
|
7
|
+
Author-email: City of Helsinki <dev@hel.fi>
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Keywords: django,extra tools,logging,plugin extension
|
|
11
|
+
Classifier: Environment :: Web Environment
|
|
12
|
+
Classifier: Framework :: Django
|
|
13
|
+
Classifier: Framework :: Django :: 4.2
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
16
|
+
Classifier: Operating System :: OS Independent
|
|
17
|
+
Classifier: Programming Language :: Python
|
|
18
|
+
Classifier: Programming Language :: Python :: 3
|
|
19
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
23
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
24
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
25
|
+
Classifier: Topic :: Internet :: WWW/HTTP
|
|
26
|
+
Classifier: Topic :: Internet :: WWW/HTTP :: Dynamic Content
|
|
27
|
+
Requires-Python: >=3.9
|
|
28
|
+
Requires-Dist: apscheduler>=3.0.0
|
|
29
|
+
Requires-Dist: django>=4.2
|
|
30
|
+
Requires-Dist: elasticsearch>=8.0.0
|
|
31
|
+
Provides-Extra: test
|
|
32
|
+
Requires-Dist: pytest; extra == 'test'
|
|
33
|
+
Requires-Dist: pytest-django; extra == 'test'
|
|
File without changes
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "django-resilient-logger"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
authors = [{ name = "City of Helsinki", email = "dev@hel.fi" }]
|
|
5
|
+
description = "A module that fixes provides django-specific resilient logger module."
|
|
6
|
+
readme = "README.md"
|
|
7
|
+
requires-python = ">=3.9"
|
|
8
|
+
keywords = [
|
|
9
|
+
"django",
|
|
10
|
+
"logging",
|
|
11
|
+
"extra tools",
|
|
12
|
+
"plugin extension",
|
|
13
|
+
]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Environment :: Web Environment",
|
|
16
|
+
"Framework :: Django",
|
|
17
|
+
"Framework :: Django :: 4.2",
|
|
18
|
+
"Intended Audience :: Developers",
|
|
19
|
+
"License :: OSI Approved :: MIT License",
|
|
20
|
+
"Operating System :: OS Independent",
|
|
21
|
+
"Programming Language :: Python",
|
|
22
|
+
"Programming Language :: Python :: 3",
|
|
23
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
24
|
+
"Programming Language :: Python :: 3.9",
|
|
25
|
+
"Programming Language :: Python :: 3.10",
|
|
26
|
+
"Programming Language :: Python :: 3.11",
|
|
27
|
+
"Programming Language :: Python :: 3.12",
|
|
28
|
+
"Programming Language :: Python :: 3.13",
|
|
29
|
+
"Topic :: Internet :: WWW/HTTP",
|
|
30
|
+
"Topic :: Internet :: WWW/HTTP :: Dynamic Content",
|
|
31
|
+
]
|
|
32
|
+
license = "MIT"
|
|
33
|
+
license-files = ["LICENSE"]
|
|
34
|
+
dependencies = [
|
|
35
|
+
"django>=4.2",
|
|
36
|
+
"apscheduler>=3.0.0",
|
|
37
|
+
"elasticsearch>=8.0.0"
|
|
38
|
+
]
|
|
39
|
+
|
|
40
|
+
[project.urls]
|
|
41
|
+
Homepage = "https://github.com/City-of-Helsinki/django-resilient-logger"
|
|
42
|
+
Issues = "https://github.com/City-of-Helsinki/django-resilient-logger/issues"
|
|
43
|
+
|
|
44
|
+
[project.optional-dependencies]
|
|
45
|
+
test = ["pytest", "pytest-django"]
|
|
46
|
+
|
|
47
|
+
[build-system]
|
|
48
|
+
requires = ["hatchling"]
|
|
49
|
+
build-backend = "hatchling.build"
|
|
50
|
+
|
|
51
|
+
[tool.hatch.build.targets.sdist]
|
|
52
|
+
include = ["/resilient_logger"]
|
|
53
|
+
|
|
54
|
+
[tool.hatch.build.targets.wheel]
|
|
55
|
+
packages = ["resilient_logger"]
|
|
56
|
+
|
|
57
|
+
[tool.ruff]
|
|
58
|
+
target-version = "py39"
|
|
59
|
+
|
|
60
|
+
[tool.ruff.lint]
|
|
61
|
+
select = [
|
|
62
|
+
# Pyflakes
|
|
63
|
+
"F",
|
|
64
|
+
# pycodestyle
|
|
65
|
+
"E",
|
|
66
|
+
"W",
|
|
67
|
+
# isort
|
|
68
|
+
"I",
|
|
69
|
+
# pep8-naming
|
|
70
|
+
"N",
|
|
71
|
+
# flake8-bugbear without opinionated rules
|
|
72
|
+
"B0",
|
|
73
|
+
# flake8-pie
|
|
74
|
+
"PIE",
|
|
75
|
+
# flake8-print
|
|
76
|
+
"T20",
|
|
77
|
+
]
|
|
78
|
+
extend-per-file-ignores = { "./tests/*" = ["E501"] }
|
|
79
|
+
|
|
80
|
+
[tool.pytest.ini_options]
|
|
81
|
+
pythonpath = "."
|
|
82
|
+
DJANGO_SETTINGS_MODULE = "tests.settings"
|
|
83
|
+
norecursedirs = [".git", "venv"]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
from abc import abstractmethod
|
|
2
|
+
from typing import Any, Generator, Self, Union
|
|
3
|
+
|
|
4
|
+
class AbstractLogFacade:
|
|
5
|
+
"""
|
|
6
|
+
Abstract base class (interface) that defines the method signatures.
|
|
7
|
+
This is required because Django will not work if we import something that relies on Models too early.
|
|
8
|
+
"""
|
|
9
|
+
@abstractmethod
|
|
10
|
+
def get_id(self) -> Union[str, int]:
|
|
11
|
+
return NotImplemented
|
|
12
|
+
|
|
13
|
+
@abstractmethod
|
|
14
|
+
def get_level(self) -> int:
|
|
15
|
+
return NotImplemented
|
|
16
|
+
|
|
17
|
+
@abstractmethod
|
|
18
|
+
def get_message(self) -> Any:
|
|
19
|
+
return NotImplemented
|
|
20
|
+
|
|
21
|
+
@abstractmethod
|
|
22
|
+
def get_context(self) -> Any:
|
|
23
|
+
return NotImplemented
|
|
24
|
+
|
|
25
|
+
@abstractmethod
|
|
26
|
+
def is_sent(self) -> bool:
|
|
27
|
+
return NotImplemented
|
|
28
|
+
|
|
29
|
+
@abstractmethod
|
|
30
|
+
def mark_sent(self) -> None:
|
|
31
|
+
return NotImplemented
|
|
32
|
+
|
|
33
|
+
@classmethod
|
|
34
|
+
@abstractmethod
|
|
35
|
+
def create(cls, level: int, message: Any, context: Any) -> Self:
|
|
36
|
+
return NotImplemented
|
|
37
|
+
|
|
38
|
+
@classmethod
|
|
39
|
+
@abstractmethod
|
|
40
|
+
def get_unsent_entries(cls, chunk_size: int) -> Generator[Self, None, None]:
|
|
41
|
+
return NotImplemented
|
|
42
|
+
|
|
43
|
+
@classmethod
|
|
44
|
+
@abstractmethod
|
|
45
|
+
def clear_sent_entries(cls, days_to_keep: int = 30) -> None:
|
|
46
|
+
return NotImplemented
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
from abc import abstractmethod
|
|
2
|
+
from django.db import transaction
|
|
3
|
+
|
|
4
|
+
import logging
|
|
5
|
+
import os
|
|
6
|
+
|
|
7
|
+
from typing import Any, Dict, List, Optional, Generator, Type, TypedDict
|
|
8
|
+
|
|
9
|
+
from resilient_logger.abstract_log_facade import AbstractLogFacade
|
|
10
|
+
from apscheduler.schedulers.background import BackgroundScheduler
|
|
11
|
+
|
|
12
|
+
logger = logging.getLogger(__name__)
|
|
13
|
+
|
|
14
|
+
class IntervalOptions(TypedDict):
|
|
15
|
+
"""
|
|
16
|
+
Defines internal format for apscheduler trigger definitions (trigger, trigger_args).
|
|
17
|
+
See: https://apscheduler.readthedocs.io/en/3.x/modules/schedulers/base.html#apscheduler.schedulers.base.BaseScheduler.add_job
|
|
18
|
+
"""
|
|
19
|
+
trigger: str
|
|
20
|
+
args: Dict[str, Any]
|
|
21
|
+
|
|
22
|
+
_default_submit_unsent_entries_trigger = IntervalOptions({
|
|
23
|
+
'trigger': 'interval',
|
|
24
|
+
'args': { 'minutes': 15 }
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
_default_clear_sent_entries_trigger = IntervalOptions({
|
|
28
|
+
'trigger': 'cron',
|
|
29
|
+
'args': {
|
|
30
|
+
'month':"*",
|
|
31
|
+
'day': "1",
|
|
32
|
+
'hour': "0",
|
|
33
|
+
'minute': "0",
|
|
34
|
+
'second': "0",
|
|
35
|
+
}
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
class AbstractSubmitter:
|
|
39
|
+
"""
|
|
40
|
+
Abstract base class for different submitters. By design, does not use any implementation specific
|
|
41
|
+
storages or submit targets. Those are defined by sub-classes and runtime provided log_facade class.
|
|
42
|
+
"""
|
|
43
|
+
_batch_limit: int
|
|
44
|
+
_chunk_size: int
|
|
45
|
+
_log_facade: Type[AbstractLogFacade]
|
|
46
|
+
|
|
47
|
+
def __init__(
|
|
48
|
+
self,
|
|
49
|
+
log_facade: Type[AbstractLogFacade],
|
|
50
|
+
batch_limit: int = 5000,
|
|
51
|
+
chunk_size: int = 500,
|
|
52
|
+
submit_unsent_entries_trigger: Optional[IntervalOptions] = _default_submit_unsent_entries_trigger,
|
|
53
|
+
clear_sent_entries_trigger: Optional[IntervalOptions] = _default_clear_sent_entries_trigger,
|
|
54
|
+
) -> None:
|
|
55
|
+
self._log_facade = log_facade
|
|
56
|
+
self._batch_limit = batch_limit
|
|
57
|
+
self._chunk_size = chunk_size
|
|
58
|
+
|
|
59
|
+
if not submit_unsent_entries_trigger:
|
|
60
|
+
logger.info("NOT scheduling submit_entries, submit_unsent_entries_trigger is set to None.")
|
|
61
|
+
|
|
62
|
+
if not clear_sent_entries_trigger:
|
|
63
|
+
logger.info("NOT scheduling clear_old_entries, clear_sent_entries_trigger is set to None.")
|
|
64
|
+
|
|
65
|
+
if (submit_unsent_entries_trigger or clear_sent_entries_trigger) and self.should_schedule():
|
|
66
|
+
scheduler = BackgroundScheduler()
|
|
67
|
+
scheduler.start()
|
|
68
|
+
|
|
69
|
+
if submit_unsent_entries_trigger:
|
|
70
|
+
scheduler.add_job(
|
|
71
|
+
self.submit_unsent_entries,
|
|
72
|
+
id="submit_unsent_entries",
|
|
73
|
+
name="submit_unsent_entries",
|
|
74
|
+
max_instances=1,
|
|
75
|
+
replace_existing=True,
|
|
76
|
+
trigger=submit_unsent_entries_trigger.get('trigger'),
|
|
77
|
+
**submit_unsent_entries_trigger.get('args'),
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
if clear_sent_entries_trigger:
|
|
81
|
+
scheduler.add_job(
|
|
82
|
+
self.clear_sent_entries,
|
|
83
|
+
id="clear_sent_entries",
|
|
84
|
+
name="clear_sent_entries",
|
|
85
|
+
max_instances=1,
|
|
86
|
+
replace_existing=True,
|
|
87
|
+
trigger=clear_sent_entries_trigger.get('trigger'),
|
|
88
|
+
**clear_sent_entries_trigger.get('args'),
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
@abstractmethod
|
|
92
|
+
def _submit_entry(self, entry: AbstractLogFacade) -> Optional[str]:
|
|
93
|
+
"""This method is different for each submitter, so it's required to override this."""
|
|
94
|
+
return NotImplemented
|
|
95
|
+
|
|
96
|
+
@transaction.atomic
|
|
97
|
+
def submit_entry(self, entry: AbstractLogFacade) -> Optional[str]:
|
|
98
|
+
result_id = self._submit_entry(entry)
|
|
99
|
+
if result_id is not None:
|
|
100
|
+
entry.mark_sent()
|
|
101
|
+
return result_id
|
|
102
|
+
|
|
103
|
+
return None
|
|
104
|
+
|
|
105
|
+
def submit(self, level: int, message: Any, context: Any) -> Optional[str]:
|
|
106
|
+
entry = self.get_log_facade().create(level, message, context)
|
|
107
|
+
return self.submit_entry(entry)
|
|
108
|
+
|
|
109
|
+
@transaction.atomic
|
|
110
|
+
def submit_unsent_entries(self) -> Optional[List[str]]:
|
|
111
|
+
result_ids: List[str] = []
|
|
112
|
+
|
|
113
|
+
for count, entry in enumerate(self.get_unsent_entries()):
|
|
114
|
+
if count >= self._batch_limit:
|
|
115
|
+
print(f"Job limit of {self._batch_limit} logs reached, stopping...")
|
|
116
|
+
break
|
|
117
|
+
|
|
118
|
+
result_id = self.submit_entry(entry)
|
|
119
|
+
|
|
120
|
+
if result_id is not None:
|
|
121
|
+
entry.mark_sent()
|
|
122
|
+
result_ids.append(result_id)
|
|
123
|
+
|
|
124
|
+
return result_ids
|
|
125
|
+
|
|
126
|
+
def get_unsent_entries(self) -> Generator[AbstractLogFacade, None, None]:
|
|
127
|
+
return self.get_log_facade().get_unsent_entries(self._chunk_size)
|
|
128
|
+
|
|
129
|
+
def clear_sent_entries(self, days_to_keep: int = 30) -> None:
|
|
130
|
+
return self.get_log_facade().clear_sent_entries(days_to_keep)
|
|
131
|
+
|
|
132
|
+
def get_log_facade(self) -> Type[AbstractLogFacade]:
|
|
133
|
+
if not self._log_facade:
|
|
134
|
+
raise Exception("self._log_facade is None, cannot proceed without it.")
|
|
135
|
+
|
|
136
|
+
return self._log_facade
|
|
137
|
+
|
|
138
|
+
@staticmethod
|
|
139
|
+
def should_schedule():
|
|
140
|
+
if os.environ.get("SUBMITTER_SCHEDULED", None) is None:
|
|
141
|
+
os.environ['SUBMITTER_SCHEDULED'] = 'True'
|
|
142
|
+
return True
|
|
143
|
+
|
|
144
|
+
return False
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import logging
|
|
3
|
+
|
|
4
|
+
from django.contrib import admin
|
|
5
|
+
from django.core.paginator import Paginator
|
|
6
|
+
from django.utils.html import escape
|
|
7
|
+
from django.utils.safestring import mark_safe
|
|
8
|
+
|
|
9
|
+
from resilient_logger.models import ResilientLogEntry
|
|
10
|
+
|
|
11
|
+
logger = logging.getLogger(__name__)
|
|
12
|
+
|
|
13
|
+
@admin.register(ResilientLogEntry)
|
|
14
|
+
class ResilientLogEntryAdmin(admin.ModelAdmin):
|
|
15
|
+
exclude = ("message",)
|
|
16
|
+
readonly_fields = ("id", "created_at", "is_sent", "message_prettified")
|
|
17
|
+
list_display = ("id", "__str__", "created_at", "is_sent")
|
|
18
|
+
list_filter = ("created_at", "is_sent")
|
|
19
|
+
|
|
20
|
+
# For increasing listing performance
|
|
21
|
+
show_full_result_count = False
|
|
22
|
+
paginator = Paginator
|
|
23
|
+
|
|
24
|
+
def has_delete_permission(self, request, obj=None):
|
|
25
|
+
return False
|
|
26
|
+
|
|
27
|
+
def has_add_permission(self, request):
|
|
28
|
+
return False
|
|
29
|
+
|
|
30
|
+
def has_change_permission(self, request, obj=None):
|
|
31
|
+
return False
|
|
32
|
+
|
|
33
|
+
@admin.display(description="message")
|
|
34
|
+
def message_prettified(self, instance):
|
|
35
|
+
"""Format the message to be a bit a more user-friendly."""
|
|
36
|
+
message = json.dumps(instance.message, indent=2, sort_keys=True)
|
|
37
|
+
content = f"<pre>{escape(message)}</pre>"
|
|
38
|
+
return mark_safe(content)
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
from typing import Optional, Type, override
|
|
3
|
+
from elasticsearch import Elasticsearch
|
|
4
|
+
from resilient_logger.abstract_log_facade import AbstractLogFacade
|
|
5
|
+
from resilient_logger.abstract_submitter import AbstractSubmitter
|
|
6
|
+
|
|
7
|
+
logger = logging.getLogger(__name__)
|
|
8
|
+
|
|
9
|
+
# Constants
|
|
10
|
+
ES_STATUS_CREATED = "created"
|
|
11
|
+
|
|
12
|
+
class ElasticsearchSubmitter(AbstractSubmitter):
|
|
13
|
+
"""
|
|
14
|
+
Submitter class that sends the resilient log entries to Elasticsearch.
|
|
15
|
+
"""
|
|
16
|
+
_client: Elasticsearch
|
|
17
|
+
_index: str
|
|
18
|
+
|
|
19
|
+
def __init__(self,
|
|
20
|
+
log_facade: Type[AbstractLogFacade],
|
|
21
|
+
client: Elasticsearch,
|
|
22
|
+
index: str,
|
|
23
|
+
batch_limit: int = 5000,
|
|
24
|
+
chunk_size: int = 500,
|
|
25
|
+
) -> None:
|
|
26
|
+
super().__init__(log_facade, batch_limit, chunk_size)
|
|
27
|
+
|
|
28
|
+
if not client:
|
|
29
|
+
raise Exception(f"ElasticsearchSubmitter is missing required argument client.")
|
|
30
|
+
|
|
31
|
+
if not index:
|
|
32
|
+
raise Exception(f"ElasticsearchSubmitter is missing required argument index.")
|
|
33
|
+
|
|
34
|
+
self._index = index
|
|
35
|
+
self._client = client
|
|
36
|
+
|
|
37
|
+
@override
|
|
38
|
+
def _submit_entry(self, entry: AbstractLogFacade) -> Optional[str]:
|
|
39
|
+
document = entry.get_context()
|
|
40
|
+
document["level"] = entry.get_level()
|
|
41
|
+
document["message"] = entry.get_message()
|
|
42
|
+
|
|
43
|
+
response = self._client.index(
|
|
44
|
+
index=self._index,
|
|
45
|
+
id=str(entry.get_id()),
|
|
46
|
+
document=document,
|
|
47
|
+
op_type="create",
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
logger.info(f"Sending status: {response}")
|
|
51
|
+
|
|
52
|
+
if response.get("result") == ES_STATUS_CREATED:
|
|
53
|
+
return response.get("_id")
|
|
54
|
+
|
|
55
|
+
return None
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
from collections.abc import Callable
|
|
3
|
+
from functools import cached_property
|
|
4
|
+
from resilient_logger.abstract_log_facade import AbstractLogFacade
|
|
5
|
+
from resilient_logger.abstract_submitter import AbstractSubmitter
|
|
6
|
+
from typing import Dict, Optional
|
|
7
|
+
from resilient_logger.utils import dynamic_class, get_log_record_extra
|
|
8
|
+
|
|
9
|
+
logger = logging.getLogger(__name__)
|
|
10
|
+
|
|
11
|
+
class ResilientLogHandler(logging.Handler):
|
|
12
|
+
_submitter_construct: Optional[Callable[[], AbstractSubmitter]]
|
|
13
|
+
|
|
14
|
+
def __init__(self, submitter: Dict, log_facade: Dict, level: int = logging.NOTSET):
|
|
15
|
+
super().__init__(level)
|
|
16
|
+
|
|
17
|
+
def submitter_construct():
|
|
18
|
+
"""
|
|
19
|
+
Submitter rely on Django's DB models and cannot be instantiated during init since Django app registry is not ready by then.
|
|
20
|
+
Work around this by doing lazy initialization when the submitter is used first time.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
submitter_args = submitter.copy()
|
|
24
|
+
submitter_class = submitter_args.pop('class')
|
|
25
|
+
|
|
26
|
+
log_facade_args = log_facade.copy()
|
|
27
|
+
log_facade_class = log_facade_args.pop('class')
|
|
28
|
+
|
|
29
|
+
Submitter = dynamic_class(AbstractSubmitter, submitter_class)
|
|
30
|
+
LogFacade = dynamic_class(AbstractLogFacade, log_facade_class)
|
|
31
|
+
|
|
32
|
+
instance = Submitter(**submitter_args, log_facade=LogFacade)
|
|
33
|
+
return instance
|
|
34
|
+
|
|
35
|
+
self._submitter_construct = submitter_construct
|
|
36
|
+
|
|
37
|
+
def emit(self, record: logging.LogRecord):
|
|
38
|
+
self._submitter.submit(record.levelno, record.getMessage(), get_log_record_extra(record))
|
|
39
|
+
|
|
40
|
+
@cached_property
|
|
41
|
+
def _submitter(self) -> AbstractSubmitter:
|
|
42
|
+
if self._submitter_construct is None:
|
|
43
|
+
raise Exception("Unable to proceed without lazy constructor")
|
|
44
|
+
|
|
45
|
+
instance = self._submitter_construct()
|
|
46
|
+
self._submitter_construct = None
|
|
47
|
+
return instance
|
|
48
|
+
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# Generated by Django 5.1.7 on 2025-04-10 10:10
|
|
2
|
+
|
|
3
|
+
from django.db import migrations, models
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class Migration(migrations.Migration):
|
|
7
|
+
|
|
8
|
+
initial = True
|
|
9
|
+
|
|
10
|
+
dependencies = [
|
|
11
|
+
]
|
|
12
|
+
|
|
13
|
+
operations = [
|
|
14
|
+
migrations.CreateModel(
|
|
15
|
+
name='ResilientLogEntry',
|
|
16
|
+
fields=[
|
|
17
|
+
('id', models.AutoField(primary_key=True, serialize=False)),
|
|
18
|
+
('is_sent', models.BooleanField(default=False, verbose_name='is sent')),
|
|
19
|
+
('level', models.IntegerField(default=0, verbose_name='level')),
|
|
20
|
+
('message', models.JSONField(verbose_name='message')),
|
|
21
|
+
('context', models.JSONField(null=True, verbose_name='context')),
|
|
22
|
+
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='created at')),
|
|
23
|
+
],
|
|
24
|
+
options={
|
|
25
|
+
'verbose_name_plural': 'resilient log entries',
|
|
26
|
+
},
|
|
27
|
+
),
|
|
28
|
+
]
|
|
File without changes
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
from django.db import models
|
|
2
|
+
from django.utils.translation import gettext_lazy as _
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class ResilientLogEntry(models.Model):
|
|
6
|
+
id = models.AutoField(primary_key=True)
|
|
7
|
+
is_sent = models.BooleanField(default=False, verbose_name=_("is sent"))
|
|
8
|
+
level = models.IntegerField(verbose_name=_("level"), default=0)
|
|
9
|
+
message = models.JSONField(verbose_name=_("message"))
|
|
10
|
+
context = models.JSONField(verbose_name=_("context"), null=True)
|
|
11
|
+
created_at = models.DateTimeField(auto_now_add=True, verbose_name=_("created at"))
|
|
12
|
+
|
|
13
|
+
class Meta:
|
|
14
|
+
verbose_name_plural = "resilient log entries"
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import uuid
|
|
3
|
+
from typing import Optional, Type, override
|
|
4
|
+
from resilient_logger.abstract_log_facade import AbstractLogFacade
|
|
5
|
+
from resilient_logger.abstract_submitter import AbstractSubmitter
|
|
6
|
+
|
|
7
|
+
class ProxySubmitter(AbstractSubmitter):
|
|
8
|
+
"""
|
|
9
|
+
Submitter class that sends the resilient log entries to another logger.
|
|
10
|
+
"""
|
|
11
|
+
_logger: logging.Logger
|
|
12
|
+
|
|
13
|
+
def __init__(self,
|
|
14
|
+
log_facade: Type[AbstractLogFacade],
|
|
15
|
+
name: str = __name__,
|
|
16
|
+
batch_limit: int = 5000,
|
|
17
|
+
chunk_size: int = 500,
|
|
18
|
+
) -> None:
|
|
19
|
+
super().__init__(log_facade, batch_limit, chunk_size)
|
|
20
|
+
self._logger = logging.getLogger(name)
|
|
21
|
+
|
|
22
|
+
@override
|
|
23
|
+
def _submit_entry(self, entry: AbstractLogFacade) -> Optional[str]:
|
|
24
|
+
self._logger.log(entry.get_level(), entry.get_message(), extra=entry.get_context() or {})
|
|
25
|
+
return str(uuid.uuid4())
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
from datetime import timedelta, datetime
|
|
2
|
+
from django.db import transaction
|
|
3
|
+
from resilient_logger.abstract_log_facade import AbstractLogFacade
|
|
4
|
+
from resilient_logger.models import ResilientLogEntry
|
|
5
|
+
from typing import Any, Generator, Self, Union, override
|
|
6
|
+
|
|
7
|
+
class ResilientLogFacade(AbstractLogFacade):
|
|
8
|
+
_log: ResilientLogEntry
|
|
9
|
+
|
|
10
|
+
def __init__(self, log: ResilientLogEntry):
|
|
11
|
+
self._log = log
|
|
12
|
+
|
|
13
|
+
@override
|
|
14
|
+
def get_id(self) -> Union[str, int]:
|
|
15
|
+
return self._log.id
|
|
16
|
+
|
|
17
|
+
@override
|
|
18
|
+
def get_level(self) -> int:
|
|
19
|
+
return self._log.level
|
|
20
|
+
|
|
21
|
+
@override
|
|
22
|
+
def get_message(self) -> Any:
|
|
23
|
+
return self._log.message
|
|
24
|
+
|
|
25
|
+
@override
|
|
26
|
+
def get_context(self) -> Any:
|
|
27
|
+
return self._log.context
|
|
28
|
+
|
|
29
|
+
@override
|
|
30
|
+
def is_sent(self) -> bool:
|
|
31
|
+
return self._log.is_sent
|
|
32
|
+
|
|
33
|
+
@override
|
|
34
|
+
def mark_sent(self) -> None:
|
|
35
|
+
self._log.is_sent = True
|
|
36
|
+
self._log.save(update_fields=["is_sent"])
|
|
37
|
+
|
|
38
|
+
@override
|
|
39
|
+
@classmethod
|
|
40
|
+
@transaction.atomic
|
|
41
|
+
def create(cls, level: int, message: Any, context: Any) -> Self:
|
|
42
|
+
entry = ResilientLogEntry.objects.create(level=level, message=message, context=context)
|
|
43
|
+
return cls(entry)
|
|
44
|
+
|
|
45
|
+
@override
|
|
46
|
+
@classmethod
|
|
47
|
+
@transaction.atomic
|
|
48
|
+
def get_unsent_entries(cls, chunk_size: int) -> Generator[AbstractLogFacade, None, None]:
|
|
49
|
+
entries = (
|
|
50
|
+
ResilientLogEntry
|
|
51
|
+
.objects
|
|
52
|
+
.filter(is_sent=False)
|
|
53
|
+
.order_by("created_at")
|
|
54
|
+
.iterator(chunk_size=chunk_size)
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
for entry in entries:
|
|
58
|
+
yield ResilientLogFacade(entry)
|
|
59
|
+
|
|
60
|
+
@override
|
|
61
|
+
@classmethod
|
|
62
|
+
@transaction.atomic
|
|
63
|
+
def clear_sent_entries(cls, days_to_keep: int = 30) -> None:
|
|
64
|
+
ResilientLogEntry.objects.filter(
|
|
65
|
+
is_sent=True,
|
|
66
|
+
created_at__lte=(datetime.now() - timedelta(days=days_to_keep)),
|
|
67
|
+
).select_for_update().delete()
|
|
File without changes
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
from unittest.mock import Mock
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
from django.contrib.admin import AdminSite
|
|
5
|
+
|
|
6
|
+
from resilient_logger.admin import ResilientLogEntryAdmin
|
|
7
|
+
from resilient_logger.models import ResilientLogEntry
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@pytest.mark.django_db
|
|
11
|
+
def test_resilient_logger_admin_message_prettified(superuser):
|
|
12
|
+
request = Mock(user=superuser)
|
|
13
|
+
model_admin = ResilientLogEntryAdmin(ResilientLogEntry, AdminSite())
|
|
14
|
+
assert list(model_admin.get_fields(request)) == [
|
|
15
|
+
"id",
|
|
16
|
+
"created_at",
|
|
17
|
+
"is_sent",
|
|
18
|
+
"message_prettified",
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@pytest.mark.django_db
|
|
23
|
+
def test_resilient_logger_admin_permissions(superuser):
|
|
24
|
+
request = Mock(user=superuser)
|
|
25
|
+
log_entry = ResilientLogEntry.objects.create(message={})
|
|
26
|
+
model_admin = ResilientLogEntryAdmin(ResilientLogEntry, AdminSite())
|
|
27
|
+
# The user should have permission to view, but not modify or delete resilient logs
|
|
28
|
+
assert model_admin.has_view_permission(request, log_entry)
|
|
29
|
+
assert not model_admin.has_add_permission(request)
|
|
30
|
+
assert not model_admin.has_change_permission(request, log_entry)
|
|
31
|
+
assert not model_admin.has_delete_permission(request, log_entry)
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
from importlib import import_module
|
|
2
|
+
import logging
|
|
3
|
+
from typing import Type, TypeVar
|
|
4
|
+
|
|
5
|
+
BUILTIN_LOG_RECORD_ATTRS = {
|
|
6
|
+
'args',
|
|
7
|
+
'asctime',
|
|
8
|
+
'created',
|
|
9
|
+
'exc_info',
|
|
10
|
+
'exc_text',
|
|
11
|
+
'filename',
|
|
12
|
+
'funcName',
|
|
13
|
+
'levelname',
|
|
14
|
+
'levelno',
|
|
15
|
+
'lineno',
|
|
16
|
+
'module',
|
|
17
|
+
'msecs',
|
|
18
|
+
'message',
|
|
19
|
+
'msg',
|
|
20
|
+
'name',
|
|
21
|
+
'pathname',
|
|
22
|
+
'process',
|
|
23
|
+
'processName',
|
|
24
|
+
'relativeCreated',
|
|
25
|
+
'stack_info',
|
|
26
|
+
'taskName',
|
|
27
|
+
'thread',
|
|
28
|
+
'threadName',
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
TClass = TypeVar("TClass")
|
|
32
|
+
|
|
33
|
+
def dynamic_class(type: Type[TClass], class_path: str) -> Type[TClass]:
|
|
34
|
+
"""
|
|
35
|
+
Loads dynamically class of given type from class_path
|
|
36
|
+
and ensures it's sub-class of given input type.
|
|
37
|
+
"""
|
|
38
|
+
parts = class_path.split('.')
|
|
39
|
+
class_name = parts.pop()
|
|
40
|
+
module_name = '.'.join(parts)
|
|
41
|
+
module = import_module(module_name)
|
|
42
|
+
cls = getattr(module, class_name)
|
|
43
|
+
|
|
44
|
+
if not issubclass(cls, type):
|
|
45
|
+
raise Exception(f"Class '{class_path}' is not sub-class of the {type}.")
|
|
46
|
+
|
|
47
|
+
return cls
|
|
48
|
+
|
|
49
|
+
def get_log_record_extra(record: logging.LogRecord):
|
|
50
|
+
"""Returns `extra` passed to the logger."""
|
|
51
|
+
return {
|
|
52
|
+
name: record.__dict__[name]
|
|
53
|
+
for name in record.__dict__
|
|
54
|
+
if name not in BUILTIN_LOG_RECORD_ATTRS
|
|
55
|
+
}
|