qurvo-python 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.
- qurvo_python-0.1.0/.gitignore +31 -0
- qurvo_python-0.1.0/CLAUDE.md +63 -0
- qurvo_python-0.1.0/PKG-INFO +109 -0
- qurvo_python-0.1.0/README.md +88 -0
- qurvo_python-0.1.0/pyproject.toml +33 -0
- qurvo_python-0.1.0/src/qurvo/__init__.py +27 -0
- qurvo_python-0.1.0/src/qurvo/_queue.py +263 -0
- qurvo_python-0.1.0/src/qurvo/_transport.py +105 -0
- qurvo_python-0.1.0/src/qurvo/_types.py +140 -0
- qurvo_python-0.1.0/src/qurvo/client.py +262 -0
- qurvo_python-0.1.0/src/qurvo/py.typed +0 -0
- qurvo_python-0.1.0/tests/__init__.py +0 -0
- qurvo_python-0.1.0/tests/conftest.py +3 -0
- qurvo_python-0.1.0/tests/test_client.py +355 -0
- qurvo_python-0.1.0/tests/test_queue.py +635 -0
- qurvo_python-0.1.0/tests/test_transport.py +242 -0
- qurvo_python-0.1.0/tests/test_types.py +163 -0
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
node_modules/
|
|
2
|
+
dist/
|
|
3
|
+
dist-server/
|
|
4
|
+
.turbo/
|
|
5
|
+
*.tsbuildinfo
|
|
6
|
+
.env
|
|
7
|
+
.env.local
|
|
8
|
+
.DS_Store
|
|
9
|
+
.playwright-mcp/
|
|
10
|
+
*.png
|
|
11
|
+
|
|
12
|
+
# Kubernetes credentials
|
|
13
|
+
*-config.yaml
|
|
14
|
+
kubeconfig*
|
|
15
|
+
config.yaml
|
|
16
|
+
.idea
|
|
17
|
+
values.local-secrets.yaml
|
|
18
|
+
|
|
19
|
+
.last-deploy
|
|
20
|
+
.claude/worktrees/*
|
|
21
|
+
.claude/state/execution-state.json
|
|
22
|
+
.claude/state/operations.log
|
|
23
|
+
.claude/state/worktree-base-branch
|
|
24
|
+
.claude/results/
|
|
25
|
+
.claude/issues/
|
|
26
|
+
.mcp.json
|
|
27
|
+
|
|
28
|
+
apps/web/storybook-static/
|
|
29
|
+
|
|
30
|
+
# Temporary Storybook stories (not committed)
|
|
31
|
+
apps/web/src/**/*.tmp.stories.tsx
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# CLAUDE.md -- qurvo-python
|
|
2
|
+
|
|
3
|
+
Python SDK for Qurvo analytics. Mirrors the architecture of the TypeScript SDK (`@qurvo/sdk-core` + `@qurvo/sdk-node`).
|
|
4
|
+
|
|
5
|
+
## Structure
|
|
6
|
+
|
|
7
|
+
```
|
|
8
|
+
packages/qurvo-python/
|
|
9
|
+
├── pyproject.toml # hatchling build, zero runtime deps, python>=3.9
|
|
10
|
+
├── CLAUDE.md
|
|
11
|
+
├── README.md # User-facing docs with usage examples
|
|
12
|
+
├── src/
|
|
13
|
+
│ └── qurvo/
|
|
14
|
+
│ ├── __init__.py # Public API re-exports
|
|
15
|
+
│ ├── _types.py # Dataclasses (EventPayload, EventContext, etc.) + exceptions
|
|
16
|
+
│ ├── _transport.py # HttpTransport -- urllib + gzip, status code mapping
|
|
17
|
+
│ ├── _queue.py # EventQueue -- threaded batching, backoff, flush loop
|
|
18
|
+
│ └── client.py # Qurvo class -- public API, mirrors sdk-node/src/index.ts
|
|
19
|
+
└── tests/
|
|
20
|
+
├── conftest.py
|
|
21
|
+
├── test_types.py
|
|
22
|
+
├── test_transport.py
|
|
23
|
+
├── test_queue.py
|
|
24
|
+
└── test_client.py
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## Key Design Decisions
|
|
28
|
+
|
|
29
|
+
- **Zero runtime dependencies** -- only stdlib (`urllib.request`, `gzip`, `json`, `dataclasses`, `threading`, `uuid`)
|
|
30
|
+
- **`from __future__ import annotations`** everywhere for Python 3.9 compat with modern type hints
|
|
31
|
+
- **Dataclasses with `asdict()` + None-filtering** for JSON serialization -- ingest expects absent fields, not null
|
|
32
|
+
- **Private modules** (`_types.py`, `_transport.py`, `_queue.py`) -- public API via `__init__.py` re-exports
|
|
33
|
+
- **`Qurvo` client class** (`client.py`) is the primary public interface -- mirrors `@qurvo/sdk-node`
|
|
34
|
+
- **Status code mapping** mirrors TypeScript `FetchTransport`:
|
|
35
|
+
- 202 -> success
|
|
36
|
+
- 200 + `quota_limited` body -> `QuotaExceededError`
|
|
37
|
+
- 4xx -> `NonRetryableError` (SDK should drop, not retry)
|
|
38
|
+
- 5xx / network error -> generic exception (retryable)
|
|
39
|
+
- **`$set` / `$set_once` envelope pattern** -- `user_properties: {"$set": {...}}` matches sdk-node behavior
|
|
40
|
+
- **`sdk_name` / `sdk_version`** automatically added to `context` on every event
|
|
41
|
+
- **`event_id`** via `uuid4()` for deduplication on retry
|
|
42
|
+
|
|
43
|
+
## Commands
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
# Install in dev mode
|
|
47
|
+
cd packages/qurvo-python && pip install -e ".[dev]"
|
|
48
|
+
|
|
49
|
+
# Run tests
|
|
50
|
+
cd packages/qurvo-python && pytest -v
|
|
51
|
+
|
|
52
|
+
# Run tests with coverage
|
|
53
|
+
cd packages/qurvo-python && pytest --cov=qurvo --cov-report=term-missing
|
|
54
|
+
|
|
55
|
+
# Publish to PyPI
|
|
56
|
+
cd packages/qurvo-python && python -m build && twine upload dist/*
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
## Related Packages
|
|
60
|
+
|
|
61
|
+
- `@qurvo/sdk-core` -- TypeScript equivalent (types.ts, fetch-transport.ts, queue.ts)
|
|
62
|
+
- `@qurvo/sdk-node` -- TypeScript Node.js client (index.ts -- direct API mirror)
|
|
63
|
+
- `apps/ingest` -- Event ingestion endpoint (POST /v1/batch)
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: qurvo-python
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Qurvo analytics SDK for Python — zero-dependency event tracking
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
Classifier: Development Status :: 3 - Alpha
|
|
7
|
+
Classifier: Intended Audience :: Developers
|
|
8
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
9
|
+
Classifier: Programming Language :: Python :: 3
|
|
10
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
15
|
+
Classifier: Typing :: Typed
|
|
16
|
+
Requires-Python: >=3.9
|
|
17
|
+
Provides-Extra: dev
|
|
18
|
+
Requires-Dist: pytest-cov; extra == 'dev'
|
|
19
|
+
Requires-Dist: pytest>=8; extra == 'dev'
|
|
20
|
+
Description-Content-Type: text/markdown
|
|
21
|
+
|
|
22
|
+
# qurvo-python
|
|
23
|
+
|
|
24
|
+
Python SDK for [Qurvo](https://qurvo.pro) analytics. Zero runtime dependencies -- uses only the Python standard library.
|
|
25
|
+
|
|
26
|
+
## Installation
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
pip install qurvo-python
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
## Quick Start
|
|
33
|
+
|
|
34
|
+
```python
|
|
35
|
+
from qurvo import Qurvo
|
|
36
|
+
|
|
37
|
+
qurvo = Qurvo(api_key="qk_...")
|
|
38
|
+
|
|
39
|
+
# Track a custom event
|
|
40
|
+
qurvo.track("user-123", "purchase", {"amount": 99.99, "currency": "USD"})
|
|
41
|
+
|
|
42
|
+
# Identify a user
|
|
43
|
+
qurvo.identify("user-123", {"name": "John", "plan": "pro"})
|
|
44
|
+
|
|
45
|
+
# Set user properties (overwrites existing)
|
|
46
|
+
qurvo.set("user-123", {"plan": "enterprise"})
|
|
47
|
+
|
|
48
|
+
# Set user properties only if not already set
|
|
49
|
+
qurvo.set_once("user-123", {"first_seen": "2026-01-01"})
|
|
50
|
+
|
|
51
|
+
# Track a screen view
|
|
52
|
+
qurvo.screen("user-123", "HomeScreen", {"tab": "overview"})
|
|
53
|
+
|
|
54
|
+
# Gracefully flush and shut down
|
|
55
|
+
qurvo.shutdown()
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
## Configuration
|
|
59
|
+
|
|
60
|
+
```python
|
|
61
|
+
qurvo = Qurvo(
|
|
62
|
+
api_key="qk_...", # Required
|
|
63
|
+
endpoint="https://ingest.qurvo.pro", # Default
|
|
64
|
+
flush_interval=5.0, # Seconds between flushes
|
|
65
|
+
flush_size=20, # Max events per batch
|
|
66
|
+
max_queue_size=1000, # Max queued events
|
|
67
|
+
timeout=30.0, # HTTP timeout in seconds
|
|
68
|
+
logger=lambda msg: print(f"[qurvo] {msg}"), # Optional debug logger
|
|
69
|
+
)
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
## API Reference
|
|
73
|
+
|
|
74
|
+
### `Qurvo(api_key, **kwargs)`
|
|
75
|
+
|
|
76
|
+
Create a new client instance. Starts a background thread that periodically flushes queued events to the ingest endpoint.
|
|
77
|
+
|
|
78
|
+
### `.track(distinct_id, event, properties=None)`
|
|
79
|
+
|
|
80
|
+
Track a custom event.
|
|
81
|
+
|
|
82
|
+
### `.identify(distinct_id, user_properties, anonymous_id=None)`
|
|
83
|
+
|
|
84
|
+
Identify a user and set their properties. Optionally merge an anonymous ID.
|
|
85
|
+
|
|
86
|
+
### `.set(distinct_id, properties)`
|
|
87
|
+
|
|
88
|
+
Set user properties (overwrites existing values). Uses the `$set` envelope pattern.
|
|
89
|
+
|
|
90
|
+
### `.set_once(distinct_id, properties)`
|
|
91
|
+
|
|
92
|
+
Set user properties only if they are not already set. Uses the `$set_once` envelope pattern.
|
|
93
|
+
|
|
94
|
+
### `.screen(distinct_id, screen_name, properties=None)`
|
|
95
|
+
|
|
96
|
+
Track a screen view event. The `screen_name` is added as `$screen_name` in properties.
|
|
97
|
+
|
|
98
|
+
### `.shutdown(timeout=30.0)`
|
|
99
|
+
|
|
100
|
+
Gracefully flush all remaining events and stop the background thread. Call this before your application exits.
|
|
101
|
+
|
|
102
|
+
## Requirements
|
|
103
|
+
|
|
104
|
+
- Python >= 3.9
|
|
105
|
+
- No runtime dependencies
|
|
106
|
+
|
|
107
|
+
## License
|
|
108
|
+
|
|
109
|
+
MIT
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# qurvo-python
|
|
2
|
+
|
|
3
|
+
Python SDK for [Qurvo](https://qurvo.pro) analytics. Zero runtime dependencies -- uses only the Python standard library.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pip install qurvo-python
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Quick Start
|
|
12
|
+
|
|
13
|
+
```python
|
|
14
|
+
from qurvo import Qurvo
|
|
15
|
+
|
|
16
|
+
qurvo = Qurvo(api_key="qk_...")
|
|
17
|
+
|
|
18
|
+
# Track a custom event
|
|
19
|
+
qurvo.track("user-123", "purchase", {"amount": 99.99, "currency": "USD"})
|
|
20
|
+
|
|
21
|
+
# Identify a user
|
|
22
|
+
qurvo.identify("user-123", {"name": "John", "plan": "pro"})
|
|
23
|
+
|
|
24
|
+
# Set user properties (overwrites existing)
|
|
25
|
+
qurvo.set("user-123", {"plan": "enterprise"})
|
|
26
|
+
|
|
27
|
+
# Set user properties only if not already set
|
|
28
|
+
qurvo.set_once("user-123", {"first_seen": "2026-01-01"})
|
|
29
|
+
|
|
30
|
+
# Track a screen view
|
|
31
|
+
qurvo.screen("user-123", "HomeScreen", {"tab": "overview"})
|
|
32
|
+
|
|
33
|
+
# Gracefully flush and shut down
|
|
34
|
+
qurvo.shutdown()
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## Configuration
|
|
38
|
+
|
|
39
|
+
```python
|
|
40
|
+
qurvo = Qurvo(
|
|
41
|
+
api_key="qk_...", # Required
|
|
42
|
+
endpoint="https://ingest.qurvo.pro", # Default
|
|
43
|
+
flush_interval=5.0, # Seconds between flushes
|
|
44
|
+
flush_size=20, # Max events per batch
|
|
45
|
+
max_queue_size=1000, # Max queued events
|
|
46
|
+
timeout=30.0, # HTTP timeout in seconds
|
|
47
|
+
logger=lambda msg: print(f"[qurvo] {msg}"), # Optional debug logger
|
|
48
|
+
)
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## API Reference
|
|
52
|
+
|
|
53
|
+
### `Qurvo(api_key, **kwargs)`
|
|
54
|
+
|
|
55
|
+
Create a new client instance. Starts a background thread that periodically flushes queued events to the ingest endpoint.
|
|
56
|
+
|
|
57
|
+
### `.track(distinct_id, event, properties=None)`
|
|
58
|
+
|
|
59
|
+
Track a custom event.
|
|
60
|
+
|
|
61
|
+
### `.identify(distinct_id, user_properties, anonymous_id=None)`
|
|
62
|
+
|
|
63
|
+
Identify a user and set their properties. Optionally merge an anonymous ID.
|
|
64
|
+
|
|
65
|
+
### `.set(distinct_id, properties)`
|
|
66
|
+
|
|
67
|
+
Set user properties (overwrites existing values). Uses the `$set` envelope pattern.
|
|
68
|
+
|
|
69
|
+
### `.set_once(distinct_id, properties)`
|
|
70
|
+
|
|
71
|
+
Set user properties only if they are not already set. Uses the `$set_once` envelope pattern.
|
|
72
|
+
|
|
73
|
+
### `.screen(distinct_id, screen_name, properties=None)`
|
|
74
|
+
|
|
75
|
+
Track a screen view event. The `screen_name` is added as `$screen_name` in properties.
|
|
76
|
+
|
|
77
|
+
### `.shutdown(timeout=30.0)`
|
|
78
|
+
|
|
79
|
+
Gracefully flush all remaining events and stop the background thread. Call this before your application exits.
|
|
80
|
+
|
|
81
|
+
## Requirements
|
|
82
|
+
|
|
83
|
+
- Python >= 3.9
|
|
84
|
+
- No runtime dependencies
|
|
85
|
+
|
|
86
|
+
## License
|
|
87
|
+
|
|
88
|
+
MIT
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "qurvo-python"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Qurvo analytics SDK for Python — zero-dependency event tracking"
|
|
5
|
+
requires-python = ">=3.9"
|
|
6
|
+
dependencies = []
|
|
7
|
+
license = "MIT"
|
|
8
|
+
readme = "README.md"
|
|
9
|
+
classifiers = [
|
|
10
|
+
"Development Status :: 3 - Alpha",
|
|
11
|
+
"Intended Audience :: Developers",
|
|
12
|
+
"License :: OSI Approved :: MIT License",
|
|
13
|
+
"Programming Language :: Python :: 3",
|
|
14
|
+
"Programming Language :: Python :: 3.9",
|
|
15
|
+
"Programming Language :: Python :: 3.10",
|
|
16
|
+
"Programming Language :: Python :: 3.11",
|
|
17
|
+
"Programming Language :: Python :: 3.12",
|
|
18
|
+
"Programming Language :: Python :: 3.13",
|
|
19
|
+
"Typing :: Typed",
|
|
20
|
+
]
|
|
21
|
+
|
|
22
|
+
[project.optional-dependencies]
|
|
23
|
+
dev = ["pytest>=8", "pytest-cov"]
|
|
24
|
+
|
|
25
|
+
[build-system]
|
|
26
|
+
requires = ["hatchling"]
|
|
27
|
+
build-backend = "hatchling.build"
|
|
28
|
+
|
|
29
|
+
[tool.hatch.build.targets.wheel]
|
|
30
|
+
packages = ["src/qurvo"]
|
|
31
|
+
|
|
32
|
+
[tool.pytest.ini_options]
|
|
33
|
+
testpaths = ["tests"]
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""Qurvo analytics SDK for Python."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from qurvo._types import (
|
|
6
|
+
BatchEnvelope,
|
|
7
|
+
EventContext,
|
|
8
|
+
EventPayload,
|
|
9
|
+
NonRetryableError,
|
|
10
|
+
QuotaExceededError,
|
|
11
|
+
SdkConfig,
|
|
12
|
+
)
|
|
13
|
+
from qurvo._transport import HttpTransport
|
|
14
|
+
from qurvo._queue import EventQueue
|
|
15
|
+
from qurvo.client import Qurvo
|
|
16
|
+
|
|
17
|
+
__all__ = [
|
|
18
|
+
"BatchEnvelope",
|
|
19
|
+
"EventContext",
|
|
20
|
+
"EventPayload",
|
|
21
|
+
"EventQueue",
|
|
22
|
+
"HttpTransport",
|
|
23
|
+
"NonRetryableError",
|
|
24
|
+
"QuotaExceededError",
|
|
25
|
+
"Qurvo",
|
|
26
|
+
"SdkConfig",
|
|
27
|
+
]
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
"""Event queue with background flush thread, batching and exponential backoff.
|
|
2
|
+
|
|
3
|
+
Mirrors ``@qurvo/sdk-core/src/queue.ts``. Uses only stdlib
|
|
4
|
+
(``threading``, ``collections.deque``) -- no third-party dependencies.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import collections
|
|
10
|
+
import threading
|
|
11
|
+
import time
|
|
12
|
+
from datetime import datetime, timezone
|
|
13
|
+
from typing import Any, Dict, List, Optional
|
|
14
|
+
|
|
15
|
+
from qurvo._transport import HttpTransport
|
|
16
|
+
from qurvo._types import (
|
|
17
|
+
LogFn,
|
|
18
|
+
NonRetryableError,
|
|
19
|
+
QuotaExceededError,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class EventQueue:
|
|
24
|
+
"""Thread-safe event queue with automatic batched flushing.
|
|
25
|
+
|
|
26
|
+
Parameters
|
|
27
|
+
----------
|
|
28
|
+
transport:
|
|
29
|
+
HTTP transport used to send batches.
|
|
30
|
+
endpoint:
|
|
31
|
+
Target URL (e.g. ``https://ingest.qurvo.io/v1/batch``).
|
|
32
|
+
api_key:
|
|
33
|
+
Project API key passed as ``x-api-key`` header.
|
|
34
|
+
flush_interval:
|
|
35
|
+
Seconds between automatic flushes (default 5.0).
|
|
36
|
+
flush_size:
|
|
37
|
+
Max events per batch (default 20).
|
|
38
|
+
max_queue_size:
|
|
39
|
+
When exceeded, oldest events are dropped (default 1000).
|
|
40
|
+
timeout:
|
|
41
|
+
HTTP timeout in seconds for each flush (default 30.0).
|
|
42
|
+
logger:
|
|
43
|
+
Optional logging callback.
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
def __init__(
|
|
47
|
+
self,
|
|
48
|
+
transport: HttpTransport,
|
|
49
|
+
endpoint: str,
|
|
50
|
+
api_key: str,
|
|
51
|
+
flush_interval: float = 5.0,
|
|
52
|
+
flush_size: int = 20,
|
|
53
|
+
max_queue_size: int = 1000,
|
|
54
|
+
timeout: float = 30.0,
|
|
55
|
+
logger: Optional[LogFn] = None,
|
|
56
|
+
) -> None:
|
|
57
|
+
self._transport = transport
|
|
58
|
+
self._endpoint = endpoint
|
|
59
|
+
self._api_key = api_key
|
|
60
|
+
self._flush_interval = flush_interval
|
|
61
|
+
self._flush_size = flush_size
|
|
62
|
+
self._max_queue_size = max_queue_size
|
|
63
|
+
self._timeout = timeout
|
|
64
|
+
self._logger = logger
|
|
65
|
+
|
|
66
|
+
self._queue: collections.deque[Dict[str, Any]] = collections.deque()
|
|
67
|
+
self._lock = threading.Lock()
|
|
68
|
+
self._flushing = False
|
|
69
|
+
self._failure_count = 0
|
|
70
|
+
self._retry_after = 0.0
|
|
71
|
+
self._max_backoff = 30.0
|
|
72
|
+
|
|
73
|
+
self._stop_event = threading.Event()
|
|
74
|
+
self._flush_now_event = threading.Event()
|
|
75
|
+
self._timer_thread: Optional[threading.Thread] = None
|
|
76
|
+
self._stopped_permanently = False
|
|
77
|
+
|
|
78
|
+
# ------------------------------------------------------------------
|
|
79
|
+
# Public API
|
|
80
|
+
# ------------------------------------------------------------------
|
|
81
|
+
|
|
82
|
+
def enqueue(self, payload: Dict[str, Any]) -> None:
|
|
83
|
+
"""Add an event to the queue.
|
|
84
|
+
|
|
85
|
+
If the queue is at capacity, the oldest event is dropped.
|
|
86
|
+
If the queue reaches ``flush_size``, an immediate flush is triggered.
|
|
87
|
+
"""
|
|
88
|
+
with self._lock:
|
|
89
|
+
if len(self._queue) >= self._max_queue_size:
|
|
90
|
+
self._queue.popleft()
|
|
91
|
+
if self._logger:
|
|
92
|
+
self._logger(
|
|
93
|
+
f"queue full ({self._max_queue_size}), oldest event dropped"
|
|
94
|
+
)
|
|
95
|
+
self._queue.append(payload)
|
|
96
|
+
should_flush = len(self._queue) >= self._flush_size
|
|
97
|
+
|
|
98
|
+
if should_flush:
|
|
99
|
+
self._flush_now_event.set()
|
|
100
|
+
self.flush()
|
|
101
|
+
|
|
102
|
+
def start(self) -> None:
|
|
103
|
+
"""Start the background flush timer thread."""
|
|
104
|
+
if self._timer_thread is not None and self._timer_thread.is_alive():
|
|
105
|
+
return
|
|
106
|
+
if self._stopped_permanently:
|
|
107
|
+
return
|
|
108
|
+
self._stop_event.clear()
|
|
109
|
+
self._timer_thread = threading.Thread(
|
|
110
|
+
target=self._flush_loop, daemon=True, name="qurvo-flush"
|
|
111
|
+
)
|
|
112
|
+
self._timer_thread.start()
|
|
113
|
+
|
|
114
|
+
def stop(self) -> None:
|
|
115
|
+
"""Stop the background flush timer thread (does NOT flush remaining)."""
|
|
116
|
+
self._stop_event.set()
|
|
117
|
+
self._flush_now_event.set() # wake up sleeping thread
|
|
118
|
+
if self._timer_thread is not None:
|
|
119
|
+
self._timer_thread.join(timeout=5.0)
|
|
120
|
+
self._timer_thread = None
|
|
121
|
+
|
|
122
|
+
def flush(self) -> None:
|
|
123
|
+
"""Flush up to ``flush_size`` events synchronously.
|
|
124
|
+
|
|
125
|
+
Thread-safe: only one flush can run at a time (``_flushing`` guard).
|
|
126
|
+
The lock is held only for queue mutation, never during the HTTP call.
|
|
127
|
+
"""
|
|
128
|
+
if self._flushing:
|
|
129
|
+
return
|
|
130
|
+
|
|
131
|
+
with self._lock:
|
|
132
|
+
if len(self._queue) == 0:
|
|
133
|
+
return
|
|
134
|
+
if time.monotonic() < self._retry_after:
|
|
135
|
+
return
|
|
136
|
+
|
|
137
|
+
self._flushing = True
|
|
138
|
+
try:
|
|
139
|
+
self._do_flush()
|
|
140
|
+
finally:
|
|
141
|
+
self._flushing = False
|
|
142
|
+
|
|
143
|
+
def flush_all(self) -> None:
|
|
144
|
+
"""Flush the entire queue in batches.
|
|
145
|
+
|
|
146
|
+
Resets backoff state before flushing. Stops if the queue is not
|
|
147
|
+
shrinking (circuit breaker, mirrors ``queue.ts:121-124``).
|
|
148
|
+
"""
|
|
149
|
+
self._retry_after = 0.0
|
|
150
|
+
self._failure_count = 0
|
|
151
|
+
while True:
|
|
152
|
+
with self._lock:
|
|
153
|
+
size_before = len(self._queue)
|
|
154
|
+
if size_before == 0:
|
|
155
|
+
break
|
|
156
|
+
self.flush()
|
|
157
|
+
with self._lock:
|
|
158
|
+
size_after = len(self._queue)
|
|
159
|
+
if size_after >= size_before:
|
|
160
|
+
break
|
|
161
|
+
|
|
162
|
+
def shutdown(self, timeout: float = 30.0) -> None:
|
|
163
|
+
"""Stop the timer and flush all remaining events.
|
|
164
|
+
|
|
165
|
+
Parameters
|
|
166
|
+
----------
|
|
167
|
+
timeout:
|
|
168
|
+
Maximum seconds to wait for the timer thread to join.
|
|
169
|
+
"""
|
|
170
|
+
self.stop()
|
|
171
|
+
if self.size == 0:
|
|
172
|
+
return
|
|
173
|
+
self.flush_all()
|
|
174
|
+
if self._timer_thread is not None:
|
|
175
|
+
self._timer_thread.join(timeout=timeout)
|
|
176
|
+
|
|
177
|
+
@property
|
|
178
|
+
def size(self) -> int:
|
|
179
|
+
"""Number of events in the queue (does not include in-flight)."""
|
|
180
|
+
with self._lock:
|
|
181
|
+
return len(self._queue)
|
|
182
|
+
|
|
183
|
+
# ------------------------------------------------------------------
|
|
184
|
+
# Private helpers
|
|
185
|
+
# ------------------------------------------------------------------
|
|
186
|
+
|
|
187
|
+
def _flush_loop(self) -> None:
|
|
188
|
+
"""Background loop: sleep for ``flush_interval``, then flush."""
|
|
189
|
+
while not self._stop_event.is_set():
|
|
190
|
+
self._flush_now_event.clear()
|
|
191
|
+
# Wait for either the interval to elapse or an explicit trigger
|
|
192
|
+
self._stop_event.wait(timeout=self._flush_interval)
|
|
193
|
+
if self._stop_event.is_set():
|
|
194
|
+
break
|
|
195
|
+
self.flush()
|
|
196
|
+
|
|
197
|
+
def _do_flush(self) -> None:
|
|
198
|
+
"""Execute a single flush cycle (extract batch, send, handle result)."""
|
|
199
|
+
with self._lock:
|
|
200
|
+
if len(self._queue) == 0:
|
|
201
|
+
return
|
|
202
|
+
batch: List[Dict[str, Any]] = []
|
|
203
|
+
for _ in range(min(self._flush_size, len(self._queue))):
|
|
204
|
+
batch.append(self._queue.popleft())
|
|
205
|
+
|
|
206
|
+
envelope = {
|
|
207
|
+
"events": batch,
|
|
208
|
+
"sent_at": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3]
|
|
209
|
+
+ "Z",
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
try:
|
|
213
|
+
ok = self._transport.send(
|
|
214
|
+
self._endpoint,
|
|
215
|
+
self._api_key,
|
|
216
|
+
envelope,
|
|
217
|
+
timeout=self._timeout,
|
|
218
|
+
)
|
|
219
|
+
if ok:
|
|
220
|
+
self._failure_count = 0
|
|
221
|
+
self._retry_after = 0.0
|
|
222
|
+
else:
|
|
223
|
+
# Transport returned False — re-queue and backoff
|
|
224
|
+
with self._lock:
|
|
225
|
+
self._queue.extendleft(reversed(batch))
|
|
226
|
+
self._schedule_backoff()
|
|
227
|
+
if self._logger:
|
|
228
|
+
backoff = min(
|
|
229
|
+
1.0 * 2 ** (self._failure_count - 1), self._max_backoff
|
|
230
|
+
)
|
|
231
|
+
self._logger(
|
|
232
|
+
f"flush failed, {len(batch)} events re-queued, "
|
|
233
|
+
f"retry in {backoff:.0f}s"
|
|
234
|
+
)
|
|
235
|
+
except QuotaExceededError:
|
|
236
|
+
with self._lock:
|
|
237
|
+
self._queue.clear()
|
|
238
|
+
self._stopped_permanently = True
|
|
239
|
+
self.stop()
|
|
240
|
+
if self._logger:
|
|
241
|
+
self._logger("quota exceeded, events dropped and queue stopped")
|
|
242
|
+
except NonRetryableError as exc:
|
|
243
|
+
# Drop the batch — retrying won't help
|
|
244
|
+
if self._logger:
|
|
245
|
+
self._logger(
|
|
246
|
+
f"non-retryable error ({exc.status_code}), "
|
|
247
|
+
f"{len(batch)} events dropped"
|
|
248
|
+
)
|
|
249
|
+
except Exception:
|
|
250
|
+
# 5xx / network error — re-queue and backoff
|
|
251
|
+
with self._lock:
|
|
252
|
+
self._queue.extendleft(reversed(batch))
|
|
253
|
+
self._schedule_backoff()
|
|
254
|
+
if self._logger:
|
|
255
|
+
self._logger(
|
|
256
|
+
f"flush error, {len(batch)} events re-queued"
|
|
257
|
+
)
|
|
258
|
+
|
|
259
|
+
def _schedule_backoff(self) -> None:
|
|
260
|
+
"""Increment failure count and compute next retry-after timestamp."""
|
|
261
|
+
self._failure_count += 1
|
|
262
|
+
backoff = min(1.0 * 2 ** (self._failure_count - 1), self._max_backoff)
|
|
263
|
+
self._retry_after = time.monotonic() + backoff
|