mnm-sdk 0.1.1__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.
mnm_sdk-0.1.1/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Sohan Narewadi and Radhesh Nair
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.
mnm_sdk-0.1.1/PKG-INFO ADDED
@@ -0,0 +1,73 @@
1
+ Metadata-Version: 2.4
2
+ Name: mnm-sdk
3
+ Version: 0.1.1
4
+ Summary: MNM client: log model predictions asynchronously, without slowing inference
5
+ License: MIT
6
+ Project-URL: Repository, https://github.com/Sohan-Narewadi/MNM
7
+ Requires-Python: >=3.9
8
+ Description-Content-Type: text/markdown
9
+ License-File: LICENSE
10
+ Provides-Extra: dev
11
+ Requires-Dist: pytest>=8; extra == "dev"
12
+ Requires-Dist: ruff>=0.6; extra == "dev"
13
+ Requires-Dist: black>=24; extra == "dev"
14
+ Dynamic: license-file
15
+
16
+ # sdk/ — MNM Python client
17
+
18
+ A tiny library a model service imports to log predictions to MNM **without slowing down inference**.
19
+
20
+ ## Install
21
+ ```bash
22
+ pip install mnm-sdk
23
+ ```
24
+
25
+ ## Usage
26
+ ```python
27
+ from mnm_sdk import Monitor
28
+
29
+ monitor = Monitor(api_url="http://backend:8000", model="churn", version="3")
30
+
31
+ @app.post("/predict")
32
+ def predict(req: Request):
33
+ t0 = time.perf_counter()
34
+ y = model.predict(req.features)
35
+ monitor.log(features=req.features, output=y, latency_ms=(time.perf_counter() - t0) * 1000)
36
+ return y
37
+ ```
38
+
39
+ ## How it behaves
40
+ - `log()` only appends to an in-memory bounded queue and returns: it never waits on the network and never raises, whatever is passed in.
41
+ - A background daemon thread sends batches to `POST /v1/ingest` when `batch_size` events are queued or every `flush_interval` seconds, whichever comes first.
42
+ - A failed batch is retried with exponential backoff (`max_retries`, `retry_backoff`). Each event carries its own `request_id` and `ts`, and ingestion is idempotent on those, so a retry never stores an event twice. A batch the server rejects as invalid (4xx) is not retried.
43
+ - `NaN` and `Infinity` (for example a missing feature from pandas or numpy) are sent as `null`, because JSON has no such numbers.
44
+ - If the queue is full, the **oldest** event is dropped and counted. Nothing blocks.
45
+ - `close()` runs at interpreter exit (`atexit`) and sends what is still queued.
46
+ - `monitor.stats` reports `logged`, `sent`, `dropped`, `failed` and `queued`.
47
+ - No dependencies outside the standard library. Python 3.9+.
48
+
49
+ | Option | Default | Meaning |
50
+ |---|---|---|
51
+ | `batch_size` | 100 | Send as soon as this many events are queued |
52
+ | `flush_interval` | 1.0 s | Send whatever is queued at least this often |
53
+ | `max_queue` | 10 000 | Queue capacity; beyond it the oldest event is dropped |
54
+ | `max_retries` | 3 | Extra attempts for a batch that failed |
55
+ | `retry_backoff` | 0.5 s | Wait before the first retry; doubles each time |
56
+ | `timeout` | 5.0 s | HTTP timeout per request |
57
+
58
+ `log()` also accepts `request_id=`, `label=` and `ts=`. The event format is the ingest contract in [`docs/api-spec.md`](../docs/api-spec.md).
59
+
60
+ ## Development
61
+ ```bash
62
+ cd sdk
63
+ pip install -e ".[dev]"
64
+ pytest # no network and no backend needed
65
+ ```
66
+
67
+ ## Layout
68
+ ```
69
+ sdk/
70
+ ├── mnm_sdk/__init__.py, monitor.py, transport.py
71
+ ├── tests/
72
+ └── pyproject.toml
73
+ ```
@@ -0,0 +1,58 @@
1
+ # sdk/ — MNM Python client
2
+
3
+ A tiny library a model service imports to log predictions to MNM **without slowing down inference**.
4
+
5
+ ## Install
6
+ ```bash
7
+ pip install mnm-sdk
8
+ ```
9
+
10
+ ## Usage
11
+ ```python
12
+ from mnm_sdk import Monitor
13
+
14
+ monitor = Monitor(api_url="http://backend:8000", model="churn", version="3")
15
+
16
+ @app.post("/predict")
17
+ def predict(req: Request):
18
+ t0 = time.perf_counter()
19
+ y = model.predict(req.features)
20
+ monitor.log(features=req.features, output=y, latency_ms=(time.perf_counter() - t0) * 1000)
21
+ return y
22
+ ```
23
+
24
+ ## How it behaves
25
+ - `log()` only appends to an in-memory bounded queue and returns: it never waits on the network and never raises, whatever is passed in.
26
+ - A background daemon thread sends batches to `POST /v1/ingest` when `batch_size` events are queued or every `flush_interval` seconds, whichever comes first.
27
+ - A failed batch is retried with exponential backoff (`max_retries`, `retry_backoff`). Each event carries its own `request_id` and `ts`, and ingestion is idempotent on those, so a retry never stores an event twice. A batch the server rejects as invalid (4xx) is not retried.
28
+ - `NaN` and `Infinity` (for example a missing feature from pandas or numpy) are sent as `null`, because JSON has no such numbers.
29
+ - If the queue is full, the **oldest** event is dropped and counted. Nothing blocks.
30
+ - `close()` runs at interpreter exit (`atexit`) and sends what is still queued.
31
+ - `monitor.stats` reports `logged`, `sent`, `dropped`, `failed` and `queued`.
32
+ - No dependencies outside the standard library. Python 3.9+.
33
+
34
+ | Option | Default | Meaning |
35
+ |---|---|---|
36
+ | `batch_size` | 100 | Send as soon as this many events are queued |
37
+ | `flush_interval` | 1.0 s | Send whatever is queued at least this often |
38
+ | `max_queue` | 10 000 | Queue capacity; beyond it the oldest event is dropped |
39
+ | `max_retries` | 3 | Extra attempts for a batch that failed |
40
+ | `retry_backoff` | 0.5 s | Wait before the first retry; doubles each time |
41
+ | `timeout` | 5.0 s | HTTP timeout per request |
42
+
43
+ `log()` also accepts `request_id=`, `label=` and `ts=`. The event format is the ingest contract in [`docs/api-spec.md`](../docs/api-spec.md).
44
+
45
+ ## Development
46
+ ```bash
47
+ cd sdk
48
+ pip install -e ".[dev]"
49
+ pytest # no network and no backend needed
50
+ ```
51
+
52
+ ## Layout
53
+ ```
54
+ sdk/
55
+ ├── mnm_sdk/__init__.py, monitor.py, transport.py
56
+ ├── tests/
57
+ └── pyproject.toml
58
+ ```
@@ -0,0 +1,6 @@
1
+ """MNM SDK: log model predictions to MNM without slowing down inference."""
2
+
3
+ from mnm_sdk.monitor import Monitor, Stats
4
+ from mnm_sdk.transport import HttpTransport, Transport, TransportError
5
+
6
+ __all__ = ["HttpTransport", "Monitor", "Stats", "Transport", "TransportError"]
@@ -0,0 +1,212 @@
1
+ """Monitor: the object a model service uses to log predictions.
2
+
3
+ `log()` only appends to a bounded in-memory queue. A background thread sends
4
+ the queue to the MNM ingestion API in batches. Nothing in here may slow down
5
+ or break the caller's prediction path: `log()` never blocks on the network
6
+ and never raises.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import atexit
12
+ import logging
13
+ import threading
14
+ import uuid
15
+ from collections import deque
16
+ from collections.abc import Mapping
17
+ from dataclasses import dataclass
18
+ from datetime import datetime, timezone
19
+ from typing import Any
20
+
21
+ from mnm_sdk.transport import HttpTransport, Transport, TransportError
22
+
23
+ logger = logging.getLogger("mnm")
24
+
25
+
26
+ @dataclass(frozen=True)
27
+ class Stats:
28
+ """Counters since the Monitor was created."""
29
+
30
+ logged: int # events accepted by log()
31
+ sent: int # events delivered to the ingestion API
32
+ dropped: int # events discarded before sending (queue full, invalid, closed)
33
+ failed: int # events given up on after the retries ran out
34
+ queued: int # events waiting in the queue right now
35
+
36
+
37
+ class Monitor:
38
+ """Logs predictions of one model version to MNM in the background.
39
+
40
+ Args:
41
+ api_url: Base URL of the MNM backend, e.g. ``http://backend:8000``.
42
+ model: Model name.
43
+ version: Model version.
44
+ batch_size: Send as soon as this many events are queued.
45
+ flush_interval: Send whatever is queued at least this often (seconds).
46
+ max_queue: Queue capacity. When full, the oldest event is dropped.
47
+ max_retries: Extra attempts for a batch that failed to send.
48
+ retry_backoff: Wait before the first retry (seconds); doubles each time.
49
+ timeout: HTTP timeout per request (seconds).
50
+ transport: Replace the HTTP transport (used by tests).
51
+ """
52
+
53
+ def __init__(
54
+ self,
55
+ api_url: str,
56
+ model: str,
57
+ version: str | int,
58
+ *,
59
+ batch_size: int = 100,
60
+ flush_interval: float = 1.0,
61
+ max_queue: int = 10_000,
62
+ max_retries: int = 3,
63
+ retry_backoff: float = 0.5,
64
+ timeout: float = 5.0,
65
+ transport: Transport | None = None,
66
+ ) -> None:
67
+ self._model = model
68
+ self._version = str(version)
69
+ self._batch_size = max(1, batch_size)
70
+ self._flush_interval = flush_interval
71
+ self._max_queue = max(1, max_queue)
72
+ self._max_retries = max(0, max_retries)
73
+ self._retry_backoff = retry_backoff
74
+ self._transport = transport or HttpTransport(api_url, timeout=timeout)
75
+
76
+ self._queue: deque[dict[str, Any]] = deque()
77
+ self._lock = threading.Lock()
78
+ self._drained = threading.Condition(self._lock)
79
+ self._wake = threading.Event()
80
+ self._closing = threading.Event()
81
+ self._in_flight = 0
82
+ self._logged = self._sent = self._dropped = self._failed = 0
83
+
84
+ self._thread = threading.Thread(
85
+ target=self._run, name="mnm-monitor", daemon=True
86
+ )
87
+ self._thread.start()
88
+ atexit.register(self.close)
89
+
90
+ # -- called on the prediction path -------------------------------------
91
+
92
+ def log(
93
+ self,
94
+ features: Mapping[str, Any],
95
+ output: Any,
96
+ latency_ms: float,
97
+ *,
98
+ request_id: str | None = None,
99
+ label: Any = None,
100
+ ts: datetime | None = None,
101
+ ) -> None:
102
+ """Queue one prediction. Returns immediately and never raises."""
103
+ try:
104
+ event: dict[str, Any] = {
105
+ "model": self._model,
106
+ "version": self._version,
107
+ "request_id": request_id or uuid.uuid4().hex,
108
+ "ts": (ts or datetime.now(timezone.utc)).isoformat(),
109
+ "features": dict(features),
110
+ "output": output,
111
+ "latency_ms": float(latency_ms),
112
+ }
113
+ if label is not None:
114
+ event["label"] = label
115
+ except Exception:
116
+ with self._lock:
117
+ self._dropped += 1
118
+ logger.debug("mnm: dropped an event that could not be built", exc_info=True)
119
+ return
120
+
121
+ with self._lock:
122
+ if self._closing.is_set():
123
+ self._dropped += 1
124
+ return
125
+ if len(self._queue) >= self._max_queue:
126
+ self._queue.popleft()
127
+ self._dropped += 1
128
+ self._queue.append(event)
129
+ self._logged += 1
130
+ batch_ready = len(self._queue) >= self._batch_size
131
+ if batch_ready:
132
+ self._wake.set()
133
+
134
+ # -- control -------------------------------------------------------------
135
+
136
+ @property
137
+ def stats(self) -> Stats:
138
+ with self._lock:
139
+ return Stats(
140
+ logged=self._logged,
141
+ sent=self._sent,
142
+ dropped=self._dropped,
143
+ failed=self._failed,
144
+ queued=len(self._queue),
145
+ )
146
+
147
+ def flush(self, timeout: float | None = None) -> bool:
148
+ """Block until everything logged so far was sent or given up on.
149
+
150
+ Returns False if that did not happen within `timeout` seconds.
151
+ """
152
+ self._wake.set()
153
+ with self._drained:
154
+ return self._drained.wait_for(
155
+ lambda: not self._queue and not self._in_flight, timeout
156
+ )
157
+
158
+ def close(self, timeout: float = 5.0) -> None:
159
+ """Send what is queued, then stop the background thread. Idempotent."""
160
+ self._closing.set()
161
+ self._wake.set()
162
+ self._thread.join(timeout)
163
+
164
+ # -- background thread ---------------------------------------------------
165
+
166
+ def _run(self) -> None:
167
+ while True:
168
+ self._wake.wait(self._flush_interval)
169
+ self._wake.clear()
170
+ self._drain()
171
+ if self._closing.is_set():
172
+ self._drain() # anything logged while the last batch was sending
173
+ return
174
+
175
+ def _drain(self) -> None:
176
+ while True:
177
+ with self._lock:
178
+ if not self._queue:
179
+ self._drained.notify_all()
180
+ return
181
+ size = min(self._batch_size, len(self._queue))
182
+ batch = [self._queue.popleft() for _ in range(size)]
183
+ self._in_flight = size
184
+ delivered = self._send(batch)
185
+ with self._lock:
186
+ self._in_flight = 0
187
+ if delivered:
188
+ self._sent += len(batch)
189
+ else:
190
+ self._failed += len(batch)
191
+ self._drained.notify_all()
192
+
193
+ def _send(self, batch: list[dict[str, Any]]) -> bool:
194
+ """Try to deliver one batch, retrying with backoff. Never raises."""
195
+ delay = self._retry_backoff
196
+ for attempt in range(self._max_retries + 1):
197
+ try:
198
+ self._transport.send(batch)
199
+ return True
200
+ except TransportError as error:
201
+ reason: Exception = error
202
+ if not error.retryable:
203
+ break
204
+ # A bug in a transport must not kill the logging thread.
205
+ except Exception as error: # noqa: BLE001
206
+ reason = error
207
+ if attempt < self._max_retries:
208
+ # Returns early when close() is called: do not delay shutdown.
209
+ self._closing.wait(delay)
210
+ delay *= 2
211
+ logger.warning("mnm: gave up on %d events: %s", len(batch), reason)
212
+ return False
@@ -0,0 +1,76 @@
1
+ """How batches leave the process: HTTP POST to the MNM ingestion API."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import urllib.error
7
+ import urllib.request
8
+ from typing import Any, Protocol
9
+
10
+ INGEST_PATH = "/v1/ingest"
11
+
12
+
13
+ class TransportError(Exception):
14
+ """A batch could not be delivered.
15
+
16
+ `retryable` is False when sending the same batch again cannot succeed
17
+ (the server rejected it as invalid).
18
+ """
19
+
20
+ def __init__(self, message: str, *, retryable: bool = True) -> None:
21
+ super().__init__(message)
22
+ self.retryable = retryable
23
+
24
+
25
+ class Transport(Protocol):
26
+ def send(self, events: list[dict[str, Any]]) -> None:
27
+ """Deliver one batch or raise TransportError."""
28
+
29
+
30
+ def _json_default(value: Any) -> Any:
31
+ """Encode values `json` does not know, e.g. numpy scalars and arrays."""
32
+ for method in ("item", "tolist"):
33
+ convert = getattr(value, method, None)
34
+ if callable(convert):
35
+ return convert()
36
+ return str(value)
37
+
38
+
39
+ def encode(events: list[dict[str, Any]]) -> bytes:
40
+ """Serialise a batch as strict JSON.
41
+
42
+ NaN and +/-Infinity are not JSON. Python would write them as bare tokens,
43
+ which the ingestion database rejects for the whole batch, so they are sent
44
+ as null (a missing value). The round trip also catches the ones that only
45
+ appear after numpy values have been converted.
46
+ """
47
+ loose = json.dumps(events, default=_json_default)
48
+ if "NaN" not in loose and "Infinity" not in loose:
49
+ return loose.encode("utf-8")
50
+ strict = json.loads(loose, parse_constant=lambda _token: None)
51
+ return json.dumps(strict, allow_nan=False).encode("utf-8")
52
+
53
+
54
+ class HttpTransport:
55
+ def __init__(self, api_url: str, *, timeout: float = 5.0) -> None:
56
+ self._url = api_url.rstrip("/") + INGEST_PATH
57
+ self._timeout = timeout
58
+
59
+ def send(self, events: list[dict[str, Any]]) -> None:
60
+ request = urllib.request.Request(
61
+ self._url,
62
+ data=encode(events),
63
+ headers={"Content-Type": "application/json"},
64
+ method="POST",
65
+ )
66
+ try:
67
+ with urllib.request.urlopen(request, timeout=self._timeout) as response:
68
+ response.read()
69
+ except urllib.error.HTTPError as error:
70
+ # 4xx means the batch itself is wrong, except "slow down" answers.
71
+ retryable = error.code >= 500 or error.code in (408, 429)
72
+ raise TransportError(
73
+ f"MNM ingest answered HTTP {error.code}", retryable=retryable
74
+ ) from error
75
+ except OSError as error: # URLError, timeouts, refused connections
76
+ raise TransportError(f"MNM ingest unreachable: {error}") from error
@@ -0,0 +1,73 @@
1
+ Metadata-Version: 2.4
2
+ Name: mnm-sdk
3
+ Version: 0.1.1
4
+ Summary: MNM client: log model predictions asynchronously, without slowing inference
5
+ License: MIT
6
+ Project-URL: Repository, https://github.com/Sohan-Narewadi/MNM
7
+ Requires-Python: >=3.9
8
+ Description-Content-Type: text/markdown
9
+ License-File: LICENSE
10
+ Provides-Extra: dev
11
+ Requires-Dist: pytest>=8; extra == "dev"
12
+ Requires-Dist: ruff>=0.6; extra == "dev"
13
+ Requires-Dist: black>=24; extra == "dev"
14
+ Dynamic: license-file
15
+
16
+ # sdk/ — MNM Python client
17
+
18
+ A tiny library a model service imports to log predictions to MNM **without slowing down inference**.
19
+
20
+ ## Install
21
+ ```bash
22
+ pip install mnm-sdk
23
+ ```
24
+
25
+ ## Usage
26
+ ```python
27
+ from mnm_sdk import Monitor
28
+
29
+ monitor = Monitor(api_url="http://backend:8000", model="churn", version="3")
30
+
31
+ @app.post("/predict")
32
+ def predict(req: Request):
33
+ t0 = time.perf_counter()
34
+ y = model.predict(req.features)
35
+ monitor.log(features=req.features, output=y, latency_ms=(time.perf_counter() - t0) * 1000)
36
+ return y
37
+ ```
38
+
39
+ ## How it behaves
40
+ - `log()` only appends to an in-memory bounded queue and returns: it never waits on the network and never raises, whatever is passed in.
41
+ - A background daemon thread sends batches to `POST /v1/ingest` when `batch_size` events are queued or every `flush_interval` seconds, whichever comes first.
42
+ - A failed batch is retried with exponential backoff (`max_retries`, `retry_backoff`). Each event carries its own `request_id` and `ts`, and ingestion is idempotent on those, so a retry never stores an event twice. A batch the server rejects as invalid (4xx) is not retried.
43
+ - `NaN` and `Infinity` (for example a missing feature from pandas or numpy) are sent as `null`, because JSON has no such numbers.
44
+ - If the queue is full, the **oldest** event is dropped and counted. Nothing blocks.
45
+ - `close()` runs at interpreter exit (`atexit`) and sends what is still queued.
46
+ - `monitor.stats` reports `logged`, `sent`, `dropped`, `failed` and `queued`.
47
+ - No dependencies outside the standard library. Python 3.9+.
48
+
49
+ | Option | Default | Meaning |
50
+ |---|---|---|
51
+ | `batch_size` | 100 | Send as soon as this many events are queued |
52
+ | `flush_interval` | 1.0 s | Send whatever is queued at least this often |
53
+ | `max_queue` | 10 000 | Queue capacity; beyond it the oldest event is dropped |
54
+ | `max_retries` | 3 | Extra attempts for a batch that failed |
55
+ | `retry_backoff` | 0.5 s | Wait before the first retry; doubles each time |
56
+ | `timeout` | 5.0 s | HTTP timeout per request |
57
+
58
+ `log()` also accepts `request_id=`, `label=` and `ts=`. The event format is the ingest contract in [`docs/api-spec.md`](../docs/api-spec.md).
59
+
60
+ ## Development
61
+ ```bash
62
+ cd sdk
63
+ pip install -e ".[dev]"
64
+ pytest # no network and no backend needed
65
+ ```
66
+
67
+ ## Layout
68
+ ```
69
+ sdk/
70
+ ├── mnm_sdk/__init__.py, monitor.py, transport.py
71
+ ├── tests/
72
+ └── pyproject.toml
73
+ ```
@@ -0,0 +1,13 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ mnm_sdk/__init__.py
5
+ mnm_sdk/monitor.py
6
+ mnm_sdk/transport.py
7
+ mnm_sdk.egg-info/PKG-INFO
8
+ mnm_sdk.egg-info/SOURCES.txt
9
+ mnm_sdk.egg-info/dependency_links.txt
10
+ mnm_sdk.egg-info/requires.txt
11
+ mnm_sdk.egg-info/top_level.txt
12
+ tests/test_monitor.py
13
+ tests/test_transport.py
@@ -0,0 +1,5 @@
1
+
2
+ [dev]
3
+ pytest>=8
4
+ ruff>=0.6
5
+ black>=24
@@ -0,0 +1 @@
1
+ mnm_sdk
@@ -0,0 +1,24 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "mnm-sdk"
7
+ version = "0.1.1"
8
+ description = "MNM client: log model predictions asynchronously, without slowing inference"
9
+ requires-python = ">=3.9"
10
+ readme = "README.md"
11
+ license = { text = "MIT" }
12
+ dependencies = []
13
+
14
+ [project.urls]
15
+ Repository = "https://github.com/Sohan-Narewadi/MNM"
16
+
17
+ [project.optional-dependencies]
18
+ dev = ["pytest>=8", "ruff>=0.6", "black>=24"]
19
+
20
+ [tool.setuptools.packages.find]
21
+ include = ["mnm_sdk*"]
22
+
23
+ [tool.pytest.ini_options]
24
+ testpaths = ["tests"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,270 @@
1
+ """Monitor behaviour, tested through a fake transport (no network)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import threading
6
+ import time
7
+ from collections.abc import Iterator
8
+ from datetime import datetime
9
+ from typing import Any
10
+
11
+ import pytest
12
+
13
+ from mnm_sdk import Monitor, TransportError
14
+
15
+
16
+ class FakeTransport:
17
+ """Records every batch. Can be told to fail or to block."""
18
+
19
+ def __init__(self) -> None:
20
+ self.batches: list[list[dict[str, Any]]] = []
21
+ self.attempts = 0
22
+ self.failures: list[Exception] = []
23
+ self.gate = threading.Event()
24
+ self.gate.set()
25
+
26
+ def send(self, events: list[dict[str, Any]]) -> None:
27
+ self.gate.wait(timeout=10)
28
+ self.attempts += 1
29
+ if self.failures:
30
+ raise self.failures.pop(0)
31
+ self.batches.append(events)
32
+
33
+ @property
34
+ def events(self) -> list[dict[str, Any]]:
35
+ return [event for batch in self.batches for event in batch]
36
+
37
+
38
+ @pytest.fixture
39
+ def transport() -> FakeTransport:
40
+ return FakeTransport()
41
+
42
+
43
+ @pytest.fixture
44
+ def make_monitor(transport: FakeTransport) -> Iterator[Any]:
45
+ monitors: list[Monitor] = []
46
+
47
+ def factory(**options: Any) -> Monitor:
48
+ options.setdefault("retry_backoff", 0.01)
49
+ monitor = Monitor(
50
+ "http://mnm.invalid",
51
+ model="churn",
52
+ version="3",
53
+ transport=transport,
54
+ **options,
55
+ )
56
+ monitors.append(monitor)
57
+ return monitor
58
+
59
+ yield factory
60
+ transport.gate.set()
61
+ for monitor in monitors:
62
+ monitor.close(timeout=2)
63
+
64
+
65
+ def log_n(monitor: Monitor, n: int) -> None:
66
+ for i in range(n):
67
+ monitor.log(features={"i": i}, output=i, latency_ms=1.0)
68
+
69
+
70
+ def test_logged_event_reaches_the_transport_in_the_ingest_format(
71
+ make_monitor: Any, transport: FakeTransport
72
+ ) -> None:
73
+ monitor = make_monitor()
74
+
75
+ monitor.log(features={"age": 41, "plan": "pro"}, output=0.83, latency_ms=3.2)
76
+ assert monitor.flush(timeout=2)
77
+
78
+ (event,) = transport.events
79
+ assert event["model"] == "churn"
80
+ assert event["version"] == "3"
81
+ assert event["features"] == {"age": 41, "plan": "pro"}
82
+ assert event["output"] == 0.83
83
+ assert event["latency_ms"] == 3.2
84
+ assert len(event["request_id"]) >= 16
85
+ assert datetime.fromisoformat(event["ts"]).tzinfo is not None
86
+ assert "label" not in event
87
+
88
+
89
+ def test_caller_supplied_request_id_and_label_are_kept(
90
+ make_monitor: Any, transport: FakeTransport
91
+ ) -> None:
92
+ monitor = make_monitor()
93
+
94
+ monitor.log(features={}, output=1, latency_ms=0.5, request_id="abc", label=1)
95
+ monitor.flush(timeout=2)
96
+
97
+ assert transport.events[0]["request_id"] == "abc"
98
+ assert transport.events[0]["label"] == 1
99
+
100
+
101
+ def test_events_are_sent_in_batches_of_batch_size(
102
+ make_monitor: Any, transport: FakeTransport
103
+ ) -> None:
104
+ transport.gate.clear()
105
+ monitor = make_monitor(batch_size=10, flush_interval=60)
106
+
107
+ log_n(monitor, 25)
108
+ transport.gate.set()
109
+ monitor.flush(timeout=2)
110
+
111
+ assert [len(batch) for batch in transport.batches] == [10, 10, 5]
112
+ assert [event["output"] for event in transport.events] == list(range(25))
113
+
114
+
115
+ def test_a_partial_batch_is_sent_after_the_flush_interval(
116
+ make_monitor: Any, transport: FakeTransport
117
+ ) -> None:
118
+ monitor = make_monitor(batch_size=100, flush_interval=0.05)
119
+
120
+ log_n(monitor, 3)
121
+
122
+ deadline = time.monotonic() + 2
123
+ while not transport.events and time.monotonic() < deadline:
124
+ time.sleep(0.01)
125
+ assert len(transport.events) == 3
126
+
127
+
128
+ def test_log_does_not_wait_for_a_stuck_backend(
129
+ make_monitor: Any, transport: FakeTransport
130
+ ) -> None:
131
+ transport.gate.clear() # every send now hangs
132
+ monitor = make_monitor(batch_size=10, flush_interval=0.01)
133
+
134
+ started = time.perf_counter()
135
+ log_n(monitor, 1000)
136
+ elapsed = time.perf_counter() - started
137
+
138
+ assert elapsed < 0.5
139
+ assert transport.batches == []
140
+
141
+
142
+ def test_a_full_queue_drops_the_oldest_events_and_counts_them(
143
+ make_monitor: Any, transport: FakeTransport
144
+ ) -> None:
145
+ transport.gate.clear()
146
+ monitor = make_monitor(batch_size=5, flush_interval=0.01, max_queue=10)
147
+ monitor.log(features={}, output="first", latency_ms=1.0)
148
+ deadline = time.monotonic() + 2
149
+ while monitor.stats.queued and time.monotonic() < deadline:
150
+ time.sleep(0.005) # wait until the worker holds "first" and hangs
151
+
152
+ log_n(monitor, 15)
153
+ assert monitor.stats.dropped == 5
154
+ transport.gate.set()
155
+ monitor.flush(timeout=2)
156
+
157
+ outputs = [event["output"] for event in transport.events]
158
+ assert outputs == ["first", *range(5, 15)]
159
+ assert monitor.stats.sent == 11
160
+
161
+
162
+ def test_a_failed_send_is_retried_with_the_same_events(
163
+ make_monitor: Any, transport: FakeTransport
164
+ ) -> None:
165
+ transport.failures = [TransportError("down"), TransportError("still down")]
166
+ monitor = make_monitor(max_retries=3)
167
+
168
+ monitor.log(features={}, output=1, latency_ms=1.0, request_id="only")
169
+ monitor.flush(timeout=2)
170
+
171
+ assert transport.attempts == 3
172
+ assert [event["request_id"] for event in transport.events] == ["only"]
173
+ assert monitor.stats.sent == 1
174
+ assert monitor.stats.failed == 0
175
+
176
+
177
+ def test_a_batch_is_given_up_after_max_retries_and_counted(
178
+ make_monitor: Any, transport: FakeTransport
179
+ ) -> None:
180
+ transport.failures = [TransportError("down")] * 3
181
+ monitor = make_monitor(max_retries=2)
182
+
183
+ monitor.log(features={}, output="lost", latency_ms=1.0)
184
+ monitor.flush(timeout=2)
185
+ monitor.log(features={}, output="kept", latency_ms=1.0)
186
+ monitor.flush(timeout=2)
187
+
188
+ assert transport.attempts == 4 # 1 try + 2 retries, then the next batch
189
+ assert [event["output"] for event in transport.events] == ["kept"]
190
+ assert monitor.stats.failed == 1
191
+ assert monitor.stats.sent == 1
192
+
193
+
194
+ def test_a_rejected_batch_is_not_retried(
195
+ make_monitor: Any, transport: FakeTransport
196
+ ) -> None:
197
+ transport.failures = [TransportError("422 invalid", retryable=False)]
198
+ monitor = make_monitor(max_retries=5)
199
+
200
+ monitor.log(features={}, output=1, latency_ms=1.0)
201
+ monitor.flush(timeout=2)
202
+
203
+ assert transport.attempts == 1
204
+ assert monitor.stats.failed == 1
205
+
206
+
207
+ def test_an_unexpected_transport_crash_does_not_kill_logging(
208
+ make_monitor: Any, transport: FakeTransport
209
+ ) -> None:
210
+ transport.failures = [RuntimeError("bug in transport")]
211
+ monitor = make_monitor(max_retries=0)
212
+
213
+ monitor.log(features={}, output="lost", latency_ms=1.0)
214
+ monitor.flush(timeout=2)
215
+ monitor.log(features={}, output="kept", latency_ms=1.0)
216
+ monitor.flush(timeout=2)
217
+
218
+ assert [event["output"] for event in transport.events] == ["kept"]
219
+
220
+
221
+ def test_log_never_raises_on_bad_arguments(
222
+ make_monitor: Any, transport: FakeTransport
223
+ ) -> None:
224
+ monitor = make_monitor()
225
+
226
+ monitor.log(features=None, output=1, latency_ms=1.0) # type: ignore[arg-type]
227
+ monitor.log(features=object(), output=1, latency_ms="x") # type: ignore[arg-type]
228
+ monitor.log(features={"ok": 1}, output=1, latency_ms=1.0)
229
+ monitor.flush(timeout=2)
230
+
231
+ assert [event["features"] for event in transport.events] == [{"ok": 1}]
232
+ assert monitor.stats.dropped == 2
233
+
234
+
235
+ def test_later_changes_to_the_features_dict_do_not_leak_into_the_event(
236
+ make_monitor: Any, transport: FakeTransport
237
+ ) -> None:
238
+ transport.gate.clear()
239
+ monitor = make_monitor()
240
+ features = {"age": 41}
241
+
242
+ monitor.log(features=features, output=1, latency_ms=1.0)
243
+ features["age"] = 99
244
+ transport.gate.set()
245
+ monitor.flush(timeout=2)
246
+
247
+ assert transport.events[0]["features"] == {"age": 41}
248
+
249
+
250
+ def test_close_sends_what_is_still_queued(
251
+ make_monitor: Any, transport: FakeTransport
252
+ ) -> None:
253
+ monitor = make_monitor(batch_size=100, flush_interval=60)
254
+ log_n(monitor, 7)
255
+
256
+ monitor.close(timeout=2)
257
+
258
+ assert len(transport.events) == 7
259
+
260
+
261
+ def test_logging_after_close_is_ignored_and_counted(
262
+ make_monitor: Any, transport: FakeTransport
263
+ ) -> None:
264
+ monitor = make_monitor()
265
+ monitor.close(timeout=2)
266
+
267
+ monitor.log(features={}, output=1, latency_ms=1.0)
268
+
269
+ assert transport.events == []
270
+ assert monitor.stats.dropped == 1
@@ -0,0 +1,129 @@
1
+ """HttpTransport against a throwaway HTTP server on localhost."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import threading
7
+ from collections.abc import Iterator
8
+ from http.server import BaseHTTPRequestHandler, HTTPServer
9
+ from typing import Any
10
+
11
+ import pytest
12
+
13
+ from mnm_sdk import TransportError
14
+ from mnm_sdk.transport import HttpTransport
15
+
16
+
17
+ class Recorder(HTTPServer):
18
+ status = 202
19
+ requests: list[dict[str, Any]]
20
+
21
+
22
+ class Handler(BaseHTTPRequestHandler):
23
+ server: Recorder
24
+
25
+ def do_POST(self) -> None:
26
+ body = self.rfile.read(int(self.headers["Content-Length"]))
27
+ self.server.requests.append(
28
+ {
29
+ "path": self.path,
30
+ "content_type": self.headers["Content-Type"],
31
+ "body": json.loads(body),
32
+ "raw": body.decode("utf-8"),
33
+ }
34
+ )
35
+ self.send_response(self.server.status)
36
+ self.end_headers()
37
+ self.wfile.write(b"{}")
38
+
39
+ def log_message(self, *args: Any) -> None:
40
+ pass
41
+
42
+
43
+ @pytest.fixture
44
+ def server() -> Iterator[Recorder]:
45
+ recorder = Recorder(("127.0.0.1", 0), Handler)
46
+ recorder.requests = []
47
+ thread = threading.Thread(target=recorder.serve_forever, daemon=True)
48
+ thread.start()
49
+ yield recorder
50
+ recorder.shutdown()
51
+ recorder.server_close()
52
+
53
+
54
+ def url(server: Recorder) -> str:
55
+ return f"http://127.0.0.1:{server.server_address[1]}"
56
+
57
+
58
+ def test_send_posts_the_events_as_a_json_array_to_v1_ingest(server: Recorder) -> None:
59
+ events = [{"model": "churn", "features": {"age": 41}}]
60
+
61
+ HttpTransport(url(server) + "/").send(events)
62
+
63
+ (request,) = server.requests
64
+ assert request["path"] == "/v1/ingest"
65
+ assert request["content_type"] == "application/json"
66
+ assert request["body"] == events
67
+
68
+
69
+ def test_values_json_cannot_encode_are_converted_not_fatal(server: Recorder) -> None:
70
+ class NumpyLikeScalar:
71
+ def item(self) -> float:
72
+ return 1.5
73
+
74
+ HttpTransport(url(server)).send(
75
+ [{"output": NumpyLikeScalar(), "features": {"x": {1, 2}.__class__}}]
76
+ )
77
+
78
+ body = server.requests[0]["body"]
79
+ assert body[0]["output"] == 1.5
80
+ assert isinstance(body[0]["features"]["x"], str)
81
+
82
+
83
+ def test_a_server_error_is_retryable(server: Recorder) -> None:
84
+ server.status = 503
85
+
86
+ with pytest.raises(TransportError) as raised:
87
+ HttpTransport(url(server)).send([{}])
88
+
89
+ assert raised.value.retryable
90
+
91
+
92
+ def test_a_validation_error_is_not_retryable(server: Recorder) -> None:
93
+ server.status = 422
94
+
95
+ with pytest.raises(TransportError) as raised:
96
+ HttpTransport(url(server)).send([{}])
97
+
98
+ assert not raised.value.retryable
99
+
100
+
101
+ def test_an_unreachable_server_is_retryable() -> None:
102
+ with pytest.raises(TransportError) as raised:
103
+ HttpTransport("http://127.0.0.1:9", timeout=0.5).send([{}])
104
+
105
+ assert raised.value.retryable
106
+
107
+
108
+ def test_nan_and_infinity_are_sent_as_null_because_json_has_neither(
109
+ server: Recorder,
110
+ ) -> None:
111
+ class NumpyLikeNan:
112
+ def item(self) -> float:
113
+ return float("nan")
114
+
115
+ HttpTransport(url(server)).send(
116
+ [
117
+ {
118
+ "features": {"age": float("nan"), "x": float("inf"), "ok": 1.5},
119
+ "output": [NumpyLikeNan(), float("-inf")],
120
+ }
121
+ ]
122
+ )
123
+
124
+ request = server.requests[0]
125
+ assert "NaN" not in request["raw"]
126
+ assert "Infinity" not in request["raw"]
127
+ assert request["body"] == [
128
+ {"features": {"age": None, "x": None, "ok": 1.5}, "output": [None, None]}
129
+ ]