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/_record.py
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
"""Records: attribute values, exceptions and size cuts (docs/12 §3)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import math
|
|
7
|
+
import time
|
|
8
|
+
import traceback
|
|
9
|
+
from collections.abc import Mapping
|
|
10
|
+
from dataclasses import dataclass, replace
|
|
11
|
+
from types import TracebackType
|
|
12
|
+
from typing import Optional, Union
|
|
13
|
+
|
|
14
|
+
from ._levels import Level
|
|
15
|
+
|
|
16
|
+
AttributeValue = Union[str, bool, int, float]
|
|
17
|
+
|
|
18
|
+
# docs/12 §3 size cuts, in UTF-8 bytes.
|
|
19
|
+
BODY_MAX_BYTES = 16 * 1024
|
|
20
|
+
STACKTRACE_MAX_BYTES = 40 * 1024
|
|
21
|
+
ATTRIBUTE_MAX_BYTES = 4 * 1024
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass(frozen=True)
|
|
25
|
+
class LogRecord:
|
|
26
|
+
scope: str
|
|
27
|
+
time_unix_nano: str
|
|
28
|
+
observed_time_unix_nano: str
|
|
29
|
+
level: Optional[Level]
|
|
30
|
+
body: str
|
|
31
|
+
attributes: dict[str, object]
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def now_unix_nano() -> str:
|
|
35
|
+
return str(time.time_ns())
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def ms_to_unix_nano(ms: float) -> str:
|
|
39
|
+
return str(round(ms * 1000) * 1000)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def seconds_to_unix_nano(seconds: float) -> str:
|
|
43
|
+
return str(round(seconds * 1_000_000) * 1000)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def normalize_value(value: object) -> Optional[AttributeValue]:
|
|
47
|
+
"""Strings, booleans, integers and finite floats keep their type; None is
|
|
48
|
+
skipped; anything else is its compact JSON, or str() when JSON cannot render it."""
|
|
49
|
+
if value is None:
|
|
50
|
+
return None
|
|
51
|
+
if isinstance(value, (str, bool, int)):
|
|
52
|
+
return value
|
|
53
|
+
if isinstance(value, float) and math.isfinite(value):
|
|
54
|
+
return value
|
|
55
|
+
try:
|
|
56
|
+
return json.dumps(value, separators=(",", ":"), ensure_ascii=False, default=str)
|
|
57
|
+
except Exception:
|
|
58
|
+
pass
|
|
59
|
+
try:
|
|
60
|
+
return str(value)
|
|
61
|
+
except Exception:
|
|
62
|
+
return object.__repr__(value)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def normalize_attributes(source: object, into: Optional[dict] = None) -> dict:
|
|
66
|
+
out: dict = {} if into is None else into
|
|
67
|
+
if not isinstance(source, Mapping):
|
|
68
|
+
return out
|
|
69
|
+
for key, raw in source.items():
|
|
70
|
+
value = normalize_value(raw)
|
|
71
|
+
if value is not None:
|
|
72
|
+
out[str(key)] = value
|
|
73
|
+
return out
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def exception_type(exc: BaseException) -> str:
|
|
77
|
+
cls = type(exc)
|
|
78
|
+
name = getattr(cls, "__qualname__", None) or cls.__name__
|
|
79
|
+
module = getattr(cls, "__module__", None)
|
|
80
|
+
return name if module in (None, "builtins") else f"{module}.{name}"
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def exception_message(exc: BaseException) -> str:
|
|
84
|
+
try:
|
|
85
|
+
return str(exc)
|
|
86
|
+
except Exception:
|
|
87
|
+
return "<exception str() failed>"
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def exception_title(type_: str, message: str) -> str:
|
|
91
|
+
return f"{type_}: {message}" if message else type_
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def exception_attributes(exc: BaseException, tb: Optional[TracebackType] = None) -> dict[str, str]:
|
|
95
|
+
"""docs/12 §3 Errors: format_exception already includes chained exceptions."""
|
|
96
|
+
type_ = exception_type(exc)
|
|
97
|
+
message = exception_message(exc)
|
|
98
|
+
try:
|
|
99
|
+
stack = "".join(traceback.format_exception(type(exc), exc, tb if tb is not None else exc.__traceback__))
|
|
100
|
+
except Exception:
|
|
101
|
+
stack = exception_title(type_, message)
|
|
102
|
+
return {"exception.type": type_, "exception.message": message, "exception.stacktrace": stack}
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def utf8_cut_index(data: bytes, max_bytes: int) -> int:
|
|
106
|
+
"""The largest index <= max_bytes that does not split a UTF-8 sequence."""
|
|
107
|
+
if len(data) <= max_bytes:
|
|
108
|
+
return len(data)
|
|
109
|
+
end = max_bytes
|
|
110
|
+
while end > 0 and end > max_bytes - 3 and (data[end] & 0xC0) == 0x80:
|
|
111
|
+
end -= 1
|
|
112
|
+
return end
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def utf8_safe(text: str) -> str:
|
|
116
|
+
"""Lone surrogates (from surrogateescape decoding) cannot be UTF-8 encoded: they become '?'."""
|
|
117
|
+
try:
|
|
118
|
+
text.encode("utf-8")
|
|
119
|
+
return text
|
|
120
|
+
except UnicodeEncodeError:
|
|
121
|
+
return text.encode("utf-8", "replace").decode("utf-8")
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def cut_utf8(text: str, max_bytes: int) -> tuple[str, bool]:
|
|
125
|
+
if text.isascii() and len(text) <= max_bytes:
|
|
126
|
+
return text, False
|
|
127
|
+
data = utf8_safe(text).encode("utf-8")
|
|
128
|
+
if len(data) <= max_bytes:
|
|
129
|
+
return data.decode("utf-8"), False
|
|
130
|
+
return data[: utf8_cut_index(data, max_bytes)].decode("utf-8"), True
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def finalize_record(record: LogRecord) -> LogRecord:
|
|
134
|
+
"""Normalizes attribute values and applies the size cuts; a cut record
|
|
135
|
+
carries logify.truncated = true."""
|
|
136
|
+
body, truncated = cut_utf8(record.body, BODY_MAX_BYTES)
|
|
137
|
+
attributes: dict[str, object] = {}
|
|
138
|
+
for key, raw in record.attributes.items():
|
|
139
|
+
value = normalize_value(raw)
|
|
140
|
+
if value is None:
|
|
141
|
+
continue
|
|
142
|
+
name = utf8_safe(str(key))
|
|
143
|
+
if isinstance(value, str):
|
|
144
|
+
value, cut = cut_utf8(value, STACKTRACE_MAX_BYTES if name == "exception.stacktrace" else ATTRIBUTE_MAX_BYTES)
|
|
145
|
+
truncated = truncated or cut
|
|
146
|
+
attributes[name] = value
|
|
147
|
+
if truncated:
|
|
148
|
+
attributes["logify.truncated"] = True
|
|
149
|
+
return replace(record, body=body, attributes=attributes)
|
dlogify/_singleton.py
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""The client created by init(); LogifyHandler without a client reads it too."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import threading
|
|
6
|
+
from typing import TYPE_CHECKING, Optional
|
|
7
|
+
|
|
8
|
+
if TYPE_CHECKING:
|
|
9
|
+
from ._client import Client
|
|
10
|
+
|
|
11
|
+
lock = threading.Lock()
|
|
12
|
+
current: Optional["Client"] = None
|
dlogify/_version.py
ADDED
dlogify/handler.py
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""LogifyHandler: sends `logging` records to Logify (spec §3). init() installs
|
|
2
|
+
one; applications that configure logging with dictConfig can name the class:
|
|
3
|
+
{"class": "dlogify.LogifyHandler"}."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import logging
|
|
8
|
+
from typing import TYPE_CHECKING, Optional, Union
|
|
9
|
+
|
|
10
|
+
from . import _singleton
|
|
11
|
+
from ._diagnostics import describe_error
|
|
12
|
+
from ._guard import guarded, is_guarded
|
|
13
|
+
from ._levels import level_from_levelno
|
|
14
|
+
from ._record import exception_attributes, exception_title, seconds_to_unix_nano
|
|
15
|
+
|
|
16
|
+
if TYPE_CHECKING:
|
|
17
|
+
from ._client import Client
|
|
18
|
+
|
|
19
|
+
# The attributes every LogRecord has; anything else came from `extra=`.
|
|
20
|
+
_STANDARD = frozenset(logging.LogRecord("", 0, "", 0, "", (), None).__dict__) | {"message", "asctime"}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class LogifyHandler(logging.Handler):
|
|
24
|
+
def __init__(self, level: Union[int, str] = logging.NOTSET, *, client: Optional["Client"] = None) -> None:
|
|
25
|
+
super().__init__(level)
|
|
26
|
+
self._client = client
|
|
27
|
+
|
|
28
|
+
def emit(self, record: logging.LogRecord) -> None:
|
|
29
|
+
if is_guarded() or record.name == "dlogify" or record.name.startswith("dlogify."):
|
|
30
|
+
return
|
|
31
|
+
client = self._client if self._client is not None else _singleton.current
|
|
32
|
+
if client is None:
|
|
33
|
+
return
|
|
34
|
+
# A handler named in dictConfig and init()'s capture both reach a
|
|
35
|
+
# record: each client sends it once. Ids, not clients: QueueHandler and
|
|
36
|
+
# SocketHandler pickle the record's attributes.
|
|
37
|
+
try:
|
|
38
|
+
sent_to = record.__dict__.setdefault("_dlogify_clients", [])
|
|
39
|
+
if id(client) in sent_to:
|
|
40
|
+
return
|
|
41
|
+
sent_to.append(id(client))
|
|
42
|
+
with guarded():
|
|
43
|
+
body, attributes = _parts(record)
|
|
44
|
+
client._emit(
|
|
45
|
+
"logging", level_from_levelno(record.levelno), body, attributes, time_unix_nano=seconds_to_unix_nano(record.created)
|
|
46
|
+
)
|
|
47
|
+
except Exception as err:
|
|
48
|
+
client.diagnostics.debug(f"dropped a logging record: {describe_error(err)}")
|
|
49
|
+
|
|
50
|
+
def handleError(self, record: logging.LogRecord) -> None:
|
|
51
|
+
"""Never print logging's error report: emit() reports through diagnostics."""
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _parts(record: logging.LogRecord) -> tuple[str, dict[str, object]]:
|
|
55
|
+
try:
|
|
56
|
+
message = record.getMessage()
|
|
57
|
+
except Exception:
|
|
58
|
+
message = str(record.msg)
|
|
59
|
+
attributes: dict[str, object] = {}
|
|
60
|
+
exc_info = record.exc_info
|
|
61
|
+
if isinstance(exc_info, tuple) and len(exc_info) == 3 and isinstance(exc_info[1], BaseException):
|
|
62
|
+
exception = exception_attributes(exc_info[1], exc_info[2])
|
|
63
|
+
attributes.update(exception)
|
|
64
|
+
if not message:
|
|
65
|
+
message = exception_title(exception["exception.type"], exception["exception.message"])
|
|
66
|
+
attributes["logger.name"] = record.name
|
|
67
|
+
for key, value in record.__dict__.items():
|
|
68
|
+
if key not in _STANDARD and not key.startswith("_"):
|
|
69
|
+
attributes[key] = value
|
|
70
|
+
return message, attributes
|
dlogify/py.typed
ADDED
|
File without changes
|
dlogify/run/__init__.py
ADDED
|
File without changes
|
dlogify/run/child.py
ADDED
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
"""docs/12 §5.7 and spec §5: run a command, pass its output through unchanged,
|
|
2
|
+
send a parsed copy."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import os
|
|
7
|
+
import signal
|
|
8
|
+
import subprocess
|
|
9
|
+
import threading
|
|
10
|
+
import time
|
|
11
|
+
from collections.abc import Sequence
|
|
12
|
+
from typing import IO, Optional
|
|
13
|
+
|
|
14
|
+
from .._client import Client, Internals
|
|
15
|
+
from .._diagnostics import Diagnostics, describe_error
|
|
16
|
+
from .._parse.stream_parser import StreamParser, StreamRecord
|
|
17
|
+
from .._version import USER_AGENT
|
|
18
|
+
|
|
19
|
+
USAGE = "usage: logify run -- <command> [args...]\n"
|
|
20
|
+
EXIT_FLUSH_TIMEOUT = 5.0
|
|
21
|
+
TICK = 0.1
|
|
22
|
+
# After the child exits, how long its pipes may still deliver output. A
|
|
23
|
+
# background grandchild can hold them open forever; we do not wait for it.
|
|
24
|
+
PIPE_GRACE = 0.5
|
|
25
|
+
_READ_SIZE = 65536
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def parse_run_args(argv: Sequence[str]) -> Optional[tuple[str, list[str]]]:
|
|
29
|
+
if len(argv) < 3 or argv[0] != "run" or argv[1] != "--":
|
|
30
|
+
return None
|
|
31
|
+
return argv[2], list(argv[3:])
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _write_all(fd: int, data: bytes) -> None:
|
|
35
|
+
view = memoryview(data)
|
|
36
|
+
while view:
|
|
37
|
+
written = os.write(fd, view)
|
|
38
|
+
view = view[written:]
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class _Relay:
|
|
42
|
+
"""Copies one child stream to ours first, unchanged, then parses a copy."""
|
|
43
|
+
|
|
44
|
+
def __init__(self, source: IO[bytes], target_fd: int, parser: StreamParser, diagnostics: Diagnostics) -> None:
|
|
45
|
+
self._source = source
|
|
46
|
+
self._target_fd = target_fd
|
|
47
|
+
self._parser = parser
|
|
48
|
+
self._diagnostics = diagnostics
|
|
49
|
+
self._lock = threading.Lock()
|
|
50
|
+
self._finished = False
|
|
51
|
+
self.reader_gone = False
|
|
52
|
+
self._thread = threading.Thread(target=self._run, name=f"logify-run-{parser.stream}", daemon=True)
|
|
53
|
+
|
|
54
|
+
def start(self) -> None:
|
|
55
|
+
self._thread.start()
|
|
56
|
+
|
|
57
|
+
def join(self, timeout: float) -> None:
|
|
58
|
+
self._thread.join(max(0.0, timeout))
|
|
59
|
+
|
|
60
|
+
def tick(self) -> None:
|
|
61
|
+
with self._lock:
|
|
62
|
+
if not self._finished:
|
|
63
|
+
self._safely(self._parser.tick)
|
|
64
|
+
|
|
65
|
+
def finish(self) -> None:
|
|
66
|
+
with self._lock:
|
|
67
|
+
if not self._finished:
|
|
68
|
+
self._finished = True
|
|
69
|
+
self._safely(self._parser.end)
|
|
70
|
+
|
|
71
|
+
def _run(self) -> None:
|
|
72
|
+
fd = self._source.fileno()
|
|
73
|
+
while True:
|
|
74
|
+
try:
|
|
75
|
+
chunk = os.read(fd, _READ_SIZE)
|
|
76
|
+
except OSError:
|
|
77
|
+
return
|
|
78
|
+
if not chunk:
|
|
79
|
+
return
|
|
80
|
+
try:
|
|
81
|
+
_write_all(self._target_fd, chunk)
|
|
82
|
+
except OSError:
|
|
83
|
+
# Our reader went away (as in `logify run -- cmd | head`): close
|
|
84
|
+
# the child's pipe too, so the child sees it as it would without us.
|
|
85
|
+
self.reader_gone = True
|
|
86
|
+
try:
|
|
87
|
+
self._source.close()
|
|
88
|
+
except OSError:
|
|
89
|
+
pass
|
|
90
|
+
return
|
|
91
|
+
with self._lock:
|
|
92
|
+
if not self._finished:
|
|
93
|
+
self._safely(lambda: self._parser.push(chunk))
|
|
94
|
+
|
|
95
|
+
def _safely(self, action) -> None:
|
|
96
|
+
try:
|
|
97
|
+
action()
|
|
98
|
+
except Exception as err:
|
|
99
|
+
self._diagnostics.debug(f"parse failed: {describe_error(err)}")
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _cannot_run(command: str, reason: str, code: int) -> int:
|
|
103
|
+
try:
|
|
104
|
+
os.write(2, f"logify: cannot run {command}: {reason}\n".encode("utf-8", "replace"))
|
|
105
|
+
except OSError:
|
|
106
|
+
pass
|
|
107
|
+
return code
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _signal_name(signum: int) -> str:
|
|
111
|
+
try:
|
|
112
|
+
return signal.Signals(signum).name
|
|
113
|
+
except ValueError:
|
|
114
|
+
return f"signal {signum}"
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _stdin_is_tty() -> bool:
|
|
118
|
+
try:
|
|
119
|
+
return os.isatty(0)
|
|
120
|
+
except OSError:
|
|
121
|
+
return False
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _reraise(signum: int) -> None:
|
|
125
|
+
"""Dies from the child's signal, like the child did."""
|
|
126
|
+
try:
|
|
127
|
+
signal.signal(signum, signal.SIG_DFL)
|
|
128
|
+
except (OSError, ValueError):
|
|
129
|
+
pass # SIGKILL and SIGSTOP cannot be changed, and need not be
|
|
130
|
+
try:
|
|
131
|
+
os.kill(os.getpid(), signum)
|
|
132
|
+
except OSError:
|
|
133
|
+
return
|
|
134
|
+
time.sleep(1) # delivery is asynchronous; still alive afterwards: the signal does not kill
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def run_command(command: str, args: list[str]) -> int:
|
|
138
|
+
# `logify run` is not the application: no logging or uncaught capture.
|
|
139
|
+
client = Client(capture_logging=False, capture_uncaught=False, _internals=Internals(user_agent=f"{USER_AGENT} run"))
|
|
140
|
+
env = dict(os.environ)
|
|
141
|
+
env.setdefault("PYTHONUNBUFFERED", "1")
|
|
142
|
+
try:
|
|
143
|
+
child = subprocess.Popen([command, *args], stdout=subprocess.PIPE, stderr=subprocess.PIPE, env=env, bufsize=0)
|
|
144
|
+
except FileNotFoundError:
|
|
145
|
+
return _cannot_run(command, "command not found", 127)
|
|
146
|
+
except PermissionError:
|
|
147
|
+
return _cannot_run(command, "permission denied", 126)
|
|
148
|
+
except OSError as err:
|
|
149
|
+
return _cannot_run(command, err.strerror or describe_error(err), 126)
|
|
150
|
+
|
|
151
|
+
def emit(record: StreamRecord) -> None:
|
|
152
|
+
client._emit("logify.run", record.level, record.body, dict(record.attributes), time_unix_nano=record.time_unix_nano)
|
|
153
|
+
|
|
154
|
+
def now() -> float:
|
|
155
|
+
return time.time() * 1000
|
|
156
|
+
|
|
157
|
+
assert child.stdout is not None and child.stderr is not None
|
|
158
|
+
relays = [
|
|
159
|
+
_Relay(child.stdout, 1, StreamParser("stdout", now, emit), client.diagnostics),
|
|
160
|
+
_Relay(child.stderr, 2, StreamParser("stderr", now, emit), client.diagnostics),
|
|
161
|
+
]
|
|
162
|
+
for relay in relays:
|
|
163
|
+
relay.start()
|
|
164
|
+
stop_ticker = threading.Event()
|
|
165
|
+
|
|
166
|
+
def tick_loop() -> None:
|
|
167
|
+
while not stop_ticker.wait(TICK):
|
|
168
|
+
for relay in relays:
|
|
169
|
+
relay.tick()
|
|
170
|
+
|
|
171
|
+
threading.Thread(target=tick_loop, name="logify-run-ticker", daemon=True).start()
|
|
172
|
+
|
|
173
|
+
# Signals logify run received, forwarded or not: a child they end is not reported.
|
|
174
|
+
received: set[int] = set()
|
|
175
|
+
from_terminal = _stdin_is_tty()
|
|
176
|
+
# A terminal sends Ctrl-C and hang-up to its whole foreground process group,
|
|
177
|
+
# child included: forwarding them again would deliver each one twice.
|
|
178
|
+
terminal_signals = {signal.SIGINT, signal.SIGHUP}
|
|
179
|
+
|
|
180
|
+
def on_signal(signum: int, frame: object) -> None:
|
|
181
|
+
received.add(signum)
|
|
182
|
+
if from_terminal and signum in terminal_signals:
|
|
183
|
+
return
|
|
184
|
+
try:
|
|
185
|
+
child.send_signal(signum)
|
|
186
|
+
except OSError:
|
|
187
|
+
pass
|
|
188
|
+
|
|
189
|
+
previous = {sig: signal.signal(sig, on_signal) for sig in (signal.SIGINT, signal.SIGTERM, signal.SIGHUP)}
|
|
190
|
+
returncode = 1
|
|
191
|
+
signum: Optional[int] = None
|
|
192
|
+
try:
|
|
193
|
+
returncode = child.wait()
|
|
194
|
+
deadline = time.monotonic() + PIPE_GRACE
|
|
195
|
+
for relay in relays:
|
|
196
|
+
relay.join(deadline - time.monotonic())
|
|
197
|
+
stop_ticker.set()
|
|
198
|
+
for relay in relays:
|
|
199
|
+
relay.finish()
|
|
200
|
+
signum = -returncode if returncode < 0 else None
|
|
201
|
+
# `logify run -- cmd | head`: the child died as it would have without us.
|
|
202
|
+
pipeline_ended = signum == signal.SIGPIPE and any(relay.reader_gone for relay in relays)
|
|
203
|
+
if signum is not None and signum not in received and not pipeline_ended:
|
|
204
|
+
name = _signal_name(signum)
|
|
205
|
+
client._emit("logify.run", "error", f"Process terminated by {name}", {"process.signal": name})
|
|
206
|
+
elif signum is None and returncode != 0:
|
|
207
|
+
client._emit("logify.run", "error", f"Process exited with code {returncode}", {"process.exit_code": returncode})
|
|
208
|
+
client.flush(EXIT_FLUSH_TIMEOUT)
|
|
209
|
+
finally:
|
|
210
|
+
for sig, handler in previous.items():
|
|
211
|
+
signal.signal(sig, handler)
|
|
212
|
+
if signum is not None:
|
|
213
|
+
_reraise(signum)
|
|
214
|
+
return 128 + signum
|
|
215
|
+
return returncode
|
dlogify/run/cli.py
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""`logify run -- <cmd> [args…]` (also `pipx run dlogify run -- …` and `python -m dlogify run -- …`)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import sys
|
|
7
|
+
from typing import NoReturn, Optional
|
|
8
|
+
|
|
9
|
+
from .child import USAGE, parse_run_args
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def main(argv: Optional[list[str]] = None) -> NoReturn:
|
|
13
|
+
parsed = parse_run_args(sys.argv[1:] if argv is None else argv)
|
|
14
|
+
if parsed is None:
|
|
15
|
+
sys.stderr.write(USAGE)
|
|
16
|
+
sys.exit(2)
|
|
17
|
+
if os.name == "nt":
|
|
18
|
+
sys.stderr.write("logify: logify run is not supported on Windows\n")
|
|
19
|
+
sys.exit(1)
|
|
20
|
+
from .child import run_command
|
|
21
|
+
|
|
22
|
+
sys.exit(run_command(*parsed))
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: dlogify
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Send Python logs and uncaught exceptions to dlogify.
|
|
5
|
+
Project-URL: Homepage, https://dlogify.com
|
|
6
|
+
Project-URL: Documentation, https://docs.dlogify.com/send-logs/python
|
|
7
|
+
Author: Logify
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Keywords: dlogify,errors,exceptions,logging,opentelemetry
|
|
11
|
+
Classifier: Operating System :: OS Independent
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Topic :: System :: Logging
|
|
14
|
+
Classifier: Typing :: Typed
|
|
15
|
+
Requires-Python: >=3.10
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
|
|
18
|
+
# dlogify
|
|
19
|
+
|
|
20
|
+
Send your Python logs and uncaught exceptions to [Logify](https://dlogify.com), which groups errors, classifies them with AI and delivers tickets with the full context.
|
|
21
|
+
|
|
22
|
+
Requires Python 3.10 or later. No dependencies.
|
|
23
|
+
|
|
24
|
+
## Install
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
pip install dlogify
|
|
28
|
+
export LOGIFY_API_KEY=lgf_live_…
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## With the SDK
|
|
32
|
+
|
|
33
|
+
```python
|
|
34
|
+
import logging
|
|
35
|
+
|
|
36
|
+
import dlogify
|
|
37
|
+
|
|
38
|
+
dlogify.init(service="billing")
|
|
39
|
+
logging.basicConfig(level=logging.INFO)
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
`init()` sends every record that passes your `logging` configuration, and every uncaught exception (main thread and other threads). It changes no logger's level and adds no handler, so `logging.basicConfig()` works before or after it and your console output stays as it was. Python's root logger shows `WARNING` and above unless you configure it, as above.
|
|
43
|
+
|
|
44
|
+
```python
|
|
45
|
+
log = logging.getLogger("billing")
|
|
46
|
+
|
|
47
|
+
try:
|
|
48
|
+
charge(order_id)
|
|
49
|
+
except PaymentError:
|
|
50
|
+
log.exception("charge failed", extra={"order_id": order_id})
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
Keys passed with `extra=` become attributes; `log.exception()` (or `exc_info=True`) attaches the traceback.
|
|
54
|
+
|
|
55
|
+
You can also log directly:
|
|
56
|
+
|
|
57
|
+
```python
|
|
58
|
+
dlogify.info("order placed", attributes={"order_id": order_id})
|
|
59
|
+
dlogify.error("charge failed", exc=err, attributes={"order_id": order_id})
|
|
60
|
+
dlogify.log("warning", "disk almost full") # any level name, any case
|
|
61
|
+
dlogify.capture_exception() # inside an except block
|
|
62
|
+
dlogify.flush() # before os._exit()
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
`log()` takes `trace`, `debug`, `info`, `warn`, `error` and `fatal`, and also Python's `warning` and `critical`, in any case; an unknown name logs at `info`.
|
|
66
|
+
|
|
67
|
+
- `init(capture_logging=False)` or `init(capture_uncaught=False)` turns a capture off.
|
|
68
|
+
- Calls before `init()` are dropped, and a second `init()` is ignored.
|
|
69
|
+
- `dlogify.Client(**options)` creates an independent client.
|
|
70
|
+
- `dictConfig` users can name the handler instead: `{"class": "dlogify.LogifyHandler"}` with `init(capture_logging=False)`; it sends through the `init()` client. With the capture left on, each record is still sent once.
|
|
71
|
+
- `structlog` and `loguru` can write to the standard `logging` module, which dlogify captures.
|
|
72
|
+
|
|
73
|
+
## Any process: logify run
|
|
74
|
+
|
|
75
|
+
```bash
|
|
76
|
+
LOGIFY_API_KEY=lgf_live_… pipx run dlogify run -- python manage.py runserver
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
With the package installed, the command is `logify run -- <cmd>`. The output appears in your terminal exactly as without dlogify, and the exit code is the command's. Python tracebacks, stack traces from other languages and Go panics become single events; JSON lines are read as structured records. A non-zero exit or a death by signal is reported as one more error, except a `SIGPIPE` after the reader of the output went away, as in `| head`. Not available on Windows.
|
|
80
|
+
|
|
81
|
+
## Configuration
|
|
82
|
+
|
|
83
|
+
Options passed to `init()` win over environment variables, which win over the defaults.
|
|
84
|
+
|
|
85
|
+
| Variable | Option | Default |
|
|
86
|
+
|---|---|---|
|
|
87
|
+
| `LOGIFY_API_KEY` | `api_key` | Required; without it the client is disabled. |
|
|
88
|
+
| `LOGIFY_ENDPOINT` | `endpoint` | `https://api.dlogify.com` |
|
|
89
|
+
| `LOGIFY_SERVICE` | `service` | `OTEL_SERVICE_NAME`, then the `[project] name` of the nearest `pyproject.toml` from the working directory, then `unknown_service` |
|
|
90
|
+
| `LOGIFY_ENVIRONMENT` | `environment` | `default` |
|
|
91
|
+
| `LOGIFY_RELEASE` | `release` | empty |
|
|
92
|
+
| `LOGIFY_MIN_LEVEL` | `min_level` | `info` (`trace`, `debug`, `info`, `warn`, `error`, `fatal`) |
|
|
93
|
+
| `LOGIFY_DISABLED` | `disabled` | off (`1` or `true` turns the client into a no-op) |
|
|
94
|
+
| `LOGIFY_DEBUG` | `debug` | off (`1` or `true` prints the client's diagnostics to stderr) |
|
|
95
|
+
|
|
96
|
+
Also `attributes` (added to every record) and `max_queue_size` (default 2000). `disabled`, `debug`, `capture_logging` and `capture_uncaught` take `True` or `False`; any other value is ignored with a warning.
|
|
97
|
+
|
|
98
|
+
## Guarantees
|
|
99
|
+
|
|
100
|
+
- No call raises, and logging never waits for the network: records are sent by a background daemon thread, in compressed batches, with retries for up to 5 minutes.
|
|
101
|
+
- If the key is rejected, the client stops for the rest of the process with one warning. Redirects are not followed: one warning names the URL to put in `LOGIFY_ENDPOINT`.
|
|
102
|
+
- If the endpoint's TLS certificate cannot be verified, one warning says how to fix it: with Python from python.org on macOS, run `Install Certificates.command`; behind a proxy that inspects TLS, set `SSL_CERT_FILE` to its CA bundle.
|
|
103
|
+
- After an uncaught exception the client sends what is queued, then Python reports the exception as usual. At normal exit it flushes too. An exit spends at most 2 seconds on this in total. `os._exit()` skips both, so call `dlogify.flush()` first.
|
|
104
|
+
- Works with pre-fork servers such as gunicorn, and with `multiprocessing` children, which flush when their target returns. `Pool.terminate()` (also run when a `with Pool()` block ends) stops workers before they flush: call `pool.close()` and `pool.join()` instead. Other worker pools that end with `os._exit()` should call `dlogify.flush()` first.
|
|
105
|
+
- The client's own messages go to stderr, never through `logging`.
|
|
106
|
+
|
|
107
|
+
## What is sent
|
|
108
|
+
|
|
109
|
+
Only log records: message, level, time, logger name, the attributes you pass, the exception's type, message and traceback, and the service name, environment, release, host name and SDK version. The SDK makes no other network calls and opens no ports. Personal data is redacted by Logify on arrival.
|
|
110
|
+
|
|
111
|
+
Documentation: https://docs.dlogify.com/send-logs/python
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
dlogify/__init__.py,sha256=Kv1MycTyEBAhha8Wg3UWrtcV3dp_gWoyLWwKm4kpQXU,4862
|
|
2
|
+
dlogify/__main__.py,sha256=6RSh346QjO75J3d6h1QNgxzm46ZBogNQ2HpQHE1DiYM,34
|
|
3
|
+
dlogify/_capture_logging.py,sha256=zo-NccwyUT9EU9YicE7WgUJ_QkyaOD22oTdB5Zuq3h0,2128
|
|
4
|
+
dlogify/_client.py,sha256=gK4TD8oyY8IH9WU1V5SRNYRTcUH3K4T7wdAPLQXV6uc,10024
|
|
5
|
+
dlogify/_config.py,sha256=yvH-uRFr6OyfzMKjT-u8osfYioibICgh05gTHUuUwKk,6718
|
|
6
|
+
dlogify/_diagnostics.py,sha256=7eN0mtALexYNYJO6go8yB4ZcP-uVpArSRltFBqe483g,1438
|
|
7
|
+
dlogify/_encode.py,sha256=ZORsngCxtxtf8TmsuSMtLhL9YMdzpujrv4t3O4uHzi8,3420
|
|
8
|
+
dlogify/_exporter.py,sha256=AVM9CqNTFPygwWfTTafgnKeXgpSr7XZ8g8EoMKRgoL4,15791
|
|
9
|
+
dlogify/_guard.py,sha256=Bu6mcxiC0uJL0oNLMD7iwxSfE2fxuS027T0RHaOzf_Q,716
|
|
10
|
+
dlogify/_hooks.py,sha256=A_49x7G92vxCr7aunSciMWDWjoS6XyL6Lo2fPk738Tc,3220
|
|
11
|
+
dlogify/_http.py,sha256=VJnn4Tlau3_KObT_VRsmgCi8P51KSYF4otPToZGZfKQ,1606
|
|
12
|
+
dlogify/_levels.py,sha256=BuoqSBBNjfK9OLHaE215Aa1dxH1W2NpjG6CV-TzEXgw,2385
|
|
13
|
+
dlogify/_record.py,sha256=VBFEhi-F9ETE0ptbevABug0R_c5moIuMqRRLgo_yWFo,4714
|
|
14
|
+
dlogify/_singleton.py,sha256=Ox3CPZJXo3wBUKooL678Uxsn_f06zc2rx9ixzGP6U1E,289
|
|
15
|
+
dlogify/_version.py,sha256=0jSTKJwvVJIrWp3INk0MyX5pUM58Yu17Cwzmngf9S34,109
|
|
16
|
+
dlogify/handler.py,sha256=_TbzM-opDmhINBchoyTr-6SSD-YkjkswY-fXGaPeNSM,2985
|
|
17
|
+
dlogify/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
18
|
+
dlogify/_parse/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
19
|
+
dlogify/_parse/compact_json.py,sha256=qm9ytdTJYhmqzaYXb9gYa3DPZBvCFLiYF81MtescrRs,1893
|
|
20
|
+
dlogify/_parse/json_line.py,sha256=V_arqLnzyke_EsQHHtnzmZlV9-0WnGntF9F9_QeSloc,6072
|
|
21
|
+
dlogify/_parse/lines.py,sha256=PJl-CMkpyXGkMfy8Qy6ZUjzWEyDSxezXxhV3UYBD3SE,1778
|
|
22
|
+
dlogify/_parse/stream_parser.py,sha256=4xL3ibEJvkF9HN7av260lxo6PhoALXsn_vsgW9fTJ8s,3468
|
|
23
|
+
dlogify/_parse/text_event.py,sha256=Q_FXrxwW78eYoVkl70EJzB8j-a7wmo-ruK3zZckS3_Q,5943
|
|
24
|
+
dlogify/run/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
25
|
+
dlogify/run/child.py,sha256=ExdgTc2A8qCH8rknle7cJTM5aociL_9SjuA9f74Eh8M,7485
|
|
26
|
+
dlogify/run/cli.py,sha256=f1-FAhc-fviW8rRf2Ft4-CuGSjL9K4rXsl14bM2JZ58,642
|
|
27
|
+
dlogify-0.1.0.dist-info/METADATA,sha256=Ad2tDWS_7hFMmyl99ObHecQOyQiF9X2lkySdeohQXgE,5955
|
|
28
|
+
dlogify-0.1.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
29
|
+
dlogify-0.1.0.dist-info/entry_points.txt,sha256=P8nsnJ3WXqEYGaiQXf3R-BAfGxYtMMi4SqKYWc2rPlE,79
|
|
30
|
+
dlogify-0.1.0.dist-info/licenses/LICENSE,sha256=lvxNg1CobdsJEl7Qr0NKRtS_8nDYnjBywS131kfk_lM,1063
|
|
31
|
+
dlogify-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Logify
|
|
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.
|