light-telemetry 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,24 @@
1
+ **/target
2
+ .idea/*
3
+ .idea_modules/
4
+ *.iws
5
+ *.user
6
+ cmake-build-*/
7
+
8
+
9
+ IDEA
10
+ !.idea/modules.xml
11
+ !.idea/vcs.xml
12
+ !.idea/misc.xml
13
+ !.idea/encodings.xml
14
+ !.idea/runConfigurations/
15
+ !.idea/codeStyles/
16
+ !.idea/inspectionProfiles/
17
+ !.idea/scopes/
18
+ !*.iml
19
+ !.idea/*.iml
20
+ !.idea/modules/
21
+
22
+ .idea/runConfigurations/**.local.xml
23
+ .idea/dataSources/
24
+ .idea/dataSources.local.xml
@@ -0,0 +1,6 @@
1
+ Metadata-Version: 2.5
2
+ Name: light-telemetry
3
+ Version: 0.1.0
4
+ Summary: UDP fire-and-forget telemetry client for LightTelemetry
5
+ Requires-Python: >=3.11
6
+ Requires-Dist: msgpack>=1.0
@@ -0,0 +1,18 @@
1
+ """UDP fire-and-forget telemetry client for LightTelemetry."""
2
+
3
+ from . import config
4
+ from .client import ClientStats, TelemetryClient
5
+ from .handler import TelemetryLogHandler
6
+ from .serialize import MTU_SAFE, encode, now_micros
7
+
8
+ __version__ = "0.1.0"
9
+
10
+ __all__ = [
11
+ "MTU_SAFE",
12
+ "ClientStats",
13
+ "TelemetryClient",
14
+ "TelemetryLogHandler",
15
+ "config",
16
+ "encode",
17
+ "now_micros",
18
+ ]
@@ -0,0 +1,200 @@
1
+ """UDP fire-and-forget telemetry client for LightTelemetry.
2
+
3
+ Design contract (shared with the Rust and Go clients):
4
+
5
+ - ``emit`` serializes one packet and sends one datagram. No buffering, no
6
+ retries — telemetry must never block the host.
7
+ - A background thread watches reverse heartbeats from the server. When they
8
+ stop, ``degraded`` flips and ``emit`` drops before serializing.
9
+ - Oversized packets are truncated (``payload -> {"truncated": true}``).
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import json
15
+ import socket
16
+ import threading
17
+ import time
18
+ from dataclasses import dataclass, field
19
+ from types import TracebackType
20
+ from typing import Any, Self
21
+
22
+ from . import config as _config
23
+ from .serialize import encode, now_micros
24
+
25
+
26
+ @dataclass
27
+ class ClientStats:
28
+ """Client-side counters for diagnostics and tests."""
29
+
30
+ encoded: int = 0
31
+ sent: int = 0
32
+ dropped: int = 0
33
+ truncated: int = 0
34
+ errors: int = 0
35
+
36
+
37
+ @dataclass
38
+ class _Watcher:
39
+ thread: threading.Thread | None = field(default=None)
40
+ stop: threading.Event = field(default_factory=threading.Event)
41
+
42
+
43
+ class TelemetryClient:
44
+ """One UDP socket plus an optional heartbeat watcher thread."""
45
+
46
+ def __init__(
47
+ self,
48
+ server_addr: tuple[str, int],
49
+ host_id: str,
50
+ host_class: str,
51
+ *,
52
+ use_msgpack: bool = False,
53
+ heartbeat_timeout: float | None = 3.0,
54
+ ) -> None:
55
+ """Bind an ephemeral UDP socket toward ``server_addr``.
56
+
57
+ ``heartbeat_timeout`` (seconds) is the liveness window for the
58
+ reverse-heartbeat watcher; ``None`` disables the watcher.
59
+ """
60
+ self._server_addr = server_addr
61
+ self._host_id = host_id
62
+ self._host_class = host_class
63
+ self._use_msgpack = use_msgpack
64
+ self._heartbeat_timeout = heartbeat_timeout
65
+ self._sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
66
+ self._sock.bind(("0.0.0.0", 0))
67
+ self._degraded = threading.Event()
68
+ self._watcher = _Watcher()
69
+ self.stats = ClientStats()
70
+
71
+ @classmethod
72
+ def local(
73
+ cls,
74
+ host_id: str,
75
+ host_class: str,
76
+ *,
77
+ config_path: str | None = None,
78
+ use_msgpack: bool = False,
79
+ ) -> TelemetryClient:
80
+ """Localhost default: adopt the server's ``telemetry.toml``.
81
+
82
+ Reads ``config_path`` or, absent, ``/etc/telemetry.toml`` then
83
+ ``$HOME/.config/telemetry.toml`` (first hit wins — same as the
84
+ daemon). ``udp_bind`` becomes the target with wildcards mapped
85
+ to loopback; ``heartbeat_ms × 3`` becomes the liveness window.
86
+ Missing files or keys fall back to ``127.0.0.1:9876`` / 3 s.
87
+ """
88
+ srv, _ = _config.load(config_path)
89
+ host, port = _config.localize(srv.get("udp_bind", "0.0.0.0:9876"))
90
+ hb_ms = srv.get("heartbeat_ms", _config.DEFAULT_HEARTBEAT_MS)
91
+ return cls(
92
+ (host, port),
93
+ host_id,
94
+ host_class,
95
+ use_msgpack=use_msgpack,
96
+ heartbeat_timeout=hb_ms * 3 / 1000.0,
97
+ )
98
+
99
+ # -- lifecycle ------------------------------------------------------
100
+
101
+ def start(self) -> Self:
102
+ """Spawn the heartbeat watcher (if configured). Idempotent."""
103
+ if self._heartbeat_timeout is None or self._watcher.thread is not None:
104
+ return self
105
+ self._watcher.thread = threading.Thread(
106
+ target=self._watch,
107
+ name="light-telemetry-heartbeat",
108
+ daemon=True,
109
+ )
110
+ self._watcher.thread.start()
111
+ return self
112
+
113
+ def stop(self) -> None:
114
+ """Stop the watcher and release the socket. Safe to call twice."""
115
+ self._watcher.stop.set()
116
+ if self._watcher.thread is not None:
117
+ self._watcher.thread.join(timeout=2.0)
118
+ self._watcher.thread = None
119
+ self._sock.close()
120
+
121
+ close = stop
122
+
123
+ def __enter__(self) -> Self:
124
+ return self.start()
125
+
126
+ def __exit__(
127
+ self,
128
+ exc_type: type[BaseException] | None,
129
+ exc: BaseException | None,
130
+ tb: TracebackType | None,
131
+ ) -> None:
132
+ self.stop()
133
+
134
+ # -- state ----------------------------------------------------------
135
+
136
+ @property
137
+ def degraded(self) -> bool:
138
+ """Whether the server is currently considered unreachable."""
139
+ return self._degraded.is_set()
140
+
141
+ @property
142
+ def local_addr(self) -> tuple[str, int]:
143
+ """Local socket address."""
144
+ host, port = self._sock.getsockname()
145
+ return str(host), int(port)
146
+
147
+ # -- emission -------------------------------------------------------
148
+
149
+ def emit(self, topic: str, payload: dict[str, Any] | None = None) -> None:
150
+ """Emit one event. Drops silently when degraded; never raises."""
151
+ if self.degraded:
152
+ self.stats.dropped += 1
153
+ return
154
+ packet = {
155
+ "topic": topic,
156
+ "id": self._host_id,
157
+ "class": self._host_class,
158
+ "ts": now_micros(),
159
+ "payload": payload if payload is not None else {},
160
+ }
161
+ self.stats.encoded += 1
162
+ try:
163
+ data, truncated = encode(packet, self._use_msgpack)
164
+ if truncated:
165
+ self.stats.truncated += 1
166
+ self._sock.sendto(data, self._server_addr)
167
+ self.stats.sent += 1
168
+ except Exception: # noqa: BLE001 — fire-and-forget: never raise to the caller
169
+ self.stats.errors += 1
170
+
171
+ # -- heartbeat watcher -----------------------------------------------
172
+
173
+ def _watch(self) -> None:
174
+ assert self._heartbeat_timeout is not None
175
+ timeout = self._heartbeat_timeout
176
+ read_timeout = min(max(timeout / 4.0, 0.02), 0.2)
177
+ self._sock.settimeout(read_timeout)
178
+ last_hb: float | None = None
179
+ while not self._watcher.stop.is_set():
180
+ try:
181
+ data, addr = self._sock.recvfrom(2048)
182
+ except TimeoutError:
183
+ data = b""
184
+ addr = ("", 0)
185
+ except OSError:
186
+ break
187
+ if addr == self._server_addr:
188
+ try:
189
+ msg = json.loads(data)
190
+ if isinstance(msg, dict) and msg.get("type") == "hb":
191
+ last_hb = time.monotonic()
192
+ except (ValueError, UnicodeDecodeError):
193
+ pass
194
+ stale = (
195
+ (time.monotonic() - last_hb) > timeout if last_hb is not None else False
196
+ )
197
+ if stale:
198
+ self._degraded.set()
199
+ else:
200
+ self._degraded.clear()
@@ -0,0 +1,71 @@
1
+ """Discover the server's ``telemetry.toml`` for localhost clients.
2
+
3
+ Same search order as the server: explicit path, else
4
+ ``/etc/telemetry.toml``, else ``$HOME/.config/telemetry.toml``; the first
5
+ existing file wins. Only the keys the client consumes are read —
6
+ server-only keys are ignored.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import os
12
+ import tomllib
13
+ from pathlib import Path
14
+ from typing import TypedDict
15
+
16
+ ETC_PATH = Path("/etc/telemetry.toml")
17
+ USER_PATH = Path.home() / ".config" / "telemetry.toml"
18
+
19
+ DEFAULT_BIND = ("0.0.0.0", 9876)
20
+ DEFAULT_HEARTBEAT_MS = 1000
21
+
22
+
23
+ class ServerConfig(TypedDict, total=False):
24
+ """Server keys a client consumes."""
25
+
26
+ udp_bind: str
27
+ heartbeat_ms: int
28
+
29
+
30
+ def _default_paths() -> list[Path]:
31
+ paths = [ETC_PATH]
32
+ home = os.environ.get("HOME")
33
+ if home:
34
+ paths.append(Path(home) / ".config" / "telemetry.toml")
35
+ return paths
36
+
37
+
38
+ def load(path: str | Path | None = None) -> tuple[ServerConfig, Path | None]:
39
+ """Load the server config.
40
+
41
+ ``path`` must exist (``FileNotFoundError`` propagates); without it the
42
+ first existing default path wins; absent files → empty config.
43
+ """
44
+ if path is not None:
45
+ p = Path(path)
46
+ return _parse(p), p
47
+ for cand in _default_paths():
48
+ if cand.is_file():
49
+ return _parse(cand), cand
50
+ return ServerConfig(), None
51
+
52
+
53
+ def _parse(p: Path) -> ServerConfig:
54
+ data = tomllib.loads(p.read_text())
55
+ cfg: ServerConfig = {}
56
+ if isinstance(v := data.get("udp_bind"), str):
57
+ cfg["udp_bind"] = v
58
+ if isinstance(v := data.get("heartbeat_ms"), int):
59
+ cfg["heartbeat_ms"] = v
60
+ return cfg
61
+
62
+
63
+ def localize(bind: str) -> tuple[str, int]:
64
+ """Map a bind address to a send target: wildcard → loopback."""
65
+ host, _, port = bind.rpartition(":")
66
+ host = host.strip("[]")
67
+ if host in ("0.0.0.0", "*"):
68
+ return "127.0.0.1", int(port)
69
+ if host in ("::", "::1"):
70
+ return "::1", int(port)
71
+ return host or "0.0.0.0", int(port)
@@ -0,0 +1,46 @@
1
+ """``logging.Handler`` that forwards records to a ``TelemetryClient``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ from typing import Any
7
+
8
+ from .client import TelemetryClient
9
+
10
+ #: Attributes present on every LogRecord; everything else is user ``extra``.
11
+ _RESERVED = frozenset(vars(logging.makeLogRecord({}))) | {
12
+ "taskName",
13
+ "message",
14
+ "asctime",
15
+ }
16
+
17
+
18
+ class TelemetryLogHandler(logging.Handler):
19
+ """Emit each ``LogRecord`` as a telemetry packet under ``topic``.
20
+
21
+ The payload carries ``level``, ``logger``, ``message``, plus every
22
+ non-reserved record attribute (``extra={...}`` fields). Handler
23
+ failures are reported via ``handleError`` — never raised.
24
+ """
25
+
26
+ def __init__(self, client: TelemetryClient, topic: str = "logger") -> None:
27
+ super().__init__()
28
+ self._client = client
29
+ self._topic = topic
30
+
31
+ def emit(self, record: logging.LogRecord) -> None:
32
+ try:
33
+ payload: dict[str, Any] = {
34
+ "level": record.levelname,
35
+ "logger": record.name,
36
+ "message": record.getMessage(),
37
+ }
38
+ for key, value in record.__dict__.items():
39
+ if key not in _RESERVED and not key.startswith("_"):
40
+ payload[key] = value
41
+ if record.exc_info:
42
+ formatter = self.formatter or logging.Formatter()
43
+ payload["exc_info"] = formatter.formatException(record.exc_info)
44
+ self._client.emit(self._topic, payload)
45
+ except Exception: # noqa: BLE001 — logging.Handler contract: emit never raises
46
+ self.handleError(record)
@@ -0,0 +1,37 @@
1
+ """Wire packet encoding (JSON or MessagePack) with MTU truncation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import time
7
+ from typing import Any, cast
8
+
9
+ import msgpack
10
+
11
+ #: Maximum wire size before the payload is replaced by a truncation marker.
12
+ MTU_SAFE = 1432
13
+
14
+
15
+ def now_micros() -> int:
16
+ """Current UNIX epoch time in microseconds."""
17
+ return time.time_ns() // 1000
18
+
19
+
20
+ def _dumps(packet: dict[str, Any], use_msgpack: bool) -> bytes:
21
+ if use_msgpack:
22
+ packed = msgpack.packb(packet, default=repr)
23
+ return cast(bytes, packed)
24
+ return json.dumps(packet, separators=(",", ":"), default=str).encode()
25
+
26
+
27
+ def encode(packet: dict[str, Any], use_msgpack: bool) -> tuple[bytes, bool]:
28
+ """Encode ``packet``; truncate payload over :data:`MTU_SAFE`.
29
+
30
+ Returns ``(wire_bytes, was_truncated)``.
31
+ """
32
+ data = _dumps(packet, use_msgpack)
33
+ if len(data) <= MTU_SAFE:
34
+ return data, False
35
+ truncated = {**packet, "payload": {"truncated": True}}
36
+ data = _dumps(truncated, use_msgpack)
37
+ return data, True
@@ -0,0 +1,31 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "light-telemetry"
7
+ version = "0.1.0"
8
+ description = "UDP fire-and-forget telemetry client for LightTelemetry"
9
+ requires-python = ">=3.11"
10
+ dependencies = ["msgpack>=1.0"]
11
+
12
+ [dependency-groups]
13
+ dev = [
14
+ "pytest>=8",
15
+ "jsonschema>=4",
16
+ "ruff>=0.7",
17
+ "mypy>=1.11",
18
+ ]
19
+
20
+ [tool.hatch.build.targets.wheel]
21
+ packages = ["light_telemetry"]
22
+
23
+ [tool.pytest.ini_options]
24
+ testpaths = ["tests"]
25
+
26
+ [tool.mypy]
27
+ strict = true
28
+
29
+ [[tool.mypy.overrides]]
30
+ module = "msgpack"
31
+ ignore_missing_imports = true
@@ -0,0 +1,222 @@
1
+ """Client and handler tests: live UDP, degradation, handler mapping."""
2
+
3
+ import json
4
+ import logging
5
+ import socket
6
+ import threading
7
+ import time
8
+ from typing import Any
9
+
10
+ import msgpack
11
+
12
+ from light_telemetry import TelemetryClient, TelemetryLogHandler
13
+
14
+
15
+ def _recv(sock: socket.socket, timeout: float = 2.0) -> tuple[bytes, Any]:
16
+ sock.settimeout(timeout)
17
+ return sock.recvfrom(65535)
18
+
19
+
20
+ def _server() -> socket.socket:
21
+ s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
22
+ s.bind(("127.0.0.1", 0))
23
+ return s
24
+
25
+
26
+ def _client(server: socket.socket) -> TelemetryClient:
27
+ return TelemetryClient(
28
+ server.getsockname(), "test-host", "test-class", heartbeat_timeout=None
29
+ )
30
+
31
+
32
+ def test_emit_sends_json_datagram() -> None:
33
+ server = _server()
34
+ client = _client(server)
35
+ client.emit("t", {"k": 1})
36
+ data, _ = _recv(server)
37
+ v = json.loads(data)
38
+ assert v["topic"] == "t"
39
+ assert v["id"] == "test-host"
40
+ assert v["class"] == "test-class"
41
+ assert v["ts"] > 0
42
+ assert v["payload"]["k"] == 1
43
+ assert client.stats.sent == 1
44
+ assert client.stats.encoded == 1
45
+ client.stop()
46
+
47
+
48
+ def test_emit_sends_msgpack_when_configured() -> None:
49
+ server = _server()
50
+ client = TelemetryClient(
51
+ server.getsockname(), "h", "c", use_msgpack=True, heartbeat_timeout=None
52
+ )
53
+ client.emit("t", {"k": 1})
54
+ data, _ = _recv(server)
55
+ assert data[0:1] != b"{"
56
+ assert msgpack.unpackb(data)["topic"] == "t"
57
+ client.stop()
58
+
59
+
60
+ def test_emit_to_unreachable_server_does_not_raise() -> None:
61
+ client = TelemetryClient(("127.0.0.1", 9), "h", "c", heartbeat_timeout=None)
62
+ client.emit("t", {})
63
+ assert client.stats.sent == 1
64
+ client.stop()
65
+
66
+
67
+ def test_degraded_drops_before_serialization() -> None:
68
+ server = _server()
69
+ client = _client(server)
70
+ client._degraded.set()
71
+ encoded_before = client.stats.encoded
72
+ client.emit("t", {"expensive": object()})
73
+ assert client.stats.encoded == encoded_before
74
+ assert client.stats.dropped == 1
75
+ assert client.stats.sent == 0
76
+ client.stop()
77
+
78
+
79
+ def test_socket_error_swallowed() -> None:
80
+ client = TelemetryClient(("127.0.0.1", 9), "h", "c", heartbeat_timeout=None)
81
+ client._sock.close() # force sendto to fail
82
+ client.emit("t", {})
83
+ assert client.stats.errors == 1
84
+
85
+
86
+ def test_stop_without_start_is_safe() -> None:
87
+ client = TelemetryClient(("127.0.0.1", 9), "h", "c")
88
+ client.stop()
89
+ client.stop() # idempotent
90
+
91
+
92
+ def test_watcher_marks_degraded_when_heartbeats_stop() -> None:
93
+ server = _server()
94
+ server_addr = server.getsockname()
95
+ client = TelemetryClient(server_addr, "h", "c", heartbeat_timeout=0.15).start()
96
+ try:
97
+ client_addr = client.local_addr
98
+ hb = json.dumps({"type": "hb", "srv_ts": 1}).encode()
99
+
100
+ # Heartbeats → stays healthy.
101
+ for _ in range(4):
102
+ server.sendto(hb, client_addr)
103
+ time.sleep(0.04)
104
+ time.sleep(0.08)
105
+ assert not client.degraded
106
+
107
+ # Silence → degraded.
108
+ time.sleep(0.4)
109
+ assert client.degraded
110
+
111
+ # Resume inside the window → recovers.
112
+ for _ in range(3):
113
+ server.sendto(hb, client_addr)
114
+ time.sleep(0.04)
115
+ time.sleep(0.06)
116
+ assert not client.degraded
117
+ finally:
118
+ client.stop()
119
+
120
+
121
+ def test_watcher_ignores_non_heartbeat_datagrams() -> None:
122
+ server = _server()
123
+ client = TelemetryClient(
124
+ server.getsockname(), "h", "c", heartbeat_timeout=0.15
125
+ ).start()
126
+ try:
127
+ server.sendto(b"random junk", client.local_addr)
128
+ time.sleep(0.3)
129
+ assert not client.degraded # never saw a real hb
130
+ finally:
131
+ client.stop()
132
+
133
+
134
+ def test_context_manager_lifecycle() -> None:
135
+ server = _server()
136
+ with TelemetryClient(
137
+ server.getsockname(), "h", "c", heartbeat_timeout=None
138
+ ) as client:
139
+ client.emit("t", {})
140
+ assert client.stats.sent == 1
141
+
142
+
143
+ # -- handler --------------------------------------------------------------
144
+
145
+
146
+ def _logging_capture(server: socket.socket) -> tuple[TelemetryClient, logging.Logger]:
147
+ client = _client(server)
148
+ logger = logging.getLogger("telemetry.test.capture")
149
+ logger.handlers.clear()
150
+ logger.setLevel(logging.DEBUG)
151
+ logger.addHandler(TelemetryLogHandler(client, topic="logger"))
152
+ return client, logger
153
+
154
+
155
+ def test_handler_emits_record_with_extra_fields() -> None:
156
+ server = _server()
157
+ client, logger = _logging_capture(server)
158
+ logger.info("login %s", "ok", extra={"user": "alice", "answer": 42})
159
+ data, _ = _recv(server)
160
+ v = json.loads(data)
161
+ assert v["topic"] == "logger"
162
+ p = v["payload"]
163
+ assert p["level"] == "INFO"
164
+ assert p["logger"] == "telemetry.test.capture"
165
+ assert p["message"] == "login ok"
166
+ assert p["user"] == "alice"
167
+ assert p["answer"] == 42
168
+ client.stop()
169
+
170
+
171
+ def test_handler_without_extra() -> None:
172
+ server = _server()
173
+ client, logger = _logging_capture(server)
174
+ logger.warning("plain")
175
+ data, _ = _recv(server)
176
+ p = json.loads(data)["payload"]
177
+ assert p["message"] == "plain"
178
+ assert p["level"] == "WARNING"
179
+ client.stop()
180
+
181
+
182
+ def test_handler_includes_exc_info() -> None:
183
+ server = _server()
184
+ client, logger = _logging_capture(server)
185
+ try:
186
+ raise ValueError("boom")
187
+ except ValueError:
188
+ logger.exception("op failed")
189
+ data, _ = _recv(server)
190
+ p = json.loads(data)["payload"]
191
+ assert "ValueError: boom" in p["exc_info"]
192
+ client.stop()
193
+
194
+
195
+ def test_handler_survives_unreachable_server() -> None:
196
+ client = TelemetryClient(("127.0.0.1", 9), "h", "c", heartbeat_timeout=None)
197
+ logger = logging.getLogger("telemetry.test.dead")
198
+ logger.handlers.clear()
199
+ logger.addHandler(TelemetryLogHandler(client))
200
+ logging.raiseExceptions = False # simulate production handler behavior
201
+ try:
202
+ logger.error("nothing hears this") # must not raise
203
+ finally:
204
+ logging.raiseExceptions = True
205
+ client.stop()
206
+
207
+
208
+ def test_concurrent_emit_is_thread_safe_enough() -> None:
209
+ server = _server()
210
+ client = _client(server)
211
+
212
+ def worker() -> None:
213
+ for _ in range(20):
214
+ client.emit("t", {})
215
+
216
+ threads = [threading.Thread(target=worker) for _ in range(4)]
217
+ for t in threads:
218
+ t.start()
219
+ for t in threads:
220
+ t.join()
221
+ assert client.stats.encoded == 80
222
+ client.stop()