whisperr 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.
- whisperr-0.1.0/.github/workflows/ci.yml +24 -0
- whisperr-0.1.0/.github/workflows/publish.yml +33 -0
- whisperr-0.1.0/.gitignore +12 -0
- whisperr-0.1.0/LICENSE +21 -0
- whisperr-0.1.0/PKG-INFO +134 -0
- whisperr-0.1.0/README.md +111 -0
- whisperr-0.1.0/pyproject.toml +38 -0
- whisperr-0.1.0/src/whisperr/__init__.py +18 -0
- whisperr-0.1.0/src/whisperr/client.py +275 -0
- whisperr-0.1.0/src/whisperr/django.py +93 -0
- whisperr-0.1.0/src/whisperr/errors.py +13 -0
- whisperr-0.1.0/src/whisperr/py.typed +0 -0
- whisperr-0.1.0/src/whisperr/transport.py +90 -0
- whisperr-0.1.0/tests/test_client.py +232 -0
- whisperr-0.1.0/tests/test_conformance.py +86 -0
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
name: CI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
pull_request:
|
|
6
|
+
|
|
7
|
+
permissions:
|
|
8
|
+
contents: read
|
|
9
|
+
|
|
10
|
+
jobs:
|
|
11
|
+
test:
|
|
12
|
+
runs-on: ubuntu-latest
|
|
13
|
+
strategy:
|
|
14
|
+
fail-fast: false
|
|
15
|
+
matrix:
|
|
16
|
+
python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"]
|
|
17
|
+
steps:
|
|
18
|
+
- uses: actions/checkout@v4
|
|
19
|
+
- uses: actions/setup-python@v5
|
|
20
|
+
with:
|
|
21
|
+
python-version: ${{ matrix.python-version }}
|
|
22
|
+
- run: python -m pip install --upgrade pip
|
|
23
|
+
- run: pip install -e ".[dev]"
|
|
24
|
+
- run: pytest -q
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
name: Publish
|
|
2
|
+
|
|
3
|
+
# Publishes whisperr to PyPI when a version tag is pushed:
|
|
4
|
+
# git tag v0.1.0 && git push origin v0.1.0
|
|
5
|
+
#
|
|
6
|
+
# Auth: PyPI Trusted Publishing (OIDC) — NO stored token. Before the first
|
|
7
|
+
# publish, configure a Trusted Publisher for the `whisperr` project on pypi.org
|
|
8
|
+
# (Publishing → Add a new pending publisher: owner WhisperrAI, repo
|
|
9
|
+
# whisperr-python, workflow publish.yml, environment pypi).
|
|
10
|
+
|
|
11
|
+
on:
|
|
12
|
+
push:
|
|
13
|
+
tags:
|
|
14
|
+
- "v*"
|
|
15
|
+
|
|
16
|
+
permissions:
|
|
17
|
+
contents: read
|
|
18
|
+
id-token: write # required for PyPI trusted publishing
|
|
19
|
+
|
|
20
|
+
jobs:
|
|
21
|
+
publish:
|
|
22
|
+
runs-on: ubuntu-latest
|
|
23
|
+
environment: pypi
|
|
24
|
+
steps:
|
|
25
|
+
- uses: actions/checkout@v4
|
|
26
|
+
- uses: actions/setup-python@v5
|
|
27
|
+
with:
|
|
28
|
+
python-version: "3.12"
|
|
29
|
+
- run: python -m pip install --upgrade pip build
|
|
30
|
+
- run: pip install -e ".[dev]"
|
|
31
|
+
- run: pytest -q
|
|
32
|
+
- run: python -m build
|
|
33
|
+
- uses: pypa/gh-action-pypi-publish@release/v1
|
whisperr-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Whisperr
|
|
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.
|
whisperr-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: whisperr
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Whisperr server-side SDK for Python — reliable churn-signal event tracking.
|
|
5
|
+
Project-URL: Homepage, https://whisperr.net
|
|
6
|
+
Project-URL: Repository, https://github.com/WhisperrAI/whisperr-python
|
|
7
|
+
Author: Whisperr
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Keywords: analytics,churn,events,retention,tracking,whisperr
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
13
|
+
Classifier: Operating System :: OS Independent
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
16
|
+
Requires-Python: >=3.8
|
|
17
|
+
Provides-Extra: dev
|
|
18
|
+
Requires-Dist: django>=3.2; extra == 'dev'
|
|
19
|
+
Requires-Dist: pytest>=7; extra == 'dev'
|
|
20
|
+
Provides-Extra: django
|
|
21
|
+
Requires-Dist: django>=3.2; extra == 'django'
|
|
22
|
+
Description-Content-Type: text/markdown
|
|
23
|
+
|
|
24
|
+
# whisperr (Python)
|
|
25
|
+
|
|
26
|
+
The Whisperr **server-side** SDK for Python — reliable churn-signal event
|
|
27
|
+
tracking for any Python backend. The backend is where the highest-signal churn
|
|
28
|
+
events live (payment failures, cancellations, trial expiry, usage drops), so
|
|
29
|
+
this is where Whisperr gets its most valuable signal.
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
pip install whisperr
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
## Quick start
|
|
36
|
+
|
|
37
|
+
```python
|
|
38
|
+
import os
|
|
39
|
+
from whisperr import Whisperr
|
|
40
|
+
|
|
41
|
+
whisperr = Whisperr(api_key=os.environ["WHISPERR_API_KEY"])
|
|
42
|
+
|
|
43
|
+
# A server-side churn signal:
|
|
44
|
+
whisperr.track("user_8842", "payment_failed", {"amount_cents": 4900, "reason": "card_declined"})
|
|
45
|
+
|
|
46
|
+
# Associate traits / contact channels with a user:
|
|
47
|
+
whisperr.identify("user_8842", email="ada@example.com", traits={"plan": "pro"})
|
|
48
|
+
|
|
49
|
+
# Deliver everything before the process exits:
|
|
50
|
+
whisperr.shutdown()
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
The user id (`external_user_id`) is **always explicit** here — unlike a browser
|
|
54
|
+
SDK, the server has no persisted session to infer it from. Pass the same id you
|
|
55
|
+
use everywhere else for that user, and frontend + backend events land on one
|
|
56
|
+
timeline automatically.
|
|
57
|
+
|
|
58
|
+
## Design
|
|
59
|
+
|
|
60
|
+
- **Same wire contract as the other Whisperr SDKs.** Events post to
|
|
61
|
+
`/v1/events/batch`, identities to `/v1/identify`, authenticated with
|
|
62
|
+
`X-API-Key`.
|
|
63
|
+
- **Non-blocking.** `track()`/`identify()` enqueue and return immediately; a
|
|
64
|
+
background thread batches and delivers. Call `flush()` for a barrier.
|
|
65
|
+
- **Reliable in-process.** In-memory queue, batching, retry with backoff
|
|
66
|
+
(429/5xx), malformed-4xx drop, per-event idempotency key. On `401/403` or
|
|
67
|
+
exhausted retries, delivery pauses and **events stay buffered for the next
|
|
68
|
+
flush** rather than being dropped. The queue is not crash-durable — call
|
|
69
|
+
`flush()`/`shutdown()` before exit (see *Short-lived processes*).
|
|
70
|
+
- **Process-friendly.** A daemon thread that never blocks exit; `shutdown()` is
|
|
71
|
+
also registered via `atexit`.
|
|
72
|
+
- **Zero runtime dependencies.** Uses the standard library only.
|
|
73
|
+
|
|
74
|
+
## Django
|
|
75
|
+
|
|
76
|
+
```python
|
|
77
|
+
# settings.py
|
|
78
|
+
WHISPERR_API_KEY = os.environ["WHISPERR_API_KEY"]
|
|
79
|
+
MIDDLEWARE = [
|
|
80
|
+
# ... after your authentication middleware ...
|
|
81
|
+
"whisperr.django.WhisperrMiddleware",
|
|
82
|
+
]
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
```python
|
|
86
|
+
# views.py
|
|
87
|
+
def upgrade(request):
|
|
88
|
+
request.whisperr.track("plan_upgraded", {"plan": "pro"})
|
|
89
|
+
...
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
`request.whisperr.track()` is bound to the request's authenticated user
|
|
93
|
+
(`request.user.pk` by default; override with `WHISPERR_RESOLVE_USER`, a dotted
|
|
94
|
+
path to `def resolve(request) -> str | None`). For events with no request —
|
|
95
|
+
Celery tasks, webhooks, management commands — use a client directly with the
|
|
96
|
+
user id from your domain data:
|
|
97
|
+
|
|
98
|
+
```python
|
|
99
|
+
from whisperr.django import get_client
|
|
100
|
+
get_client().track(subscription.user_id, "subscription_cancelled", {"reason": reason})
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
Install the Django extra if you want the dependency pinned: `pip install whisperr[django]`.
|
|
104
|
+
|
|
105
|
+
## Short-lived processes
|
|
106
|
+
|
|
107
|
+
In scripts, serverless handlers, or management commands, call `flush()` (or
|
|
108
|
+
`shutdown()`) before exit so queued events aren't lost:
|
|
109
|
+
|
|
110
|
+
```python
|
|
111
|
+
whisperr.track(user_id, "report_generated")
|
|
112
|
+
whisperr.flush()
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
## Options
|
|
116
|
+
|
|
117
|
+
| Option | Default | Notes |
|
|
118
|
+
|---|---|---|
|
|
119
|
+
| `api_key` | — | App ingestion key (`wrk_…`). Required. |
|
|
120
|
+
| `base_url` | `https://api.whisperr.net` | Ingestion base URL. |
|
|
121
|
+
| `flush_at` | `20` | Flush when this many events are queued. |
|
|
122
|
+
| `flush_interval` | `10.0` | Background flush cadence (seconds). |
|
|
123
|
+
| `max_queue_size` | `10000` | Oldest events drop on overflow. |
|
|
124
|
+
| `max_batch_size` | `500` | Events per batch (hard backend cap is 500). |
|
|
125
|
+
| `max_retries` | `6` | Consecutive retries before giving up a batch. |
|
|
126
|
+
| `request_timeout` | `10.0` | Per-request timeout (seconds). |
|
|
127
|
+
| `disabled` | `False` | No-op client (useful in tests). |
|
|
128
|
+
| `debug` | `False` | Verbose logging to stderr. |
|
|
129
|
+
| `on_error` | — | `Callable[[WhisperrError], None]` for observability. |
|
|
130
|
+
|
|
131
|
+
---
|
|
132
|
+
|
|
133
|
+
Whisperr — predict churn, automate interventions, recover revenue.
|
|
134
|
+
[whisperr.net](https://whisperr.net)
|
whisperr-0.1.0/README.md
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
# whisperr (Python)
|
|
2
|
+
|
|
3
|
+
The Whisperr **server-side** SDK for Python — reliable churn-signal event
|
|
4
|
+
tracking for any Python backend. The backend is where the highest-signal churn
|
|
5
|
+
events live (payment failures, cancellations, trial expiry, usage drops), so
|
|
6
|
+
this is where Whisperr gets its most valuable signal.
|
|
7
|
+
|
|
8
|
+
```bash
|
|
9
|
+
pip install whisperr
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
## Quick start
|
|
13
|
+
|
|
14
|
+
```python
|
|
15
|
+
import os
|
|
16
|
+
from whisperr import Whisperr
|
|
17
|
+
|
|
18
|
+
whisperr = Whisperr(api_key=os.environ["WHISPERR_API_KEY"])
|
|
19
|
+
|
|
20
|
+
# A server-side churn signal:
|
|
21
|
+
whisperr.track("user_8842", "payment_failed", {"amount_cents": 4900, "reason": "card_declined"})
|
|
22
|
+
|
|
23
|
+
# Associate traits / contact channels with a user:
|
|
24
|
+
whisperr.identify("user_8842", email="ada@example.com", traits={"plan": "pro"})
|
|
25
|
+
|
|
26
|
+
# Deliver everything before the process exits:
|
|
27
|
+
whisperr.shutdown()
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
The user id (`external_user_id`) is **always explicit** here — unlike a browser
|
|
31
|
+
SDK, the server has no persisted session to infer it from. Pass the same id you
|
|
32
|
+
use everywhere else for that user, and frontend + backend events land on one
|
|
33
|
+
timeline automatically.
|
|
34
|
+
|
|
35
|
+
## Design
|
|
36
|
+
|
|
37
|
+
- **Same wire contract as the other Whisperr SDKs.** Events post to
|
|
38
|
+
`/v1/events/batch`, identities to `/v1/identify`, authenticated with
|
|
39
|
+
`X-API-Key`.
|
|
40
|
+
- **Non-blocking.** `track()`/`identify()` enqueue and return immediately; a
|
|
41
|
+
background thread batches and delivers. Call `flush()` for a barrier.
|
|
42
|
+
- **Reliable in-process.** In-memory queue, batching, retry with backoff
|
|
43
|
+
(429/5xx), malformed-4xx drop, per-event idempotency key. On `401/403` or
|
|
44
|
+
exhausted retries, delivery pauses and **events stay buffered for the next
|
|
45
|
+
flush** rather than being dropped. The queue is not crash-durable — call
|
|
46
|
+
`flush()`/`shutdown()` before exit (see *Short-lived processes*).
|
|
47
|
+
- **Process-friendly.** A daemon thread that never blocks exit; `shutdown()` is
|
|
48
|
+
also registered via `atexit`.
|
|
49
|
+
- **Zero runtime dependencies.** Uses the standard library only.
|
|
50
|
+
|
|
51
|
+
## Django
|
|
52
|
+
|
|
53
|
+
```python
|
|
54
|
+
# settings.py
|
|
55
|
+
WHISPERR_API_KEY = os.environ["WHISPERR_API_KEY"]
|
|
56
|
+
MIDDLEWARE = [
|
|
57
|
+
# ... after your authentication middleware ...
|
|
58
|
+
"whisperr.django.WhisperrMiddleware",
|
|
59
|
+
]
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
```python
|
|
63
|
+
# views.py
|
|
64
|
+
def upgrade(request):
|
|
65
|
+
request.whisperr.track("plan_upgraded", {"plan": "pro"})
|
|
66
|
+
...
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
`request.whisperr.track()` is bound to the request's authenticated user
|
|
70
|
+
(`request.user.pk` by default; override with `WHISPERR_RESOLVE_USER`, a dotted
|
|
71
|
+
path to `def resolve(request) -> str | None`). For events with no request —
|
|
72
|
+
Celery tasks, webhooks, management commands — use a client directly with the
|
|
73
|
+
user id from your domain data:
|
|
74
|
+
|
|
75
|
+
```python
|
|
76
|
+
from whisperr.django import get_client
|
|
77
|
+
get_client().track(subscription.user_id, "subscription_cancelled", {"reason": reason})
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
Install the Django extra if you want the dependency pinned: `pip install whisperr[django]`.
|
|
81
|
+
|
|
82
|
+
## Short-lived processes
|
|
83
|
+
|
|
84
|
+
In scripts, serverless handlers, or management commands, call `flush()` (or
|
|
85
|
+
`shutdown()`) before exit so queued events aren't lost:
|
|
86
|
+
|
|
87
|
+
```python
|
|
88
|
+
whisperr.track(user_id, "report_generated")
|
|
89
|
+
whisperr.flush()
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
## Options
|
|
93
|
+
|
|
94
|
+
| Option | Default | Notes |
|
|
95
|
+
|---|---|---|
|
|
96
|
+
| `api_key` | — | App ingestion key (`wrk_…`). Required. |
|
|
97
|
+
| `base_url` | `https://api.whisperr.net` | Ingestion base URL. |
|
|
98
|
+
| `flush_at` | `20` | Flush when this many events are queued. |
|
|
99
|
+
| `flush_interval` | `10.0` | Background flush cadence (seconds). |
|
|
100
|
+
| `max_queue_size` | `10000` | Oldest events drop on overflow. |
|
|
101
|
+
| `max_batch_size` | `500` | Events per batch (hard backend cap is 500). |
|
|
102
|
+
| `max_retries` | `6` | Consecutive retries before giving up a batch. |
|
|
103
|
+
| `request_timeout` | `10.0` | Per-request timeout (seconds). |
|
|
104
|
+
| `disabled` | `False` | No-op client (useful in tests). |
|
|
105
|
+
| `debug` | `False` | Verbose logging to stderr. |
|
|
106
|
+
| `on_error` | — | `Callable[[WhisperrError], None]` for observability. |
|
|
107
|
+
|
|
108
|
+
---
|
|
109
|
+
|
|
110
|
+
Whisperr — predict churn, automate interventions, recover revenue.
|
|
111
|
+
[whisperr.net](https://whisperr.net)
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "whisperr"
|
|
7
|
+
dynamic = ["version"]
|
|
8
|
+
description = "Whisperr server-side SDK for Python — reliable churn-signal event tracking."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
requires-python = ">=3.8"
|
|
12
|
+
authors = [{ name = "Whisperr" }]
|
|
13
|
+
keywords = ["whisperr", "analytics", "churn", "events", "tracking", "retention"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Programming Language :: Python :: 3",
|
|
16
|
+
"License :: OSI Approved :: MIT License",
|
|
17
|
+
"Operating System :: OS Independent",
|
|
18
|
+
"Intended Audience :: Developers",
|
|
19
|
+
"Topic :: Software Development :: Libraries",
|
|
20
|
+
]
|
|
21
|
+
dependencies = []
|
|
22
|
+
|
|
23
|
+
[project.urls]
|
|
24
|
+
Homepage = "https://whisperr.net"
|
|
25
|
+
Repository = "https://github.com/WhisperrAI/whisperr-python"
|
|
26
|
+
|
|
27
|
+
[project.optional-dependencies]
|
|
28
|
+
django = ["django>=3.2"]
|
|
29
|
+
dev = ["pytest>=7", "django>=3.2"]
|
|
30
|
+
|
|
31
|
+
[tool.hatch.version]
|
|
32
|
+
path = "src/whisperr/__init__.py"
|
|
33
|
+
|
|
34
|
+
[tool.hatch.build.targets.wheel]
|
|
35
|
+
packages = ["src/whisperr"]
|
|
36
|
+
|
|
37
|
+
[tool.pytest.ini_options]
|
|
38
|
+
testpaths = ["tests"]
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""Whisperr server-side SDK for Python.
|
|
2
|
+
|
|
3
|
+
Reliable churn-signal event tracking for any Python backend. The backend is
|
|
4
|
+
where the highest-signal churn events live (payment failures, cancellations,
|
|
5
|
+
trial expiry, usage drops), so this is where Whisperr gets its best signal.
|
|
6
|
+
|
|
7
|
+
from whisperr import Whisperr
|
|
8
|
+
|
|
9
|
+
whisperr = Whisperr(api_key=os.environ["WHISPERR_API_KEY"])
|
|
10
|
+
whisperr.track("user_8842", "payment_failed", {"amount_cents": 4900})
|
|
11
|
+
whisperr.shutdown() # flush before the process exits
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from .client import Whisperr
|
|
15
|
+
from .errors import WhisperrError
|
|
16
|
+
|
|
17
|
+
__all__ = ["Whisperr", "WhisperrError", "__version__"]
|
|
18
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import atexit
|
|
4
|
+
import random
|
|
5
|
+
import re
|
|
6
|
+
import sys
|
|
7
|
+
import threading
|
|
8
|
+
import time
|
|
9
|
+
import uuid
|
|
10
|
+
from collections import deque
|
|
11
|
+
from datetime import datetime, timezone
|
|
12
|
+
from typing import Any, Callable, Deque, Dict, List, Optional, Sequence
|
|
13
|
+
|
|
14
|
+
from .errors import WhisperrError
|
|
15
|
+
from .transport import Transport
|
|
16
|
+
|
|
17
|
+
DEFAULT_BASE = "https://api.whisperr.net"
|
|
18
|
+
# Mirrors the server's accepted event_type shape.
|
|
19
|
+
_SNAKE = re.compile(r"^[a-z0-9]+(?:_[a-z0-9]+)*$")
|
|
20
|
+
|
|
21
|
+
Channel = Dict[str, Any]
|
|
22
|
+
OnError = Callable[[WhisperrError], None]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class Whisperr:
|
|
26
|
+
"""A Whisperr client for a Python backend.
|
|
27
|
+
|
|
28
|
+
Hold one instance per process and call :meth:`shutdown` before exit (also
|
|
29
|
+
registered via ``atexit``). ``track``/``identify`` are non-blocking: they
|
|
30
|
+
enqueue and a background thread batches + delivers with retries.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
def __init__(
|
|
34
|
+
self,
|
|
35
|
+
api_key: str,
|
|
36
|
+
*,
|
|
37
|
+
base_url: str = DEFAULT_BASE,
|
|
38
|
+
flush_at: int = 20,
|
|
39
|
+
flush_interval: float = 10.0,
|
|
40
|
+
max_queue_size: int = 10000,
|
|
41
|
+
max_batch_size: int = 500,
|
|
42
|
+
max_retries: int = 6,
|
|
43
|
+
request_timeout: float = 10.0,
|
|
44
|
+
disabled: bool = False,
|
|
45
|
+
debug: bool = False,
|
|
46
|
+
on_error: Optional[OnError] = None,
|
|
47
|
+
transport: Optional[Transport] = None,
|
|
48
|
+
) -> None:
|
|
49
|
+
if not api_key and not disabled:
|
|
50
|
+
raise ValueError("api_key is required")
|
|
51
|
+
self._muted = bool(disabled)
|
|
52
|
+
self._flush_at = flush_at
|
|
53
|
+
self._max_batch = min(max_batch_size, 500)
|
|
54
|
+
self._max_queue = max_queue_size
|
|
55
|
+
self._max_retries = max_retries
|
|
56
|
+
self._debug = debug
|
|
57
|
+
self._on_error = on_error
|
|
58
|
+
self._flush_interval = flush_interval
|
|
59
|
+
self._transport = transport or Transport(base_url, api_key, request_timeout, self._warn)
|
|
60
|
+
|
|
61
|
+
# Ordered in-memory buffer. A drain pass takes a batch off the front,
|
|
62
|
+
# delivers it, and only puts it back (at the front) if delivery gives up —
|
|
63
|
+
# so a failed flush *retains* events for the next flush instead of
|
|
64
|
+
# dropping them. There's no durable store: a process crash loses unsent
|
|
65
|
+
# events, which is an acceptable trade for zero I/O on the hot path.
|
|
66
|
+
self._buf: Deque[Dict[str, Any]] = deque()
|
|
67
|
+
self._buf_lock = threading.Lock()
|
|
68
|
+
# Serializes delivery so the background thread and an explicit flush()
|
|
69
|
+
# never send concurrently (and never fight over the same batch).
|
|
70
|
+
self._deliver_lock = threading.Lock()
|
|
71
|
+
self._wake = threading.Event()
|
|
72
|
+
|
|
73
|
+
self._running = not self._muted
|
|
74
|
+
self._thread: Optional[threading.Thread] = None
|
|
75
|
+
|
|
76
|
+
if not self._muted:
|
|
77
|
+
self._thread = threading.Thread(target=self._run, name="whisperr", daemon=True)
|
|
78
|
+
self._thread.start()
|
|
79
|
+
atexit.register(self.shutdown)
|
|
80
|
+
|
|
81
|
+
@property
|
|
82
|
+
def ready(self) -> bool:
|
|
83
|
+
return not self._muted
|
|
84
|
+
|
|
85
|
+
def identify(
|
|
86
|
+
self,
|
|
87
|
+
external_user_id: str,
|
|
88
|
+
*,
|
|
89
|
+
traits: Optional[Dict[str, Any]] = None,
|
|
90
|
+
email: Optional[str] = None,
|
|
91
|
+
phone: Optional[str] = None,
|
|
92
|
+
push_token: Optional[str] = None,
|
|
93
|
+
preferred_channel: Optional[str] = None,
|
|
94
|
+
channels: Optional[Sequence[Channel]] = None,
|
|
95
|
+
) -> None:
|
|
96
|
+
"""Associate traits/contact channels with a user. ``external_user_id``
|
|
97
|
+
is always explicit — pass the same id you use in :meth:`track`."""
|
|
98
|
+
if self._muted or not external_user_id:
|
|
99
|
+
return
|
|
100
|
+
self._enqueue(
|
|
101
|
+
{
|
|
102
|
+
"kind": "identify",
|
|
103
|
+
"external_user_id": external_user_id,
|
|
104
|
+
"traits": traits,
|
|
105
|
+
"preferred_channel": preferred_channel,
|
|
106
|
+
"channels": list(channels)
|
|
107
|
+
if channels is not None
|
|
108
|
+
else _build_channels(email, phone, push_token),
|
|
109
|
+
}
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
def track(
|
|
113
|
+
self,
|
|
114
|
+
external_user_id: str,
|
|
115
|
+
event_type: str,
|
|
116
|
+
properties: Optional[Dict[str, Any]] = None,
|
|
117
|
+
context: Optional[Dict[str, Any]] = None,
|
|
118
|
+
) -> None:
|
|
119
|
+
"""Record a product event for a known user."""
|
|
120
|
+
if self._muted or not external_user_id or not event_type:
|
|
121
|
+
return
|
|
122
|
+
if self._debug and not _SNAKE.match(event_type):
|
|
123
|
+
self._warn(f'event_type "{event_type}" is not snake_case — the server will reject it')
|
|
124
|
+
self._enqueue(
|
|
125
|
+
{
|
|
126
|
+
"kind": "track",
|
|
127
|
+
"external_user_id": external_user_id,
|
|
128
|
+
"event_type": event_type,
|
|
129
|
+
"properties": properties,
|
|
130
|
+
"context": context,
|
|
131
|
+
"occurred_at": _now_iso(),
|
|
132
|
+
"message_id": uuid.uuid4().hex,
|
|
133
|
+
}
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
def flush(self) -> None:
|
|
137
|
+
"""Best-effort barrier: deliver everything currently buffered, blocking
|
|
138
|
+
until the buffer drains or delivery can't proceed (auth/transport
|
|
139
|
+
failure). Events that can't be delivered stay buffered for the next
|
|
140
|
+
flush rather than being dropped."""
|
|
141
|
+
if self._muted:
|
|
142
|
+
return
|
|
143
|
+
self._drain_once()
|
|
144
|
+
|
|
145
|
+
def shutdown(self) -> None:
|
|
146
|
+
"""Stop the background thread after a final delivery pass. Idempotent."""
|
|
147
|
+
if self._muted or not self._running:
|
|
148
|
+
return
|
|
149
|
+
self._running = False
|
|
150
|
+
self._wake.set()
|
|
151
|
+
if self._thread is not None:
|
|
152
|
+
self._thread.join()
|
|
153
|
+
|
|
154
|
+
# ---- internals ----
|
|
155
|
+
|
|
156
|
+
def _enqueue(self, op: Dict[str, Any]) -> None:
|
|
157
|
+
dropped = 0
|
|
158
|
+
with self._buf_lock:
|
|
159
|
+
self._buf.append(op)
|
|
160
|
+
while len(self._buf) > self._max_queue:
|
|
161
|
+
self._buf.popleft()
|
|
162
|
+
dropped += 1
|
|
163
|
+
size = len(self._buf)
|
|
164
|
+
if dropped:
|
|
165
|
+
self._emit("dropped", f"queue full — dropped {dropped} oldest event(s)")
|
|
166
|
+
# Wake the worker to flush early once enough has accumulated.
|
|
167
|
+
if size >= self._flush_at:
|
|
168
|
+
self._wake.set()
|
|
169
|
+
|
|
170
|
+
def _run(self) -> None:
|
|
171
|
+
# Periodic + size-triggered delivery. Always makes one final pass after
|
|
172
|
+
# shutdown so the loop can't spin on an undeliverable (retained) batch.
|
|
173
|
+
while True:
|
|
174
|
+
self._wake.wait(timeout=self._flush_interval)
|
|
175
|
+
self._wake.clear()
|
|
176
|
+
try:
|
|
177
|
+
self._drain_once()
|
|
178
|
+
except Exception:
|
|
179
|
+
pass # never let the worker thread die on a delivery error
|
|
180
|
+
if not self._running:
|
|
181
|
+
return
|
|
182
|
+
|
|
183
|
+
def _take_batch(self) -> List[Dict[str, Any]]:
|
|
184
|
+
"""Remove and return the next batch from the front of the buffer.
|
|
185
|
+
|
|
186
|
+
An identify is sent on its own; track ops are grouped into a leading run
|
|
187
|
+
(up to ``max_batch``). Returns ``[]`` when the buffer is empty."""
|
|
188
|
+
with self._buf_lock:
|
|
189
|
+
if not self._buf:
|
|
190
|
+
return []
|
|
191
|
+
if self._buf[0]["kind"] == "identify":
|
|
192
|
+
return [self._buf.popleft()]
|
|
193
|
+
batch: List[Dict[str, Any]] = []
|
|
194
|
+
while self._buf and len(batch) < self._max_batch and self._buf[0]["kind"] == "track":
|
|
195
|
+
batch.append(self._buf.popleft())
|
|
196
|
+
return batch
|
|
197
|
+
|
|
198
|
+
def _requeue_front(self, batch: List[Dict[str, Any]]) -> None:
|
|
199
|
+
with self._buf_lock:
|
|
200
|
+
self._buf.extendleft(reversed(batch))
|
|
201
|
+
|
|
202
|
+
def _drain_once(self) -> None:
|
|
203
|
+
# One delivery pass, serialized against the background thread.
|
|
204
|
+
with self._deliver_lock:
|
|
205
|
+
while True:
|
|
206
|
+
batch = self._take_batch()
|
|
207
|
+
if not batch:
|
|
208
|
+
return
|
|
209
|
+
result = self._deliver(batch)
|
|
210
|
+
if result in ("ok", "drop"):
|
|
211
|
+
continue
|
|
212
|
+
# auth / retry_exhausted: hand the batch back to the front and
|
|
213
|
+
# stop; a later flush retries from where we left off.
|
|
214
|
+
self._requeue_front(batch)
|
|
215
|
+
return
|
|
216
|
+
|
|
217
|
+
def _deliver(self, batch: List[Dict[str, Any]]) -> str:
|
|
218
|
+
if batch[0]["kind"] == "identify":
|
|
219
|
+
send: Callable[[], str] = lambda: self._transport.send_identify(batch[0])
|
|
220
|
+
count = 1
|
|
221
|
+
else:
|
|
222
|
+
send = lambda: self._transport.send_batch(batch)
|
|
223
|
+
count = len(batch)
|
|
224
|
+
retries = 0
|
|
225
|
+
while True:
|
|
226
|
+
result = send()
|
|
227
|
+
if result == "ok":
|
|
228
|
+
return "ok"
|
|
229
|
+
if result == "drop":
|
|
230
|
+
self._emit("dropped", f"dropped {count} event(s) — rejected by server")
|
|
231
|
+
return "drop"
|
|
232
|
+
if result == "auth":
|
|
233
|
+
self._emit("auth", "delivery paused — API key rejected", 401)
|
|
234
|
+
return "auth"
|
|
235
|
+
retries += 1
|
|
236
|
+
# Stop early when shutting down so exit isn't held up by backoff.
|
|
237
|
+
if retries > self._max_retries or not self._running:
|
|
238
|
+
self._emit("retry_exhausted", "delivery failed after retries; will retry on next flush")
|
|
239
|
+
return "retry_exhausted"
|
|
240
|
+
time.sleep(_backoff(retries))
|
|
241
|
+
|
|
242
|
+
def _emit(self, type_: str, message: str, status: Optional[int] = None) -> None:
|
|
243
|
+
if not self._on_error:
|
|
244
|
+
return
|
|
245
|
+
try:
|
|
246
|
+
self._on_error(WhisperrError(type=type_, message=message, status=status))
|
|
247
|
+
except Exception:
|
|
248
|
+
pass # host callback threw — ignore
|
|
249
|
+
|
|
250
|
+
def _warn(self, msg: str) -> None:
|
|
251
|
+
if self._debug:
|
|
252
|
+
print(f"[whisperr] {msg}", file=sys.stderr)
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def _build_channels(
|
|
256
|
+
email: Optional[str], phone: Optional[str], push_token: Optional[str]
|
|
257
|
+
) -> Optional[List[Channel]]:
|
|
258
|
+
out: List[Channel] = []
|
|
259
|
+
if email:
|
|
260
|
+
out.append({"type": "email", "address": email, "opted_in": True})
|
|
261
|
+
if phone:
|
|
262
|
+
out.append({"type": "sms", "address": phone, "opted_in": True})
|
|
263
|
+
if push_token:
|
|
264
|
+
out.append({"type": "push", "address": push_token, "opted_in": True})
|
|
265
|
+
return out or None
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def _backoff(attempt: int) -> float:
|
|
269
|
+
base = min(30.0, 1.0 * (2 ** attempt))
|
|
270
|
+
return base + random.random() * 0.25
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def _now_iso() -> str:
|
|
274
|
+
now = datetime.now(timezone.utc)
|
|
275
|
+
return now.strftime("%Y-%m-%dT%H:%M:%S.") + f"{now.microsecond // 1000:03d}Z"
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"""Django integration for Whisperr.
|
|
2
|
+
|
|
3
|
+
Add to settings.py::
|
|
4
|
+
|
|
5
|
+
WHISPERR_API_KEY = os.environ["WHISPERR_API_KEY"]
|
|
6
|
+
MIDDLEWARE = [
|
|
7
|
+
# ... after your authentication middleware ...
|
|
8
|
+
"whisperr.django.WhisperrMiddleware",
|
|
9
|
+
]
|
|
10
|
+
|
|
11
|
+
Then in a view::
|
|
12
|
+
|
|
13
|
+
request.whisperr.track("plan_upgraded", {"plan": "pro"})
|
|
14
|
+
|
|
15
|
+
For events with no request (Celery tasks, webhooks, management commands), use a
|
|
16
|
+
client directly with the user id from your domain data.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
from importlib import import_module
|
|
22
|
+
from typing import Any, Callable, Dict, Optional, Sequence
|
|
23
|
+
|
|
24
|
+
from .client import Whisperr
|
|
25
|
+
|
|
26
|
+
_client: Optional[Whisperr] = None
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def get_client() -> Whisperr:
|
|
30
|
+
"""The process-wide Whisperr client, built from Django settings on first use."""
|
|
31
|
+
global _client
|
|
32
|
+
if _client is None:
|
|
33
|
+
from django.conf import settings
|
|
34
|
+
|
|
35
|
+
_client = Whisperr(
|
|
36
|
+
api_key=getattr(settings, "WHISPERR_API_KEY", ""),
|
|
37
|
+
base_url=getattr(settings, "WHISPERR_BASE_URL", "https://api.whisperr.net"),
|
|
38
|
+
disabled=getattr(settings, "WHISPERR_DISABLED", False),
|
|
39
|
+
debug=getattr(settings, "WHISPERR_DEBUG", False),
|
|
40
|
+
)
|
|
41
|
+
return _client
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class _RequestWhisperr:
|
|
45
|
+
"""Per-request helper bound to the request's resolved user."""
|
|
46
|
+
|
|
47
|
+
def __init__(self, client: Whisperr, user_id: Optional[str]) -> None:
|
|
48
|
+
self.client = client
|
|
49
|
+
self.user_id = user_id
|
|
50
|
+
|
|
51
|
+
def track(
|
|
52
|
+
self,
|
|
53
|
+
event_type: str,
|
|
54
|
+
properties: Optional[Dict[str, Any]] = None,
|
|
55
|
+
context: Optional[Dict[str, Any]] = None,
|
|
56
|
+
) -> None:
|
|
57
|
+
if not self.user_id:
|
|
58
|
+
return
|
|
59
|
+
self.client.track(self.user_id, event_type, properties, context)
|
|
60
|
+
|
|
61
|
+
def identify(self, **kwargs: Any) -> None:
|
|
62
|
+
if not self.user_id:
|
|
63
|
+
return
|
|
64
|
+
self.client.identify(self.user_id, **kwargs)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _default_resolve_user(request: Any) -> Optional[str]:
|
|
68
|
+
user = getattr(request, "user", None)
|
|
69
|
+
if user is not None and getattr(user, "is_authenticated", False):
|
|
70
|
+
return str(user.pk)
|
|
71
|
+
return None
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _import_callable(dotted: str) -> Callable[[Any], Optional[str]]:
|
|
75
|
+
module_path, _, attr = dotted.rpartition(".")
|
|
76
|
+
module = import_module(module_path)
|
|
77
|
+
return getattr(module, attr)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class WhisperrMiddleware:
|
|
81
|
+
"""Attaches ``request.whisperr`` bound to the request's user."""
|
|
82
|
+
|
|
83
|
+
def __init__(self, get_response: Callable[[Any], Any]) -> None:
|
|
84
|
+
self.get_response = get_response
|
|
85
|
+
self.client = get_client()
|
|
86
|
+
from django.conf import settings
|
|
87
|
+
|
|
88
|
+
resolver = getattr(settings, "WHISPERR_RESOLVE_USER", None)
|
|
89
|
+
self.resolve_user = _import_callable(resolver) if resolver else _default_resolve_user
|
|
90
|
+
|
|
91
|
+
def __call__(self, request: Any) -> Any:
|
|
92
|
+
request.whisperr = _RequestWhisperr(self.client, self.resolve_user(request))
|
|
93
|
+
return self.get_response(request)
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import Optional
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass
|
|
8
|
+
class WhisperrError:
|
|
9
|
+
"""Passed to the ``on_error`` callback for delivery/drop observability."""
|
|
10
|
+
|
|
11
|
+
type: str # "auth" | "dropped" | "retry_exhausted"
|
|
12
|
+
message: str
|
|
13
|
+
status: Optional[int] = None
|
|
File without changes
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import urllib.error
|
|
5
|
+
import urllib.request
|
|
6
|
+
from typing import Any, Callable, Dict, List
|
|
7
|
+
|
|
8
|
+
# Delivery outcome of one request, mirroring the @whisperr/node SDK:
|
|
9
|
+
# "ok" — delivered
|
|
10
|
+
# "retry" — transient (429, 5xx, network/timeout)
|
|
11
|
+
# "auth" — key rejected (401/403); stop and surface
|
|
12
|
+
# "drop" — other 4xx (malformed); discard to avoid an infinite retry loop
|
|
13
|
+
SendResult = str
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class Transport:
|
|
17
|
+
"""HTTP transport for the Whisperr ingestion API (stdlib urllib, zero deps)."""
|
|
18
|
+
|
|
19
|
+
def __init__(
|
|
20
|
+
self,
|
|
21
|
+
base_url: str,
|
|
22
|
+
api_key: str,
|
|
23
|
+
timeout: float,
|
|
24
|
+
warn: Callable[[str], None],
|
|
25
|
+
) -> None:
|
|
26
|
+
self._base = base_url.rstrip("/")
|
|
27
|
+
self._key = api_key
|
|
28
|
+
self._timeout = timeout
|
|
29
|
+
self._warn = warn
|
|
30
|
+
|
|
31
|
+
def send_batch(self, events: List[Dict[str, Any]]) -> SendResult:
|
|
32
|
+
payload = {
|
|
33
|
+
"events": [
|
|
34
|
+
{
|
|
35
|
+
"external_user_id": e["external_user_id"],
|
|
36
|
+
"event_type": e["event_type"],
|
|
37
|
+
"occurred_at": e["occurred_at"],
|
|
38
|
+
"properties": e.get("properties") or {},
|
|
39
|
+
# $message_id is an idempotency key for backend dedup, nested
|
|
40
|
+
# in the free-form context so the strict ingestion accepts it.
|
|
41
|
+
"context": {**(e.get("context") or {}), "$message_id": e["message_id"]},
|
|
42
|
+
}
|
|
43
|
+
for e in events
|
|
44
|
+
]
|
|
45
|
+
}
|
|
46
|
+
if not payload["events"]:
|
|
47
|
+
return "ok"
|
|
48
|
+
return self._post("/v1/events/batch", payload)
|
|
49
|
+
|
|
50
|
+
def send_identify(self, op: Dict[str, Any]) -> SendResult:
|
|
51
|
+
body: Dict[str, Any] = {"external_user_id": op["external_user_id"]}
|
|
52
|
+
if op.get("traits"):
|
|
53
|
+
body["traits"] = op["traits"]
|
|
54
|
+
if op.get("preferred_channel"):
|
|
55
|
+
body["preferred_channel"] = op["preferred_channel"]
|
|
56
|
+
channels = op.get("channels")
|
|
57
|
+
if channels:
|
|
58
|
+
body["channels"] = [
|
|
59
|
+
{
|
|
60
|
+
"channel": c["type"],
|
|
61
|
+
"address": c["address"],
|
|
62
|
+
"opted_in": c.get("opted_in", True),
|
|
63
|
+
**({"verified": c["verified"]} if "verified" in c else {}),
|
|
64
|
+
}
|
|
65
|
+
for c in channels
|
|
66
|
+
]
|
|
67
|
+
return self._post("/v1/identify", body)
|
|
68
|
+
|
|
69
|
+
def _post(self, path: str, body: Any) -> SendResult:
|
|
70
|
+
data = json.dumps(body).encode("utf-8")
|
|
71
|
+
req = urllib.request.Request(
|
|
72
|
+
self._base + path,
|
|
73
|
+
data=data,
|
|
74
|
+
method="POST",
|
|
75
|
+
headers={"Content-Type": "application/json", "X-API-Key": self._key},
|
|
76
|
+
)
|
|
77
|
+
try:
|
|
78
|
+
with urllib.request.urlopen(req, timeout=self._timeout):
|
|
79
|
+
return "ok" # urlopen raises on >= 400, so reaching here is 2xx/3xx
|
|
80
|
+
except urllib.error.HTTPError as e:
|
|
81
|
+
if e.code in (401, 403):
|
|
82
|
+
self._warn(f"auth rejected ({e.code}) — check your Whisperr API key")
|
|
83
|
+
return "auth"
|
|
84
|
+
if e.code == 429 or e.code >= 500:
|
|
85
|
+
return "retry"
|
|
86
|
+
self._warn(f"request to {path} dropped ({e.code})")
|
|
87
|
+
return "drop"
|
|
88
|
+
except Exception:
|
|
89
|
+
# Network error / timeout — retry later.
|
|
90
|
+
return "retry"
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import urllib.error
|
|
3
|
+
import urllib.request
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
|
|
7
|
+
from whisperr import Whisperr, WhisperrError
|
|
8
|
+
from whisperr.transport import Transport
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class FakeTransport:
|
|
12
|
+
"""Records ops at the client boundary (before wire serialization)."""
|
|
13
|
+
|
|
14
|
+
def __init__(self, result="ok"):
|
|
15
|
+
self.result = result
|
|
16
|
+
self.batches = []
|
|
17
|
+
self.identifies = []
|
|
18
|
+
|
|
19
|
+
def send_batch(self, events):
|
|
20
|
+
self.batches.append([dict(e) for e in events])
|
|
21
|
+
return self.result
|
|
22
|
+
|
|
23
|
+
def send_identify(self, op):
|
|
24
|
+
self.identifies.append(dict(op))
|
|
25
|
+
return self.result
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def make_client(transport, **kwargs):
|
|
29
|
+
return Whisperr(
|
|
30
|
+
api_key="wrk_test",
|
|
31
|
+
transport=transport,
|
|
32
|
+
flush_interval=0.05,
|
|
33
|
+
**kwargs,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
# ---- client behavior ----
|
|
38
|
+
|
|
39
|
+
def test_track_delivers_events_with_explicit_user_and_message_id():
|
|
40
|
+
t = FakeTransport()
|
|
41
|
+
w = make_client(t)
|
|
42
|
+
try:
|
|
43
|
+
w.track("user_8842", "payment_failed", {"amount_cents": 4900})
|
|
44
|
+
w.track("user_8842", "subscription_cancelled")
|
|
45
|
+
w.flush()
|
|
46
|
+
finally:
|
|
47
|
+
w.shutdown()
|
|
48
|
+
|
|
49
|
+
# Batching is opportunistic, so events may arrive in one or several batches —
|
|
50
|
+
# what matters is that all of them are delivered, correctly shaped.
|
|
51
|
+
events = [e for batch in t.batches for e in batch]
|
|
52
|
+
assert len(events) == 2
|
|
53
|
+
by_type = {e["event_type"]: e for e in events}
|
|
54
|
+
assert by_type["payment_failed"]["external_user_id"] == "user_8842"
|
|
55
|
+
assert by_type["payment_failed"]["properties"] == {"amount_cents": 4900}
|
|
56
|
+
assert by_type["payment_failed"]["occurred_at"].endswith("Z")
|
|
57
|
+
assert len({e["message_id"] for e in events}) == 2
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def test_bulk_delivery():
|
|
61
|
+
# All queued events are delivered exactly once (across however many batches).
|
|
62
|
+
t = FakeTransport()
|
|
63
|
+
w = Whisperr(api_key="wrk_test", transport=t, flush_interval=10.0)
|
|
64
|
+
try:
|
|
65
|
+
for i in range(50):
|
|
66
|
+
w.track("user_1", f"event_{i}")
|
|
67
|
+
w.flush()
|
|
68
|
+
finally:
|
|
69
|
+
w.shutdown()
|
|
70
|
+
delivered = [e for batch in t.batches for e in batch]
|
|
71
|
+
assert len(delivered) == 50
|
|
72
|
+
assert len({e["event_type"] for e in delivered}) == 50 # no dupes, none lost
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def test_identify_carries_traits_and_channels():
|
|
76
|
+
t = FakeTransport()
|
|
77
|
+
w = make_client(t)
|
|
78
|
+
try:
|
|
79
|
+
w.identify("user_8842", traits={"plan": "pro"}, email="a@b.com", preferred_channel="email")
|
|
80
|
+
w.flush()
|
|
81
|
+
finally:
|
|
82
|
+
w.shutdown()
|
|
83
|
+
|
|
84
|
+
assert len(t.identifies) == 1
|
|
85
|
+
op = t.identifies[0]
|
|
86
|
+
assert op["external_user_id"] == "user_8842"
|
|
87
|
+
assert op["traits"] == {"plan": "pro"}
|
|
88
|
+
assert op["preferred_channel"] == "email"
|
|
89
|
+
assert op["channels"] == [{"type": "email", "address": "a@b.com", "opted_in": True}]
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def test_track_requires_user_and_event_type():
|
|
93
|
+
t = FakeTransport()
|
|
94
|
+
w = make_client(t)
|
|
95
|
+
try:
|
|
96
|
+
w.track("", "payment_failed")
|
|
97
|
+
w.track("user_1", "")
|
|
98
|
+
w.flush()
|
|
99
|
+
finally:
|
|
100
|
+
w.shutdown()
|
|
101
|
+
assert t.batches == []
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def test_auth_failure_emits_and_stops():
|
|
105
|
+
t = FakeTransport(result="auth")
|
|
106
|
+
errors = []
|
|
107
|
+
w = make_client(t, on_error=errors.append)
|
|
108
|
+
try:
|
|
109
|
+
w.track("user_1", "feature_used")
|
|
110
|
+
w.flush()
|
|
111
|
+
finally:
|
|
112
|
+
w.shutdown()
|
|
113
|
+
assert any(e.type == "auth" for e in errors)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def test_drop_on_4xx():
|
|
117
|
+
t = FakeTransport(result="drop")
|
|
118
|
+
errors = []
|
|
119
|
+
w = make_client(t, on_error=errors.append)
|
|
120
|
+
try:
|
|
121
|
+
w.track("user_1", "feature_used")
|
|
122
|
+
w.flush()
|
|
123
|
+
finally:
|
|
124
|
+
w.shutdown()
|
|
125
|
+
assert any(e.type == "dropped" for e in errors)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def test_retry_exhausted_is_bounded():
|
|
129
|
+
t = FakeTransport(result="retry")
|
|
130
|
+
errors = []
|
|
131
|
+
w = make_client(t, on_error=errors.append, max_retries=0)
|
|
132
|
+
try:
|
|
133
|
+
w.track("user_1", "feature_used")
|
|
134
|
+
w.flush()
|
|
135
|
+
finally:
|
|
136
|
+
w.shutdown()
|
|
137
|
+
assert any(e.type == "retry_exhausted" for e in errors)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def test_failed_events_are_retained_and_retried():
|
|
141
|
+
# A delivery that fails on auth must keep the events buffered so a later
|
|
142
|
+
# flush retries the SAME events — they must not be silently dropped.
|
|
143
|
+
t = FakeTransport(result="auth")
|
|
144
|
+
# High flush_interval so only our explicit flush() calls drive delivery.
|
|
145
|
+
w = Whisperr(api_key="wrk_test", transport=t, flush_interval=3600)
|
|
146
|
+
try:
|
|
147
|
+
w.track("user_1", "feature_used")
|
|
148
|
+
w.flush() # auth → event retained, not delivered
|
|
149
|
+
attempts_before = len(t.batches)
|
|
150
|
+
|
|
151
|
+
t.result = "ok"
|
|
152
|
+
w.flush() # retries the retained event, now succeeds
|
|
153
|
+
attempts_after = len(t.batches)
|
|
154
|
+
finally:
|
|
155
|
+
w.shutdown()
|
|
156
|
+
|
|
157
|
+
# The event was re-sent after the failure was fixed (retained), not dropped.
|
|
158
|
+
assert attempts_after == attempts_before + 1
|
|
159
|
+
assert [e["event_type"] for e in t.batches[-1]] == ["feature_used"]
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def test_disabled_is_noop():
|
|
163
|
+
t = FakeTransport()
|
|
164
|
+
w = Whisperr(api_key="wrk_test", transport=t, disabled=True)
|
|
165
|
+
assert w.ready is False
|
|
166
|
+
w.track("user_1", "feature_used")
|
|
167
|
+
w.flush()
|
|
168
|
+
w.shutdown()
|
|
169
|
+
assert t.batches == [] and t.identifies == []
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def test_missing_api_key_raises():
|
|
173
|
+
with pytest.raises(ValueError):
|
|
174
|
+
Whisperr(api_key="")
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
# ---- transport wire mapping ----
|
|
178
|
+
|
|
179
|
+
def _capture_urlopen(captured):
|
|
180
|
+
class FakeResp:
|
|
181
|
+
def __enter__(self):
|
|
182
|
+
return self
|
|
183
|
+
|
|
184
|
+
def __exit__(self, *a):
|
|
185
|
+
return False
|
|
186
|
+
|
|
187
|
+
def fake(req, timeout=None):
|
|
188
|
+
captured["url"] = req.full_url
|
|
189
|
+
captured["headers"] = {k.lower(): v for k, v in req.header_items()}
|
|
190
|
+
captured["body"] = json.loads(req.data.decode())
|
|
191
|
+
return FakeResp()
|
|
192
|
+
|
|
193
|
+
return fake
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def test_transport_batch_wire_shape(monkeypatch):
|
|
197
|
+
captured = {}
|
|
198
|
+
monkeypatch.setattr(urllib.request, "urlopen", _capture_urlopen(captured))
|
|
199
|
+
t = Transport("https://api.whisperr.net/", "wrk_test", 10, lambda m: None)
|
|
200
|
+
result = t.send_batch(
|
|
201
|
+
[{"external_user_id": "u1", "event_type": "x", "occurred_at": "2026-01-01T00:00:00.000Z",
|
|
202
|
+
"properties": {"a": 1}, "context": {"k": "v"}, "message_id": "mid1"}]
|
|
203
|
+
)
|
|
204
|
+
assert result == "ok"
|
|
205
|
+
assert captured["url"] == "https://api.whisperr.net/v1/events/batch"
|
|
206
|
+
assert captured["headers"]["x-api-key"] == "wrk_test"
|
|
207
|
+
ev = captured["body"]["events"][0]
|
|
208
|
+
assert ev["external_user_id"] == "u1"
|
|
209
|
+
assert ev["context"] == {"k": "v", "$message_id": "mid1"}
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def test_transport_identify_channel_mapping(monkeypatch):
|
|
213
|
+
captured = {}
|
|
214
|
+
monkeypatch.setattr(urllib.request, "urlopen", _capture_urlopen(captured))
|
|
215
|
+
t = Transport("https://api.whisperr.net", "wrk_test", 10, lambda m: None)
|
|
216
|
+
t.send_identify(
|
|
217
|
+
{"external_user_id": "u1", "traits": {"plan": "pro"},
|
|
218
|
+
"channels": [{"type": "email", "address": "a@b.com", "opted_in": True}]}
|
|
219
|
+
)
|
|
220
|
+
assert captured["url"] == "https://api.whisperr.net/v1/identify"
|
|
221
|
+
assert captured["body"]["channels"] == [{"channel": "email", "address": "a@b.com", "opted_in": True}]
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
@pytest.mark.parametrize("code,expected", [(401, "auth"), (403, "auth"), (429, "retry"), (500, "retry"), (400, "drop"), (422, "drop")])
|
|
225
|
+
def test_transport_status_classification(monkeypatch, code, expected):
|
|
226
|
+
def raiser(req, timeout=None):
|
|
227
|
+
raise urllib.error.HTTPError(req.full_url, code, "err", {}, None)
|
|
228
|
+
|
|
229
|
+
monkeypatch.setattr(urllib.request, "urlopen", raiser)
|
|
230
|
+
t = Transport("https://api.whisperr.net", "wrk_test", 10, lambda m: None)
|
|
231
|
+
assert t.send_batch([{"external_user_id": "u1", "event_type": "x",
|
|
232
|
+
"occurred_at": "2026-01-01T00:00:00.000Z", "message_id": "m"}]) == expected
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import os
|
|
3
|
+
import re
|
|
4
|
+
import urllib.request
|
|
5
|
+
|
|
6
|
+
from whisperr import Whisperr
|
|
7
|
+
|
|
8
|
+
SPEC_URL = "https://raw.githubusercontent.com/WhisperrAI/whisperr-spec/main/conformance/wire.json"
|
|
9
|
+
RFC3339_Z = re.compile(r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$")
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def load_spec():
|
|
13
|
+
local = os.environ.get("WHISPERR_SPEC_PATH")
|
|
14
|
+
if local:
|
|
15
|
+
with open(local) as f:
|
|
16
|
+
return json.load(f)
|
|
17
|
+
with urllib.request.urlopen(SPEC_URL, timeout=15) as r:
|
|
18
|
+
return json.loads(r.read())
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def apply_case(client, case):
|
|
22
|
+
s = case["scenario"]
|
|
23
|
+
if case["op"] == "track":
|
|
24
|
+
client.track(s["externalUserId"], s["eventType"], s.get("properties"))
|
|
25
|
+
return
|
|
26
|
+
channels = None
|
|
27
|
+
if s.get("channels"):
|
|
28
|
+
channels = []
|
|
29
|
+
for ch in s["channels"]:
|
|
30
|
+
c = {"type": ch["type"], "address": ch["address"], "opted_in": ch.get("optedIn", True)}
|
|
31
|
+
if "verified" in ch:
|
|
32
|
+
c["verified"] = ch["verified"]
|
|
33
|
+
channels.append(c)
|
|
34
|
+
client.identify(
|
|
35
|
+
s["externalUserId"],
|
|
36
|
+
traits=s.get("traits"),
|
|
37
|
+
email=s.get("email"),
|
|
38
|
+
phone=s.get("phone"),
|
|
39
|
+
push_token=s.get("pushToken"),
|
|
40
|
+
preferred_channel=s.get("preferredChannel"),
|
|
41
|
+
channels=channels,
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def test_wire_conformance(monkeypatch):
|
|
46
|
+
# Load the spec first, before we patch urlopen to capture SDK requests.
|
|
47
|
+
spec = load_spec()
|
|
48
|
+
assert spec["cases"], "spec has no cases"
|
|
49
|
+
|
|
50
|
+
captured = []
|
|
51
|
+
|
|
52
|
+
class FakeResp:
|
|
53
|
+
def __enter__(self):
|
|
54
|
+
return self
|
|
55
|
+
|
|
56
|
+
def __exit__(self, *a):
|
|
57
|
+
return False
|
|
58
|
+
|
|
59
|
+
def fake_urlopen(req, timeout=None):
|
|
60
|
+
captured.append({"url": req.full_url, "body": json.loads(req.data.decode())})
|
|
61
|
+
return FakeResp()
|
|
62
|
+
|
|
63
|
+
monkeypatch.setattr(urllib.request, "urlopen", fake_urlopen)
|
|
64
|
+
client = Whisperr(api_key="wrk_test", flush_interval=0.05)
|
|
65
|
+
|
|
66
|
+
for case in spec["cases"]:
|
|
67
|
+
captured.clear()
|
|
68
|
+
apply_case(client, case)
|
|
69
|
+
client.flush()
|
|
70
|
+
|
|
71
|
+
call = next((c for c in captured if c["url"].endswith(case["endpoint"])), None)
|
|
72
|
+
assert call is not None, f"{case['name']}: expected POST {case['endpoint']}"
|
|
73
|
+
|
|
74
|
+
if case["op"] == "track":
|
|
75
|
+
event = call["body"]["events"][0]
|
|
76
|
+
for k, v in case.get("expectedEvent", {}).items():
|
|
77
|
+
assert event[k] == v, f"{case['name']}.{k}"
|
|
78
|
+
for key in case.get("contextMustContain", []):
|
|
79
|
+
assert key in event["context"], f"{case['name']} context.{key}"
|
|
80
|
+
if case.get("occurredAtRfc3339Z"):
|
|
81
|
+
assert RFC3339_Z.match(event["occurred_at"]), event["occurred_at"]
|
|
82
|
+
else:
|
|
83
|
+
for k, v in case.get("expectedBody", {}).items():
|
|
84
|
+
assert call["body"][k] == v, f"{case['name']}.{k}"
|
|
85
|
+
|
|
86
|
+
client.shutdown()
|