light-telemetry 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.
@@ -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,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,8 @@
1
+ light_telemetry/__init__.py,sha256=Udt4dIN-PPqsUG86t0gCeXL_mXUYPrzXthJdWjS2u2g,396
2
+ light_telemetry/client.py,sha256=keS4HWiTlsJzKJRgPdoM7aOhzpcVgUhcT-F-_rWSlbg,6601
3
+ light_telemetry/config.py,sha256=dZPKr7C6yPGdHj5nxviiCKl9lzKnemP6Nl284vgkPrU,2002
4
+ light_telemetry/handler.py,sha256=Yp151DO38wT8PEpMjhkwRGZSzvbpbENoAtsIdwbUqIo,1641
5
+ light_telemetry/serialize.py,sha256=byDRsP0FzcfqV5YV3oQkW3pxBOWIb2nVzaUs_Obkcmo,1048
6
+ light_telemetry-0.1.0.dist-info/METADATA,sha256=pE9WtEbd7ikEXy5CzlfdlPfEAH3ZF7cVOllhqrX47Bc,176
7
+ light_telemetry-0.1.0.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
8
+ light_telemetry-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.33.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any