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.
- light_telemetry-0.1.0/.gitignore +24 -0
- light_telemetry-0.1.0/PKG-INFO +6 -0
- light_telemetry-0.1.0/light_telemetry/__init__.py +18 -0
- light_telemetry-0.1.0/light_telemetry/client.py +200 -0
- light_telemetry-0.1.0/light_telemetry/config.py +71 -0
- light_telemetry-0.1.0/light_telemetry/handler.py +46 -0
- light_telemetry-0.1.0/light_telemetry/serialize.py +37 -0
- light_telemetry-0.1.0/pyproject.toml +31 -0
- light_telemetry-0.1.0/tests/test_client.py +222 -0
- light_telemetry-0.1.0/tests/test_config.py +83 -0
- light_telemetry-0.1.0/tests/test_serialize.py +71 -0
- light_telemetry-0.1.0/tests/test_spec_vectors.py +49 -0
- light_telemetry-0.1.0/uv.lock +660 -0
|
@@ -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,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()
|