dlogify 0.1.0__py3-none-any.whl
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.
- dlogify/__init__.py +160 -0
- dlogify/__main__.py +3 -0
- dlogify/_capture_logging.py +56 -0
- dlogify/_client.py +239 -0
- dlogify/_config.py +194 -0
- dlogify/_diagnostics.py +54 -0
- dlogify/_encode.py +100 -0
- dlogify/_exporter.py +407 -0
- dlogify/_guard.py +29 -0
- dlogify/_hooks.py +95 -0
- dlogify/_http.py +52 -0
- dlogify/_levels.py +88 -0
- dlogify/_parse/__init__.py +0 -0
- dlogify/_parse/compact_json.py +55 -0
- dlogify/_parse/json_line.py +178 -0
- dlogify/_parse/lines.py +64 -0
- dlogify/_parse/stream_parser.py +106 -0
- dlogify/_parse/text_event.py +150 -0
- dlogify/_record.py +149 -0
- dlogify/_singleton.py +12 -0
- dlogify/_version.py +4 -0
- dlogify/handler.py +70 -0
- dlogify/py.typed +0 -0
- dlogify/run/__init__.py +0 -0
- dlogify/run/child.py +215 -0
- dlogify/run/cli.py +22 -0
- dlogify-0.1.0.dist-info/METADATA +111 -0
- dlogify-0.1.0.dist-info/RECORD +31 -0
- dlogify-0.1.0.dist-info/WHEEL +4 -0
- dlogify-0.1.0.dist-info/entry_points.txt +3 -0
- dlogify-0.1.0.dist-info/licenses/LICENSE +21 -0
dlogify/__init__.py
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
"""dlogify: send Python logs and uncaught exceptions to Logify.
|
|
2
|
+
|
|
3
|
+
import dlogify
|
|
4
|
+
dlogify.init()
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import os
|
|
10
|
+
from collections.abc import Mapping
|
|
11
|
+
from typing import Optional
|
|
12
|
+
|
|
13
|
+
from . import _singleton
|
|
14
|
+
from ._client import Client
|
|
15
|
+
from ._diagnostics import Diagnostics, is_flag
|
|
16
|
+
from ._version import VERSION as __version__
|
|
17
|
+
from .handler import LogifyHandler
|
|
18
|
+
|
|
19
|
+
__all__ = [
|
|
20
|
+
"init",
|
|
21
|
+
"trace",
|
|
22
|
+
"debug",
|
|
23
|
+
"info",
|
|
24
|
+
"warn",
|
|
25
|
+
"warning",
|
|
26
|
+
"error",
|
|
27
|
+
"fatal",
|
|
28
|
+
"critical",
|
|
29
|
+
"log",
|
|
30
|
+
"capture_exception",
|
|
31
|
+
"flush",
|
|
32
|
+
"shutdown",
|
|
33
|
+
"Client",
|
|
34
|
+
"LogifyHandler",
|
|
35
|
+
"__version__",
|
|
36
|
+
]
|
|
37
|
+
|
|
38
|
+
_noted_before_init = False
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _active() -> Optional[Client]:
|
|
42
|
+
global _noted_before_init
|
|
43
|
+
client = _singleton.current
|
|
44
|
+
if client is None and not _noted_before_init:
|
|
45
|
+
_noted_before_init = True
|
|
46
|
+
try:
|
|
47
|
+
Diagnostics(is_flag(os.environ.get("LOGIFY_DEBUG"))).debug("a log call before init() was dropped")
|
|
48
|
+
except Exception:
|
|
49
|
+
pass
|
|
50
|
+
return client
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def init(
|
|
54
|
+
*,
|
|
55
|
+
api_key: Optional[str] = None,
|
|
56
|
+
endpoint: Optional[str] = None,
|
|
57
|
+
service: Optional[str] = None,
|
|
58
|
+
environment: Optional[str] = None,
|
|
59
|
+
release: Optional[str] = None,
|
|
60
|
+
min_level: Optional[str] = None,
|
|
61
|
+
capture_logging: bool = True,
|
|
62
|
+
capture_uncaught: bool = True,
|
|
63
|
+
attributes: Optional[Mapping[str, object]] = None,
|
|
64
|
+
max_queue_size: Optional[int] = None,
|
|
65
|
+
disabled: Optional[bool] = None,
|
|
66
|
+
debug: Optional[bool] = None,
|
|
67
|
+
) -> None:
|
|
68
|
+
"""Starts the client: captures logging and uncaught exceptions unless turned off.
|
|
69
|
+
Safe to call from any thread; a second call is ignored."""
|
|
70
|
+
try:
|
|
71
|
+
with _singleton.lock:
|
|
72
|
+
existing = _singleton.current
|
|
73
|
+
if existing is not None:
|
|
74
|
+
existing.diagnostics.debug("init() was called again; ignored")
|
|
75
|
+
return
|
|
76
|
+
_singleton.current = Client(
|
|
77
|
+
api_key=api_key,
|
|
78
|
+
endpoint=endpoint,
|
|
79
|
+
service=service,
|
|
80
|
+
environment=environment,
|
|
81
|
+
release=release,
|
|
82
|
+
min_level=min_level,
|
|
83
|
+
capture_logging=capture_logging,
|
|
84
|
+
capture_uncaught=capture_uncaught,
|
|
85
|
+
attributes=attributes,
|
|
86
|
+
max_queue_size=max_queue_size,
|
|
87
|
+
disabled=disabled,
|
|
88
|
+
debug=debug,
|
|
89
|
+
)
|
|
90
|
+
except Exception:
|
|
91
|
+
pass # init() never raises
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def trace(message: object, *, exc: Optional[BaseException] = None, attributes: Optional[Mapping[str, object]] = None) -> None:
|
|
95
|
+
client = _active()
|
|
96
|
+
if client is not None:
|
|
97
|
+
client.trace(message, exc=exc, attributes=attributes)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def debug(message: object, *, exc: Optional[BaseException] = None, attributes: Optional[Mapping[str, object]] = None) -> None:
|
|
101
|
+
client = _active()
|
|
102
|
+
if client is not None:
|
|
103
|
+
client.debug(message, exc=exc, attributes=attributes)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def info(message: object, *, exc: Optional[BaseException] = None, attributes: Optional[Mapping[str, object]] = None) -> None:
|
|
107
|
+
client = _active()
|
|
108
|
+
if client is not None:
|
|
109
|
+
client.info(message, exc=exc, attributes=attributes)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def warn(message: object, *, exc: Optional[BaseException] = None, attributes: Optional[Mapping[str, object]] = None) -> None:
|
|
113
|
+
client = _active()
|
|
114
|
+
if client is not None:
|
|
115
|
+
client.warn(message, exc=exc, attributes=attributes)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
warning = warn
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def error(message: object, *, exc: Optional[BaseException] = None, attributes: Optional[Mapping[str, object]] = None) -> None:
|
|
122
|
+
client = _active()
|
|
123
|
+
if client is not None:
|
|
124
|
+
client.error(message, exc=exc, attributes=attributes)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def fatal(message: object, *, exc: Optional[BaseException] = None, attributes: Optional[Mapping[str, object]] = None) -> None:
|
|
128
|
+
client = _active()
|
|
129
|
+
if client is not None:
|
|
130
|
+
client.fatal(message, exc=exc, attributes=attributes)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
critical = fatal
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def log(level: str, message: object, *, exc: Optional[BaseException] = None, attributes: Optional[Mapping[str, object]] = None) -> None:
|
|
137
|
+
client = _active()
|
|
138
|
+
if client is not None:
|
|
139
|
+
client.log(level, message, exc=exc, attributes=attributes)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def capture_exception(exc: Optional[BaseException] = None, *, attributes: Optional[Mapping[str, object]] = None) -> None:
|
|
143
|
+
client = _active()
|
|
144
|
+
if client is not None:
|
|
145
|
+
client.capture_exception(exc, attributes=attributes)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def flush(timeout: float = 2.0) -> None:
|
|
149
|
+
client = _singleton.current
|
|
150
|
+
if client is not None:
|
|
151
|
+
client.flush(timeout)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def shutdown(timeout: float = 2.0) -> None:
|
|
155
|
+
"""Flushes, stops and restores what init() installed; init() may run again."""
|
|
156
|
+
with _singleton.lock:
|
|
157
|
+
client = _singleton.current
|
|
158
|
+
_singleton.current = None
|
|
159
|
+
if client is not None:
|
|
160
|
+
client.shutdown(timeout)
|
dlogify/__main__.py
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""init() hands every logging record to its LogifyHandler by wrapping
|
|
2
|
+
Logger.callHandlers instead of adding a handler to the root logger: a root
|
|
3
|
+
logger without handlers keeps logging.basicConfig() and logging.lastResort
|
|
4
|
+
working (spec D3)."""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import logging
|
|
9
|
+
import threading
|
|
10
|
+
from typing import Callable
|
|
11
|
+
|
|
12
|
+
_lock = threading.Lock()
|
|
13
|
+
_handlers: list[logging.Handler] = []
|
|
14
|
+
# The wrapper we installed last, while Logger.callHandlers is still it.
|
|
15
|
+
_installed: "Callable | None" = None
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _wrap(previous: Callable) -> Callable:
|
|
19
|
+
def call_handlers(self: logging.Logger, record: logging.LogRecord) -> None:
|
|
20
|
+
try:
|
|
21
|
+
previous(self, record)
|
|
22
|
+
finally:
|
|
23
|
+
# Another library may have wrapped us and been wrapped again: each
|
|
24
|
+
# record reaches our handlers once.
|
|
25
|
+
if not getattr(record, "_dlogify_seen", False):
|
|
26
|
+
try:
|
|
27
|
+
record._dlogify_seen = True
|
|
28
|
+
for handler in tuple(_handlers):
|
|
29
|
+
if record.levelno >= handler.level:
|
|
30
|
+
handler.handle(record)
|
|
31
|
+
except Exception:
|
|
32
|
+
pass # never break the application's logging call
|
|
33
|
+
|
|
34
|
+
call_handlers._dlogify_previous = previous # type: ignore[attr-defined]
|
|
35
|
+
return call_handlers
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def install_logging_capture(handler: logging.Handler) -> Callable[[], None]:
|
|
39
|
+
global _installed
|
|
40
|
+
with _lock:
|
|
41
|
+
current = logging.Logger.callHandlers
|
|
42
|
+
if not hasattr(current, "_dlogify_previous"):
|
|
43
|
+
_installed = _wrap(current)
|
|
44
|
+
logging.Logger.callHandlers = _installed # type: ignore[method-assign]
|
|
45
|
+
_handlers.append(handler)
|
|
46
|
+
|
|
47
|
+
def undo() -> None:
|
|
48
|
+
global _installed
|
|
49
|
+
with _lock:
|
|
50
|
+
if handler in _handlers:
|
|
51
|
+
_handlers.remove(handler)
|
|
52
|
+
if not _handlers and _installed is not None and logging.Logger.callHandlers is _installed:
|
|
53
|
+
logging.Logger.callHandlers = _installed._dlogify_previous # type: ignore[attr-defined,method-assign]
|
|
54
|
+
_installed = None
|
|
55
|
+
|
|
56
|
+
return undo
|
dlogify/_client.py
ADDED
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import math
|
|
5
|
+
import os
|
|
6
|
+
import sys
|
|
7
|
+
import threading
|
|
8
|
+
from collections.abc import Mapping
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
from typing import Any, Callable, Optional
|
|
11
|
+
|
|
12
|
+
from ._capture_logging import install_logging_capture
|
|
13
|
+
from ._config import ResolvedConfig, disabled_config, resolve_config
|
|
14
|
+
from ._diagnostics import Diagnostics, describe_error
|
|
15
|
+
from ._encode import resource_attributes
|
|
16
|
+
from ._exporter import Exporter
|
|
17
|
+
from ._guard import guarded
|
|
18
|
+
from ._hooks import ExitBudget, install_exit_flush, install_uncaught_capture
|
|
19
|
+
from ._levels import LEVELNO, Level, level_from_word, level_rank
|
|
20
|
+
from ._record import (
|
|
21
|
+
LogRecord,
|
|
22
|
+
exception_attributes,
|
|
23
|
+
exception_title,
|
|
24
|
+
finalize_record,
|
|
25
|
+
normalize_attributes,
|
|
26
|
+
normalize_value,
|
|
27
|
+
now_unix_nano,
|
|
28
|
+
)
|
|
29
|
+
from ._version import USER_AGENT
|
|
30
|
+
from .handler import LogifyHandler
|
|
31
|
+
|
|
32
|
+
DEFAULT_FLUSH_TIMEOUT = 2.0
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass
|
|
36
|
+
class Internals:
|
|
37
|
+
"""Not part of the documented API: tests and `logify run` use it."""
|
|
38
|
+
|
|
39
|
+
env: Optional[Mapping[str, str]] = None
|
|
40
|
+
cwd: Optional[str] = None
|
|
41
|
+
user_agent: Optional[str] = None
|
|
42
|
+
exporter: dict[str, Any] = field(default_factory=dict)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _valid_timeout(timeout: object) -> float:
|
|
46
|
+
if isinstance(timeout, (int, float)) and not isinstance(timeout, bool) and math.isfinite(timeout) and timeout >= 0:
|
|
47
|
+
return float(timeout)
|
|
48
|
+
return DEFAULT_FLUSH_TIMEOUT
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _as_text(value: object) -> str:
|
|
52
|
+
if isinstance(value, str):
|
|
53
|
+
return value
|
|
54
|
+
normalized = normalize_value(value)
|
|
55
|
+
if normalized is None:
|
|
56
|
+
return ""
|
|
57
|
+
return normalized if isinstance(normalized, str) else json.dumps(normalized)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class Client:
|
|
61
|
+
def __init__(
|
|
62
|
+
self,
|
|
63
|
+
*,
|
|
64
|
+
api_key: Optional[str] = None,
|
|
65
|
+
endpoint: Optional[str] = None,
|
|
66
|
+
service: Optional[str] = None,
|
|
67
|
+
environment: Optional[str] = None,
|
|
68
|
+
release: Optional[str] = None,
|
|
69
|
+
min_level: Optional[str] = None,
|
|
70
|
+
capture_logging: bool = True,
|
|
71
|
+
capture_uncaught: bool = True,
|
|
72
|
+
attributes: Optional[Mapping[str, object]] = None,
|
|
73
|
+
max_queue_size: Optional[int] = None,
|
|
74
|
+
disabled: Optional[bool] = None,
|
|
75
|
+
debug: Optional[bool] = None,
|
|
76
|
+
_internals: Optional[Internals] = None,
|
|
77
|
+
) -> None:
|
|
78
|
+
internals = _internals or Internals()
|
|
79
|
+
options = {
|
|
80
|
+
"api_key": api_key,
|
|
81
|
+
"endpoint": endpoint,
|
|
82
|
+
"service": service,
|
|
83
|
+
"environment": environment,
|
|
84
|
+
"release": release,
|
|
85
|
+
"min_level": min_level,
|
|
86
|
+
"capture_logging": capture_logging,
|
|
87
|
+
"capture_uncaught": capture_uncaught,
|
|
88
|
+
"attributes": attributes,
|
|
89
|
+
"max_queue_size": max_queue_size,
|
|
90
|
+
"disabled": disabled,
|
|
91
|
+
"debug": debug,
|
|
92
|
+
}
|
|
93
|
+
try:
|
|
94
|
+
env = os.environ if internals.env is None else internals.env
|
|
95
|
+
cwd = os.getcwd() if internals.cwd is None else internals.cwd
|
|
96
|
+
config, warnings = resolve_config(options, env, cwd)
|
|
97
|
+
except Exception as err:
|
|
98
|
+
config, warnings = disabled_config(), [f"invalid configuration ({describe_error(err)}); Logify is disabled"]
|
|
99
|
+
self.config: ResolvedConfig = config
|
|
100
|
+
self.diagnostics = Diagnostics(config.debug)
|
|
101
|
+
self._exporter: Optional[Exporter] = None
|
|
102
|
+
# Undo steps for everything this client installed, run by shutdown().
|
|
103
|
+
self._undo: list[Callable[[], None]] = []
|
|
104
|
+
self._lock = threading.Lock()
|
|
105
|
+
self._shut_down = False
|
|
106
|
+
self._closed = False
|
|
107
|
+
for warning in warnings:
|
|
108
|
+
self.diagnostics.warn(warning)
|
|
109
|
+
if not config.enabled:
|
|
110
|
+
return
|
|
111
|
+
try:
|
|
112
|
+
self._exporter = Exporter(
|
|
113
|
+
url=config.logs_url,
|
|
114
|
+
api_key=config.api_key,
|
|
115
|
+
user_agent=internals.user_agent or USER_AGENT,
|
|
116
|
+
resource=resource_attributes(
|
|
117
|
+
service=config.service, environment=config.environment, release=config.release, attributes=config.attributes
|
|
118
|
+
),
|
|
119
|
+
max_queue_size=config.max_queue_size,
|
|
120
|
+
diagnostics=self.diagnostics,
|
|
121
|
+
**internals.exporter,
|
|
122
|
+
)
|
|
123
|
+
self._install()
|
|
124
|
+
except Exception as err:
|
|
125
|
+
self.diagnostics.warn(f"could not start: {describe_error(err)}")
|
|
126
|
+
|
|
127
|
+
def _install(self) -> None:
|
|
128
|
+
"""Installs the captures the configuration asks for."""
|
|
129
|
+
budget = ExitBudget()
|
|
130
|
+
self._undo.append(install_exit_flush(self, budget))
|
|
131
|
+
if self.config.capture_logging:
|
|
132
|
+
self._undo.append(install_logging_capture(LogifyHandler(LEVELNO[self.config.min_level], client=self)))
|
|
133
|
+
if self.config.capture_uncaught:
|
|
134
|
+
self._undo.append(install_uncaught_capture(self, budget))
|
|
135
|
+
|
|
136
|
+
def trace(self, message: object, *, exc: Optional[BaseException] = None, attributes: Optional[Mapping[str, object]] = None) -> None:
|
|
137
|
+
self.log("trace", message, exc=exc, attributes=attributes)
|
|
138
|
+
|
|
139
|
+
def debug(self, message: object, *, exc: Optional[BaseException] = None, attributes: Optional[Mapping[str, object]] = None) -> None:
|
|
140
|
+
self.log("debug", message, exc=exc, attributes=attributes)
|
|
141
|
+
|
|
142
|
+
def info(self, message: object, *, exc: Optional[BaseException] = None, attributes: Optional[Mapping[str, object]] = None) -> None:
|
|
143
|
+
self.log("info", message, exc=exc, attributes=attributes)
|
|
144
|
+
|
|
145
|
+
def warn(self, message: object, *, exc: Optional[BaseException] = None, attributes: Optional[Mapping[str, object]] = None) -> None:
|
|
146
|
+
self.log("warn", message, exc=exc, attributes=attributes)
|
|
147
|
+
|
|
148
|
+
warning = warn
|
|
149
|
+
|
|
150
|
+
def error(self, message: object, *, exc: Optional[BaseException] = None, attributes: Optional[Mapping[str, object]] = None) -> None:
|
|
151
|
+
self.log("error", message, exc=exc, attributes=attributes)
|
|
152
|
+
|
|
153
|
+
def fatal(self, message: object, *, exc: Optional[BaseException] = None, attributes: Optional[Mapping[str, object]] = None) -> None:
|
|
154
|
+
self.log("fatal", message, exc=exc, attributes=attributes)
|
|
155
|
+
|
|
156
|
+
critical = fatal
|
|
157
|
+
|
|
158
|
+
def log(
|
|
159
|
+
self, level: str, message: object, *, exc: Optional[BaseException] = None, attributes: Optional[Mapping[str, object]] = None
|
|
160
|
+
) -> None:
|
|
161
|
+
try:
|
|
162
|
+
if self._exporter is None:
|
|
163
|
+
return
|
|
164
|
+
lvl = level_from_word(level) if isinstance(level, str) else None
|
|
165
|
+
if lvl is None:
|
|
166
|
+
self.diagnostics.debug(f"unknown level {json.dumps(level) if isinstance(level, str) else repr(level)}; logged as info")
|
|
167
|
+
lvl = "info"
|
|
168
|
+
if not self._accepts(lvl):
|
|
169
|
+
return
|
|
170
|
+
body = _as_text(message)
|
|
171
|
+
record_attributes: dict[str, object] = {}
|
|
172
|
+
if isinstance(exc, BaseException):
|
|
173
|
+
exception = exception_attributes(exc)
|
|
174
|
+
record_attributes.update(exception)
|
|
175
|
+
if not body:
|
|
176
|
+
body = exception_title(exception["exception.type"], exception["exception.message"])
|
|
177
|
+
normalize_attributes(attributes, record_attributes)
|
|
178
|
+
self._emit("logify", lvl, body, record_attributes)
|
|
179
|
+
except Exception as err:
|
|
180
|
+
self.diagnostics.debug(f"dropped a record: {describe_error(err)}")
|
|
181
|
+
|
|
182
|
+
def capture_exception(self, exc: Optional[BaseException] = None, *, attributes: Optional[Mapping[str, object]] = None) -> None:
|
|
183
|
+
"""Level error; the body is "<type>: <message>". Without exc, the exception being handled."""
|
|
184
|
+
try:
|
|
185
|
+
if self._exporter is None:
|
|
186
|
+
return
|
|
187
|
+
if exc is None:
|
|
188
|
+
exc = sys.exc_info()[1]
|
|
189
|
+
if not isinstance(exc, BaseException):
|
|
190
|
+
self.diagnostics.debug("capture_exception() without an exception; ignored")
|
|
191
|
+
return
|
|
192
|
+
exception = exception_attributes(exc)
|
|
193
|
+
record_attributes: dict[str, object] = dict(exception)
|
|
194
|
+
normalize_attributes(attributes, record_attributes)
|
|
195
|
+
self._emit("logify", "error", exception_title(exception["exception.type"], exception["exception.message"]), record_attributes)
|
|
196
|
+
except Exception as err:
|
|
197
|
+
self.diagnostics.debug(f"dropped a record: {describe_error(err)}")
|
|
198
|
+
|
|
199
|
+
def _emit(
|
|
200
|
+
self, scope: str, level: Optional[Level], body: str, attributes: dict[str, object], time_unix_nano: Optional[str] = None
|
|
201
|
+
) -> None:
|
|
202
|
+
"""Internal entry for captures and `logify run`: applies min_level, the
|
|
203
|
+
size cuts and the times, then queues the record."""
|
|
204
|
+
exporter = self._exporter
|
|
205
|
+
if exporter is None or self._closed or not self._accepts(level):
|
|
206
|
+
return
|
|
207
|
+
try:
|
|
208
|
+
observed = now_unix_nano()
|
|
209
|
+
with guarded():
|
|
210
|
+
exporter.enqueue(finalize_record(LogRecord(scope, time_unix_nano or observed, observed, level, body, attributes)))
|
|
211
|
+
except Exception as err:
|
|
212
|
+
self.diagnostics.debug(f"dropped a record: {describe_error(err)}")
|
|
213
|
+
|
|
214
|
+
def flush(self, timeout: float = DEFAULT_FLUSH_TIMEOUT) -> None:
|
|
215
|
+
try:
|
|
216
|
+
if self._exporter is not None:
|
|
217
|
+
self._exporter.flush(_valid_timeout(timeout))
|
|
218
|
+
except Exception as err:
|
|
219
|
+
self.diagnostics.debug(f"flush failed: {describe_error(err)}")
|
|
220
|
+
|
|
221
|
+
def shutdown(self, timeout: float = DEFAULT_FLUSH_TIMEOUT) -> None:
|
|
222
|
+
"""Flushes, stops the export thread and restores everything this client installed."""
|
|
223
|
+
with self._lock:
|
|
224
|
+
if self._shut_down:
|
|
225
|
+
return
|
|
226
|
+
self._shut_down = True
|
|
227
|
+
self.flush(timeout)
|
|
228
|
+
self._closed = True
|
|
229
|
+
if self._exporter is not None:
|
|
230
|
+
self._exporter.close()
|
|
231
|
+
while self._undo:
|
|
232
|
+
undo = self._undo.pop()
|
|
233
|
+
try:
|
|
234
|
+
undo()
|
|
235
|
+
except Exception:
|
|
236
|
+
pass # restoring is best effort
|
|
237
|
+
|
|
238
|
+
def _accepts(self, level: Optional[Level]) -> bool:
|
|
239
|
+
return level_rank(level) >= level_rank(self.config.min_level)
|
dlogify/_config.py
ADDED
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
"""docs/12 §1: option > environment variable > default. Never raises for bad
|
|
2
|
+
values: it falls back to the default and returns a warning."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import re
|
|
9
|
+
import urllib.parse
|
|
10
|
+
from collections.abc import Mapping
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
from typing import Optional
|
|
13
|
+
|
|
14
|
+
from ._diagnostics import is_flag
|
|
15
|
+
from ._levels import Level, is_level
|
|
16
|
+
from ._record import AttributeValue, normalize_attributes
|
|
17
|
+
|
|
18
|
+
DEFAULT_ENDPOINT = "https://api.dlogify.com"
|
|
19
|
+
DEFAULT_MAX_QUEUE_SIZE = 2000
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass(frozen=True)
|
|
23
|
+
class ResolvedConfig:
|
|
24
|
+
api_key: str
|
|
25
|
+
endpoint: str
|
|
26
|
+
logs_url: str
|
|
27
|
+
service: str
|
|
28
|
+
environment: str
|
|
29
|
+
release: str
|
|
30
|
+
min_level: Level
|
|
31
|
+
capture_logging: bool
|
|
32
|
+
capture_uncaught: bool
|
|
33
|
+
attributes: dict[str, AttributeValue] = field(default_factory=dict)
|
|
34
|
+
max_queue_size: int = DEFAULT_MAX_QUEUE_SIZE
|
|
35
|
+
enabled: bool = False
|
|
36
|
+
debug: bool = False
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def resolve_config(options: Mapping[str, object], env: Mapping[str, str], cwd: str) -> tuple[ResolvedConfig, list[str]]:
|
|
40
|
+
warnings: list[str] = []
|
|
41
|
+
|
|
42
|
+
def from_env(name: str) -> Optional[str]:
|
|
43
|
+
value = env.get(name)
|
|
44
|
+
value = value.strip() if isinstance(value, str) else ""
|
|
45
|
+
return value or None
|
|
46
|
+
|
|
47
|
+
def read(option: str, name: str) -> Optional[str]:
|
|
48
|
+
value = options.get(option)
|
|
49
|
+
if isinstance(value, str) and value.strip():
|
|
50
|
+
return value.strip()
|
|
51
|
+
return from_env(name)
|
|
52
|
+
|
|
53
|
+
def option_flag(option: str) -> Optional[bool]:
|
|
54
|
+
value = options.get(option)
|
|
55
|
+
if value is None or isinstance(value, bool):
|
|
56
|
+
return value
|
|
57
|
+
warnings.append(f"invalid {option} {value!r} (expected True or False); ignored")
|
|
58
|
+
return None
|
|
59
|
+
|
|
60
|
+
def flag(option: str, name: str) -> bool:
|
|
61
|
+
value = option_flag(option)
|
|
62
|
+
return value if value is not None else is_flag(env.get(name))
|
|
63
|
+
|
|
64
|
+
debug = flag("debug", "LOGIFY_DEBUG")
|
|
65
|
+
enabled = not flag("disabled", "LOGIFY_DISABLED")
|
|
66
|
+
api_key = read("api_key", "LOGIFY_API_KEY") or ""
|
|
67
|
+
if enabled and not api_key:
|
|
68
|
+
enabled = False
|
|
69
|
+
warnings.append("LOGIFY_API_KEY is not set; Logify is disabled")
|
|
70
|
+
elif enabled and not (api_key.isascii() and api_key.isprintable()):
|
|
71
|
+
enabled = False
|
|
72
|
+
warnings.append("LOGIFY_API_KEY has characters an HTTP header cannot carry; Logify is disabled")
|
|
73
|
+
|
|
74
|
+
endpoint = DEFAULT_ENDPOINT
|
|
75
|
+
raw_endpoint = read("endpoint", "LOGIFY_ENDPOINT")
|
|
76
|
+
if raw_endpoint is not None:
|
|
77
|
+
parsed = _parse_endpoint(raw_endpoint)
|
|
78
|
+
if parsed:
|
|
79
|
+
endpoint = parsed
|
|
80
|
+
else:
|
|
81
|
+
warnings.append(f"invalid endpoint {json.dumps(raw_endpoint)}; using {DEFAULT_ENDPOINT}")
|
|
82
|
+
|
|
83
|
+
min_level: Level = "info"
|
|
84
|
+
raw_level = read("min_level", "LOGIFY_MIN_LEVEL")
|
|
85
|
+
if raw_level is not None:
|
|
86
|
+
if is_level(raw_level.lower()):
|
|
87
|
+
min_level = raw_level.lower() # type: ignore[assignment]
|
|
88
|
+
else:
|
|
89
|
+
warnings.append(f"unknown level {json.dumps(raw_level.lower())}; using info")
|
|
90
|
+
|
|
91
|
+
max_queue_size = DEFAULT_MAX_QUEUE_SIZE
|
|
92
|
+
raw_size = options.get("max_queue_size")
|
|
93
|
+
if raw_size is not None:
|
|
94
|
+
if isinstance(raw_size, int) and not isinstance(raw_size, bool) and raw_size > 0:
|
|
95
|
+
max_queue_size = raw_size
|
|
96
|
+
else:
|
|
97
|
+
warnings.append(f"invalid max_queue_size {raw_size!r}; using {DEFAULT_MAX_QUEUE_SIZE}")
|
|
98
|
+
|
|
99
|
+
config = ResolvedConfig(
|
|
100
|
+
api_key=api_key,
|
|
101
|
+
endpoint=endpoint,
|
|
102
|
+
logs_url=f"{endpoint}/v1/logs",
|
|
103
|
+
service=read("service", "LOGIFY_SERVICE") or from_env("OTEL_SERVICE_NAME") or project_name(cwd) or "unknown_service",
|
|
104
|
+
environment=read("environment", "LOGIFY_ENVIRONMENT") or "default",
|
|
105
|
+
release=read("release", "LOGIFY_RELEASE") or "",
|
|
106
|
+
min_level=min_level,
|
|
107
|
+
capture_logging=option_flag("capture_logging") is not False,
|
|
108
|
+
capture_uncaught=option_flag("capture_uncaught") is not False,
|
|
109
|
+
attributes=normalize_attributes(options.get("attributes")),
|
|
110
|
+
max_queue_size=max_queue_size,
|
|
111
|
+
enabled=enabled,
|
|
112
|
+
debug=debug,
|
|
113
|
+
)
|
|
114
|
+
return config, warnings
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def disabled_config() -> ResolvedConfig:
|
|
118
|
+
"""Used when resolution itself fails."""
|
|
119
|
+
return ResolvedConfig(
|
|
120
|
+
api_key="",
|
|
121
|
+
endpoint=DEFAULT_ENDPOINT,
|
|
122
|
+
logs_url=f"{DEFAULT_ENDPOINT}/v1/logs",
|
|
123
|
+
service="unknown_service",
|
|
124
|
+
environment="default",
|
|
125
|
+
release="",
|
|
126
|
+
min_level="info",
|
|
127
|
+
capture_logging=False,
|
|
128
|
+
capture_uncaught=False,
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _parse_endpoint(raw: str) -> Optional[str]:
|
|
133
|
+
if any(char.isspace() for char in raw):
|
|
134
|
+
return None
|
|
135
|
+
try:
|
|
136
|
+
parts = urllib.parse.urlsplit(raw)
|
|
137
|
+
parts.port # raises ValueError for a port out of range
|
|
138
|
+
except ValueError:
|
|
139
|
+
return None
|
|
140
|
+
if parts.scheme.lower() not in ("http", "https") or not parts.hostname or parts.query or parts.fragment:
|
|
141
|
+
return None
|
|
142
|
+
if "?" in raw or "#" in raw:
|
|
143
|
+
return None
|
|
144
|
+
return raw.rstrip("/")
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def project_name(start: str) -> Optional[str]:
|
|
148
|
+
"""The [project] name of the nearest pyproject.toml, walking up from start."""
|
|
149
|
+
directory = os.path.abspath(start)
|
|
150
|
+
while True:
|
|
151
|
+
try:
|
|
152
|
+
with open(os.path.join(directory, "pyproject.toml"), "rb") as file:
|
|
153
|
+
data = file.read()
|
|
154
|
+
except OSError:
|
|
155
|
+
data = None
|
|
156
|
+
if data is not None:
|
|
157
|
+
return _project_name_from_toml(data.decode("utf-8", "replace"))
|
|
158
|
+
parent = os.path.dirname(directory)
|
|
159
|
+
if parent == directory:
|
|
160
|
+
return None
|
|
161
|
+
directory = parent
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _project_name_from_toml(text: str) -> Optional[str]:
|
|
165
|
+
try:
|
|
166
|
+
import tomllib
|
|
167
|
+
except ImportError: # Python 3.10
|
|
168
|
+
return scan_project_name(text)
|
|
169
|
+
try:
|
|
170
|
+
name = tomllib.loads(text).get("project", {}).get("name")
|
|
171
|
+
except Exception:
|
|
172
|
+
return None
|
|
173
|
+
return name if isinstance(name, str) and name else None
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
_TABLE = re.compile(r"^\s*\[\s*([^\]]+?)\s*\]\s*(?:#.*)?$")
|
|
177
|
+
_NAME = re.compile(r"""^\s*name\s*=\s*(?:"([^"\\]*)"|'([^']*)')\s*(?:#.*)?$""")
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def scan_project_name(text: str) -> Optional[str]:
|
|
181
|
+
"""A minimal reader for Python 3.10, which has no tomllib: the `name` key of
|
|
182
|
+
the [project] table, as a basic or literal string on one line."""
|
|
183
|
+
in_project = False
|
|
184
|
+
for line in text.splitlines():
|
|
185
|
+
table = _TABLE.match(line)
|
|
186
|
+
if table:
|
|
187
|
+
in_project = table.group(1) == "project"
|
|
188
|
+
continue
|
|
189
|
+
if in_project:
|
|
190
|
+
match = _NAME.match(line)
|
|
191
|
+
if match:
|
|
192
|
+
name = match.group(1) if match.group(1) is not None else match.group(2)
|
|
193
|
+
return name or None
|
|
194
|
+
return None
|