alitycs 1.0.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.
alitycs/__init__.py ADDED
@@ -0,0 +1,63 @@
1
+ """Official Alitycs analytics SDK for Python servers.
2
+
3
+ Quickstart::
4
+
5
+ import alitycs
6
+
7
+ alitycs.init("pk_...", flush_size=20, flush_interval=2.0)
8
+ alitycs.identify("usr_123", {"plan": "pro"})
9
+ alitycs.track("checkout_completed", {"total": "19.99"})
10
+ alitycs.shutdown() # bounded drain; configure persistence for restart safety
11
+
12
+ Zero runtime dependencies — HTTP goes through ``urllib.request``.
13
+ """
14
+
15
+ from .client import (
16
+ Alitycs,
17
+ capture_error,
18
+ flush,
19
+ get_default_instance,
20
+ identify,
21
+ init,
22
+ page,
23
+ reset,
24
+ set_global_properties,
25
+ shutdown,
26
+ track,
27
+ track_revenue,
28
+ )
29
+ from .config import DEFAULT_ENDPOINT, AlitycsConfig
30
+ from .types import (
31
+ AnalyticsEvent,
32
+ BatchPayload,
33
+ EventContext,
34
+ EventType,
35
+ RevenueError,
36
+ RevenuePayload,
37
+ )
38
+
39
+ __version__ = "1.0.0"
40
+
41
+ __all__ = [
42
+ "Alitycs",
43
+ "AlitycsConfig",
44
+ "AnalyticsEvent",
45
+ "BatchPayload",
46
+ "DEFAULT_ENDPOINT",
47
+ "EventContext",
48
+ "EventType",
49
+ "RevenueError",
50
+ "RevenuePayload",
51
+ "__version__",
52
+ "capture_error",
53
+ "flush",
54
+ "get_default_instance",
55
+ "identify",
56
+ "init",
57
+ "page",
58
+ "reset",
59
+ "set_global_properties",
60
+ "shutdown",
61
+ "track",
62
+ "track_revenue",
63
+ ]
alitycs/batch.py ADDED
@@ -0,0 +1,500 @@
1
+ """Queueing and batch dispatch on a background flusher thread.
2
+
3
+ Design notes, in the shadow of the ``@alitycs/core`` flush-lock defect this SDK must
4
+ never repeat (see .agents/plans/phase-0-harness.md §1.1):
5
+
6
+ - A single daemon worker thread owns size-triggered and timer-triggered dispatch,
7
+ taking exactly ``flush_size`` events per batch.
8
+ - :meth:`flush` does not signal the worker and hope — it drains the queue itself on
9
+ the calling thread, ``flush_size`` events at a time, and resolves only once nothing
10
+ is queued **and** no send is in flight. Waiting against an in-flight send is
11
+ precisely the spot where ``core`` used to no-op and lose whatever was queued
12
+ behind it.
13
+ - Delivery is honest: :meth:`flush` returns ``True`` only when everything drained was
14
+ delivered. A whole-batch rejection (HTTP 400 — one invalid event poisons the entire
15
+ batch) splits the payload in half and re-sends each half; a transient failure
16
+ re-queues survivors at the head of the queue preserving order. Drained-but-
17
+ undelivered events are never silently dropped.
18
+ - :meth:`shutdown` marks the manager draining and gives the worker a bounded window.
19
+ When that deadline expires, queued work is persisted when durability is enabled;
20
+ callers can pass ``join_timeout=None`` when they explicitly want an unbounded drain.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import threading
26
+ import time
27
+ from collections import deque
28
+ from typing import Callable, List, Optional, Set, Tuple, Union
29
+
30
+ from .transport import SendFailed, SendRejected, SendSuccess
31
+ from .types import AnalyticsEvent, BatchPayload
32
+ from .utils import debug_warn, generate_id, now_ms, warn
33
+
34
+ #: Legacy fakes may return ``None``; that is treated as success.
35
+ SendFn = Callable[[BatchPayload], Optional[Union[SendSuccess, SendRejected, SendFailed]]]
36
+ DeadlineSendFn = Callable[
37
+ [BatchPayload, Optional[float]], Optional[Union[SendSuccess, SendRejected, SendFailed]]
38
+ ]
39
+ Clock = Callable[[], float]
40
+ RecoverFn = Callable[[Optional[float]], bool]
41
+ DurablePendingFn = Callable[[], int]
42
+ DurablePendingSnapshotFn = Callable[[List[str]], Tuple[int, int]]
43
+ PersistFn = Callable[[BatchPayload], bool]
44
+
45
+ # Outcome of one dispatched batch.
46
+ _OK = "ok" # every event delivered
47
+ _REJECTED = "rejected" # server permanently refused; events dropped loudly
48
+ _TRANSIENT = "transient" # transport failure; events re-queued at the head
49
+
50
+ # Pause for the background worker after a transient failure so it does not
51
+ # hot-loop over re-queued events; explicit flushes retry immediately instead.
52
+ _WORKER_RETRY_BACKOFF_SECONDS = 0.5
53
+
54
+ # Whole-batch rejection isolation is useful but must not amplify one response into an
55
+ # unbounded request storm when a caller configures a very large flush size.
56
+ _MAX_SPLIT_SENDS = 64
57
+
58
+
59
+ class BatchManager:
60
+ def __init__(
61
+ self,
62
+ flush_size: int,
63
+ flush_interval: Optional[float],
64
+ max_queue_size: int,
65
+ send_fn: SendFn,
66
+ debug: bool = False,
67
+ clock: Optional[Clock] = None,
68
+ recover_fn: Optional[RecoverFn] = None,
69
+ durable_pending_fn: Optional[DurablePendingFn] = None,
70
+ durable: bool = False,
71
+ persist_fn: Optional[PersistFn] = None,
72
+ send_with_deadline_fn: Optional[DeadlineSendFn] = None,
73
+ durable_pending_snapshot_fn: Optional[DurablePendingSnapshotFn] = None,
74
+ ) -> None:
75
+ self._flush_size = flush_size
76
+ self._flush_interval = flush_interval
77
+ self._max_queue_size = max_queue_size
78
+ self._send_fn = send_fn
79
+ self._debug = debug
80
+ self._clock: Clock = clock if clock is not None else time.monotonic
81
+ self._recover_fn = recover_fn
82
+ self._durable_pending_fn = durable_pending_fn
83
+ self._durable = durable
84
+ self._persist_fn = persist_fn
85
+ self._send_with_deadline_fn = send_with_deadline_fn
86
+ self._durable_pending_snapshot_fn = durable_pending_snapshot_fn
87
+
88
+ self._cv = threading.Condition()
89
+ self._queue: "deque[AnalyticsEvent]" = deque()
90
+ self._inflight = 0
91
+ self._active_batch_ids: Set[str] = set()
92
+ self._stopping = False
93
+ self._closed = False
94
+ self._finite_shutdown_complete = False
95
+ self._thread: Optional[threading.Thread] = None
96
+ self._next_tick = self._clock() + flush_interval if flush_interval else None
97
+
98
+ self._delivered_count = 0
99
+ self._requeued_count = 0
100
+ self._lost_count = 0
101
+
102
+ @property
103
+ def pending(self) -> int:
104
+ """Events queued, in flight, or persisted for restart."""
105
+ with self._cv:
106
+ memory_pending = len(self._queue) + self._inflight
107
+ active_batch_ids = list(self._active_batch_ids)
108
+ inflight = self._inflight
109
+ if self._durable_pending_snapshot_fn is not None:
110
+ durable_pending, overlap = self._durable_pending_snapshot_fn(active_batch_ids)
111
+ return memory_pending + durable_pending - min(overlap, inflight)
112
+ durable_pending = 0 if self._durable_pending_fn is None else self._durable_pending_fn()
113
+ return memory_pending + durable_pending
114
+
115
+ @property
116
+ def closed(self) -> bool:
117
+ """True once :meth:`shutdown` stopped this manager from accepting events."""
118
+ with self._cv:
119
+ return self._closed
120
+
121
+ @property
122
+ def delivered_total(self) -> int:
123
+ """Events confirmed delivered since this manager was created."""
124
+ with self._cv:
125
+ return self._delivered_count
126
+
127
+ @property
128
+ def requeued_total(self) -> int:
129
+ """Events re-queued at the head after transient send failures."""
130
+ with self._cv:
131
+ return self._requeued_count
132
+
133
+ @property
134
+ def lost_total(self) -> int:
135
+ """Events permanently lost: dropped by the server or overflowed from the queue."""
136
+ with self._cv:
137
+ return self._lost_count
138
+
139
+ def add(self, event: AnalyticsEvent) -> bool:
140
+ """Enqueue one event, dispatching when the queue reaches ``flush_size``.
141
+ Returns ``False`` when the event was dropped (queue full or shut down)."""
142
+ with self._cv:
143
+ if self._closed:
144
+ if self._debug:
145
+ debug_warn("Queue closed — dropping event")
146
+ return False
147
+ if len(self._queue) >= self._max_queue_size:
148
+ self._lost_count += 1
149
+ warn("Queue full — dropping event")
150
+ return False
151
+ self._queue.append(event)
152
+ self._cv.notify_all()
153
+ self._ensure_worker()
154
+ return True
155
+
156
+ def flush(self, timeout: Optional[float] = None) -> bool:
157
+ """Drain fully: send everything queued and wait for in-flight sends to land.
158
+ Returns ``True`` only when the drain finished with every event delivered;
159
+ ``False`` when a send failed (survivors stay queued), or ``timeout`` elapsed.
160
+ Safe to call concurrently — callers share in-flight sends instead of
161
+ duplicating them."""
162
+ deadline = None if timeout is None else self._clock() + timeout
163
+ with self._cv:
164
+ # A caller has explicitly resumed draining after a previous finite shutdown.
165
+ # Late failures must become pending work for this caller, not terminal loss.
166
+ self._finite_shutdown_complete = False
167
+ if not self._recover_durable(deadline):
168
+ return False
169
+ self._ensure_worker()
170
+ all_delivered = True
171
+ while True:
172
+ batch: List[AnalyticsEvent] = []
173
+ cv = self._cv # bind: reset_for_child may swap the condition mid-flight
174
+ with cv:
175
+ while True:
176
+ now = self._clock()
177
+ if not self._queue and self._inflight == 0:
178
+ durable_pending = (
179
+ 0 if self._durable_pending_fn is None else self._durable_pending_fn()
180
+ )
181
+ return all_delivered and durable_pending == 0
182
+ if deadline is not None and now >= deadline:
183
+ return False
184
+ if self._queue:
185
+ # Drain inline rather than depend on the worker waking up;
186
+ # this is the path shutdown relies on. Take flush_size-sized
187
+ # chunks so payloads match the size-triggered batches.
188
+ batch = self._pop(min(self._flush_size, len(self._queue)))
189
+ self._advance_tick(now)
190
+ break
191
+ # Nothing queued but a send is in flight: wait for its completion
192
+ # notification (or the worker picking up newly queued events).
193
+ cv.wait(None if deadline is None else max(0.0, deadline - now))
194
+ if not batch:
195
+ continue
196
+ outcome = self._send_batch(batch, deadline)
197
+ if outcome == _OK:
198
+ continue
199
+ all_delivered = False
200
+ if outcome == _REJECTED:
201
+ # Those events are gone (loudly); keep draining the rest of the queue.
202
+ continue
203
+ # Transient failure: survivors were re-queued at the head. Retrying inside
204
+ # this loop would hot-spin against the same failure — wait out any other
205
+ # in-flight sends and report honestly; a later flush retries.
206
+ self._wait_for_inflight(deadline)
207
+ return False
208
+
209
+ def shutdown(self, join_timeout: Optional[float] = 30.0) -> None:
210
+ """Stop accepting events and wait up to ``join_timeout`` for the worker.
211
+
212
+ When the deadline expires, queued events are persisted when durability is
213
+ enabled instead of extending process shutdown with unbounded network waits.
214
+ Passing ``None`` preserves the fully blocking drain contract.
215
+ """
216
+ deadline = None if join_timeout is None else self._clock() + max(0.0, join_timeout)
217
+ with self._cv:
218
+ if deadline is None:
219
+ self._finite_shutdown_complete = False
220
+ self._stopping = True
221
+ self._closed = True
222
+ self._cv.notify_all()
223
+ thread = self._thread
224
+ if thread is not None and thread is not threading.current_thread():
225
+ remaining = None if deadline is None else max(0.0, deadline - self._clock())
226
+ thread.join(remaining)
227
+
228
+ if deadline is not None:
229
+ # The worker owns network delivery. A finite shutdown must never re-enter
230
+ # that path inline because HTTP retries and Retry-After sleeps can outlive
231
+ # the caller's deadline. Any queued remainder is safe on disk when durable;
232
+ # an in-flight durable batch was persisted by the transport before sending.
233
+ with self._cv:
234
+ self._finite_shutdown_complete = True
235
+ self._persist_queued_for_shutdown()
236
+ return
237
+
238
+ # An explicitly unbounded shutdown retains the full drain guarantee.
239
+ self._wait_for_inflight(None)
240
+ if not self.flush():
241
+ self._persist_queued_for_shutdown()
242
+
243
+ def reset_for_child(self) -> None:
244
+ """Post-``os.fork`` repair, run in the child only.
245
+
246
+ Locks are replaced because a parent thread may have held them at fork time. The
247
+ inherited queue is discarded: the parent still owns those events, and sending
248
+ the child's copy would double-count them.
249
+ """
250
+ self._cv = threading.Condition()
251
+ with self._cv:
252
+ self._thread = None
253
+ self._inflight = 0 # the parent's in-flight send can never complete here
254
+ self._active_batch_ids.clear()
255
+ self._queue.clear()
256
+ self._next_tick = self._clock() + self._flush_interval if self._flush_interval else None
257
+
258
+ # Internals ------------------------------------------------------------
259
+
260
+ def _ensure_worker(self) -> None:
261
+ with self._cv:
262
+ if self._closed:
263
+ return
264
+ if self._thread is not None and self._thread.is_alive():
265
+ return
266
+ self._thread = threading.Thread(target=self._run, name="alitycs-flusher", daemon=True)
267
+ # Start under the lock so two racing callers cannot spawn duplicates.
268
+ self._thread.start()
269
+
270
+ def _worker_alive(self) -> bool:
271
+ return self._thread is not None and self._thread.is_alive()
272
+
273
+ def _run(self) -> None:
274
+ try:
275
+ while True:
276
+ cv = self._cv # bind: reset_for_child may swap the condition mid-flight
277
+ with cv:
278
+ batch = self._take_batch(cv)
279
+ if batch is None:
280
+ return
281
+ outcome = self._send_batch(batch)
282
+ if outcome == _TRANSIENT:
283
+ with self._cv:
284
+ if self._stopping:
285
+ return
286
+ # Survivors were re-queued at the head; back off before retrying so
287
+ # a downed endpoint does not turn into a hot loop.
288
+ time.sleep(_WORKER_RETRY_BACKOFF_SECONDS)
289
+ finally:
290
+ with self._cv:
291
+ self._cv.notify_all()
292
+
293
+ def _take_batch(self, cv: threading.Condition) -> Optional[List[AnalyticsEvent]]:
294
+ """Pop the next batch under the lock, or ``None`` when fully drained and
295
+ shutting down. Caller holds ``cv`` (bound by :meth:`_run`, not re-read from
296
+ ``self._cv`` which ``reset_for_child`` may have replaced)."""
297
+ while True:
298
+ now = self._clock()
299
+ if self._stopping and not self._queue:
300
+ return None
301
+ if self._stopping:
302
+ return self._pop(min(self._flush_size, len(self._queue)))
303
+ if len(self._queue) >= self._flush_size:
304
+ return self._pop(self._flush_size)
305
+ if self._timer_due(now):
306
+ self._advance_tick(now)
307
+ if self._queue:
308
+ return self._pop(min(self._flush_size, len(self._queue)))
309
+ continue # timer fired with an empty queue: skip the missed tick
310
+ cv.wait(self._time_until_tick(now))
311
+
312
+ def _pop(self, count: int) -> List[AnalyticsEvent]:
313
+ batch: List[AnalyticsEvent] = []
314
+ while self._queue and len(batch) < count:
315
+ batch.append(self._queue.popleft())
316
+ self._inflight += len(batch)
317
+ return batch
318
+
319
+ def _timer_due(self, now: float) -> bool:
320
+ return self._next_tick is not None and now >= self._next_tick
321
+
322
+ def _advance_tick(self, now: float) -> None:
323
+ if self._flush_interval:
324
+ self._next_tick = now + self._flush_interval
325
+
326
+ def _time_until_tick(self, now: float) -> Optional[float]:
327
+ if self._next_tick is None:
328
+ return None
329
+ return max(0.0, self._next_tick - now)
330
+
331
+ def _wait_for_inflight(self, deadline: Optional[float]) -> bool:
332
+ """Block until no send is in flight. Returns True when the queue and inflight
333
+ are both empty, False otherwise (timeout or undelivered work remains)."""
334
+ with self._cv:
335
+ while True:
336
+ now = self._clock()
337
+ if self._inflight == 0:
338
+ return not self._queue
339
+ if deadline is not None and now >= deadline:
340
+ return False
341
+ self._cv.wait(None if deadline is None else max(0.0, deadline - now))
342
+
343
+ def _send_batch(
344
+ self, batch: List[AnalyticsEvent], deadline: Optional[float] = None
345
+ ) -> str:
346
+ """Dispatch one batch. Returns ``_OK``, ``_REJECTED``, or ``_TRANSIENT``
347
+ (with survivors re-queued at the head). Never raises."""
348
+ active_batch_ids: List[str] = []
349
+ try:
350
+ if not self._recover_durable(deadline):
351
+ self._requeue_at_head(batch)
352
+ result = _TRANSIENT
353
+ else:
354
+ result = self._deliver(
355
+ list(batch), [_MAX_SPLIT_SENDS], deadline, active_batch_ids
356
+ )
357
+ except Exception as exc: # noqa: BLE001 - delivery must never crash the host
358
+ warn(f"Batch dispatch failed ({type(exc).__name__}: {exc})")
359
+ self._requeue_at_head(batch)
360
+ result = _TRANSIENT
361
+ finally:
362
+ with self._cv:
363
+ for batch_id in active_batch_ids:
364
+ self._active_batch_ids.discard(batch_id)
365
+ self._inflight -= len(batch)
366
+ self._cv.notify_all()
367
+ return result
368
+
369
+ def _recover_durable(self, deadline: Optional[float] = None) -> bool:
370
+ if self._recover_fn is None:
371
+ return True
372
+ try:
373
+ return self._recover_fn(deadline)
374
+ except Exception as exc: # noqa: BLE001 - persistence/network failures are reported
375
+ warn(f"Durable batch recovery failed ({type(exc).__name__}: {exc})")
376
+ return False
377
+
378
+ def _persist_queued_for_shutdown(self) -> None:
379
+ """Persist queued work FIFO when an older durable batch blocks recovery."""
380
+ with self._cv:
381
+ queued = list(self._queue)
382
+ self._queue.clear()
383
+
384
+ if not queued:
385
+ return
386
+ if not self._durable or self._persist_fn is None:
387
+ with self._cv:
388
+ self._lost_count += len(queued)
389
+ warn(f"Shutdown could not deliver {len(queued)} queued event(s) — persistence unavailable")
390
+ return
391
+
392
+ for index, event in enumerate(queued):
393
+ payload = BatchPayload(
394
+ batch_id=f"batch_{generate_id()}",
395
+ sent_at=now_ms(),
396
+ events=[event],
397
+ )
398
+ try:
399
+ persisted = self._persist_fn(payload)
400
+ except Exception as exc: # noqa: BLE001 - count the unresolved suffix honestly
401
+ warn(f"Shutdown persistence failed ({type(exc).__name__}: {exc})")
402
+ persisted = False
403
+ if persisted:
404
+ continue
405
+
406
+ lost = len(queued) - index
407
+ with self._cv:
408
+ self._lost_count += lost
409
+ warn(f"Shutdown persistence failed — {lost} queued event(s) lost")
410
+ return
411
+
412
+ def _deliver(
413
+ self,
414
+ events: List[AnalyticsEvent],
415
+ remaining_sends: List[int],
416
+ deadline: Optional[float] = None,
417
+ active_batch_ids: Optional[List[str]] = None,
418
+ ) -> str:
419
+ if remaining_sends[0] <= 0:
420
+ with self._cv:
421
+ self._lost_count += len(events)
422
+ warn(
423
+ f"Batch rejection split limit reached — dropping {len(events)} unresolved event(s)"
424
+ )
425
+ return _REJECTED
426
+ remaining_sends[0] -= 1
427
+ payload = BatchPayload(
428
+ batch_id=f"batch_{generate_id()}",
429
+ sent_at=now_ms(),
430
+ events=list(events),
431
+ )
432
+ if active_batch_ids is not None:
433
+ with self._cv:
434
+ self._active_batch_ids.add(payload.batch_id)
435
+ active_batch_ids.append(payload.batch_id)
436
+ try:
437
+ outcome = (
438
+ self._send_fn(payload)
439
+ if self._send_with_deadline_fn is None
440
+ else self._send_with_deadline_fn(payload, deadline)
441
+ )
442
+ except Exception as exc: # noqa: BLE001 - legacy send fns raise instead of outcomes
443
+ warn(f"Batch send failed ({type(exc).__name__}: {exc})")
444
+ outcome = SendFailed(f"{type(exc).__name__}: {exc}")
445
+
446
+ if isinstance(outcome, SendRejected):
447
+ if outcome.is_batch_reject and len(events) > 1:
448
+ # The whole batch bounced because one event violated a limit. Split in
449
+ # half and retry each side so valid events still land; depth is bounded
450
+ # by log2(len(events)).
451
+ mid = len(events) // 2
452
+ left = self._deliver(
453
+ events[:mid], remaining_sends, deadline, active_batch_ids
454
+ )
455
+ right = self._deliver(
456
+ events[mid:], remaining_sends, deadline, active_batch_ids
457
+ )
458
+ if _TRANSIENT in (left, right):
459
+ return _TRANSIENT
460
+ return _OK if _REJECTED not in (left, right) else _REJECTED
461
+ with self._cv:
462
+ self._lost_count += len(events)
463
+ warn(
464
+ f"Server rejected {len(events)} event(s) with HTTP {outcome.status} — "
465
+ "dropped, not retried"
466
+ )
467
+ return _REJECTED
468
+
469
+ if isinstance(outcome, SendFailed):
470
+ if self._durable and outcome.durable:
471
+ warn(f"Transport failure ({outcome.reason}) — exact batch retained for restart")
472
+ else:
473
+ warn(f"Transport failure ({outcome.reason}) — re-queueing {len(events)} event(s)")
474
+ self._requeue_at_head(events)
475
+ return _TRANSIENT
476
+
477
+ if self._debug and outcome is not None and not isinstance(outcome, SendSuccess):
478
+ debug_warn(f"Unexpected send outcome {outcome!r} — treating as delivered")
479
+
480
+ with self._cv:
481
+ self._delivered_count += len(events)
482
+ return _OK
483
+
484
+ def _requeue_at_head(self, events: List[AnalyticsEvent]) -> None:
485
+ abandoned = False
486
+ with self._cv:
487
+ if self._finite_shutdown_complete:
488
+ self._lost_count += len(events)
489
+ abandoned = True
490
+ else:
491
+ self._requeued_count += len(events)
492
+ # extendleft reverses its argument, so pass the reversed list to keep the
493
+ # original order at the head of the queue.
494
+ self._queue.extendleft(reversed(events))
495
+ self._cv.notify_all()
496
+ if abandoned:
497
+ warn(
498
+ f"Transport failed after the shutdown deadline — {len(events)} "
499
+ "in-flight event(s) lost"
500
+ )