pepe-logger 0.0.3__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.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2024 Jose Luis Alonzo Velazquez
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,61 @@
1
+ Metadata-Version: 2.4
2
+ Name: pepe-logger
3
+ Version: 0.0.3
4
+ Summary: Structured JSON logging for distributed systems, with a Go log shipper.
5
+ License: MIT
6
+ License-File: LICENSE
7
+ Keywords: logging,json,observability,distributed-systems,structured-logging
8
+ Author: Jose Luis Alonzo Velazquez
9
+ Author-email: pepemxl@gmail.com
10
+ Requires-Python: >=3.10,<4.0
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Operating System :: OS Independent
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.10
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Programming Language :: Python :: 3.13
20
+ Classifier: Programming Language :: Python :: 3.14
21
+ Classifier: Topic :: System :: Logging
22
+ Classifier: Typing :: Typed
23
+ Project-URL: Documentation, https://github.com/pepemxl/pplogger
24
+ Project-URL: Homepage, https://github.com/pepemxl/pplogger
25
+ Project-URL: Repository, https://github.com/pepemxl/pplogger.git
26
+ Description-Content-Type: text/markdown
27
+
28
+ # PPLogger
29
+
30
+ A logger package for scaled distributed systems.
31
+
32
+ The goal is to standarize logs in distribuited systems and permit scale logging process.
33
+
34
+ This package converts log records into JSON messages.
35
+
36
+ ## Install
37
+
38
+ ```bash
39
+ pip install pepe-logger
40
+ ```
41
+
42
+ The PyPI distribution is named **`pepe-logger`**; the importable package is
43
+ **`pplogger`** (`from pplogger import initializer_logger`).
44
+
45
+ An example of a log file:
46
+ - `/tmp/service_api.module_pepe_logs.2024_07_13.log`
47
+
48
+ Example of usage:
49
+
50
+ ```python
51
+ import logging
52
+ from pplogger import initializer_logger
53
+
54
+ initializer_logger()
55
+ log = logging.getLogger(__name__)
56
+
57
+ log.info("This log info message")
58
+ log.debug("This log info message")
59
+ log.error("This log error message")
60
+ log.exception("This log exception message")
61
+ ```
@@ -0,0 +1,34 @@
1
+ # PPLogger
2
+
3
+ A logger package for scaled distributed systems.
4
+
5
+ The goal is to standarize logs in distribuited systems and permit scale logging process.
6
+
7
+ This package converts log records into JSON messages.
8
+
9
+ ## Install
10
+
11
+ ```bash
12
+ pip install pepe-logger
13
+ ```
14
+
15
+ The PyPI distribution is named **`pepe-logger`**; the importable package is
16
+ **`pplogger`** (`from pplogger import initializer_logger`).
17
+
18
+ An example of a log file:
19
+ - `/tmp/service_api.module_pepe_logs.2024_07_13.log`
20
+
21
+ Example of usage:
22
+
23
+ ```python
24
+ import logging
25
+ from pplogger import initializer_logger
26
+
27
+ initializer_logger()
28
+ log = logging.getLogger(__name__)
29
+
30
+ log.info("This log info message")
31
+ log.debug("This log info message")
32
+ log.error("This log error message")
33
+ log.exception("This log exception message")
34
+ ```
@@ -0,0 +1,24 @@
1
+ """pplogger - common logger for observability of distributed systems."""
2
+
3
+ from importlib.metadata import PackageNotFoundError, version
4
+
5
+ from pplogger.context import bind_context, clear_context, context, get_context
6
+ from pplogger.logger import build_log_path, initializer_logger
7
+
8
+ try:
9
+ # Single source of truth: the version declared in pyproject.toml, read from
10
+ # the installed distribution metadata. The distribution is named
11
+ # "pepe-logger" on PyPI even though the import package is "pplogger".
12
+ __version__ = version("pepe-logger")
13
+ except PackageNotFoundError: # running from a source tree that isn't installed
14
+ __version__ = "0.0.0+unknown"
15
+
16
+ __all__ = [
17
+ "build_log_path",
18
+ "initializer_logger",
19
+ "bind_context",
20
+ "clear_context",
21
+ "context",
22
+ "get_context",
23
+ "__version__",
24
+ ]
@@ -0,0 +1,41 @@
1
+ """Per-context structured fields for log correlation.
2
+
3
+ Fields bound here (e.g. ``request_id``, ``trace_id``) are merged into every log
4
+ record emitted from the same thread / asyncio task, so callers don't have to
5
+ thread them through ``extra={...}`` on every log call. Explicit ``extra`` fields
6
+ take precedence over bound context fields.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import contextlib
12
+ from collections.abc import Iterator
13
+ from contextvars import ContextVar
14
+ from typing import Any
15
+
16
+ _context: ContextVar[dict[str, Any] | None] = ContextVar("pplogger_context", default=None)
17
+
18
+
19
+ def get_context() -> dict[str, Any]:
20
+ """Return a copy of the fields currently bound to this context."""
21
+ return dict(_context.get() or {})
22
+
23
+
24
+ def bind_context(**fields: Any) -> None:
25
+ """Merge ``fields`` into the current context (until overwritten/cleared)."""
26
+ _context.set({**get_context(), **fields})
27
+
28
+
29
+ def clear_context() -> None:
30
+ """Remove all bound context fields."""
31
+ _context.set(None)
32
+
33
+
34
+ @contextlib.contextmanager
35
+ def context(**fields: Any) -> Iterator[None]:
36
+ """Bind ``fields`` for the duration of the ``with`` block, then restore."""
37
+ token = _context.set({**get_context(), **fields})
38
+ try:
39
+ yield
40
+ finally:
41
+ _context.reset(token)
@@ -0,0 +1,118 @@
1
+ """JSON formatter for pplogger.
2
+
3
+ Each log record is serialized to a single-line JSON document so downstream
4
+ processors (e.g. the Go shipper in ./processor) can consume it line by line.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import datetime as dt
10
+ import json
11
+ import logging
12
+ import os
13
+ import socket
14
+ import traceback
15
+ from typing import Any
16
+
17
+ from pplogger.context import get_context
18
+
19
+ # Process id is cached to avoid a syscall per record, but refreshed after fork()
20
+ # so child processes report their own pid instead of inheriting the parent's.
21
+ _PID = os.getpid()
22
+
23
+
24
+ def _refresh_pid() -> None:
25
+ global _PID
26
+ _PID = os.getpid()
27
+
28
+
29
+ if hasattr(os, "register_at_fork"):
30
+ os.register_at_fork(after_in_child=_refresh_pid)
31
+
32
+ # logging.LogRecord built-in attributes — anything not in this set is treated
33
+ # as user-supplied `extra={...}` and copied into the JSON payload.
34
+ _RESERVED_RECORD_ATTRS = frozenset(
35
+ {
36
+ "name",
37
+ "msg",
38
+ "args",
39
+ "levelname",
40
+ "levelno",
41
+ "pathname",
42
+ "filename",
43
+ "module",
44
+ "exc_info",
45
+ "exc_text",
46
+ "stack_info",
47
+ "lineno",
48
+ "funcName",
49
+ "created",
50
+ "msecs",
51
+ "relativeCreated",
52
+ "thread",
53
+ "threadName",
54
+ "processName",
55
+ "process",
56
+ "message",
57
+ "asctime",
58
+ "taskName",
59
+ }
60
+ )
61
+
62
+
63
+ class JSONFormatter(logging.Formatter):
64
+ """Serialize log records as JSON documents."""
65
+
66
+ def __init__(self, service: str, module: str, hostname: str | None = None) -> None:
67
+ super().__init__()
68
+ self.service = service
69
+ self.module = module
70
+ self.hostname = hostname or socket.gethostname()
71
+
72
+ def format(self, record: logging.LogRecord) -> str:
73
+ timestamp = dt.datetime.fromtimestamp(record.created, tz=dt.timezone.utc)
74
+ payload: dict[str, Any] = {
75
+ "timestamp": timestamp.isoformat(timespec="milliseconds").replace("+00:00", "Z"),
76
+ "level": record.levelname,
77
+ "logger": record.name,
78
+ "message": record.getMessage(),
79
+ "service": self.service,
80
+ "module": self.module,
81
+ "hostname": self.hostname,
82
+ "pid": _PID,
83
+ "source_module": record.module,
84
+ "function": record.funcName,
85
+ "line": record.lineno,
86
+ "thread": record.threadName,
87
+ }
88
+
89
+ if record.exc_info:
90
+ exc_type, exc_value, exc_tb = record.exc_info
91
+ payload["exception"] = {
92
+ "type": exc_type.__name__ if exc_type else None,
93
+ "message": str(exc_value) if exc_value else None,
94
+ "traceback": "".join(traceback.format_exception(exc_type, exc_value, exc_tb)),
95
+ }
96
+
97
+ # Collect user `extra={...}` fields, then merge them over the bound
98
+ # context so explicit per-call fields win on key collisions.
99
+ extras = {
100
+ key: _safe(value)
101
+ for key, value in record.__dict__.items()
102
+ if key not in _RESERVED_RECORD_ATTRS and not key.startswith("_") and key not in payload
103
+ }
104
+ for key, value in {**get_context(), **extras}.items():
105
+ if key in payload:
106
+ continue
107
+ payload[key] = _safe(value)
108
+
109
+ return json.dumps(payload, ensure_ascii=False, default=str)
110
+
111
+
112
+ def _safe(value: Any) -> Any:
113
+ """Best-effort conversion of `extra` values to JSON-friendly types."""
114
+ try:
115
+ json.dumps(value)
116
+ return value
117
+ except (TypeError, ValueError):
118
+ return repr(value)
@@ -0,0 +1,165 @@
1
+ """Logger initialization for pplogger."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import datetime as dt
6
+ import logging
7
+ import os
8
+ import sys
9
+ from collections.abc import Callable
10
+ from logging.handlers import RotatingFileHandler
11
+ from pathlib import Path
12
+
13
+ from pplogger.formatters import JSONFormatter
14
+
15
+ DEFAULT_LOG_DIR = Path(os.environ.get("PPLOGGER_DIR", "/tmp"))
16
+ DEFAULT_SERVICE = os.environ.get("PPLOGGER_SERVICE", "service_api")
17
+ DEFAULT_MODULE = os.environ.get("PPLOGGER_MODULE", "module_pepe")
18
+
19
+
20
+ def build_log_path(
21
+ service: str = DEFAULT_SERVICE,
22
+ module: str = DEFAULT_MODULE,
23
+ log_dir: str | os.PathLike[str] = DEFAULT_LOG_DIR,
24
+ when: dt.date | None = None,
25
+ ) -> Path:
26
+ """Return the daily log file path, e.g. /tmp/service_api.module_pepe_logs.2024_07_13.log"""
27
+ day = (when or dt.date.today()).strftime("%Y_%m_%d")
28
+ return Path(log_dir) / f"{service}.{module}_logs.{day}.log"
29
+
30
+
31
+ class DailyDatedFileHandler(logging.FileHandler):
32
+ """A ``FileHandler`` that rolls over to a new date-stamped file at midnight.
33
+
34
+ Unlike ``TimedRotatingFileHandler`` (which renames the active file and keeps
35
+ a fixed base name), this preserves pplogger's convention of putting the day
36
+ in the filename: each calendar day gets its own
37
+ ``<service>.<module>_logs.<YYYY_MM_DD>.log``. When the local date changes,
38
+ the current stream is closed and a new dated file is opened.
39
+
40
+ ``clock`` is injectable for testing; it defaults to ``datetime.date.today``.
41
+ """
42
+
43
+ def __init__(
44
+ self,
45
+ service: str,
46
+ module: str,
47
+ log_dir: str | os.PathLike[str],
48
+ encoding: str | None = None,
49
+ clock: Callable[[], dt.date] | None = None,
50
+ ) -> None:
51
+ self._service = service
52
+ self._module = module
53
+ self._log_dir = log_dir
54
+ self._clock = clock or dt.date.today
55
+ self._current_day = self._clock()
56
+ path = build_log_path(service, module, log_dir, self._current_day)
57
+ super().__init__(path, encoding=encoding)
58
+
59
+ def emit(self, record: logging.LogRecord) -> None:
60
+ today = self._clock()
61
+ if today != self._current_day:
62
+ self._roll_to(today)
63
+ super().emit(record)
64
+
65
+ def _roll_to(self, day: dt.date) -> None:
66
+ self.acquire()
67
+ try:
68
+ if self.stream:
69
+ self.stream.close()
70
+ self.stream = None
71
+ self._current_day = day
72
+ new_path = build_log_path(self._service, self._module, self._log_dir, day)
73
+ self.baseFilename = os.path.abspath(str(new_path))
74
+ self.stream = self._open()
75
+ finally:
76
+ self.release()
77
+
78
+
79
+ def initializer_logger(
80
+ service: str = DEFAULT_SERVICE,
81
+ module: str = DEFAULT_MODULE,
82
+ log_dir: str | os.PathLike[str] = DEFAULT_LOG_DIR,
83
+ debug: bool = False,
84
+ console: bool = True,
85
+ level: int | str | None = None,
86
+ max_bytes: int = 0,
87
+ backup_count: int = 0,
88
+ hostname: str | None = None,
89
+ rotate_daily: bool = False,
90
+ ) -> Path:
91
+ """Configure the root logger to emit JSON records to a daily file.
92
+
93
+ Returns the path of the active log file so callers can hand it to the
94
+ Go shipper or to tests.
95
+
96
+ :param level: explicit level (e.g. ``logging.WARNING`` or ``"WARNING"``).
97
+ Takes precedence over ``debug`` when given.
98
+ :param max_bytes: when > 0, rotate the file once it reaches this size using
99
+ ``RotatingFileHandler`` instead of a plain ``FileHandler``.
100
+ :param backup_count: number of rotated backups to keep (only with
101
+ ``max_bytes``).
102
+ :param hostname: override the ``hostname`` field (defaults to
103
+ ``socket.gethostname()``); useful in containers where the host id is
104
+ injected via the environment.
105
+ :param rotate_daily: when True (and ``max_bytes`` is 0), roll over to a new
106
+ date-stamped file at midnight via :class:`DailyDatedFileHandler`.
107
+ Ignored when ``max_bytes`` > 0 (size-based rotation takes precedence).
108
+ """
109
+ log_path = build_log_path(service=service, module=module, log_dir=log_dir)
110
+ log_path.parent.mkdir(parents=True, exist_ok=True)
111
+
112
+ resolved_level = _resolve_level(level, debug)
113
+ formatter = JSONFormatter(
114
+ service=service,
115
+ module=module,
116
+ hostname=hostname or os.environ.get("PPLOGGER_HOSTNAME"),
117
+ )
118
+
119
+ root = logging.getLogger()
120
+ root.setLevel(resolved_level)
121
+
122
+ # Replace any handlers we previously installed so repeat calls are idempotent.
123
+ for handler in list(root.handlers):
124
+ if getattr(handler, "_pplogger", False):
125
+ root.removeHandler(handler)
126
+ handler.close()
127
+
128
+ file_handler: logging.FileHandler
129
+ if max_bytes > 0:
130
+ file_handler = RotatingFileHandler(
131
+ log_path, maxBytes=max_bytes, backupCount=backup_count, encoding="utf-8"
132
+ )
133
+ elif rotate_daily:
134
+ file_handler = DailyDatedFileHandler(service, module, log_dir, encoding="utf-8")
135
+ else:
136
+ file_handler = logging.FileHandler(log_path, encoding="utf-8")
137
+ file_handler.setLevel(resolved_level)
138
+ file_handler.setFormatter(formatter)
139
+ file_handler._pplogger = True # type: ignore[attr-defined]
140
+ root.addHandler(file_handler)
141
+
142
+ if console:
143
+ stream_handler = logging.StreamHandler(stream=sys.stdout)
144
+ stream_handler.setLevel(resolved_level)
145
+ stream_handler.setFormatter(formatter)
146
+ stream_handler._pplogger = True # type: ignore[attr-defined]
147
+ root.addHandler(stream_handler)
148
+
149
+ return log_path
150
+
151
+
152
+ def _resolve_level(level: int | str | None, debug: bool) -> int:
153
+ """Resolve the effective numeric level from explicit/debug/env inputs."""
154
+ if level is not None:
155
+ if isinstance(level, str):
156
+ num = logging.getLevelName(level.upper())
157
+ if not isinstance(num, int):
158
+ raise ValueError(f"unknown log level: {level!r}")
159
+ return num
160
+ return int(level)
161
+ return logging.DEBUG if debug or _env_truthy("PPLOGGER_DEBUG") else logging.INFO
162
+
163
+
164
+ def _env_truthy(name: str) -> bool:
165
+ return os.environ.get(name, "").strip().lower() in {"1", "true", "yes", "on"}
@@ -0,0 +1,68 @@
1
+ [tool.poetry]
2
+ # Distribution name on PyPI (the importable package is still `pplogger`).
3
+ name = "pepe-logger"
4
+ description = "Structured JSON logging for distributed systems, with a Go log shipper."
5
+ authors = ["Jose Luis Alonzo Velazquez <pepemxl@gmail.com>"]
6
+ readme = "README.md"
7
+ license = "MIT"
8
+ homepage = "https://github.com/pepemxl/pplogger"
9
+ repository = "https://github.com/pepemxl/pplogger.git"
10
+ documentation = "https://github.com/pepemxl/pplogger"
11
+ keywords = ["logging", "json", "observability", "distributed-systems", "structured-logging"]
12
+ classifiers = [
13
+ "Development Status :: 3 - Alpha",
14
+ "Intended Audience :: Developers",
15
+ "Operating System :: OS Independent",
16
+ "Topic :: System :: Logging",
17
+ "Typing :: Typed",
18
+ ]
19
+ version = "0.0.3"
20
+ packages = [ {include="pplogger"}]
21
+ [tool.poetry.dependencies]
22
+ python = ">=3.10,<4.0"
23
+
24
+ [tool.poetry.group.dev.dependencies]
25
+ pytest = '*'
26
+ pytest-cov = '*'
27
+ ipython = '*'
28
+ coverage = '*'
29
+ pre-commit = '*'
30
+ mypy = '*'
31
+ ruff = '*'
32
+ mkdocs = '*'
33
+ mkdocs-material = '*'
34
+
35
+ [tool.poetry.group.test.dependencies]
36
+ pytest = "*"
37
+
38
+ [build-system]
39
+ requires = ["poetry-core>=1.0.0"]
40
+ build-backend = "poetry.core.masonry.api"
41
+
42
+ [tool.ruff]
43
+ target-version = "py310"
44
+ line-length = 100
45
+ src = ["pplogger", "tests"]
46
+
47
+ [tool.ruff.lint]
48
+ select = ["E", "F", "I", "UP", "B", "SIM"]
49
+
50
+ [tool.mypy]
51
+ python_version = "3.10"
52
+ files = ["pplogger"]
53
+ warn_unused_ignores = true
54
+ warn_redundant_casts = true
55
+ warn_unused_configs = true
56
+ disallow_untyped_defs = true
57
+ no_implicit_optional = true
58
+
59
+ [tool.pytest.ini_options]
60
+ addopts = "--cov=pplogger --cov-report=term-missing --cov-fail-under=85"
61
+ testpaths = ["tests"]
62
+
63
+ [tool.coverage.run]
64
+ branch = true
65
+ source = ["pplogger"]
66
+
67
+ [tool.coverage.report]
68
+ show_missing = true