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/_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
@@ -0,0 +1,4 @@
1
+ VERSION = "0.1.0"
2
+ USER_AGENT = f"logify-python/{VERSION}"
3
+ SDK_NAME = "logify.python"
4
+ SDK_LANGUAGE = "python"
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
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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,3 @@
1
+ [console_scripts]
2
+ dlogify = dlogify.run.cli:main
3
+ logify = dlogify.run.cli:main
@@ -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.